java并发编程实战《一》可见性、原子性和有序性
可见性、原子性和有序性问题:并发编程Bug的源头
核心矛盾:CPU、IO、内存三者之间的速度差异。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
1.CPU 增加了缓存,以均衡与内存的速度差异;
2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
缓存导致的可见性问题
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
有必要提一下的是,一个线程如何修改共享变量?
线程有自己的工作内存,可概称为栈(出自《深入理解java虚拟机》第二版,12.3.1 主内存与工作内存),而你的数据是存在主存中的,线程修改数据时先从主存中拷贝你的数据复制到自己的内存中修改,然后写回主存。
如果两个线程同时做上述操作,就引发了可见性问题。
线程切换带来的原子性问题
所谓的原子性问题即原子操作,即不会被线程调度机制打断的操作。
比如这个样子
在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。
是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix 就是因为解决了这个问题而名噪天下的。
编译优化带来的有序性问题
例如java双重检查锁
1 public class Singleton { 2 static Singleton instance; 3 static Singleton getInstance(){ 4 if (instance == null) { 5 synchronized(Singleton.class) { 6 if (instance == null) 7 instance = new Singleton(); 8 } 9 } 10 return instance; 11 } 12 }
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?
出在 new 操作上,我们以为的 new 操作应该是:
1.分配一块内存 M;
2.在内存 M 上初始化 Singleton 对象;
3.然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
1.分配一块内存 M;
2.将 M 的地址赋值给 instance 变量;
3.最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
课后思考
常听人说,在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?
long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性。
摘自极客时间王宝令老师的课程