摘要
编写正确的并发程序对我来说是一件极其困难的事情, 由于知识不足, 只知道 synchronized 这个修饰符进行同步.
本文为学习极客时间: Java 并发编程实战 01 https://time.geekbang.org/column/article/83682 的总结, 文章取图也是来自于该文章
并发 Bug 源头
在计算机系统中, 程序的执行速度为: CPU> 内存 > I/O 设备 , 为了平衡这三者的速度差异, 计算机体系机构, 操作系统, 编译程序都进行了优化:
1.CPU 增加了缓存, 以均衡和内存的速度差异
2. 操作系统增加了进程, 线程, 已分时复用 CPU, 以均衡 CPU 与 I/O 设备的速度差异
3. 编译程序优化指令执行顺序, 使得缓存能够更加合理的利用.
但是这三者导致的问题为: 可见性, 原子性, 有序性
源头之一: CPU 缓存导致的可见性问题
一个线程对共享变量的修改, 另外一个线程能够立即看到, 那么就称为可见性.
现在多核 CPU 时代中, 每颗 CPU 都有自己的缓存, CPU 之间并不会共享缓存;
如线程 A 从内存读取变量 V 到 CPU-1, 操作完成后保存在 CPU-1 缓存中, 还未写到内存中.
此时线程 B 从内存读取变量 V 到 CPU-2 中, 而 CPU-1 缓存中的变量 V 对线程 B 是不可见的
当线程 A 把更新后的变量 V 写到内存中时, 线程 B 才可以从内存中读取到最新变量 V 的值
上述过程就是线程 A 修改变量 V 后, 对线程 B 不可见, 那么就称为可见性问题.
源头之二: 线程切换带来的原子性问题
现代的操作系统都是基于线程来调度的, 现在提到的 "任务切换" 都是指 "线程切换"
Java 并发程序都是基于多线程的, 自然也会涉及到任务切换, 在高级语言中, 一条语句可能就需要多条 CPU 指令完成, 例如在代码 count += 1 中, 至少需要三条 CPU 指令.
指令 1: 把变量 count 从内存加载到 CPU 的寄存器中
指令 2: 在寄存器中把变量 count + 1
指令 3: 把变量 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() 这行代码, 我们以为的执行顺序应该是这样的:
指令 1: 分配一块内存 M
指令 2: 在内存 M 中实例化 Singleton 对象
指令 3:instance 变量指向内存地址 M
但是实际优化后的执行路径确实这样的:
指令 1: 分配一块内存 M
指令 2:instance 变量指向内存地址 M
指令 3: 在内存 M 中实例化 Singleton 对象
这样的话看出来什么问题了吗? 当线程 A 执行完了指令 2 后, 切换到了线程 B,
线程 B 判断到 if (instance != null). 直接返回 instance, 但是此时的 instance 还是没有被实例化的啊! 所以这时候我们使用 instance 可能就会触发空指针异常了. 如图:
总结
在写并发程序的时候, 需要时刻注意可见性, 原子性, 有序性的问题. 在深刻理解这三个问题后, 写起并发程序也会少一点 Bug 啦~. 记住了下面这段话: CPU 缓存会带来可见性问题, 线程切换带来的原子性问题, 编译优化带来的有序性问题.
参考文章: 极客时间: Java 并发编程实战 01 | 可见性, 原子性和有序性问题: 并发编程 Bug 的源头 https://time.geekbang.org/column/article/83682
个人博客网址: https://colablog.cn/
来源: https://www.cnblogs.com/Johnson-lin/p/12697533.html