Java 多线程编程之三 volatile 与 JMM 内存模型

大纲

JMM 内存模型介绍

JMM 概述

JMM 是 Java 内存模型 (Java Memory Model),本身是一种抽象的概念,实际上并不存在。它描述的是一组规则或规范,通过这组规范定义了程序中各个变量 (包括实例字段,静态字段和构成数组对象的元素) 的访问方式。

  • JMM 中关于同步的规定
    • 线程解锁前,必须把共享变量的值刷新回主内存
    • 线程加锁前,必须读取主内存的最新值,并存储在自己的工作内存
    • 加锁和解锁必须是同一把锁

由于 JVM 运行程序的实体是线程,而每个线程在创建时 JVM 都会为其创建一个工作内存 (有些地方称为栈空间),工作内存是每个线程的私有数据区域,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作 (读取、赋值等) 必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信 (传值) 必须通过主内存来完成。线程、工作内存、主内存工作交互图 (基于 JMM 规范),如下:

JMM 内存模型与 JVM 内存模型是两个完全不同的概念。JVM 内存模型是处于 Java 的 JVM 虚拟机层面的,实际上对于操作系统来说,本质上 JVM 还是存在于主内存中;而 JMM 是 Java 语言与 OS 和硬件架构层面的,主要作用是规定硬件架构与 Java 语言的内存模型,而本质上不存在 JMM 这个东西,JMM 只是一种规范,并不能说是某些技术的实现。进一步的讲,JMM 与 JVM 内存模型是不同的概念层次,在理解 JMM 的时候不要带着 JVM 内存模型去理解,更恰当说 JMM 描述的是一组规则,通过这组规则控制 Java 程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM 是围绕原子性、有序性、可见性拓展延伸的。JMM 与 JVM 内存模型唯一相似点,都存在共享数据区域和私有数据区域,在 JMM 中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,可能会看见主内存被描述为堆内存,工作内存被称为栈空间,实际上它们表达的都是同一个含义。

JMM 内存模型的可见性

JMM 内存模型的可见性指的是当主内存区域中的变量值被某个线程写入更改后,其它线程会马上知晓更改后的变量值,并重新得到更改后的变量值。

主内存概述

