Java并发编程之可见性、原子性和有序性
引言
CPU缓存与内存产生的一致性问题(可见性)
CPU时间片切换产生的原子性问题
CPU指令编译优化产生的有序性问题
并发编程问题的根源
CPU、内存、I/O设备三者速度差异一直是 核心矛盾 三者速度差异
可形象描述为:天上一天(CPU),地上一年(内存),地下十年(I/O) 根据木桶理论,程序整体性能取决于最慢的操作-读写I/O设备,可见单方面提高CPU性能是无效的。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都做了努力:
- CPU增加了缓存,以均衡与内存的速度差异
- 操作系统增加了进程、线程,以及分时复用CPU,进而均衡CPU与I/O设备的速度差异
- 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用
1.1 源头之一:缓存导致的可见性问题
什么是可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
但是多核时代,每颗 CPU 都有自己的缓存, CPU 缓存与内存的数据一致性就没那么容易解决了
private int count = 0;
@Test
public void testDemoAdd() throws InterruptedException {
Thread thread01 = new Thread(this::add);
Thread thread02 = new Thread(this::add);
thread01.start();
thread02.start();
thread01.join();
thread02.join();
System.out.println("count = " + count);
}
public void add(){
for (int i = 0; i < 10000; i++) {
count+=1;
}
}
1.2 源头之二:线程切换带来的原子性问题
CPU时间片
示例:count += 1,至少需要三条 CPU 指令
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
带来可能问题
原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性 CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符
1.3 源头之三:编译优化带来的有序性问题
有序性指的是程序按照代码的先后顺序执行
示例:利用双重检查创建单例对象
// volatile 存在是保证内存的可见性,禁止指令重排序
// 原因:在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();语句是分两步执行的。
// 但是JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,
// 然后再去初始化这个Singleton实例
// 举例: 重排序会 导致 step2 和 step3 执行的顺序颠倒
// 创建对象的步骤:step1:在内存中分配一块空间。step2:对内存空间进行初始化。step3:把对象在内存中的位置指向 instance。
// 现在假设有两个线程T1、T2,T1 线程执行完重排序后的 step3 ,CPU 的执行权被 T2 获得。这个时候,instance 已经不为 null 了,
// 他指向了内存中的一块地址。T2 执行到第一个 if 的时候,发现 instance 不为 null,就直接返回,但是这个 instance 并没有被初始化,
// 这就会导致 T2 在执行的过程中发生不可预知的错误。
private volatile static DoubleCheckSingleton singleton;
private DoubleCheckSingleton() {
}
public static DoubleCheckSingleton getInstance() {
// 检查是否已经被创建——第一个 if 可以避免频繁加锁,如果没有第一个 if,它就会直接尝试获取锁资源
if (singleton == null) {
// 同步块
synchronized (DoubleCheckSingleton.class) {
// 再次检测是否被创建----双重检测
// 第二个if是避免重复创建线程,破坏单例
// 现假设有两个 T1 和 T2,T1 执行到同步块时,CPU 的执行权被 T2 抢夺走,T2 执行完成之后创建了一个对象实例,
// 并且释放 Java 的类锁。这个时候 T1 又重新获得了 CPU 的执行权,并且获得了类锁。
// 如果没有第二个 if 的判断,T1 又会重新创建一个 实例对象,这样就破坏了单例。
if (singleton == null) {
// 标记3
singleton = new DoubleCheckSingleton();
}
}
}
return singleton;
}
new的理论顺序:
- 分配一块内存M
- 在内存M上初始化Singleton对象
- M的地址赋值给instance变量
经过编译器实际优化后:分配一块内存M,带来问题:
总结
可见性是一个线程对共享变量的修改,另一个线程能够立刻看到,如果不能立刻看到,就可能会产生可见性问题。在单核CPU上是不存在可见性问题的,可见性问题主要存在于运行在多核CPU上的并发程序。归根结底,可见性问题还是由CPU的缓存导致的,而缓存导致的可见性问题是导致诸多诡异的并发编程问题的“幕后黑手”之一。我们只有深入理解了缓存导致的可见性问题,并在实际工作中时刻注意避免可见性问题,才能更好的编写出高并发程序。