On lzq ways

并发编程Bug源头

2019-04-26  本文已影响0人  zhengqiuliu

CPU,内存,I/O设备不断迭代更新,朝着更快的方向努力。但是,快速发展的过程中,核心矛盾一直存在,就是这三者的速度差异。CPU是天上一天,内存是地上一年;内存是天上一天,I/O设备是地上十年。根据木桶理论,一只水桶能装多少水取决于最短的那块木板。

为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系结构,操作系统,编译程序都做出了贡献。

1,CPU增加了缓存,以均衡与内存的速度差异。

2,操作系统增加了进程,线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异。

3,编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

源头一,缓存的可见性问题

单核时代,所有线程都是在一个CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

多核时代,每个CPU都有自己的缓存,CPU缓存和内存数据的一致性就没有那么容易解决。当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。这时候A线程对共享变量的修改对线程B来说就不具有可见性。

源头二,切换线程的原子性问题

Java并发程序都是基于多线程的,自然会涉及到任务切换。高级语言里一条语句往往需要多条CPU指令完成,如 count += 1,至少需要三条CPU指令。

1,把变量count从内存加载到CPU的寄存器。

2,在寄存器中执行 +1 操作。

3,将结果写入内存。

操作系统做任务切换,可以发生在任何一条CPU指令执行完,而不是高级语言的一条语句。我们潜意识里面觉得count += 1这个操作是一个不可分割的整体。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。

源头三,编译优化带来的有序性问题

在Java领域一个经典案例就是利用双重检查创建单例对象。

在new操作时,步骤如下:

1,分配一块内存M;

2,在内存M上初始化Singleton对象;

3,然后M的地址赋值给instance变量。

但是实际上优化后的执行路径如下:

1,分配一块内存M;

2,将M的地址赋值给instance变量;

3,最后在内存M上的初始化Singleton对象。

优化后导致,假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到B上;如果B也执行getInstance()方法,那么B在执行第一个判断时会发现install != null, 所以直接返回instance,而此时的instance是没有初始化过的,就会发生空指针异常。可以声明instance为volatile禁止重排序。

总结:

缓存导致可见性问题,线程切换带来原子性问题,编译优化带来有序性的问题。其实缓存,线程,编译优化的目的和并发程序的目的是相同的,都是提高程序的性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

上一篇下一篇

猜你喜欢

热点阅读