主内存主要存储的是 Java 实例对象,所有线程创建的实例对象都存放在主内存中 (除了开启了逃逸分析和标量替换的栈上分配和 TLAB 分配),不管该实例对象是成员变量还是方法中的本地变量 (也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多个线程对同一个变量进行非原子性操作时,可能会存在线程安全问题。

工作内存概述

工作内存主要存储当前方法的所有本地变量信息 (工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关 Native 方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问彼此的工作内存,线程之间的通讯还是需要依赖于主内存,因此存储在工作内存的数据不存在线程安全问题。

工作内存与主内存交互

这里简单介绍一下主内存与工作内存的数据存储类型以及操作方式。根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量 (也称局部变量) 是基本数据类型 (boolean,byte,char,short,int,long,float,double),将直接存储在工作内存的帧栈结构中的局部变量表,但倘若本地变量是引用类型,那么该对象的在内存中的具体引用地址将会被存储在工作内存的帧栈结构中的局部变量表,而对象实例将存储在主内存 (共享数据区域,堆) 中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型 (Integer、Double 等) 还是引用类型,都会被存储到堆区 (栈上分配与 TLAB 分配除外)。至于 static 变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个类的同一个方法,那么两个线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新回主内存,简单示意图如下所示:

1
2
3
4
5
Integer num = new Integer(100);

public void add() {
num++;
}

1
2
3
4
public void add() {
Integer num = new Integer(100);
num++;
}

JMM 与硬件内存架构

计算机硬件内存架构

上图是经过简化 CPU 与内存操作的简易图,实际上没有这么简单,这里为了方便理解,省去了南北桥。就目前计算机而言,一般拥有多个 CPU 并且每个 CPU 都可能存在多个核心,多核是指在一枚处理器 (CPU) 中集成两个或多个完整的计算引擎 (内核), 这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个 CPU 核心中并行运行。在 CPU 内部有一组 CPU 寄存器,寄存器存储的是 CPU 可以直接访问和处理的数据,是一个临时放数据的空间。一般 CPU 都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于 CPU,导致 CPU 在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了 CPU 缓存,CPU 缓存比较小,但访问速度比主内存快得多。如果 CPU 总是操作主内存中的同一地址的数据,很容易影响 CPU 执行速度,此时 CPU 缓存就可以把从内存读取到的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,就可以直接从 CPU 缓存中提取,无需从主内存取。需要注意的是,寄存器并不是每次都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不是每次都可以从缓存取数据,这种现象有个专业的名称叫做 “缓存命中率”,可以从缓存中取就命中,不可以从缓存中取而从内存中取,就没命中,可见缓存命中率的高低也会影响 CPU 执行性能,这就是 CPU、缓存以及主内存间的简要交互过程。总而言之,当一个 CPU 需要访问主内存时,会先读取一部分主内存数据到 CPU 缓存 (当然,如果 CPU 缓存中存在需要的数据就会直接从缓存获取),进而再读取 CPU 缓存的数据到寄存器,当 CPU 需要写数据到主内存时,同样会先刷新寄存器中的数据到 CPU 缓存,然后再将数据刷新到主内存中。

提示

上面介绍的 CPU 寄存器 --> CPU 缓存 --> 主内存的关系,实则就类似于 Appcalition (Java) --> Cache (Redis) --> DB (MySQL) 的关系,Java 程序的性能由于 DB 需要走磁盘受到了影响,导致 Java 程序在处理请求时需要等到 DB 的处理结果,而此时负责处理该请求的线程一直处于阻塞等待状态,只有当 DB 处理结果返回了才能继续工作,那么实际上整个模型中的问题是:DB 的速度跟不上 Java 程序的性能,导致整个请求处理起来变的很慢,但是实际上在 DB 处理的过程 Java 的线程是处于阻塞不工作的状态的,那么实际上是没有必要的,因为这样最终会导致整体系统的吞吐量下降,此时可以加入 Cache (Redis) 来提升程序响应效率,从而整体提升系统吞吐和性能。实际上做性能优化的目的就是让系统的每个层面处理的速度加快,而架构实际上就是设计一套能够吞吐更大量的请求的系统。

Java 线程与 OS 映射关系

在以上的阐述中,大致介绍了 Java 内存模型和硬件的内存架构之后,接着介绍 Java 中线程的实现原理,理解线程的实现原理,有助于了解 Java 内存模型与硬件内存架构的关系。在 Windows OS 和 Linux OS 上,Java 线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面的程序去间接调用系统内核的线程模型,即在使用 Java 线程时,比如 new Thread(Runnable),JVM 内部是转而调用当前操作系统的内核线程来完成当前 Runnable 任务。这里需要了解一个术语,内核线程 (Kernel-Level Thread,KLT),它是由操作系统内核 (Kernel) 支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程 (Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间 1 对 1 的关系就称为 Java 程序中的线程与 OS 的一对一模型。如下图所示:

Java 程序中的每个线程都会经过 OS 被映射到 CPU 中进行处理,当然,如果 CPU 存在多核,那么一个 CPU 可同时并行调度执行多个线程。

JMM 与硬件内存架构的关系

通过对前面的 Java 内存模型、硬件内存架构以及 Java 多线程的实现原理,可以发现多线程的执行最终都会映射到硬件处理器上进行执行,但 Java 内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存 (线程私有数据区域) 和主内存 (堆内存) 之分,也就是说 Java 内存模型对内存的划分对硬件内存并没有任何影响,因为 JMM 只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或者寄存器中。因此总体上来说,Java 内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

指令重排序介绍

指令重排序的概述

Java 语言规范规定 JVM 线程内部维持顺序化语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫做 “指令重排序”。指令重排序的意义是什么?JVM 能根据处理器特性(CPU 多级缓存、多核处理器等)适当地对机器指令进行重排序,使机器指令更符合 CPU 的执行特性,最大限度的发挥机器性能。下图为从源码到最终执行的指令序列示意图:

重点内容

  • 处理器在进行指令重排序时,必须考虑指令之间的数据依赖性。
  • 多线程环境中线程交替执行,由于编译器优化重排序的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

指令重排序的类型

计算机在执行程序时,为了提高性能,编译器和处理器 (CPU) 往往会对指令做重排序,一般分以下 3 种:

  • 编译器优化重排序

    • 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序

    • 现代处理器 (CPU) 采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性 (即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排序

    • 由于处理器使用缓存和读写缓存冲区,这使得加载 (load) 和存储 (store) 操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
    • 其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这两种重排优化可能会导致程序出现内存可见性问题。

as-if-serial 的语义

as-if-serial 语义的意思是不管怎么重排序(编译器和处理器为了提高并行度),程序(单线程)的执行结果不能被改变。编译器、Runtime 和处理器都必须遵守 as-if-serial 语义。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

指令重排序的案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Test {

int a = 0;
boolean flag = false;

public void method01() {
// 当两个线程同时执行 method01() 与 method02() 方法时,以下两条语句可能会被重排序
// 从而导致 method02() 方法被某个线程调用后,输出的结果无法预测(不确定的)
a = 1;
flag = true;
}

public void method02() {
if (flag) {
a = a + 5;
System.out.println("reValue:" + a);
}
}

}

JMM 存在的必要性

线程安全问题

由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程如果想要操作主内存中的某个变量,那么必须通过工作内存间接完成,主要过程是将变量从主内存拷贝到每个线程各自的工作内存,然后在工作内存中对变量进行操作,操作完成后再将变量写回主内存。如果存在两个线程同时对一个主内存中的实例对象的变量进行操作,这就有可能诱发线程安全问题

第一种情况,在上图中假设主内存中存在一个共享变量 int i = 0。现在有 A 和 B 两个线程分别对变量 i 进行操作,A、B 线程各自都先将主内存中的变量 i 拷贝到自己的工作内存中,也就是将其存储为共享变量副本 i,然后再对工作内存中的 i 进行自增操作。那么假设此时 A、B 线程同时将主内存中 i = 0 拷贝到自己的工作内存中进行操作,那么 A 在自己工作内存中对 i 进行自增的操作对 B 的工作内存的副本 i 是不可见的。当 A 执行完自增操作之后会将结果 1 刷写回主内存,此时 B 也执行完了 i++ 操作,那么实际上 B 刷写回主内存的值也是基于之前从主内存中拷贝到自己工作内存的值 i = 0,那么实际上 B 刷写回主内存的值也是 1。理论上两个线程都对主内存中 i 进行了自增操作,正确的结果应该是 i = 2,但是此时的情况结果却是 i = 1,这就产生了线程安全问题。

第二种情况,在上图中假设现在 A 线程想要修改 i 的值为 2,而 B 线程却想要读取 i 的值,那么 B 线程读取到的值是 A 线程更新后的值 2 还是更新前的值 1 呢?答案是不确定,即 B 线程有可能读取到 A 线程更新前的值 1,也有可能读取到 A 线程更新后的值 2。这是因为工作内存是每个线程私有的数据区域,而线程 A 修改变量 i 时,首先是将变量从主内存拷贝到 A 线程的工作内存中,然后对变量进行操作,操作完成后再将变量 i 写回主内存,而对于 B 线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如 A 线程修改完后正在将数据写回主内存,而 B 线程此时正在读取主内存,即将 i = 1 拷贝到自己的工作内存中,这样 B 线程读取到的值就是 i = 1;但如果 A 线程将 i = 2 写回主内存后,B 线程才开始读取主内存的话,那么此时 B 线程读取到的就是 i = 2,但到底是哪种情况先发生呢?这是不确定的。

并发编程的三大特性

为了解决类似如上阐述的线程安全问题,JVM 定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为 Java 内存模型(JMM)。JMM 整体就是围绕着程序执行的原子性、可见性、有序性展开的。

原子性 (Atomicity)

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量 int i = 0,两个线程同时对它赋值,线程 A 的赋值操作为 i = 1,而线程 B 的赋值操作为 i = 2,不管线程如何运行,最终 i 的值要么是 1,要么是 2,线程 A 和线程 B 之前的操作是互不干扰的,这就是原子性操作,不可被中断的特点。特别注意的是,对于 32 位系统的来说,double 类型数据和 long 类型数据 (对于基本数据类型,byte,short,int,float,boolean,char 的读写是原子操作) 的读写并非原子性的,也就是说如果存在两个线程同时对 double 类型或者 long 类型的数据进行读写是存在相互干扰的。因为对于 32 位虚拟机来说,每次原子读写是 32 位的,而 double 和 long 则是 64 位的存储单元,这样会导致一个线程在写时,操作完前 32 位的原子操作后,轮到 B 线程读取时,恰好只读取到了后 32 位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是 “半个变量” 的数值,即 64 位数据被两个线程分成了两次读取。但也不必太担心,因为读取到 “半个变量” 的情况比较少见,至少在目前的商用虚拟机中,几乎都把 64 位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道有这么回事即可。 那么其实本质上原子性操作指的就是一组大操作要么就全部执行成功,要么就全部失败,举个例子:下单:{增加订单,减库存} 那么对于用户来说下单是一个操作,那么系统就必须保证下单操作的原子性,要么就增加订单和减库存全部成功,不存在增加订单成功,减库存失败,那么这个例子从宏观上来就就是一个原子性操作,非原子性操作反之,线程安全问题产生的根本原因也是由于多线程对一个共享资源进行非原子性操作导致的。

可见性 (Visibility)

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行(单线程)程序来说,可见性是不存在的,因为在任何一个操作中修改了某个变量的值,后续的操作中都能读取到这个变量值,并且是修改过的新值。但在多线程环境中就不一定了,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程 A 修改了共享变量 i 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 i 进行操作,但此时 A 线程工作内存中的共享变量 i 对线程 B 来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。另外,无论是编译器优化还是处理器优化的重排现象,在多线程环境下,都可能会导致程序乱序执行的问题,从而也可能会导致可见性问题。

有序性 (Ordering)

有序性是指对于单线程的执行代码,通常认为代码的执行是按顺序依次执行的,这样的理解如果是放在单线程环境下没有问题,毕竟对于单线程而言确实如此,代码由编码的顺序从上往下执行,就算发生指令重排序,由于所有硬件优化的前提都是必须遵守 as-if-serial 语义,所以不管怎么排序,都不会且不能影响单线程程序的执行结果,将这称之为 “有序执行”。反之,对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排序后的指令与原指令的顺序未必一致。要明白的是,在 Java 程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

JMM 如果解决三大特性问题

  • 原子性问题解决
    • 除了 JVM 自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以通过 synchronized 和 Lock 加锁实现原子性。
    • 因为 synchronized 和 Lock 加锁可以保证每个时刻只有一个线程执行同步代码。synchronized 可以保证原子性、可见性和有序性,但不能禁止指令重排。
  • 可见性问题解决
    • 对于指令重排和主内存与工作内存同步延迟现象导致的可见性问题,可以使用 volatile 关键字解决。volatile 可以保证可见性和禁止指令重排,但不能保证原子性。
    • 另外,synchronized 和 Lock 加锁也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到主内存中。
    • 当一个共享变量被 volatile 关键字修饰时,它可以保证修改的值立即被其他的线程看到,即修改的值立即更新到主内存中;当其他线程需要读取变量时,它会去主内存中读取新值。
  • 有序性问题解决
    • 对于指令重排导致的有序性问题,可以通过 volatile 关键字解决,因为 volatile 的其中一个作用就是禁止指令重排。
    • 另外,synchronized 和 Lock 加锁也可以保证有序性,因为 synchronized 和 Lock 加锁后可以保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,这样自然就保证了有序性。

数据同步八大原子操作

Java 程序在执行的过程中本质上就是操作系统在调度 JVM 的 “线程” 执行,而在执行的过程中是与内存的交互操作,而内存交互操作有 8 种。特别注意,虚拟机实现必须保证每一个操作都是原子的,不可拆分的,但对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许非原子性。一个共享变量如何从主内存拷贝到工作内存,又如何从工作内存同步到主内存之间的实现细节,由 Java 内存模型定义的八种操作来完成。

  • lock(锁定):作用于主内存的变量,把一个变量标记为一个线程独占状态;
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 工作使用;
  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量;
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量;
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作;
  • wirte(写入):作用于工作内存的变量,它把 store 操作从工作内存中的一个变量值传送到主内存的变量中。

总结

  • 如果要把一个变量从主内存中拷贝到工作内存中,就需要按顺序地执行 read 和 load 操作。
  • 如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。
  • Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

数据同步使用规则说明

JMM 对上述八大原子操作制定了如下的使用规则:

  • (1) 不允许 read 和 load、store 和 write 操作之一单独出现,即使用了 read 则必须 load,使用了 store 则必须 write;
  • (2) 不允许线程丢弃它最近的 assign 操作,即工作变量的数据改变了之后,必须告知主内存;
  • (3) 不允许一个线程将没有 assign 的数据从工作内存同步回主内存;
  • (4) 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量,也就是对变量执行 use、store 操作之前,必须经过 assign 和 load 操作;
  • (5) 一个变量同一时间只有一个线程能对其进行 lock,多次 lock 后,必须执行相同次数的 unlock 才能解锁;
  • (6) 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新执行 load 或 assign 操作来初始化变量的值;
  • (7) 如果一个变量没有被 lock,就不能对其进行 unlock 操作,也不能 unlock 一个被其他线程锁住的变量;
  • (8) 对一个变量进行 unlock 操作之前,必须将此变量同步回主内存(执行 store 和 write 操作)。

提示

JMM 通过这八种操作规则和对 volatile 的一些特殊规则就能确定哪些操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析,所以一般也不会通过上述规则进行分析。更多的时候,会使用 JMM 中的 happens-before 规则来进行分析。

happens-before 原则

假如在多线程开发过程中,都需要通过 sychronized、volatile 来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,而且加锁其实本质上是让多线程的并行执行变为了串行执行,这样会大大影响程序的性能。幸运的是,从 JDK 5 开始,Java 使用新的 JSR-133 内存模型,提供了 happens-before 原则(也叫先行发生原则)来辅助保证程序执行的原子性、可见性和有序性,它是判断数据是否存在竞争、线程是否安全的依据。

happens-before 原则的概述

happens-before (先行发生原则) 是 Java 内存模型中定义的两个操作之间的偏序关系。比如说操作 A 先行发生于操作 B,那么在 B 操作发生之前,A 操作产生的 “影响” 都会被操作 B 感知到。这里的影响是指修改了内存中的共享变量、发送了消息、调用了方法等。

happens-before 原则的内容

  • 程序顺序原则:即在单个线程内必须保证语义串行,也就是说按照代码顺序执行。
  • 锁规则:解锁(unlock)操作必须发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性。简单理解就是,volatile 变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 线程启动规则:线程的 start() 方法先于它的每一个动作,即如果线程 A 在执行线程 B 的 start() 方法之前修改了共享变量的值,那么当线程 B 执行 start() 方法时,线程 A 对共享变量的修改对线程 B 可见。
  • 线程终止原则:线程的所有操作先于线程的终结,Thread.join() 方法的作用是等待当前执行的线程终止。假设在线程 B 终止之前,修改了共享变量,线程 A 从线程 B 的 join() 方法成功返回,线程 B 对共享变量的修改将对线程 A 可见。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检查到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断。
  • 对象终结规则:对象的构造函数执行,结束先于 finalize() 方法。
  • 传递性规则:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

finalize () 方法说明

finalize() 是 Object 中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它 finalize() 方法,让此对象处理它生前的最后事情(这个对象可以趁这个时机挣脱死亡的命运),比如释放资源或者关闭连接等。

happens-before 原则的使用

在下述的代码中,如果有两个线程 A 和 B,线程 A 先调用 setValue() 方法,然后线程 B 调用 getValue() 方法,那么线程 B 执行方法返回的结果是什么?

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 this.value;
}

对照先行发生原则一个一个来对比。首先是程序次序规则,这里是多线程,不在一个线程中,不适用;然后是锁规则,这里没有 synchronized,自然不会发生 lock 和 unlock,不适用;后面对于线程启动规则、线程终止规则、线程中断规则也不适用;这里与对象终结规则、传递性规则也没有关系。线程 A 和线程 B 的启动时间虽然有先后,但上述代码没有符合 8 条原则中的任意一条,也没有使用任何同步手段,因此线程 B 执行结果是不确定的,即上述的操作不是线程安全的。如何修改呢,一个是对 get、set 方法加入 synchronized 关键字,即可以使用锁规则;要么对 value 加 volatile 修饰,可以使用 volatile 变量规则。


通过上面的例子可知,一个操作时间上先发生并不代表这个操作先行发生,那么一个操作先行发生是不是代表这个操作在时间上先发生?也不是,如下面的例子:

1
2
int i = 2;
int j = 1;

在同一个线程内,对 i 的赋值先行发生于对 j 赋值的操作,但是代码重排序优化,也有可能是 j 的赋值先发生,我们无法感知到这一变化。综上所述,时间先后顺序与先行发生原则之间基本没有太大关系。我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。

Volatile 关键字介绍

volatile 的作用

volatile 是 Java 虚拟机提供的轻量级的同步机制,它有如下两个作用:

  • 保证可见性
  • 禁止指令重排

volatile 的三大特性

volatile 有三大特性:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

提示

volatile 保证可见性和禁止指令重排的两大特性,其内存语义都是通过内存屏障实现的。

volatile 可以保证可见性

关于 volatile 的可见性作用,必须意识到被 volatile 修饰的共享变量对所有线程总是立即可见的,即对于 volatile 共享变量的所有写操作总是能立刻反应到其他线程中。JMM 是如何实现让 volatile 共享变量对其他线程立即可见的呢?实际上,当某个线程对一个 volatile 共享变量执行写操作时,JMM 会把该线程对应的工作内存中的共享变量副本刷新到主内存中,并通知其他线程将自己工作内存中的共享变量副本设置为无效;当某个线程对自己工作内存中的共享变量副本进行读写操作时,该线程会从主内存中重新读取共享变量的值到工作内存中。volatile 共享变量正是通过这种写 - 读方式实现对其他线程可见,但其内存语义则是通过内存屏障实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class VolatileTest {

private static boolean initFlag = false;

private volatile static int counter = 0;

public static void refresh() {
System.out.println("refresh data.......");
initFlag = true;
System.out.println("refresh data success.......");
}

public static void main(String[] args) {
// 线程A
Thread threadA = new Thread(() -> {
while (!initFlag) {
counter++;
}
System.out.println("线程 " + Thread.currentThread().getName() + ": 当前线程嗅探到 initFlag 的状态的改变");
}, "threadA");
threadA.start();

// 中间休眠500hs
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 线程B
Thread threadB = new Thread(() -> {
refresh();
}, "threadB");
threadB.start();
}

}

程序运行输出的结果:

1
2
3
refresh data.......
refresh data success.......
线程 threadA: 当前线程嗅探到 initFlag 的状态的改变

结合前面介绍的数据同步八大原子操作来分析上述的代码:

  • 线程 A 启动后,执行流程如上图所示
    • 第一步:执行 read 操作,作用于主内存,将变量 initFlag 从主内存拷贝一份,这时候还没有放到工作内存中,而是放在了总线里;
    • 第二步:执行 load 操作,作用于工作内存,将上一步拷贝的变量,放入工作内存中;
    • 第三步:执行 use 操作,作用于工作内存,把工作内存中的变量传递给执行引擎,对于线程 A 来说,执行引擎会判断 initFlag = true 吗?不等于,此时循环会一直执行。

  • 线程 B 启动后,执行流程如上图所示
    • 第一步:执行 read 操作,作用于主内存,从主内存拷贝 initFlag 变量,这时候拷贝的变量还没有放到工作内存中,这一步是为了 load 做准备;
    • 第二步:执行 load 操作,作用于工作内存,将拷贝的变量放入到工作内存中;
    • 第三步:执行 use 操作,作用于工作内存,将工作内存的变量传递给执行引擎,执行引擎判断 while(!initFlag),然后执行循环体;
    • 第四步:执行 assign 操作,作用于工作内存,把从执行引擎接收的值赋值给工作内存的变量,即设置 inifFlag = true
    • 第五步:执行 store 操作,作用于工作内存,将工作内存中的变量 initFlag = true 传递给主内存;
    • 第六步:执行 write 操作,作用于工作内存,将变量写入到主内存中。

如何保证可见性

为什么某个线程将共享变量的值更改后,其它线程可以马上知晓呢?其实这里是使用 “总线嗅探技术” 来保证可见性的。

缓存一致性

在介绍总线嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一致。为了解决缓存一致性的问题,需要各个处理器在访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有 MSI、MESI 等等。

MESI 协议

当 CPU 写入数据时,如果发现操作的变量是共享变量,即在其它 CPU 中也存在该变量的副本,就会发出信号通知其它 CPU 将该共享变量的缓存(副本)设置为无效。因此当其它 CPU 读取这个变量的缓存时,发现自己缓存的该变量是无效的,那么它就会从内存中重新读取。

总线嗅探

那么 CPU 是如何发现缓存数据是否失效呢?这里用到了 “总线嗅探技术”,就是每个处理器通过嗅探在总线上传播的数据来检查自己的缓存数据是否失效了。当处理器发现自己的缓存数据对应的内存地址被修改,就会将当前处理器的缓存数据设置为无效状态;当处理器对这个缓存数据进行修改的时候,会重新从内存中把数据读取到处理器缓存中,然后再执行修改操作。

总线风暴

总线嗅探技术有哪些缺点?由于 volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用 volatile 关键字,至于什么时候使用 volatile、什么时候用锁以及 syschonized 都是需要根据实际场景衡量的。

volatile 无法保证原子性

1
2
3
4
5
6
7
8
9
public class VolatileTest {

public static volatile int i =0;

public static void increase(){
i++;
}

}

在并发场景下,上述代码中变量 i 的任何改变都会立刻反应到其他线程中,但是如果存在多个线程同时调用 increase () 方法的话,就会出现线程安全问题。因为 i++ 并不是原子性操作,i++ 实际上是由三个操作组成,包括从主内存读取值、在工作内存中执行加 1 操作、将操作结果刷写回主内存,它们三步中其中一个线程在执行任何一步的时候都有可能被打断,所以会出现线程安全问题。如果第二个线程在第一个线程读取旧值和写回新值期间读取了 i 的值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全问题,因此需要使用 synchronized、Lock 或者原子类来保证原子性,以确保线程安全。特别注意,一旦使用 synchronized 修饰方法后,由于 sunchronized 本身也具备与 volatile 一样的可见性,因此在这样的情况下就完全可以不使用 volatile 关键字来修饰变量。

代码案例分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class VolatileAtomic {

private volatile static int counter = 0;

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
// 自加运算不是原子操作
counter++;
}
});
thread.start();
}

