深入理解 Java 虚拟机 - 高效并发

本系列文章的内容来源于周志明的《深入理解 Java 虚拟机》一书,经过了自己的加工整理和精简,主要是为了梳理知识。

12. Java 内存模型与线程


12.2 硬件的效率与一致性

计算机的 CPU 与 内存之间有几个数量级的速度差距,因此需要有一层高速缓存(Cache)来作为内存与 CPU 之间的缓冲。

但是这个高速缓存(Cache)会带来缓存一致性(Cache Coherence)的问题:多核处理器中,每个核心有各自的高速缓存,因此它们各自缓存的数据可能是不一样的,但它们又共享同一主内存,所以缓存和主内存之间的读写访问需要遵循一种协议,称为缓存一致性协议。

image

除了高速缓存外,还存在一种不一致性,来源于 CPU 可能会对指令进行乱序执行优化,它只保证执行的结果和顺序执行的结果是相同的,中间结果是不可预测的。

12.3 Java 内存模型(JMM)

C/C++ 等主流程序语言,直接用物理硬件和操作系统的内存模型,因此不同平台下的内存模型是不同的。而 JVM 试图定义一种 Java 内存模型,来屏蔽这种差异。

主内存和工作内存

Java 内存模型(JMM)和前面系统的内存模型类似:

image

JMM 的主要目标是定义程序中各个变量的访问机制,即变量存储到内存和从内存读取这样的底层细节。这里讨论的「变量」不包括线程私有的变量(即局部变量和方法参数,因为它们不会被共享),它包括了实例字段、静态字段和构成数组对象的元素。

所有的变量存储在主内存,每条线程有自己的工作内存,线程使用某变量时,先从主内存中拿一份拷贝放入工作内存,所有的读写操作都在工作内存中进行,而不直接读写主内存。

这里的主内存、工作内存和 Java 堆、栈、方法区不是一个层次的内存划分,基本没有关系。如果强行对应的话,主内存对应于 Java 堆中的对象实例数据部分;工作内存对应于 Java 栈中而部分区域。从更低层次上说,主内存对应于物理内存,而工作内存有可能会优先存储于寄存器和高速缓存中。

内存间交互操作

为了实现主内存和工作内存之间的读写,JMM 定义了八种原子性的操作:

  • lock:作用于主内存的变量
  • unlock:主内存
  • read:主内存,load 操作的前置操作,把变量的值从主内存传输到工作内存中
  • load:工作内存,read 操作的后置操作,把从主内存中得到的变量值放入工作内存的副本中
  • store:工作内存,write 操作的前置操作,把工作内存中的变量的值传送到主内存中
  • write:主内存,store 操作的后置操作,把从工作内存中得到的变量放入主内存变量中
  • use:工作内存,变量值从工作内存传输到执行引擎
  • assign:工作内存,变量值从执行引擎传输到工作内存

变量从主内存传输到工作内存,需要顺序执行 read 和 load;从工作内存传输到主内存,需要顺序执行 store 和 write。但是只需要顺序执行,不需要连续执行,中间可以插入其他指令。例如:read a、read b、load b、load a。

对于 volatile 型变量的特殊规则

volatile 是最轻量级的同步机制。JMM 对 volatile 专门定义了一些特殊的访问规则。

volatile 变量有两种特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

「可见性」是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

但是使用 volatile 不能保证线程安全。譬如 volatile 修饰的 race 变量,race++ 操作不是线程安全的,因为它不满足原子性。在不符合以下两条规则的场景中,我们还是需要通过加锁来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

「禁止指令重排序优化」这条语义必要的原因是,JMM 中有「线程内表现为串行的语义」,这个意思是执行引擎可以对指令进行重新排序执行,以提高性能,并且保证输出的结果和顺序执行的结果是相同的,因此是「表现为串行」,但是这个是限定在当前线程内的。如果另外一个线程去观测它的话,中间结果是不保证符合按顺序执行得到的结果的,所以指令重排序会对并行编程产生一定的麻烦和困扰,而 volatile 修饰之后会禁用这一个优化措施。

