Java 并发问题根源笔记

cpu 内存 磁盘

读写速度:cpu > 内存 > 磁盘
为了不浪费 CPU 的运算资源,CPU 中有一块专门用作数据缓存的小内存。
代码先加载到内存,然后加载到 CPU 的缓存区,CPU 再从缓存区读写数据,然后运算数据。最后把缓存区的数据写到内存。

知识点笔记

可见性

  • cpu 每个核心都有一个缓存区,而且该缓存区只对当前核心的线程可见

    当 A线程修改了缓存区的某个变量,那么线程B是获取不到该变量修改后的值的

  • 线程的切换时由 CPU 调度的,每个线程都有固定的执行时间,如果线程A运行超过了这个时间,CPU 就会切换到线程 B。

    所以我们在电脑上可以一边敲代码,一边听歌。这个切换完全是由 CPU 控制的。每个线程都有固定的执行时间,这个时间称为“时间片”
    例子:当需要读取磁盘内容时,流程大概是这样的,CPU 收到指令进行磁盘读取,先读取到内存(这个时间通常会很长),这个时候 CPU 不能干等啊,你写进内存,关我 CPU 什么事,对吧?CPU 会对当前进程标记为休眠,等待文件全部写进内存后,再唤醒该进程,被唤醒的进程才能拥有 CPU 的使用权.

原子性

  • 线程的切换需要考虑原子性问题

    线程切换再加上每个CPU核心缓存区的不可见性,最终可能出现原子性问题

  • 原子性

    我们把一个或者多个造作再 cpu 执行的过程中不被中断的特性称为原子性。cpu 只能保证每条CPU指令的原子性。假设我们的某一行代码是 Person p = new Person(),这一行代码其实是需要多条CPU指令操作才能完成的。所以说这行代码对于CPU而言,并不是原子性的。

1
2
3
4
5
6
7
8
public void init(){	
// p 在栈内存,new Person 在堆内存
Person p = new Person()
//CPU指令1:在栈内存分配一块内存
//CPU指令2:在堆内存中分配一块内存
//CPU指令3: 在该堆内存上初始化 Person 对象
//CPU指令4: 把这块内存(堆)的地址赋值该栈内存的 p
}

有序性

  • 有时候编译器会优化我们的代码,最终出现CPU指令发出的顺序不一样。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void init(){	
    // p 在栈内存,new Person 在堆内存
    Person p = new Person();
    //CPU指令1:在栈内存分配一块内存
    //CPU指令2:在堆内存中分配一块内存
    //CPU指令3: 在该堆内存上初始化 Person 对象
    //CPU指令4: 把这块内存(堆)的地址赋值该栈内存的 p

    p.name = "张三";
    p.age = 20;
    }

经过优化,最终可能是这样的,CPU 指令的顺序变为: 1-2-4-3,或者先编译 p.age = 20,然后编译 p.name = “张三”
在多线程情况下,这种是特别需要注意,当 CPU new 一个对象,刚刚执行完指令3,然后其他线程就访问这个p,依然会出现空指针异常的。

volatile

volatile 只能保证可见性,不能保证变量的原子性

syncchroized

会影响性能,在访问非 volatile 的变量,依然不能保证并发安全
在多线程并发环境下,现在双重检查锁的写法的单例记得加上 volatile 或者 final, 但更推使用荐静态内部类的单例形式。

优化指令的执行顺序,让缓存区利用的更加合理

1
2
3
4
5
6
a = 10  //第一行
b =1
c = 200
d - b + c
...//此处=省略一千行没有用到 a 的代码
a += 100 //1000 行+

这么写是会影响性能的,在第一行就声明的变量 a,TM的你到1000行以后才用到这个 a;在 CPU 执行步骤看来是这样的,先把 a 从内存写到缓存,然后发现第二行没有用到a ,那就从缓存中清除掉;等到需要用到a的时候,只能总内存直接读取到寄存器进行运算了。所以能一般情况下,编译器会对这样的代码进行编译优化,可能会把 a+=100 提取到 a=10 后面进行编译。
所以写代码的时候尽可能的按照就近原则。虽然编译器会帮我们优化,但这也能加快编译速度对吧。在说有时编译器也不一定靠谱。