并发编程是开发中无法规避的问题,而且在并发场景下经常会出现一些很”诡异”的问题。为了搞清楚并发,需要明确出现并发问题的源头
在计算机中cpu、缓存、I/O设备这个三者之间速度的差异一直存在问题,cpu>缓存>I/O设备。按照串行的逻辑,程序的整体速度取决速度最慢的I/O设备。
为了提高程序的整体性能,做了如下的升级
- 增加cpu缓存,平衡cpu和内存的速度差异
- 操作系统增加多进程、多线程;通过分时复用平衡CPU和I/O设备间的速度差异
- 编译器指令优化,可以充分利用缓存,提高执行效率
但是凡事有利必有弊,性能提高的同时,也会引发一些问题。并发编程中的问题源头就是因为这个导致的
缓存导致了可见性问题
目前操作系统都是多核的,cpu都有自己的缓存,这个时候就需要考虑数据的一致性问题。某一个变量被多个线程操作;相互的操作是不可见的。这个时候就会出现问题了,这就是可见性问题
线程切换带来的原子性问题
现代操作系统都是基于线程调度的,java并发程序出现的多线程,会涉及到线程切换,在一条语句可能需要多个cpu指令完成。例如代码count+=1大概需要三条指令。
- 把变量 count 从内存加载到CPU的寄存器中
- 在寄存器中把变量 count + 1
- 把变量 count 写入到内存(缓存机制导致可能写入的是CPU缓存而不是内存)
操作系统做任务切换,可以发生在任何一条CPU指令执行完,所以并不是高级语言中的一条语句,不要被 count += 1
这个操作蒙蔽了双眼。假设 count = 0
,线程A执行完 指令1 后 ,做任务切换到线程B执行了 指令1、指令2、指令3 后,再做任务切换回线程A。我们会发现虽然两个线程都执行了 count += 1
操作。但是得到的结果并不是2,而是1。
如果 count += 1
是一个不可分割的整体,线程的切换可以发生在 count += 1
之前或之后,但是不会发生在中间,就像个原子一样。 我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
编译优化带来的有序性问题
有序性是指程序按照代码的顺序执行。编译器为了优化性能,可能会改变代码的执行顺序。如: a = 1; b = 2;
,编译器可能会优化成: b = 2; a = 1
。在这个例子中,编译器优化了程序的执行先后顺序,并不影响结果。但是有时候优化后会导致意想不到的Bug。
在单例模式的双重检查创建单例对象中。如下代码:
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
new Singletion()
这行代码可能出现问题,我们以为的执行顺序应该是这样的:
- 分配一块内存m
- 在内存m中实例化singleton对象
- instance变量执行内存地址m
但是实际优化后的执行路径确实这样的:
- 分配一块内存m
- instance变量执行内存地址m
- 在内存m中实例化singleton对象
如果的是这样的话就会有问题了呀
当线程A执行完了指令2后,切换到了线程B,
线程B判断到 if (instance != null)
。直接返回 instance
,但是此时的 instance
还是没有被实例化的啊!所以这时候我们使用 instance
可能就会触发空指针异常了。如图:
总结
遇到问题,定位到问题的根本原因才好解决问题。并发编程 只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的 。