try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(counter);
}

}

程序运行输出的结果:

1
9615

可以发现上述程序实际输出的结果不到 10000,原因是因为存在并发操作,并且 volatile 不能保证原子性。每个线程只执行 counter++ 操作,那为什么不能保证原子性呢?这是因为 counter++ 操作不是一步完成的(非原子性),它分为三个步骤完成,包括从主内存读取值、在工作内存中执行加 1 操作、将操作结果刷写回主内存。假设现在有三个线程同时执行自加运算操作,三个线程都读取到主内存中的 counter 共享变量,然后三个线程在各自的工作内存中对共享变量的副本进行加 1 操作,但它们并发执行加 1 之后,因为同一时刻只能有一个线程刷写回主内存,所以其它线程的写操作会被挂起。假设线程 A 先执行写操作,在写操作执行完之后,由于 volatile 的可见性,JMM 会主动通知其它两个线程主内存中共享变量的值已经被修改了;但是由于 CPU 的调度速度实在太快了,其它两个线程还没来得及接收到通知,就陆续将加 1 的结果写入主内存,这就造成其他线程覆盖了线程 A 写入的值,从而导致出现写丢失的现象,这样也就让最终的计算结果少于 10000。

volatile 禁止指令重排优化

volatile 关键字的其中一个作用是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。关于指令重排优化前面已经介绍过,这里主要重点介绍 volatile 是如何使用内存屏障实现禁止指令重排优化的。

