在我看来,并发编程领域可以抽象成三个核心问题:分工、同步和互斥
Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。
在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法,但同时也能解决线程协作的问题。除此之外,Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。
当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行
在 Java 并发编程领域,解决协作问题的核心技术是管程(monitor)管程是解决并发问题的万能钥匙

分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题。解决线程安全问题的核心方案还是互斥。
所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?可以分场景优化,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。

除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。
并发编程全景图.png

要让自己的知识成体系,一定要挖掘 Java SDK 并发包背后的设计理念。Java SDK 并发包是并发大师 Doug Lea 设计的,他一定不是随意设计的,一定是深思熟虑的,其背后是 Doug Lea 对并发问题的深刻认识。

可见性、原子性和有序性问题:并发编程BUG的源头

CPU、内存、I/O 设备都在不断迭代,有一个核心矛盾一直存在,就是这三者的速度差异
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

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

    缓存导致的可见性问题

    多核CPU的缓存与内存关系图.png

    线程切换带来的原子性问题

    早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。非原子操作的执行路径示意图.png
    我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

    编译优化带来的有序性问题

    编译器为了优化性能,有时候会改变程序中语句的先后顺序。
    DCL 单例:
    只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的
    在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避

    Java 内存模型

    导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
    合理的方案应该是按需禁用缓存以及编译优化
    这些方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则。
    Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。

    Happens-Before规则

    Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

    程序的顺序性规则

    这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
    1. // 以下代码来源于【参考 1】
    2. class VolatileExample {
    3. int x = 0;
    4. volatile boolean v = false;
    5. public void writer() {
    6. x = 42;
    7. v = true;
    8. }
    9. public void reader() {
    10. if (v == true) {
    11. // 这里 x 会是多少呢?
    12. }
    13. }
    14. }
    x = 42 这一操作对后续操作是可见的。

    volatile 变量规则

    这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

    传递性

    这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
    happens-before传递性.png
    如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的,这个在后面的内容中会详细介绍。

    管程中锁的规则

    这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
    管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
    管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

    线程 start() 规则

    这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
    Thread B = new Thread(()->{
    // 主线程调用 B.start() 之前
    // 所有对共享变量的修改,此处皆可见
    // 此例中,var==77
    });
    // 此处对共享变量 var 修改
    var = 77;
    // 主线程启动子线程
    B.start();
    

    线程 join() 规则

    这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

Thread B = new Thread(()->{
  // 此处对共享变量 var 修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66

final

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
当然了,在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。

// 以下代码来源于【参考 1】
final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲 this 逸出,
  global.obj = this;
}

在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

线程中断规则

对线程 interrup() 方法的调用 Happens-Before 被中断线程的代码检测的中断事件的发生。

对象终结规则

一个对象的初始化完成 Happens-Before 它的 finalize() 方法的开始

解决原子性问题

原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。在早期单核 CPU 时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。

锁和锁要保护的资源是有对应关系的,改进后的锁模型.png
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。

Synchronized

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
当修饰非静态方法的时候,锁定的是当前实例对象 this。

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

但也许,你一不小心就忽视了 get() 方法。执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是 get() 方法也 synchronized 一下,完整的代码如下所示。

class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

锁和受保护资源的关系

受保护资源和锁之间的关联关系非常重要,受保护资源和锁之间的关联关系是 N:1 的关系

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

有两个锁保护一个资源(即变量 value),这两个锁分别是 thisSafeCalc.class 。由于是由于两个锁保护的,因此这两个临界区没有互斥关系,可见性也没有办法,这就导致并发问题。

如何用一把锁保护多个资源

当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。但是用一把锁有个问题,就是性能太差。
而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

保存有关联关系的多个资源

比如转账,如果用 this 这把锁保护自己的转账操作,但是不能保护接收人的转账记录。

使用锁的正确姿势

我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,在编程世界指只要我们我的锁覆盖所有受保护资源就可以了。比如可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。
还真有,就是用 Account.class 作为共享的锁

相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向 CPU 指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见

死锁

A、B 各自持有一把锁,A 向 B 转账,A 先加锁,再尝试对 B 加锁,如果成功,则进行转账操作。如果不成功则等待。但会造成死锁问题。因为此时如果 B 也对 A 发起转账,就相当于 A 和 B 都在等待对方的锁,造成死锁问题。
使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象
转账发生死锁时的资源分配图.png

如何避免死锁

要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:

  1. 共享资源存在互斥。共享资源 X 和 Y 只能被一个线程占用。
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X。
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源。
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  1. 对于 占用且等待 这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

    1. 破坏占用且等待条件

    可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,账本管理员只有当 A 锁和 B 锁同时存在时才会给张三。

    2. 破坏不可抢占条件

    破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
    Java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。

    3. 破坏循环等待条件

    需要对资源进行排序,然后按序申请资源。比如根据 ID 按从小到大进行排序。

用等待-通知机制优化循环等待

破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待。
其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题。
下面我们就来看看 Java 语言是如何支持等待 - 通知机制的。

完美的就医流程

就医流程基本上是这样:

  1. 患者先去挂号,然后到就诊门口分诊,等待叫号;
  2. 当叫到自己的号时,患者就可以找大夫就诊了;
  3. 就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
  4. 当患者做完检查后,拿检测报告重新分诊,等待叫号;
  5. 当大夫再次叫到自己的号时,患者再去找大夫就诊。

下面我们来对比看一下前面都忽视了哪些细节。

  1. 患者到就诊门口就诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了。
  2. 大夫让患者去做检查(缺乏检测报告不能诊断病因,诊断条件尚未满足),类似于线程要求的条件没有满足。
  3. 患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待 - 通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁
  4. 患者做完检查,类似于线程要求的条件已经满足;患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待 - 通知机制中也忽视了

所以加上这些至关重要的细节,综合一下,就可以得出一个完整的等待 - 通知机制:

  1. 线程首先获取互斥锁,并获取锁成功。
  2. 当线程要求的条件不满足时,释放互斥锁,进入等待状态。
  3. 当要求的条件满足时,通知等待的线程,重新获取互斥锁。

    用 synchronized实现等待-通知机制

    wait等待队列.png

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify() 和 notifyAll() 方法。我在下面这个图里为你大致描述了这个过程,当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过
为什么说是曾经满足过呢?因为notify() 只能保证在通知时间点(确切这一刻条件是满足的,在之后条件也可能会发生变化),条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。
除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。