并发程序的背景
-
冯诺依曼体系将计算机分为 CPU、运算器、存储器、输入设备、输出设备,奠定了经典计算机的发展
-
尽管 CPU、内存、IO 设备不断向更快的方向进步,但三者的速度差异一直是核心矛盾
- CPU - 内存:天上一天,地上一年
- 内存 - IO:天上一天,地上十年
-
为了缓解这一核心矛盾,计算机、操作系统、编译程序都做了相应的优化
- CPU 增加缓存,均衡与内存的速度差异
- 操作系统增加了进程、线程,分时复用 CPU,均衡 CPU 与 IO 的速度差异
- 编译程序优化指令顺序,以提高缓存利用率
CPU 缓存技术、多线程技术、指令优化技术提高了程序的执行效率,但也使并发程序运行的问题暴露无遗
并发的可见性问题
-
程序变量存储在内存中,CPU 读写变量时,会操作缓存中的变量副本,以提高运行效率
注意:对于何时把数据从缓存写到内存,没有固定的时间
-
在单核 CPU 机器上,线程操作同一个内核的缓存,多个线程看见的数据是统一的
-
在多核 CPU 机器上,每一个核心都有独立的缓存,而线程被调度到不同的核心执行
对同一变量的操作,不同缓存中的变量副本在线程之间不立即可见,导致多线程对共享变量的操作结果错误
并发的原子性问题
-
假若不考虑可见性问题,对于共享变量的非原子操作,也会引发错误的操作结果
-
CPU 只能保证每一条指令是原子执行的,但每一条程序语句是不一定的
例如
i += 1,完成这条语句执行需要 3 条指令指令 1:将变量 i 加入 CPU 寄存器 指令 2:寄存器执行 +1 操作 指令 3:将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存) -
操作系统进行多线程调度时,可能在任意指令执行完毕时进行线程切换
非原子的程序语句,在语句没有执行完毕就切换线程,导致共享变量被其他线程覆盖
并发的有序性问题
-
编译器和 CPU 为了优化性能,会改变指令的执行顺序,在并发情况下可能会印象影响程序的运行结果
-
一条语句可能是多条指令,这些指令的执行顺序可能会被重排
例如双检锁实现单例模式
public class SafeLazyMan { private static SafeLazyMan man; public static SafeLazyMan instance() { if (man == null) { // 第一次检查 synchronized (SafeLazyMan.class) { if (man == null) { // 第二次检查 man = new SafeLazyMan(); } } } return man; } }为了防止有多个线程完成第一次检查,多次创建对象,增加获取锁之后的第二次检查,保证对象不被重复的创建,但是 new 对象操作分为分配内存、初始化对象、引用赋值三个指令
指令重排后先对引用赋值,再初始化对象,会导致其他线程第一次检查时虽然不为 null 了,但其实没有对象,引发空指针问题
编译器和 CPU 对汇编指令的执行顺序的进行优化,可以缩短执行消耗的时钟周期,但可能会导致不符合并发程序运行的预期结果