内存屏障概述

内存屏障(Memory Barrier),又称内存栅栏,是一个 CPU 指令,其作用有两个:

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性
硬件层的内存屏障

Intel 硬件提供了一系列的内存屏障,主要有以下几种类型:

  • lfence:是一种 Load Barrier 读屏障;
  • sfence:是一种 Store Barrier 写屏障;
  • mfence:是一种全能型的屏障,具备 lfence 和 sfence 的能力;
  • Lock 前缀:Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。它后面可以跟 ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、and XCHG 等指令。
JVM 中的内存屏障

不同硬件实现内存屏障的方式不同,Java 内存模型屏蔽了这些底层硬件平台的差异,由 JVM 来为不同平台生成相应的机器码。JVM 中提供了四类内存屏障指令:

由于编译器和处理器 (CPU) 都能执行指令重排优化,如果在指令间插入一条内存屏障,则会告诉编译器和处理器,不管什么指令都不能和这条内存屏障指令重排序,也就是说可以通过插入内存屏障来禁止在内存屏障前后的指令执行重排序优化。内存屏障的另外一个作用是强制刷出各种处理器的缓存数据,因此任何处理器上的线程都能读取到这些数据的最新版本。总之,volatile 变量正是通过内存屏障实现其内存中的语义,即可见性和禁止指令重排优化。

