在并发编程中,需要处理两个关键问题:线程之间如何通信、线程之间如何同步。

通信是指线程之间消息的交换,在命令式编程中线程之间的通信机制主要有两种:

  • 共享内存: 线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
  • 消息传递: 通过发送消息来进行显式通信。

同步是指程序中用于控制不同线程间操作发生的相对顺序的机制:

  • 在共享内存的并发模型里,同步是显式进行的,程序员必须显式的指定某个方法或某段代码需要在线程之间互斥执行。
  • 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

在 Java 中,并发编程采用的是共享内存模型,线程之间的通信总是隐式进行的,整个通信过程对程序员完全透明。

Java内存模型与线程通信机制

在 JVM 中,堆内存是线程之间共享的,即共享变量是指堆内存中的实例域、静态域以及数组对象。而局部变量、方法定义参数和异常处理器参数不会在线程之间共享,也就不会存在并发与同步的问题了。

Java 虚拟机规范试图定义一种 Java 内存模型(Java Memory Model, JMM)来屏蔽掉各种硬件、处理器和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。这个模型需要在保证并发访问操作不会产生歧义的同时,尽量放松了对底层编译器和处理器的约束,让它们可以做尽可能多的优化来提高程序性能。

Java 内存模型(JMM)的抽象示意图如下:

jmm_abstract_diagram

可以看出,Java 线程之间的通信由 Java 内存模型控制,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中缓存了该线程已读 / 写共享变量的副本。如果线程 A 与线程 B 之间要通信的话,需要线程 A 把本地内存 A 中的共享变量刷新到主内存中,然后线程 B 从主内存中读取线程 A 之前更新过的共享变量即可。

从整体上来看,线程 A 向其它线程发送消息的通信过程必须要经过主内存,JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。

注: 这里的本地内存只是 JMM 中的一个抽象概念,并不是真实的物理存在,它涵盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化。)

内存可见性

内存可见性

一个线程对共享变量的修改,其它线程能够立刻看到,我们称之为可见性。

1.缓存带来的可见性问题

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

3.重排序带来的有序性问题

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序分为两种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 处理器优化的重排序:一方面,如果不存在数据依赖性,现代处理可以改变语句对应机器指令的执行顺序,采用「指令级并行技术」将多条指令重叠执行。另一方面,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作的实际执行顺序可能会有所变化。

这些重排序可能会导致多线程的并发编程中出现内存可见性问题,为此,JMM 作为语言级的内存模型,通过一定的策略禁止了特定情况下的编译器重排序和处理器重排序,确保在不同的编译器和处理器平台上为程序员提供一致的内存可见性保证

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为 3 种操作类型,如下表所示:

操作名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个变量
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量
读后写 a = b;b = 1; 读一个变量后,再写这个变量

上面这 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序操作指令时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。

注:这里所说的数据依赖性,仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial 语义

as-if-serial 语义的意思是:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

显然,为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作进行重排序,因为这会改变执行结果。但对于其它的众多操作,就很可能被编译器和处理器为了优化程序性能而进行重排序了。

as-if-serial 语义把单线程程序保护了起来,程序员无需担心单线程程序会受到重排序的干扰和内存可见性的问题,这为程序员创造了一个幻境:单线程程序是按程序的顺序来执行的

JMM 与 happens-before 规则

Java 专家们在设计 JMM 时有两个关键因素需要考虑:

  • 程序员对内存模型的使用:程序员希望内存模型易于理解、易于编程,程序员希望基于一个可靠的强内存模型来放心的编程。
  • 编译器和处理器对内存模型的实现:编译器和处理器希望对它们的约束越少越好,这样它们就可以做尽可能多的优化来提高性能,它们希望实现一个弱内存模型。

显然,这两个因素是互相矛盾的,所以需要找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。为此,从 JSR-133(Java Specification Requests,Java 规范提案,其中 JSR-133 从 JDK 5.0 开始实施)开始,JMM 提供了 happebs-before 规则来满足需求。

JSR-133 使用 happens-before 规则来指定两个可能跨线程的操作之间的执行顺序,向程序员提供跨线程的内存可见性保证。规范《JSR-133: Java Memory Model and Thread Specification》中对 happens-before 关系的定义如下:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happns-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序就是合法的。

即 JMM 遵循着一个基本原则:在单线程程序和正确同步的多线程程序中,只要不改变程序的执行结果,编译器和处理器怎么优化都可以。因为程序员对于指令操作是否真的被重排序并不关心,他们关心的是程序执行时的语义不能被改变。

例如以下计算圆面积代码的示例:

1
2
3
4
5
6
7
8
double pi = 3.14;  // A
double r = 2.0; // B
double area = pi * r * r; // C

// 根据 happens-before 规则,这 3 个操作有以下的 happens-before 关系:
1) A happens-before B.
2)B happens-before C.
3)A happens-before C.

这里有 A happens-before B,但实际执行时 JMM 并不要求 A 一定要在 B 之前执行,操作 B 可以先执行。因为 A 和 B 操作的重排序对最后的执行结果并没有影响,与 happens-before 规则要求的执行结果一致。

JSR-133 中定义了如下 happens-before 规则:

  1. 程序顺序性规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  3. volatile 变量规则:对一个 volatile 变量的写操作,happens-before 于后续对这个 volatile 变量的读操作。
  4. 传递性规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. 线程 start() 规则:如果线程 A 执行操作 threadB.start() 来启动线程 B,那么 A 线程中的 threadB.start() 操作 happens-before 于线程 B 中的任意操作。
  6. 线程 join() 规则:如果线程 A 执行操作 threadB.join() 来等待线程 B 终止并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 threadB.join() 成功返回的操作。

