并发编程笔记(三):Java 内存模型(一)

时间:2022-03-15 15:31:08

并发系列的文章都是根据阅读《Java 并发编程的艺术》这本书总结而来,想更深入学习的同学可以自行购买此书进行学习。


Java 线程之间的通信对程序员完全透明,内存可见性问题很容易困扰 Java 程序员。让我们来通过下文来揭开 Java 内存模型神秘的面纱。

一 Java 内存模型基础

1. 并发编程模型的两个关键问题

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

在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

共享内存的并发模型中,线程之间共享公共状态,通过写 - 读内存中的公共状态进行隐式通信。在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来显式通信

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。共享内存模型中,同步是显式进行的,例如使用 synchronized 这种显式的方法来制定线程之间的互斥操作。消息传递模型中,消息的发送必须在消息的接收之前,所以同步是隐式进行的。

Java 的并发采用的是共享内存模型。Java 线程之间通信都是隐式进行的,整个通信过程对程序员完全透明。所以如果 Java 程序员对这种隐式通信工作机制理解不深入的话,就会遇到很多内存可见性问题。

2. Java 内存模型的抽象结构

Java 中,实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享 。局部变量,方法定义参数和异常处理参数不会在线程之间共享,它们不会有内存可见性问题,不受内存模型影响。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中。每个线程都有一个私有的本地内存,本地内存是一个抽象概念,实际上不存在。它存储了该线程以读/写共享变量的副本。

线程 A 和线程 B 之间如果要通信,必须经历下面两个步骤:

  1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 线程 B 到主
  3. 内存中去读取线程 A 之前已更新过的共享变量。

从整体上来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。

3. 从源代码到指令序列的重排序

执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型:

  1. 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 变压器你在生成指令序列时,插入特定类型的内存屏障指令来禁止特定的处理器重排序。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定的编译器重排序和处理器重排序,* 为程序员提供一致的内存可见性保证*

4. 并发编程模型的分类

现代处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,避免由于处理器停顿等待向内存写入数据而产生的延迟。每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生影响,即处理器对内存的读/写操作执行顺序,不一定与内存实际发生的顺序一致。处理器都会允许对写 - 读操作进行重排序。

常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。

为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器排序。JMM 把内存屏障指令分为 4 类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保 Load1 的读取先于 Load2 及所有后续的数据读取
StoreStore Barriers Store1;StoreStore;Store2 确保 Store1 数据对其他处理器可见(刷新到主存)先于 Store2 及后续数据的存储
LoadStore Barriers Load1;LoadStore;Store2 确保 Load1 数据读取先于 Store2 及后续数据的存储
StoreLoad Barriers Store1;StoreLoad;Load2 确保 Store1 数据对其他处理器可见先于 Load2 及后续数据的读取。StoreLoad Barriers 会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers是一个「全能型」的屏障,它同时具有其他 3 个屏障的效果。现代多处理器大多支持该屏障。执行该屏障的开销很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

5. happens-before 简介

从 JDK 5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以在一个线程之内,也可以是在不同线程之间。

这里列出几个和程序员密切相关的 happens-before 规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对于一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

两个操作具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作的结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

二 重排序

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

1. 数据依赖性

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

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

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

2. as-if-serial 语义

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

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

3. 程序顺序规则

JMM 仅仅要求前一个操作的结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前。如果重排序操作 A 和操作 B 后的执行结果与操作 A 和操作 B 按 happens-before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法,JMM 允许这种重排序。

在计算机中,软硬件技术有一个共同目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从 happens-before 定义我们可以看出,JMM 同样遵从这一目标。

4. 重排序对多线程的影响

我们来看一段代码,看一下重排序对多线程程序执行结果的影响:


class ReorderExample{
int a = 0;
boolean flag = false;

public void writer(){
a = 1; //1
flag = true; //2
}

public void reader(){
if(flag){ //3
int i = a * a; //4
...
}
}
}

flag 变量是个标记,用来标识变量 a 是否已被写入。假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?答案是不一定看到

举个例子。由于操作 1 和操作 2 没有数据依赖关系,所以这两个操作可能会被重排序。同样,操作 3 和操作 4 也会被重排序。所以会产生操作 2 和操作 3 在 1 和 4 中间被执行。这时候 4 先被执行,但变量 a 还没有被线程 A 写入,多线程程序的语义就被重排序破坏了。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

三 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参考。

1. 数据竞争与顺序一致性

当程序未正确同步时,就可能会出现数据竞争,数据竞争的定义如下:

在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。

JMM 对正确同步的多线程程序的内存一致性做了以下保证:如果程序是正确同步的,程序的执行将具有顺序一致性—即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括常用同步原语(synchronized、volatile 和 final)的正确使用。

2. 顺序一致性内存模型

顺序一致性内存模型是一个理想化的理论参考模型,它为程序员提供了极强的内存可见性保证。具有两大特性;

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

未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也是不一致的。

3. 同步程序的顺序一致性效果。

我们来把之前的 ReorderExample 用锁来同步,看一下正确同步的程序如何具有顺序一致性:

class SynchronizedExample{
int a = 0;
boolean flag = false;

public synchronized void writer(){ //获取锁
a = 1;
flag = true;
} //释放锁

public synchronized void reader(){ //获取锁
if(flag){
int i = a;
...
}
} //释放锁
}

在顺序一致性模型中,所有操作完全按程序的顺序执行。而在 JMM 中,临界区内的代码可以重排序,但 JMM 不允许临界区内的代码「溢出」到临界区之外,那样会破坏监视器的语义。JMM 在退出和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性相同的内存视图。即使线程在临界区内做了重排序,但由于监视器互斥性的特性,其他线程根本无法观察到这个重排序。这种重排序既提高了效率,又没有改变程序的执行结果。

从这里可以看出,JMM 在具体实现上的方针为:在不改变(正确同步)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门

4. 未同步程序的执行特性

对于未同步或者未正确同步的多线程程序,JMM 只提供最小安全性。线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有的冒出来。

为了实现最小安全性,JVM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象,JVM 内部会同步这两个操作。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器优化,这对程序的执行性能将会产生很大影响。而且未同步程序在顺序一致性模型中的结果是无法预知的。而且即使保证未同步程序结果的一致性也没有任何意义。

JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,二顺序一致性模型保证对所有的内存读/写操作都具有原子性。

这个差异与处理器总线的工作机制相关。计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。总线事物包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。

这里关键的是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读/写。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存,这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

当 JVM 在一些 32 位处理器上运行时,可能会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写操作将不具有原子性。

那么此时当处理器 A 在写 64 位变量的时候,将 64 位写操作拆分成两个 32 位的写操作,同时另一个处理器 B 中的 64 位读操作被分配到单个读事务中执行。如果这个事务在那两个 32 位写事务之间执行,那么处理器 B 将看到仅仅被处理器「写了一半」的无效值。

JSR-133 之前,一个 64 位读/写操作可以被拆分成两个 32 位的读/写操作来执行。从 JSR-133 开始( JDK 5),只有 64 位写操作可以被拆分成两个 32 位写操作,任意的读操作都必须具有原子性,即任意读操作必须在单个读事务中执行。

volatile 的内存语义

当声明一个共享变量为 volatile 后,这个变量的读/写会很特别。

1. volatile 的特性

理解 volatile 特性的一个好方法是把 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写做了同步。

锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性。这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

锁的语义决定了临界区代码的执行具有原子性。所以,即使是 64 位的 long 型和 double 型变量,只要它是 volatile 变量,那么对这个变量的读/写操作就具有原子性。如果是多个 volatile 操作或者类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile 变量自身具有以下特性:

  • 可见性。对一个 volatile 变量的读,总是能看到任意线程对这个变量的写。
  • 原子性。 对任意单个 volatile 变量的读/写操作具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
2. volatile 写-读建立的 happens-before 关系

上面说的都是 volatile 变量自身的特性,对于程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们关注。

从 JSR-133 开始,volatile 变量的读-写可以实现线程间通信。从内存语义上来说,volatile 的写-读与锁的释放 - 获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。

下面用 volatile 变量的示例代码:

class VolatileExample{
int a = 0;
volatile boolean flag = false;

public void writer(){
a = 1; //1
flag = true; //2
}

public void reader(){
if (flag){ //3
int i = a; //4
}
}
}

假设线程 A 执行 writer() 方法后,线程 B 执行 reader() 方法,根据 happens-before 原则,这个过程建立的 happens-before 关系可以分为 3 类:

  1. 根据程序次序规则:1 happens-before 2;3 happens-before 4。
  2. 根据 volatile 规则,2 happens-before 3。
  3. 根据 happens-before 传递性规则,1 happens-before 4。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

3. volatile 写-读的内存语义

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

对 volatile 写/读的内存语义做个总结:

  • 当写一个 volatile 变量时,实质上是线程 A 向接下来要读这个 volatile 变量的某个线程发出了其对共享变量所做修改的消息。
  • 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的在 写这个 volatile 变量之前对共享变量所作修改的消息。
  • 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
4. volatile 内存语义的实现

volatile 重排序的规则如下:

  • 当第二个操作时 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作时 volatile 读的时候,不管第二个操作时什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被重排序到 volatile 读之前。
  • 当第一个操作时 volatile 写,第二个操作时 volatile 读时,不能重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此 JMM 采取保守策略,下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

上述的内存屏障插入策略非常保守,它可以保证在任意处理器平台和程序中都能得到正确的 volatile 内存语义。

这些通过添加内存屏障的优化针对任意处理器平台,由于不同的处理器有不同的「松紧度」的处理器内存模型,所以插入还可以根据具体的处理器内存模型进行继续优化。例如,X86 处理器就会省略除了最后末尾 StoreLoad 屏障以外的其他所有屏障。

5. JSR-133 为什么要增强 volatile 的内存语义

旧的内存模型中,虽然不允许 volati 变量之间的重排序,但旧的内存模型允许 volatile 变量和普通变量重排序。JSR-133 内存模型中,只要 volatile 变量与普通变量之间的重排序会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性。所以功能上,锁比 volatile 强大;在可伸缩性和执行性能上,volatile 更有优势。如果程序员在程序中用 volatile 代替锁,一定要谨慎。