单例模式 volatile 分析

这里将介绍一个非常典型的禁止指令重排优化的例子:单例模式(DCL - 双端检锁)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DoubleCheckLock {

private static DoubleCheckLock instance;

private DoubleCheckLock(){

}

public static DoubleCheckLock getInstance(){
// 第一次检测
if (instance == null){
// 同步代码块
synchronized (DoubleCheckLock.class){
// 第二次检测
if (instance == null){
// 在多线程环境下,这行代码可能会出现问题
instance = new  DoubleCheckLock();
}
}
}
return instance;
}

}

提示

上述代码在单线程环境下并没什么问题,但如果在多线程环境下就可能会出现线程安全的问题。因为当某一线程执行到第一次检测,读取到 instance 不为 null 时,instance 实例可能还没有完成初始化。

因为 instance = new DoubleCheckLock (); 可以分为以下 3 个步骤完成(伪代码):

1
2
3
memory = allocate();    // 1. 分配对象内存空间
instance(memory); // 2. 初始化对象
instance = memory; // 3. 设置 instance 指向刚分配的内存地址,此时 instance != null

步骤 1 和步骤 2 之间可能会重排序,如下:

1
2
3
memory = allocate();    // 1. 分配对象内存空间
instance = memory; // 3. 设置 instance 指向刚分配的内存地址,此时 instance != null,但是对象还没有初始化完成
instance(memory); // 2. 初始化对象