可以看出,与 as-if-serial 语义相比,happens-before 规则保证了正确同步的多线程程序的执行结果不会被改变,为程序员创造了又一个幻境:正确同步的多线程程序是按照 happens-before 规则指定的顺序来执行的

顺序一致性

当多线程程序未正确同步时,就可能会存在数据竞争。Java 内存模型对数据竞争(Data Race)的定义为:「在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读操作没有通过同步来排序」。

当代码中包含数据竞争时,这些代码的实际执行顺序会不确定,程序的执行也往往会产生预期之外的结果。如果一个多线程程序能正确同步,我们才能避免数据竞争的问题,保证程序的顺序一致性。

在 JMM 中,如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性的内存模型中的执行结果相同。这里的「同步」是指广义上的同步,包括常用同步原语 synchronized、volatile、final 的正确使用。

理想化的顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证,这种模型有两大特性:

  1. 一个线程中的所有操作必须按照程序的程序的顺序来执行。
  2. 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如下:

在概念上,顺序一致性内存模型有一个单一的全局内存,在任意时间点最多只能有一个线程可以连接到内存,而且每一个线程必须按照程序的顺序来执行读/写操作。当多个线程并发执行时,这个模型会把所有线程的所有内存读/写操作串行化。

正确同步的多线程程序在顺序一致性模型中执行时,每个线程中的操作被按照程序顺序串行起来执行,每个线程看到的都是一个相同的整体有序的执行顺序。对于未同步的多线程程序,虽然线程之间的操作可能会交叉执行,整体上的执行顺序被打乱了,但每个线程看到的仍然是一个相同的执行顺序。

JMM 中的顺序一致性

上面的顺序一致性内存模型只是一个理想化的理论参考模型,Java 内存模型中顺序一致性的具体实现与之有所不同。

对与正确同步的多线程程序,根据 JMM 规范,仅保证该程序的执行结果与该程序在顺序一致性模型中的执行结果相同。而临界区内的代码是可以重排序的,并且其它线程无法看到一个线程在临界区内的重排序。这种重排序既可以提高程序的性能,又不会改变程序的执行结果。

对于未同步或未正确同步的多线程程序,JMM 不保证该程序的执行结果与该程序在顺序一致性模型中的执行结果一致,而只提供「最小安全性」:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),不会无中生有的冒出来,这种最小安全性的实现与 JVM 中对象的初始化过程密切相关。因为如果要保证执行结果的一致:

  • 一方面,JMM 需要禁止大量的处理器和编译器优化,这对程序的执行性能会产生很大的影响。
  • 另一方面,未同步程序在顺序一致性内存模型中执行时,整体是无序的,其执行结果本就无法预知,保证执行结果的一致性就没什么意义。

除执行结果之外,顺序性一致性模型中保证对所有的内存读/写操作都具有原子性,而在未同步程序中,JMM 不保证对 64 位的 long 和 double 类型变量的写操作具有原子性。这与处理器总线的工作机制密切相关。

在计算机中,数据通过总线在处理器和内存之间传递,每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称为总线事务(Bus Transaction)。每个事务会读/写内存中一个或多个物理上连续的字,总线会采用同步机制将所有处理器对内存访问的事务以串行化的方式来执行,任意时间点只能有一个处理器可以占用总线访问内存,这个特性确保了单个总线事务之中的内存读/写操作具有原子性

而在一些 32 位的处理器上,如果要求对 64 数据的写操作具有原子性就会有比较大的开销,于是就可能会把一个 64 位 long/double 型变量的写操作拆分成两个 32 位的写操作来执行,这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写操作就不具有原子性了,那么在这两个写事务之间就可能会有其它处理器的读事务读到仅「写了一半」的无效值。

值得注意的是,从 JSR-133 内存模型开始(即从 JDK 5 开始),仅仅允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行;而任意的读操作都必须具有原子性,即任意读操作必须要在单个读事务中执行。(怎么实现的?)

volatile 关键字

volatile 的特性与内存语义

当声明共享变量为 volatile 后, 这个 volatile 变量自身便具有以下两个特性

  • 可见性:对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量最后的写入。
  • 原子性:对任意单个 volatile 变量的读/写操作具有原子性(尤其是 64 位的 long/double 型变量,但类似于 volatile++ 这种复合操作不具有原子性)。

这两个特性对应的 volatile 关键字的内存语义如下:

  • 写操作的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
  • 读操作内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,接下来该线程将从主内存中读取共享变量。

可以看出,由于线程本地内存的刷新,当读线程 B 读一个 volatile 时,写线程 A 在写这个 volatile 变量之前的、所有可见的共享变量的值,都将立即变得对读线程 B 可见。

在 JMM 中,通过内存屏障指令来限制内存操作的顺序(有序性),从而刷新缓存、限制指令重排序,以实现 volatile 关键字的内存语义。

内存屏障与 volatile 内存语义的实现

当声明变量为 volatile 时,观察汇编代码会发现多出一个 lock 前缀指令。lock 前缀指令其实就相当于一个内存屏障,内存屏障是一组处理指令,用来实现对内存操作的顺序限制,volatile 的底层就是通过内存屏障来实现的。

为了实现 volatile 的内存语义,Java 编译器在生成字节码时,会在指令序列的适当位置插入内存屏障来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为 4 类,如下表所示:

屏障指令类型 指令示例 说明

LoadLoad Barriers

Load1; LoadLoad; Load2 确保 Load1 数据的装载先于 Load2 及所有后续装在指令的装载。

StoreStore Barriers

Store1; StoreStore; Store2

LoadStore Barriers

Load1; LoadStore; Store2

StoreLoad Barriers

Store1; StoreLoad; Load2

Synchronized 锁