并发编程:volatile关键字&JMM内存模型

前面在并发编程:Synchronized底层实现&锁升级一文中详细地介绍了synchronized关键字,而相比于synchronized关键字,volatile关键字是Java虚拟机提供的一个更轻量级的同步机制,下面我们对volatile关键字展开详细介绍。

被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。要了解volatile实现原理,就必须先了解Java内存模型(JMM)

1. 内存模型概述

JMM(Java Memory Model,Java内存模型)是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。

在介绍Java内存模型之前,我们先介绍一下现代计算机的内存模型。

1.1 计算机的内存模型

早起计算机中CPU和内存的速度是差不多的,但是在现代计算中,CPU的速度远超过内存的读写速度,由于计算机的存储设备与处理器运算速度差了好几个量级,所以现代计算机必须在二者之间引入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存和处理器之间的缓冲。

将需要使用的数据复制到高速缓存中,使其进行快速的运算和存取,当运算结束后将缓存中的数据同步到内存,这样处理器就不需要等待内存缓慢的存取数据了。

基于高速缓存的方式可以很好地处理内存和处理器运算速度差异过大的问题,但是也为计算机系统带来的更高的复杂度,因为它引入了一个新的问题:缓存一致性

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存。

现代计算机内存模型

在程序运行中,会将运行所需要的数据复制一份到 CPU 高速缓存中,在进行运算时 CPU 不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后,才会将数据刷新到主存中。举一个简单的例子:

1
int i = i + 1;

当线程运行这段代码时,会先从主内存中读取i的值(假设此时i=1),然后复制一份到CPU高速缓存中,然后 CPU 执行 + 1 的操作(此时 i = 2),然后将数据 i = 2 写入到告诉缓存中,最后刷新到主存中。

其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:

假如有两个线程 A、B 都执行这个操作( i++ ),按照我们正常的逻辑思维主存中的i值应该=3 。但事实是这样么?分析如下:

两个线程从主存中读取 i 的值( 假设此时 i = 1 ),到各自的高速缓存中,然后线程 A 执行 +1 操作并将结果写入高速缓存中,最后写入主存中,此时主存 i = 2 。但是此时线程B高速缓存中i的值仍为1,线程B执行同样操作后,主存中的 i=2 。所以最终结果为 2 并不是 3 。这种现象就是缓存一致性问题

解决缓存一致性问题的方案:

  1. 在总线加LOCK锁的方式
  2. 通过缓存一致性协议

第一种方案, 存在一个问题,它是采用一种独占的方式来实现的,即总线加 LOCK# 锁的话,只能有一个 CPU 能够运行,其他 CPU 都得阻塞,效率较为低下。

第二种方案,缓存一致性协议(MESI协议),它确保每个缓存中使用的共享变量的副本是一致的。其核心思想是:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该共享变量的缓存失效,因此在其他CPU读取该变量时,发现缓存失效则会重新从主内存中读取数据。

根据缓存一致性协议,再来看前面的例子,线程A、B都执行

1
i = i + 1;

最开始主内存、缓存中i=1,线程A先执行语句,缓存1中的i=2,并刷新到主内存,同时通知其他线程缓存失效;线程B再执行语句,发现缓存数据失效,则从主内存中读取数据i=2,执行+1命令,最终i=3

1.2 JMM(Java内存模型)

JMM描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。

JMM具有以下规定:

  • 所有共享变量都存储在主内存。注意这里所说的变量包括类变量和实例变量,不包括局部变量,因为局部变量是线程私有的,不存在竞争问题。

  • 每个线程还有自己的工作内存,线程的工作内存保存了使用到的变量的工作副本。

  • 所有线程对变量的操作(读、写)都必须在工作内存中进行,而不能直接操作主内存。

  • 一个线程不能访问其他线程的工作内存中的变量,线程中变量的值的传递需要通过主内存中转实现。

工作内存与主内存:

2. JMM三大特性

2.1 可见性

根据JMM规定,我们知道各个线程中对主内存中共享变量的操作都是各个线程格子拷贝到自己的工作内存区域中进行的操作后,然后回写到主内存中的。

这就有可能存在线程A修改了共享变量X的值,但未及时更新到主内存中,另一个线程B又对共享变量X进行了操作,此时线程A对共享变量的修改是不可见的。

这种工作内存和主内存同步延迟现象导致了可见性问题。

使用volatile关键字可以解决可见性问题,后面会介绍其原理。

2.2 原子性

原子性是指一个或多个操作,要么全部执行并且执行过程中不会被任何因素打断,要么都不执行。在Java中即便是一条最简单的语句都可能是多条CPU指令组成的,例如:

一条最简单的i = i + 1的Java语句,经过反编译后可以返现它是由多条字节码执行组成,因此如果不适用额外手段保证语句i = i + 1的原子性,可能会导致字节码执行到一半时出现问题,导致后续操作无法执行成功。

volatile关键字无法保证原子性,可以通过synchronized或者加锁的方式保证原子性,或者使用java.util.concurrent包下的原子类(如AtomicInteger, AtomicLong等)。

2.3 有序性

在计算机执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排, 一般分为三种情况。

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


单线程环境下能够保证程序最终执行结果和代码顺序执行的结果一致。

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

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

3. volatile语义及实现原理

volatile是 JVM 虚拟机提供的轻量级的同步机制,它具有两层语义:

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

3.1 volatile如何保证可见性?

一个被volatile修饰的共享变量,一旦一个线程对该变量进行了写操作,立马强制把该工作内存中修改后的值刷新到主内存,然后强制其他线程的工作内存的缓存失效。

volatile是怎么实现的?

在生成汇编代码时,会在volatile修饰的共享变量进行写操作时多出Lock前缀的指令

Lock前缀在多核处理器下主要有两方面的作用:

  1. 将当前处理器缓存的数据写回主内存
  2. 这个写回内存的操作会使其他CPU缓存了该内存地址的数据失效

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存的数据写回到主内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

为什么加锁可以保证可见性?

因为线程获得锁后,会清空工作内存从主内存中拷贝共享变量最新值到工作内存作为副本,执行代码,将修改后的副本的值刷回主内存,释放锁。

而对于其他获取不到锁的线程会阻塞等待,所以变量的值一直都是最新的。

3.2 volatile禁止指令重排

学习volatile如何禁止指令重排之前,我们先来了解一下happens-before规则

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。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中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

我们着重看第三点 Volatile规则:对 volatile变量的写操作,happen-before 后续的读操作。为了实现 volatile 内存语义,JMM会重排序,其规则如下:

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile 关键字时,会多出一个 lock 前缀指令。lock 前缀指令,其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile 的底层就是通过内存屏障来实现的。

内存屏障

JMM内存屏障分为如下四类:

屏障类型 指令示例 说明
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会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

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

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

  • 在每个volatile读操作的后面插入一个LoadLoad屏障;在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序

LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序

LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

volatile 经常用于两个两个场景:状态标记变量、Double Check 。