long 和 double 变量的特殊规则

JMM 要求前面提到的八个操作都有原子性,但是对于 64 位的 long 和 double,JMM 特别定义了一条宽松的规定:允许没有 volatile 修饰的 64 位数据的读写操作划分为两次 32 位,即所谓的非原子性协定。

但是目前商用虚拟机几乎都把对 64 位数据的操作实现为原子操作,所以我们实际不用担心这一点。

JMM 的特征

JMM 是围绕并发过程中如何处理原子性、可见性和有序性这三个特征来建立的:

  • 原子性:JMM 的八种操作都是原子性的,字节码中有 monitorenter 和 monitorexit 来实现大范围的原子性保证,Java 代码中则是 synchronized 关键字

  • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。普通变量和 volatile 变量都需要在修改后将新值从工作内存同步回主内存,在变量读取前从主内存刷新变量值回工作内存。但 volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。除了 volatile,还有两个关键词能实现可见性:synchronized 和 final。

  • 有序性:Java 的普通情况下的有序性是:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。前半句指「线程内表现为串行的语义」,后半句指「指令重排序」优化和「工作内存与主内存同步延迟」现象。

先行发生原则(happens-before)

JMM 中有一些「天然的」先行发生关系,这几个原则对并发编程很重要,总共有八个:

  • 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,前面的操作先行发生于后面的操作。这里要注意是在同一个线程内。
  • 管程锁定规则(Monitor Lock Rule):同一个锁的 unlock 操作先行发生于后面(时间上先后)的 lock 操作。
  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面(时间上先后)对这个变量的读操作。

剩下的五个规则有三个是针对 Thread 对象的,一个用于 finalize() 方法,还有一个是传递性(即 A 先行发生于 B,B 先行发生于 C,则可以推论 A 先行发生于 C)

如果不能套用任何先行发生原则,则两个线程之间各自的操作在时间上先后发生顺序是不可预见的。譬如最常见的 getter/setter 方法:

1
2
3
4
5
6
7
8
9
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}

这段代码是最常见的,bean 中都是这样写的,但是两个方法都不是线程安全的。如果线程 A 执行了 setValue(1),然后线程 B 执行了 getValue(),那么线程 B 得到的返回值是多少?

首先,我们看这两个方法能否符合任何先行发生原则:

  1. 程序次序规则:这两个方法在不同的线程中执行,因此不适用这个规则
  2. 管程锁定规则:这两个方法中都没有上锁,所以也不适用
  3. volatile 变量原则:没有 volatile 修饰,显然不适用
  4. 剩下的原则都显然不适用

所以这两个方法不符合任何先行发生原则,所以即使线程 A 的 setValue(1) 在时间上确实早于线程 B 的 getValue(),线程 B 的返回结果还是不确定的。

修复这个问题的思路就是让这两个方法符合其中一种先行发生原则,所以至少有两种简单的方案:

  1. 将 getter/setter 都定义为 synchronized,这样就符合第二条规则
  2. 将 value 定义为 volatile 变量,这样就符合第三条规则

实际上,在没有先行发生原则保证的情况下,「时间上的先发生」和「先行发生」没有任何推到关系,一个操作「时间上先发生」并不一定会「先行发生」,反过来一个操作「先行发生」并不能说明它「时间上先发生」。

在处理并发安全的问题的时候,一切必须以「先行发生原则」为准,时间先后顺序和先行发生原则之间基本没有太大的关系。

12.4 Java 与线程

并发不一定依赖于多线程,也有可能是多进程(譬如 PHP),但 Java 中一般都是线程。

线程的实现

