前言
CPU , 内存, I/O 设备之间的速度差距十分大, 为了提高 CPU 的利用率并且平衡它们的速度差异. 计算机体系结构, 操作系统和编译程序都做出了改进:
CPU 增加了缓存, 用于平衡和内存之间的速度差异.
操作系统增加了进程, 线程, 以时分复用 CPU, 进而均衡 CPU 与 I/O 设备之间的速度差异.
编译程序优化指令执行次序, 使得缓存能够得到更加合理地利用.
但是, 每一种解决问题的技术出现都不可避免地带来一些其他问题. 下面这三个问题也是常见并发程序出现诡异问题的根源.
缓存 -- 可见性问题
线程切换 -- 原子性问题
编译优化 -- 有序性问题
CPU 缓存导致的可见性问题
可见性指一个线程对共享变量的修改, 另外一个线程可以立刻看见修改后的结果. 缓存导致的可见性问题即指一个线程对共享变量的修改, 另外一个线程不能看见.
单核时代: 所有线程都是在一颗 CPU 上运行, CPU 缓存与内存数据一致性很容易解决.
多核时代: 每颗 CPU 都有自己的缓存, CPU 缓存与内存数据一致性不易被解决.
例如代码:
- public class Test {
- private long count = 0;
- private void add10K() {
- int idx = 0;
- while(idx++ <10000) {
- count += 1;
- }
- }
- public static long calc() {
- final Test test = new Test();
- // 创建两个线程, 执行 add() 操作
- Thread th1 = new Thread(()->{
- test.add10K();
- });
- Thread th2 = new Thread(()->{
- test.add10K();
- });
- // 启动两个线程
- th1.start();
- th2.start();
- // 等待两个线程执行结束
- th1.join();
- th2.join();
- return count;
- }
- }
最后执行的结果肯定不是 20000,cal() 结果应该为 10000 到 20000 之间的一个随机数, 因为一个线程改变了 count 的值, 有缓存的原因所以另外一个线程不一定知道, 于是就会使用旧值. 这就是缓存导致的可见性问题.
线程切换带来的原子性问题
原子性指一个或多个操作在 CPU 执行的过程中不被中断的特性.
UNIX 因支持时分复用而名噪天下, 早期操作系统基于进程来调度 CPU, 不同进程之间是不共享内存空间的, 所以进程要做任务切换就需要切换内存映射地址, 但是这样代价高昂. 而一个进程创建的所有线程都是在一个共享内存空间中, 所以, 使用线程做任务切换的代价会比较低. 现在的 OS 都是线程调度,"任务切换"--"线程切换".
Java 的并发编程是基于多线程的. 任务切换大多数是在时间片结束时.
时间片: 操作系统将对 CPU 的使用权期限划分为一小段一小段时间, 这个小段时间就是时间片. 线程耗费完所分配的时间片后, 就会进行任务切换.
高级语言的一句代码等价于多条 CPU 指令, 而 OS 做任务切换可以发生在任何一条 CPU 指令执行完后, 所以, 一个连续的操作可能会因任务切换而被中断, 即产生原子性问题.
例如: count+=1, 至少需要三条指令:
将变量 count 从内存加载到 CPU 寄存器;
在寄存器中执行 + 1 操作;
将结果写入内存 (缓存机制导致写入的是 CPU 缓存而非内存)
例如:
竞态条件
由于不恰当的执行时序而导致的不正确的结果, 是一种非常严重的情况, 我们称之为竞态条件 (Race Condition).
当某个计算的正确性取决于多个线程的交替执行时序时, 那么就可能会发生竞态条件. 最常见的会出现竞态条件的情况便是 "先检查后执行 (Check-Then-Act)" 操作, 即通过一个可能失效的观测结果来决定下一步的动作.
例子: 延迟初始化中的竞态条件.
使用 "先检查后执行" 的一种常见情况就是延迟初始化. 延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行, 同时要确保只被初始化一次.
- public class LazyInitRace{
- private ExpensiveObject instance = null;
- public ExpensiveObject getInstance(){
- if(instance == null){
- instance = new ExpensiveObject();
- }
- return instance;
- }
- }
以上代码便展示了延迟初始化的情况. getInstance() 方法首先判断 ExpensiveObject 是否已经被初始化, 如果已经初始化则返回现有的实例, 否则, 它将创建一个新的实例, 并返回一个引用, 从而在后来的调用中就无须再执行这段高开销的代码路径.
getInstance() 方法中包含了一个竞态条件, 这将会破坏类的正确性, 即得到错误的结果.
假设线程 A 和线程 B 同时执行 getInstace() 方法, 线程 A 检查到此时 instance 为空, 因此要创建一个 ExpensiveObject 的实例. 线程 B 也会判断 instance 是否为空, 而此时 instance 是否为空则取决于不可预测的时序, 包括线程的调度方式, 以及线程 A 需要花费多长时间来初始化 ExpensiveObject 实例并设置 instance. 如果线程 B 检查到 instance 为空, 那么两次调用 getInstance() 时可能会得到不同的结果, 即使 getInstance 通常被认为是返回相同的实例.
竞态条件并不总是产生错误, 还需要某种不恰当的执行时序. 然而, 竞态条件也可能会导致严重的问题. 假设 LazyInitRace 被用于初始化应用程序范围内的注册表, 如果在多次调用中返回不同的实例, 那么要么会丢掉部分注册信息, 要么多个行为对同一组对象表现出不一致的视图.
要避免竞态条件问题, 就必须在某个线程修改该变量时, 通过某种方式防止其他线程使用这个变量, 从而确保其他线程只能在修改操作完成之前或者之后读取和修改状态, 而不是在修改状态的过程中.
编译优化带来的有序性问题
有序性是指程序按照代码的先后顺序执行. 编译器以及解释器的优化, 可能让代码产生意想不到的结果.
以 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 加锁, 但是 Java 虚拟机保证只有一个线程可以加锁成功 (假设为线程 A), 而另一个线程就会被阻塞处于等待状态 (假设是线程 B).
线程 A 会创建一个 Singleton 实例, 然后释放锁, 锁释放后, 线程 B 被唤醒, 线程 B 再次尝试对 Singleton.class 加锁, 此时可以加锁成功, 然后检查 instance==null 时, 发现对象已经被创建, 于是线程 B 不会再创建 Singleton 实例.
但是, 优化后 new 操作的指令, 将会与我们理解的不一样:
我们的理解:
分配一块内存 M;
在内存 M 上初始化 Singleton 对象;
然后将内存 M 的地址赋值给 instance 变量.
但是优化后的执行路径却是这样:
分配一块内存 M;
将内存 M 的地址赋值给 instance 变量;
在内存 M 上初始化 Singleton 对象.
优化后将造成如下问题:
在如上的异常执行路径中, 线程 B 执行第一个判断 if(instance==null) 时, 会认为 instance!=null, 于是直接返回了 instance. 但是此时的 instance 是没有进行初始化的, 这将导致空指针异常.
注意, 线程执行 synchronized 同步块时, 也可能被 OS 剥夺 CPU 的使用权, 但是其他线程依旧是拿不到锁的.
解决如上问题的一个方案就是使用 volatile 关键字修饰共享变量 instance.
- public class Singleton {
- volatile static Singleton instance; // 加上 volatile 关键字修饰
- static Singleton getInstance(){
- if (instance == null) {
- synchronized(Singleton.class) {
- if (instance == null)
- instance = new Singleton();
- }
- }
- return instance;
- }
- }
目前可以简单地将 volatile 关键字的作用理解为:
禁用重排序;
保证程序的可见性 (一个线程修改共享变量后, 会立刻刷新内存中的共享变量值).
小结
本篇博客介绍了导致并发编程 bug 出现的三个因素: 可见性, 有序性和原子性. 本文仅限于引出这三个因素, 后面将继续写文介绍如何来解决这些因素导致的问题. 如有不足, 还望各位看官指出, 万分感谢.
参考:
[1] 极客时间专栏王宝令《Java 并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java 并发编程实战 [M]. 北京: 机械工业出版社, 2016
来源: https://www.cnblogs.com/myworld7/p/12203093.html