并发程序幕后的故事
此系列文章为极客时间java并发编程课程的学习笔记。
计算机中由于CPU,内存,I/O设备三者硬件速度之间的差异,而面对三者的差异,计算机体系结构,操作系统,编译程序都为之做出了自己的贡献。
比如CPU增加了缓存机制,操作系统增加了进程,线程,分时复用CPU的理念,编译程序则会优化指令顺序。
而这些,其实就是并发程序bug的根源所在。
根源一:缓存导致的可见性问题
在早期的PC中,一般使用的是单核的CPU,CPU缓存与内存的数据一致性问题很好解决。因为不同线程都是对同一个cpu缓存进行操作,一个线程对于cpu缓存的数据更改另一个线程肯定是可见的,所以如果线程A更改了缓存中的V值,对于线程B再取出V的值就是线程A已经更改的值。

但是现如今多核时代,每个CPU都有自己的缓存,这时候缓存的可见性问题并没有那么简单了。

如下面的代码
1 | public class Test { |
上面代码表示创建两个线程,这两个线程同时为count做100000次自增,如果按照常理得到的结果应该是200000,但是其实输出的是100000到200000之间的数字。其原因在于在线程A和线程B执行的过程中,两个线程会将count都放入自己的寄存器中然后进行+操作,但是对于放回内存中这样的操作就会出现重叠,例如线程A计算出count为1了放入内存,线程B计算出count为1了放入内存,此时count就会为1而不是我们真正想看到的2。
根源二:线程切换带来的原子性问题
因为CPU执行速度是非常快的,所以在早期的PC时代中人们就发明了多进程的理念,即CPU通过切换时间片来使多个进程看似在同时执行。

在一个时间片内,如果一个进程进行一个IO操作,例如读文件,这个时候该进程可以把自己标记为“休眠状态”并让出CPU的使用权,待文件读入内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了。
这里的进程在等待IO时会释放CPU使用权,是为了提高CPU使用率。此外,如果这时有另外一个进程读文件,那么读文件的操作就会排队,磁盘驱动在完成一个进程的读操作之后,会立即进行下一个操作,这样IO的使用率也提升了。而这个就是多进程分时复用,这个操作在操作系统中具有里程碑的意义,Unix就是因为解决这个问题而名噪天下的。
早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以要做进程任务切换就要切换内存映射地址,而一个进程创建的所有线程中,他们都是共享内存空间的,所以线程做任务切换的成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
将count从内存中取出放入寄存器
在寄存器中进行加操作
从寄存器中取出放入内存中(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU 指令执行完,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

如果我们将这条语句作为原子操作那是可以的,而对于线程切换,它的基本单位是cpu指令而不是语句,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
源头三:编译优化带来的有序性问题
Java的编译优化是很智能的,为了提高程序的性能,编译器有时候会更改语句执行顺序,按照常理有些语句的顺序变更是不会影响结果的,但是在多线程的情境下就变得复杂起来了。
例如在单例模式的双重检查中
1 | public class Singleton { |
假如现有两个线程A,B同时进入getInstance方法他们同时判断instance是否为空,此时为空两个线程同时进入语句,由于synchronized的存在,JVM只会让一个线程进入并且上锁。假设此时A进入B等待锁,那么A获得锁判断instance是否为空,为空则创建对象,然后A释放锁,此时B获得锁并进入语句判断instance不为空跳出语句并return不为空的instance。一切看起来完美,但是我们一直以为的new操作是这样的。
在内存中开辟一个地址空间M
在地址空间M中初始化Singleton对象
将地址赋值给instance变量
但经过了编译优化它是这样的
在内存中开辟一个地址空间M
将地址赋值给instance变量
在地址空间初始化Singleton对象
试想一下,如果在执行2指令的时候进行了线程切换,线程B会判断instance是否为空,此时不为空那么直接返回,但是在返回instance的地址空间并没有Singleton对象,所以之后如果调用instance的变量,方法就会产生空指针异常。