由于步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排序前还是重排序后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一个线程访问 instance 实例不为 null 时,由于 instance 实例未必已经初始化完成,这就会造成线程安全问题。也就是,当没有使用 volatile 关键字时,在某些情况下会出现多次初始化实例的情况(存疑),这是由于指令重排序导致的。那么该如何解决呢,很简单,使用 volatile 禁止 instance 变量被执行指令重排优化即可。

1
2
// 禁止指令重排优化
private volatile static DoubleCheckLock instance;

volatile 内存语义的实现

内存语义的实现

前面提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下面是 JMM 针对编译器制定的 volatile 重排序规则表。

  • 从上图可以看出
    • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保了 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
    • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保了 volatile 读之后的操作不会被编译器重排序到 volatie 读之前。
    • 当第一个操作是 volatile 写,第二个操作是 volatile 读或写时,不能重排序。

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

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

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

上图是在保守策略下,volatile 写操作插入内存屏障后生成的指令序列示意图。StoreStore 屏障可以保证在 volatile 写操作之前,其前面的所有普通写操作已经对任意处理器可见。这是因为 StoreStore 屏障将保障前面所有的普通写在 volatile 写之前刷新到主内存。这里比较有意思的是,volatile 写后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在采取了保守策略:在每个 volatile 写的后面,或者在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 最终选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障,因为 volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