线程是 CPU 调度的基本单位,一个进程可以有多个线程,各个线程可以共享进程资源(内存地址、文件 I/O 等)。实现线程主要有三种方式:

  1. 使用内核线程实现:程序调用的是内核线程的一种高级接口 - 轻量级进程。这种方式系统资源消耗较大,需要系统调用(系统调用需要在用户态和内核态之间来回切换),并且轻量级进程的数量是有限的。

  2. 使用用户线程实现:用户线程的建立、同步、销毁和调度都在用户态中完成,优势是不需要内核的帮助,资源消耗低,劣势也同样是没有内核支援,自己处理线程操作比较复杂。Java 曾经使用过,后来放弃了。

  3. 使用用户线程加轻量级进程混合实现:用户线程和轻量级进程的数量是 N : M 的关系

  4. Java 线程的实现:Java 在 Windows 和 Linux 下都是一对一的线程模型实现,一条 Java 线程就映射到一条轻量级进程之中。系统提供的线程模型也就是一对一的。

Java 线程调度

线程调度方式主要有两种:

  • 协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上。这种方式相当不稳定,如果一个进程坚持不让出 CPU 执行时间,就会导致系统崩溃
  • 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。Java 采用这种方式。

线程优先级一般不太靠谱,原因是 Java 的线程是映射到系统的原生线程上来的,线程的调度最终还是取决于操作系统。

状态转换

Java 总共定义了五种线程状态:

  • 新建(New):创建了但未启动

  • 运行(Runable):包括了操作系统线程状态中的 Running 和 Ready

  • 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒。以下方法会触发该状态:

    • 没有设置 Timeout 参数的 Object.wait()
    • 没有设置 Timeout 参数的 Thread.join()
    • LockSupport.park()
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配 CPU 执行时间,但系统在一定时间后会自动唤醒它。以下方法会触发该状态:

    • Thread.sleep()
    • 设置 Timeout 参数的 Object.wait()
    • 设置 Timeout 参数的 Thread.join()
    • LockSupport.parkNanos()
    • LockSupport.parkUntil()
  • 阻塞(Blocked):阻塞状态下是在等待着获取一个排他锁,在程序等待进入同步区域的时候,线程将进入这个状态

  • 结束(Terminated):线程已经结束执行

线程状态转换关系:

image

13. 线程安全与锁优化


13.2 线程安全

线程安全指的是:代码本身封装了所有必要的正确性保障手段(譬如互斥同步),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确使用。

讨论线程安全的前提是,多个线程之间存在共享数据访问。

可以将 Java 中共享数据按照「安全程度」分为:

  1. 不可变:不可变(Immutable)的对象一定是线程安全的。如果共享数据是基本数据类型,则只要用 final 来定义就可以保证不可变;如果共享数据是对象,除了 final 定义,还要保证对象的行为不会对其状态产生任何影响(譬如 String 类的 substring、replace、concat 方法都是返回一个新的 String 对象,而不会改变对象原来的状态),这种保证最简单的实现方式是把对象中所有带状态的变量都声明为 final

  2. 绝对线程安全:这种一般很难实现,即使一个对象所有的方法都是同步的,如果这些方法组合之后,就有可能失去原子性,不是线程安全的

  3. 相对线程安全:它只需要保证对这个对象单独的操作是线程安全的,但对于一些特定顺序的连续调用,还是需要额外的同步手段的。

  4. 线程兼容:这个指的是对象本身不是线程安全的,但我们可以通过使用同步手段或线程封闭,来实现线程安全

  5. 线程对立:这个指的是无论调用端采取何种同步措施,都是无法在并发环境下使用的。

线程安全的实现方法

互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。(如果使用信号量,可以是一些线程)

临界区(Critical)、互斥量(Mutex)和信号量(Semaphore)都是互斥的实现方式。互斥是方法,同步是目的。

Java 中,synchronized 是最基本的互斥同步手段,编译之后,在同步块前后会有 monitorenter 和 monitorexit 这两个字节码,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。在 Java 中有两种使用方式:

  1. synchronized (obj) {} 代码块,明确指名对象参数
  2. 用 synchronized 修饰实例方法或类方法,自动取方法所在的对象实例或 Class 对象作为锁对象

