funnycoding / blog

A Github Issue Blog
22 stars 0 forks source link

极客时间 ——《Java并发编程实战》 01 | 可见性、原子性和有序性问题:并发编程Bug的源头 #36

Open funnycoding opened 4 years ago

funnycoding commented 4 years ago

并发程序幕后的故事

CPU内存IO 之间速度存在着很大的差异,根据木桶理论,程序整体性能取决于最慢的设备 —— I/O 设备。

所以为了合理利用 CPU 的高性能,平衡三者的速度差异,计算机体系结构操作系统编译程序(编译器)都做出了贡献,具体体现为:

  1. CPU 增加缓存,以均衡与内存之间的速度差异。
  2. 操作系统增加了进程、线程、以分时复用 CPU,进而均衡了 CPU 与 I/O 设备的速度差异。
  3. 编译程序优化指令执行次数,使得缓存能够得到更加合理地利用。

这些措施增强了程序的性能,却也带来了很多并发的诡异问题。

### 源头一:缓存导致可见性问题 单核时代,所有线程都在同一个 CPU 上执行, CPU 缓存与 内存数据的一致性容易解决。 因为所有线程都操作的是同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。 例如下面的图中,线程A 和 线程B 都操作同一个 CPU 里面的缓存,所以线程A 更新了 变量 V 的值,那么线程B 之后访问变量V 得到的一定是 V 的最新值。(线程A 写入的值) ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409140601.png) **一个线程对共享变量的修改,另一个线程能立刻看到,称为「可见性」。** 多核时代,**每颗 CPU 都有自己的缓存**,这时 CPU 缓存与内存的数据一致性就没那么容易解决了。 当多个线程在不同 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。 比如下图中,线程A 操作的是 CPU-1 上的缓存,而线程B 操作的是 CPU-2 上的缓存,很明显,这个 **「线程A」** 对 **「变量V」** 的操作对于 **「线程B」** 来说就**不具备可见性**了。 ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409141140.png) 下面使用一段代码来验证「多核场景」下的 **「可见性问题」**。 下面的代码,每执行一次 `add10K()` 方法,都会循环 `10000` 次 `count += 1` 的操作。 在 `calc()` 方法中我们创建了两个线程,每个线程调用一次 `add10K()` 方法,那么 `calc()` 方法的结果应该是多少? ```java @Slf4j public class Test { private long count = 0; private void add10K() { int idx = 0; while (idx++ < 10000) { count += 1; } log.info("add10k()方法执行完毕时Count的值: ---> {}",count); } public static long calc() throws InterruptedException { final Test test = new Test(); Thread th1 = new Thread(test::add10K); Thread th2 = new Thread(test::add10K); // 启动 th1,th2 线程 th1.start(); th2.start(); // 等待两个线程执行结束 th1.join(); th2.join(); th1.join(); th2.join(); return test.count; } public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10; i++) { long calc = Test.calc(); log.info("本次返回结果{}",calc); } } } /** 输出 [INFO ]-Thread-0-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10697 [INFO ]-Thread-1-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12674 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果12674 [INFO ]-Thread-2-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-3-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 14978 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果14978 [INFO ]-Thread-4-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12777 [INFO ]-Thread-5-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 16389 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果16389 [INFO ]-Thread-6-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10032 [INFO ]-Thread-7-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 14018 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果14018 [INFO ]-Thread-8-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10261 [INFO ]-Thread-9-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 15359 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果15359 [INFO ]-Thread-10-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12575 [INFO ]-Thread-11-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 19471 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果19471 [INFO ]-Thread-12-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-13-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-15-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 17697 [INFO ]-Thread-14-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 15704 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果17697 [INFO ]-Thread-16-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10027 [INFO ]-Thread-17-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 17544 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果17544 [INFO ]-Thread-18-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-19-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果20000 Process finished with exit code 0 */ ``` 这里我给文章中的样例代码加了10次循环,并且增加了log输出,可以非常直观的看到,`add10K()` 是一个非同步的方法,在并发环境下有多次结果都并不是预想中的 `20000` **【然后我再给方法加一个内置锁:】** ```java @Slf4j public class Test { private long count = 0; private synchronized void add10K() { int idx = 0; while (idx++ < 10000) { count += 1; } log.info("add10k()方法执行完毕时Count的值: ---> {}",count); } public static long calc() throws InterruptedException { final Test test = new Test(); Thread th1 = new Thread(test::add10K); Thread th2 = new Thread(test::add10K); // 启动 th1,th2 线程 th1.start(); th2.start(); // 等待两个线程执行结束 th1.join(); th2.join(); th1.join(); th2.join(); return test.count; } public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10; i++) { long calc = Test.calc(); log.info("本次返回结果{}",calc); } } } /** 输出 [INFO ]-Thread-0-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-1-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-2-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-3-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-4-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-5-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-6-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-7-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-8-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-9-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-10-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-11-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-12-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-13-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-14-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-15-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-16-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-17-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000 [INFO ]-Thread-18-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000 [INFO ]-Thread-19-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000 [INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000 */ ``` 可以看到加锁之后的方法,每次执行完成都是 预料中的数字 ---> `20000`。 这里之前造成 `calc()` 结果是个 10000 到 20000 之间随机数的原因是: 假设 **「线程A」** 和 **「线程B」** 同时开始执行,那么第一次都会将 `count = 0` 读取到各自的 **「CPU 缓存」**中,执行完 `count += 1` 之后,各自 **「CPU 缓存」** 中的值都是1,同时写入内存后,我们会发现内存中的值 是 `1` 而不是 期望值 `2` 。 之后由于各自的 CPU 缓存 中都有了 `count的值`,两个线程都是基于 CPU 缓存里的 count 进行计算,所以导致最终的 count 的值小于 `20000`,这就是 **「缓存可见性」** 问题。 ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409152147.png) 如果将循环条件从 `10000` 次 改为 1亿次,那么结果会更加明显,最终的 `count` 值更接近1亿 而不是 2亿。 在这个循环 `10000` 次的例子中,count 接近 10000 是因为两个线程并非同时启动,有一个时差。 **将循环条件改为 「1亿次」 之后,结果确实 如作者所说,更接近 1亿。** ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409152030.png) ### 源头二:线程切换带来的「原子性」 问题 由于 **I/O 速度太慢**,早期的操作系统就发明了**「多进程」**,即便在单核的 CPU 上我们也可以一边听歌一边做别的,给人一种这几件事并行的错觉,其实是CPU在以极小的时间分片进行切换,每个进程执行一个**「时间片」**的时间 例如 50ms , 这就是 多进程的功劳。 ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409152916.png) 在一个时间片内,如果一个进程进行一个 I/O 操作,例如读取一个文件,这个时候进程可以把自己标记为 「休眠状态」 并 出让 CPU 的使用权,当文件读取进内存这个 I/O 操作结束后, 操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。 这里的 「进程」 在等待 I/O 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待的时间里可以做别的事情,这样一来,**CPU 的使用率就增加了**。 如果这时另一个进程也在读取文件,读取文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 **I/O 的使用率也增加了**。 虽然这个逻辑看上去很简单,但是支持 **「进程分时复用」** 在操作系统的发展史上有着 「里程碑」 的意义,Unix 因为解决了这个问题而名噪天下。 「早期的操作系统」基于「进程」 来调度 CPU,**不同的进程之间不共享「内存空间」**,所以切换任务就需要切换进程,从而切换**「内存映射地址」**,而一个进程创建的所有线程,则共享同一个内存空间,所以线程之间的切换成本比进程间切换要低很多。 现代的操作系统都是基于更轻量的线程来调度,所以我们现在提到的**「任务切换」** 指的都是 **「线程切换」**。 **Java 并发程序都是基于 多线程的**,自然也会涉及到 **「任务切换」**。而同时 任务切换也是 Java 并发中 Bug 的源头之一。 **「任务切换」** 的时机大多数是在 时间片结束的时候,我们现在使用的基本都是高级编程语言,而高级编程语言中的一行代码往往对应着多个 CPU 指令操作。 比如上面代码中的 `count += 1` 就对应着 3个操作。 - **指令1 读取**:将 `count` 内存加载到 **CPU 寄存器**中 - **指令2 修改**:将 `count` 在寄存器中进行 `+1` 操作 - **指令3 写入**:将寄存器中的结果写入内存。(缓存机机制导致可能写入的是 CPU 缓存而不是 内存) **操作系统做任务切换,可以发生在任何一条 「CPU 指令」 执行完成的时候,而不是 高级编程语言中的一条语句。** **↑---【这里对原子性的解释要比 jcip 中更具体一些,我理解的原子性指的是一组高级语句中对应的所有 CPU 指令一起执行完成 才发生切换】** 对于上面的三条指令来说,假设 `count = 0`,如果 **线程A** 在 指令1 执行完成后进行了线程切换,**线程A** 和 **线程B** 按照下图的顺序执行,那么我们会发现两个线程都执行了 `count += 1` 的操作,且 `count` 的值都是以 `0` 为起点,最终得到的结果是 `1` 而不是期望的 `2`。 ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409155834.png) **我们将一个或者多个操作在 CPU 执行的过程中不被中断的特性称为 「原子性」 <---【对原子性的定义】。** **CPU** 能保证的原子操作需要是 **CPU 指令**级别的,而不是**高级语言**的操作符。 这是违背我们直觉的地方,因此很多时候我们需要在**「高级语言层面」**保证操作的原子性。 **【所以这一节的内容对于《jcip》 中所讲的 复合操作 带来的线程安全性问题,最终追溯到 Java 中的原子操作是保证线程安全的重要手段】** ### 源头之三:编译优化带来的有序性问题 【也就是**重排序**问题】 并发编程中还有一个容易导致违背直觉性的诡异Bug的原因就是**有序性**。 **有序性指的是 程序按照代码的先后顺序执行。** **【<--- 对于有序性的定义】** 但是**编译器**为了**优化性能**,有时候会改变程序中语句的先后顺序,例如 `a=6,b=7` 编译后可能变成了 `b=7,a=6` 这个顺序。 编译器调整了语句的顺序,但是不影响程序的最终结果。 但是有时候编译器和解释器的优化可能导致意想不到的Bug。 Java 中的一个经典的关于有序性问题的按理就是利用 **「双重检查」** 创建单例对象,例如下面的代码: 在获取实例 `getInstnace()` 方法中,首先判断 `intance` 是否为空,如果为空则锁定 `Singleton.class` 并再次检查 instance 是否为空,如果还未空则创建 `Singleton` 的一个实例。 ```java // 一个双重检查的单例代码示例 public class Singleton { static Singleton instance; static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } } ``` 假设有两个线程A、B 同时调用 `getInstance()` 方法,则同时发现 `instance == null` , 于是同时对 `Singleton.class` 加锁,此时 `JVM` **保证只有一个线程能够加锁成功**(假设是 线程A),此时另外一个线程会处于等待状态,线程A 创建一个 `Singleton` 实例,然后**释放锁**,锁被释放后 **线程B 被唤醒**,线程B 尝试加锁 ,本次加锁可以成功,然后线程B 检查 `instance == null` 发现已经有实例被创建,**所以线程B 不会再创建一个 `SIngleton` 实例。** 看上去很完美,但是实际上 `getInstance()` 方法存在漏洞, 问题出在 `new` 这个操作符上。 **我们以为的使用 `new` 操作符构造对象的流程:** 1. **分配一块内存 M** 2. **在内存上初始化 `Singleton` 对象** 3. **将 M 的地址 赋值给 `instance` 变量** **但是实际上被优化后的执行路径却是这样的:** 1. **分配一块n内存 M** 2. **将 M 的地址赋值给 `instance` 变量** 3. **在 内存 M 上初始化 `Singleton` 对象。** 这样将会导致: 线程A 先执行 `getInstance()` 方法,当执行完指令2时发生了线程切换,此时线程B 也执行了 `getInstance()` 方法,进入判断 `instance != null` 是 `true`,但是此时 这个对象并没有被成功构建出来,而如果对这个对象进行访问的话,就可能触发 「空指针异常」。 **【↑所以这里的B 存在的是可见性的问题,此时它看到的是一个未构建成功的 instance 实例】** **【这里说明的是「构造函数」 并不是一个原子操作,未构建完成的对象可能被逸出,导致某些错误的发生,这个是之前我看《Java 并发编程实战》时当时没太理解的一个点,再次看到这个专门看到这个解释我明白了。】** **下面是对于这个双重检查单例类的流程图解释:** ![](https://xuyanxin-blog-bucket.oss-cn-beijing.aliyuncs.com/blog/20200409163440.png) ### 总结 想写好并发程序,首先要知道并发程序的问题可能出现在哪里,是因为什么导致的。 只要我们可以深刻的理解 **可见性**、**原子性**、**有序性** 在并发场景下的原理,就可以对 Bug 进行比较准确的诊断。 这里作者特意提到了**缓存导致的可见性问题**,**线程切换带来的原子性问题**,**编译优化导致的有序性问题**。这些手段的目的都是为了提高性能,但是技术再解决一个问题的同事必然会带来新的问题。 **在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。** 专栏和书比起来,知识密度少了许多,所以阅读起来比较轻松, 再加上之前已经使用《jcip》 对并发的基础知识进行了一轮学习,所以对于专栏的学习更多的是加深理解,查漏补缺。 同时更多的实践。