上图是在保守策略下,volatile 读操作插入内存屏障后生成的指令序列示意图。LoadLoad 屏障用来禁止处理器把前面的 volatile 读与后面的普通读重排序。LoadStore 屏障用来禁止处理器把前面的 volatile 读与后面的普通写重排序。

代码案例分析

上述介绍的 volatile 写 和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileBarrierExample {

private int a;
private volatile int v1 = 1;
private volatile int v2 = 2;

    void readAndWrite() {
int i = v1;      // 第一个volatile读
int j = v2;       // 第二个volatile读
a = i + j;         // 普通写
v1 = i + 1;       // 第一个volatile写
v2 = j * 2;       // 第二个volatile写
}

}

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化:

特别注意

在上图中,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return,此时编译器可能无法准确判断后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。

上面的优化针对任意处理器平台,由于不同的处理器有不同 “松紧度” 的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 X86 处理器为例,上图中除最后的 StoreLoad 屏障外,其他的屏障都会被省略。前面保守策略下的 volatile 读和写操作,在 X86 处理器平台可以继续被优化,如下图所示。X86 处理器仅会对读 - 写操作做重排序。X86 不会对读 - 读、读 - 写 和 写 - 写 做重排序,因此在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,JMM 仅需在 volatile 写操作后面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存语义,这意味着在 X86 处理器中,volatile 写的开销比 volatile 读的开销会大很多,因为执行 StoreLoad 的屏障开销会比较大。

参考资料