JVM 规范要求,执行 monitorenter 指令时,先尝试获取对象锁,如果没被锁定或当前线程已经持有锁(线程重入),则锁的计数器加 1;执行 monitorenter 指令时,锁的计数器减 1;当计数器为 0 时,锁就被释放了。

如果执行 monitorenter 指令时,获取对象锁失败,当前线程就会进入阻塞状态。由于 Java 的线程是映射到操作系统的原生线程上的,阻塞和唤醒一个线程,都需要操作系统的支援,这就需要从用户态转换到核心态中,这个状态转换很耗费 CPU 时间,如果同步块的代码简单,则执行状态转换的消耗比执行用户代码的消耗还大。

除了 synchronized,还有 ReentrantLock 可以实现同步,两者现在的性能相近,它主要有几个高级功能:

  1. 等待可中断,如果等待的线程长期获得不到锁,可以放弃改为处理其他事
  2. 实现公平锁,公平锁是指当多个线程等待同一个锁的时候,必须按照申请锁的时间顺序依次获得锁。synchronized 的锁是非公平的。但是公平性会把性能降低约两个数量级,不必要的话,不要为公平性付出代价。
  3. 锁可以绑定多个条件
非阻塞同步

互斥同步最大的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步(Blocking Synchronization)。

从策略上来讲,互斥同步属于「悲观」的并发策略,因为不论共享数据是否真的会发生竞争,都要上锁。对应的还有一种「乐观」的基于冲突检测的并发策略:不上锁,先进行操作,如果没有数据竞争,操作就成功了,如果检测到有冲突,就采取补偿措施(常见的就是不断重试)。这种乐观的并发策略一般不需要把线程挂起,所以称为非阻塞同步(Non-Blocking Synchronization)。

乐观策略实现需要硬件指令集的支援,因为我们需要操作和冲突检测这两步具备原子性,要把看起来多次操作的行为只通过一条指令就完成,典型的有 CAS 指令(Compare-and-Swap 比较并交换),简单得说就是在更新的时候,要先检查当前值是否符合预期,如果不符合就不更新。

无同步方案

保证线程安全,并不是一定需要同步,同步只是其中一个手段。如果一个方法不涉及共享数据,就天然是线程安全的。有两类代码天生就是线程安全的:

可重入代码(Reentrant Code):也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而执行其他代码,控制权返回后,原来的程序不会出现任何错误。可重入代码有共同特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数传入的、不调用非可重入方法。如果一个方法输入相同的数据,永远都能返回相同的结果,那它就是可重入的。

线程本地存储(Thread Local Storage):我的理解就是线程封闭。生产者 - 消费者模式的队列一般都会将产品的消费过程在一个线程中消费完。最重要的实例就是 Web 交互模型的「一个请求对应一个服务器线程(Threa-per-Request)」。Java 中由 java.Lang.ThreadLocal 类来实现线程本地存储功能,每一个线程的 Thread 对象都有一个 ThreadLocalMap 对象,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口。

13.3 锁优化

自旋锁

有的时候一个线程稍微多等一会,另外一个线程就会释放锁给它,这样可以省去线程切换的开销。因此,我们可以让线程执行一个忙循环(自旋),这就叫自旋锁技术。

自旋的次数可以参数设定,也可以用自适应的自旋锁,自适应这的是会根据运行状况自己调节自旋的次数。

锁消除

如果 JIT 编译器探测到一段需要同步的代码,实际不可能存在共享数据竞争,就可能将锁去掉,这种探测主要依赖于逃逸分析。

锁粗化

虽然推荐将同步块的作用范围尽量变小,这样执行同步块时间可以缩短,等待锁的时间减少。但是如果同步块出现在循环中,频繁的 lock 和 unlock 也会导致性能损耗。

轻量级锁和偏向锁

HotSpot 对象头(Object Header)中有一个 Mark Word,用于实现一些锁标记。

偏向锁类似轻量级锁,不同点是,这个锁会偏向于第一个获得它的线程,之后如果没有其他线程获取这个锁,则持有偏向锁的线程将永远不需要再进行同步。