Java 线程同步机制

本文摘抄自《Java 多线程编程实战指南》核心篇 第三章小结

本章介绍了 Java 平台提供的各种线程同步机制。


  Java 线程同步机制的幕后助手是内存屏障。不同同步机制的功能强弱不同,相应的开销以及可能导致的问题也不同,如下表所示。因此,我们需要根据实际情况选择一个功能适用且开销较小的同步机制。

Java 线程同步机制的功能与开销/问题

volatile CAS final static
原子性保障 具备 具备② 具备 不涉及 不涉及
可见性保障 具备 具备 不具备 不具备 具备③
有序性保障 具备 具备 不涉及 具备 具备④
上下文切换? 可能① 不会 不会 不会 可能⑤
备注 ①被争用的锁可能导致上下文切换 ②仅能够保障对 volatile 变量读/写操作本身的原子性 ③④仅在一个线程初次读取一个类的静态变量时起作用
⑤静态变量所属类的初始化可能导致上下文切换

  锁是 Java 平台中功能最强大的一种线程同步机制,同时其开销也最大,可能导致的问题也最多。被争用的锁会导致上下文切换,锁还可能导致死锁/锁死等线程活性故障。锁适用于存在多个线程对多个共享数据进行更新/check-then-act 操作或者 read-modify-write 操作这样的场景。

  锁的排他性以及 Java 虚拟机在临界区前后插入的内存屏障使得临界区中的操作具有原子性。由此,锁还保障了写线程在临界区中执行操作在读线程看来是有序的,即保障了有序性。Java 虚拟机在 MonitorExit 对应的机器码后插入的内存屏障则保障了可见性。锁能够保障线程安全的前提是访问同一组共享数据的多个线程必须同步在同一个锁之上,否则原子性/可见性和有序性均无法得以保障。在满足貌似串行语义的前提下,临界区内以及临界区外的操作可以在各自范围内重排序。临界区外的操作可能会被 JIT 编译器重排到临界区内,但是临界区内的操作不会被编译器/处理器重排到临界区之外。

  Java 中的所有锁都是可重入锁。内部锁(synchronized)仅支持非公平锁,因此它可能导致饥饿。而显式锁(ReentrantLock)既支持非公平锁又支持公平锁,显式锁可能导致锁泄露。内部锁和显式锁各有所长,各有所短。读写锁(ReadWriteLock)由于其内部实现的复杂性,仅适用于只读操作比更新操作要频繁得多且读线程持有锁的时间比较长的场景。读写锁(ReadWriteLock)中的读锁和写锁是一个锁实例所充当的两个角色,并不是两个独立的锁。

  线程转储中可以包含锁的相关信息——线程在等待哪些锁,这些锁又是被哪些线程持有的。

  volatile 相当于轻量级锁。在线程安全保障方面与锁相同的是,volatile 能够保障可见性/有序性;与锁不同的是 volatile 不具有排他性,也不会导致上下文切换。与锁类似,Java 虚拟机实现 volatile 对有序性和可见性的保障也是借助于内存屏障。从这个角度来看,volatile 变量写操作相当于释放锁,volatile 变量读操作相当于获得锁——Java 虚拟机通过在 volatile 变量写操作之前插入一个释放屏障,在 volatile 读操作之后插入一个获取屏障这种成对的释放屏障和获取屏障的使用实现了 volatile 对有序性的保障。类似地,Java 虚拟机在 volatile 变量写操作之后插入一个存储屏障,在 volatile 变量读操作之前插入一个加载屏障这种成对的存储屏障与加载屏障的使用实现了 volatile 对可见性的保障。

  在原子性方面,volatile 仅能够保障 long/double 型变量写操作的原子性。如果要保障对 volatile 变量的赋值操作的线程安全,那么赋值操作右边的表达式不能涉及任何共享变量(包括被赋值的变量本身)。volatile 关键字在可见性/有序性和原子性方面的保障并不会对其修饰的数组元素的读/写起作用。

  volatile 变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。读取一个 volatile 变量总是意味着(通过高速缓存进行的)读内存操作,而不是从寄存器中读取。因此,volatile 变量读操作的成本比读取普通变量要略高一些,但比在临界区中读取变量要低。

  volatile 的典型运用场景包括:一,使用 volatile 变量作为状态标志;二,使用 volatile 保障可见性;三,使用 volatile 变量替代锁;四,使用 volatile 实现简易版读写锁。

  CAS 使得我们可以在不借助锁的情况下保障 read-modify-write 操作/check-then-act 操作的原子性,但是它并不保障可见性。原子变量类相当于基于 CAS 实现的增强型 volatile 变量(保障 volatile 无法保障的那一部分操作的原子性)。常用的原子变量类包括 AtomicInteger/AtomicLong/AtomicBoolean 等。AtomicStampedReference 则可以用于规避 CAS 的 ABA 问题。

  static 关键字能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值)。对于引用型静态变量,static 还确保了该变量引用的对象已经初始化完毕。但是,static 的这种可见性和有序性保障仅在一个线程初次读取静态变量的时候起作用。

  final 关键字在多线程环境下也有其特殊作用:当一个对象被发布到其他线程的时候,该对象的所有 final 字段(实例变量)都是初始化完毕的。而非 final 字段没有这种保障,即这些线程读取该对象的非 final 字段时所读取到的值可能仍然是相应字段的默认值。对于引用型 final 字段,final 关键字还进一步确保该字段所引用的对象已经初始化完毕。

  实现对象的安全发布,通常可以依照以下顺序选择适用且开销最小的线程同步机制。

  • 使用 static 关键字修饰引用该对象的变量。
  • 使用 final 关键字修饰引用该对象的变量。
  • 使用 volatile 关键字修饰引用该对象的变量。
  • 使用 AtomicReference 来引用该对象。
  • 对访问该对象的代码进行加锁。

  为避免将 this 代表的当前对象逸出到其他线程,我们应该避免在构造器中启动工作者线程。通常我们可以定义一个 init 方法,在该方法中启动工作者线程。在此基础上,定义一个工厂方法来创建(并返回)相应的实例,并在该方法中调用该实例的 init 方法。

本章知识结构图

文章作者: DoubleFJ
文章链接: http://putop.top/2019/07/13/Multithreading-Chapter-Three/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 DoubleFJ の Blog