Skip to content

JUC JMM与volatile

本文介绍JMM与volatile关键字。

1. JMM

1.1 概述

JMM,全称Java Memory Model,即Java内存模型。

JMM是一种抽象的概念,本身并不真实存在,它是 Java 语言规范的一部分,定义了在多线程环境中,线程如何与计算机的内存进行交互。更具体地说,JMM 规定了一个线程对共享变量的写入何时对另一个线程可见,以及各种操作的执行顺序。

为什么要有JMM?现代计算机体系结构为了提高性能,会进行以下优化:

  1. 高速缓存 (Caches):每个 CPU 内核都有自己的高速缓存。线程在执行时,可能会将共享变量的值从主内存读到自己的缓存中进行操作。这样就可能出现多个线程在各自的缓存中持有同一个变量的不同副本,导致数据不一致。一个线程修改了变量,但其修改可能只在其本地缓存中,而没有立即刷新到主内存,其他线程也就看不到这个修改。

    在Java中,可以用如下图表示:

    image-20250508134504454

  2. 指令重排序 (Instruction Reordering):编译器和处理器为了提高效率,可能会对指令的执行顺序进行调整,只要在单线程内部不改变程序的最终结果(as-if-serial 语义),那么这种重排序就是合法的。然而,这种重排序在多线程环境下可能导致意外的行为,破坏线程间的交互顺序。

    例如,下面的代码:

    java
    int a = 10;  // 指令1
    int b = 2;   // 指令2
    b = a * a;

    经过重排序后,执行顺序会发生改变:

    java
    int b = 2;   // 指令2
    int a = 10;  // 指令1
    b = a * a;

1.2 三大特性

JMM保证下面的特性:

  • 可见性 (Visibility):当一个线程修改了共享变量的值时,何时以及如何让其他线程能够看到这个新的值。

    共享变量包括对象属性、类静态属性与数组,这些都存放在堆空间中,由于堆是线程共享的,所以这些也就是共享变量了。

    例如,现在有线程t1为某个对象中的属性值赋值:obj.num = 1;,由于缓存的存在,所以线程t1操作的是obj.num在其线程空间的本地副本。那么,什么时候会将线程t1本地内存中的obj.num的值同步到主内存,并且让其他线程可见(所谓可见,就是假如线程t2也在其本地内存缓存了一份obj.num,值为100,线程t2感知到obj.num的值发生了改变,使其本地内存中的缓存失效,重新从主内存中读取)呢?这是由JMM规范决定的。

  • 有序性 (Ordering):限制了编译器和处理器对指令进行重排序的方式,确保在特定情况下(例如,通过同步或 volatile 修饰)程序的执行顺序不会被任意打乱,从而保证并发执行的正确性。

  • 原子性 (Atomicity):虽然不是 JMM 定义所有操作的原子性,但它规定了哪些基本操作是原子性的(例如,对大多数基本类型变量的读写是原子的,但不包括 longdouble 的非 volatile 读写),以及如何使用同步机制来保证复合操作的原子性。

1.3 happens-before原则

JMM 的核心在于定义了线程之间的happens-before (先行发生) 关系。

如果操作 A happens-before 操作 B,那么 A 的效果对 B 是可见的,并且 A 的执行顺序排在 B 之前。

以下是一些重要的happens-before原则:

  1. 程序顺序规则:在一个线程内,按照程序代码的顺序,前面的操作先行发生于后面的操作。这一个规则的意思并不是说在单线程内不存在重排序,而是说在逻辑上,执行代码是按照顺序执行的,但是在物理层面的执行上,还是会有可能发生重排序的。
  2. 管程锁定规则:对一个锁的解锁操作先行发生于之后对同一个锁的加锁操作。这保证了释放锁前的所有写操作对之后获得锁的线程可见。
  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于之后对这个 volatile 变量的读操作。这保证了 volatile 变量的可见性并禁止了某些重排序。
  4. 线程启动规则Thread.start() 操作先行发生于新启动线程中的任何操作。
  5. 线程终止规则:线程中的所有操作都先行发生于其他线程检测到该线程已经终止(例如,通过 Thread.join() 完成或 Thread.isAlive() 返回 false)。
  6. 对象终结规则 (Object Finalization Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  7. 传递性规则:如果 A happens-before B,并且 B happens-before C,那么 A happens-before C。

2. 问题演示

2.1 可见性问题演示

如下面代码所示,线程t1检测stop的值,当stop的值为true时,停止运行;1秒后,主线程修改stop的值为true

java
private static boolean stop = false;

public static void main(String[] args) {
    new Thread(()->{
        while (!stop){

        }
    },"t1").start();

    Sleeper.sleep(1000);
    
    log.info("修改stop为{}", stop);
  	stop = true;
}

运行上面代码,发现线程t1始终没有停下来。这就是由于主线程修改了共享变量,但是修改结果对其他线程不可见,可能的原因有:

  • 主线程修改了stop的值,但是修改的是主线程本地内存中的副本,没有将修改值刷新到主内存,所以t1线程看不到;
  • 主线程修改了stop的值并且将修改值刷新到主内存,但是t1线程一直读取的是自己工作内存中stop副本的值,没有去主内存中获取stop最新的值,所以t1线程停不下来;

2.2 有序性问题演示

如果要演示有序性问题,需要使用参考资料中的JCStress工具,进行并发测试。

代码如下:

  • JCStress是并发测试工具,我们可以理解为线程t1执行actor1()方法,线程t2执行actor2()方法;
  • 可能的结果如下:
    • 线程t1先执行并且执行完,此时readytruenum为0,r的结果为1;
    • 线程t2先执行,但是执行到num=2后进行上下文切换,线程t1开始执行,因此r的结果为1;
    • 线程t2先执行,并且执行完,此时readyfalsenum为2,r的结果为4;
java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1","4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ReorderingTest {

    int num = 0;
    boolean ready = false;

    @Actor
    public void actor1(I_Result r){
        if(ready){
            r.r1 = num + num;
        }else{
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(I_Result r){
        num = 2;
        ready = true;
    }
}

在JCStress结果总结中,有如下统计数据:

txt
RESULT        SAMPLES     FREQ       EXPECT  DESCRIPTION
     0      1,346,434    0.01%  Interesting  !!!!
     1  4,636,577,490   51.10%   Acceptable  ok
     2            291   <0.01%    Forbidden  No default case provided, assume Forbidden
     4  4,436,459,279   48.89%   Acceptable  ok

可以看到,不仅有我们分析的结果1和4,还有0和2,并且0和2的出现次数远远小于1和4的出现次数,这是为什么呢?

首先来分析出现0的原因:发生了重排序

java
@Actor
public void actor2(I_Result r){
  	ready = true;
    num = 2;
}

actor2()方法发生了重排序,那么就有可能先执行ready=true,然后线程t2由于时间片用完,发生上下文切换,此时t1线程执行,所以进入r.r1 = num + num;,而此时num的值为0,所以最终r的结果为0;

再来分析出现2的原因:也是发生了重排序,加指令级别的多次上下文切换

首先来看actor1()方法的字节码指令:

txt
 0 aload_0
 1 getfield #13 <juc/p4/ReorderingTest.ready : Z>
 4 ifeq 23 (+19)
 7 aload_1
 8 aload_0
 9 getfield #7 <juc/p4/ReorderingTest.num : I>
12 aload_0
13 getfield #7 <juc/p4/ReorderingTest.num : I>
16 iadd
17 putfield #17 <org/openjdk/jcstress/infra/results/I_Result.r1 : I>
20 goto 28 (+8)
23 aload_1
24 iconst_1
25 putfield #17 <org/openjdk/jcstress/infra/results/I_Result.r1 : I>
28 return

可以看到,执行r.r1 = num + num;时会取两次num的值,即getfield指令。

所以我们可以推测,首先发生了重排序,然后t2执行ready=true,进行上下文切换,然后t1执行到第一个getfield #7指令,此时取到的num值为0,然后t1线程时间片用完,进行上下文切换,t2继续执行num=2,之后t1执行,继续执行第二个getfield #7指令,此时num的值为2,所以最终r的值为2。

3. volatile

3.1 概述

在 Java 中,volatile 关键字主要用于修饰类变量或实例变量(字段),它有两个主要作用:

  1. **保证可见性 **
  2. 禁止指令重排序 ,保证有序性

3.2 保证可见性

我们以之前的可见性问题例子来说明,volatile保证可见性的能力:

java
private volatile static boolean stop = false;

public static void main(String[] args) {
    new Thread(()->{
        while (!stop){

        }
        log.info("退出");
    },"t1").start();

    Sleeper.sleep(1000);
    log.info("修改stop为{}", stop);
    stop = true;
}

运行上述代码,发现t1线程正常停止。可以说,volatile关键字使得主线程对共享变量stop的修改,对t1线程可见。

volatile 的作用: 当一个变量被 volatile 修饰后,对该变量的读写会直接操作主内存,而不是线程的工作内存。

  • volatile 变量的写操作会立即强制刷新到主内存。
  • volatile 变量的读操作会总是从主内存中读取最新的值,绕过线程的工作内存。

结果: 保证了当一个线程修改了 volatile 变量的值,新值对于其他线程来说是立即可见的。

3.3 保证有序性

以上述有序性问题例子为基础,我们在ready变量前加上volatile关键字(其他代码省略):

java
public class ReorderingTest {
    int num = 0;
    volatile boolean ready = false;
}

再次运行测试代码,结果如下:

txt
  RESULT        SAMPLES     FREQ       EXPECT  DESCRIPTION
       0              0    0.00%  Interesting  !!!!
       1  3,741,604,399   50.90%   Acceptable  ok
       4  3,609,049,175   49.10%   Acceptable  ok

可以发现结果只有1和4了,没有出现0和2,这说明重排序消失了,即volatile关键字可以禁止重排序。

下面总结volatile禁止重排序的规则,需要先明确几个概念:

  • 普通读写,即共享变量没有被volatile修饰,例如上面的num,普通读int a = num;,普通写num=2;

  • volatile读写,即共享变量被volatile修饰,例如上面的ready,volatile读if(ready),volatile写ready=true

  • 操作1和操作2,即按照在代码中的顺序,前面的代码称为操作1,后面的代码称为操作2,例如

    java
    num = 2;        // 操作1 
    ready = true;   // 操作2

下表总结了volatile禁止重排序的规则:

image-20250508155825338

  • 当第一个操作是volatile读时,不论第二个操作是什么,都不能重排序。即volatile读之后的操作不会被重排到volatile读之前;
  • 当第二个操作是volatile写时,不论第一个操作是什么,都不能重排序。即volatile写之前的操作不会被重排到volatile写之后。
  • 当第一个操作是volatile写时,第二个操作是volatile读时,不能重排。

4. 可见性原理

本小节说明 volatile 保证可见性的原理。

4.1 缓存

由于CPU和内存之间运行速度的差异,计算机设计者在CPU和内存之间设计了多层缓存结构。

image-20250510112953739

此处只是一个简化图,实际情况下,缓存有多层结构。

缓存是以缓存行(Cache Line)为基本单位的。

由于缓存的存在所以会出现缓存不一致问题,例如,现在在主内存中有一个变量x,值为0,其中CPU 0读取变量x,并设置值为1,CPU 1读取变量x,并设置值为2,那么最终变量x的值是什么呢?

image-20250510113312790

又例如,在主内存中有变量x,值为0,CPU 0读取变量x,CPU 1读取变量x,之后CPU 0设置变量x的值为1,这是CPU 1中的缓存已失效了,需要重新读取最新值。

这些问题由缓存一致性协议来解决。

4.2 MESI协议

DANGER

在讲解MESI协议之前,有一点需要明确,在缓存和内存中,有处理器实时监听总线上的消息,以便做出响应。

MESI是一种缓存一致性协议。它将缓存行分为四个状态:

  • M(Modified):该Cache line有效,数据被修改了,和内存中的数据不一致,最新数据在本Cache中。
  • E(Exclusive):该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
  • S(Shared):该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
  • I(Invalidate):该Cache line无效。

MESI 协议的实现依赖于CPU之间的交流,各个CPU有可能会发出以下消息:

  • Read:读取数据消息,CPU向外发送一个读取数据的请求;
  • Read Response:响应读取数据请求的消息,可能由内存或缓存响应。例如,如果某个CPU的缓存行是M状态,说明其有最新的数据,应该由缓存提供最新数据,而不是由内存。
  • Invalidate:使数据失效消息,CPU向外发送数据失效消息,则其他CPU将自己缓存中对应地址数据设置为失效状态;
  • Invalidate Acknowledge:响应数据失效请求的消息,其他CPU回复发出数据失效消息的CPU;
  • Read Invalidate:读失效消息,这是由ReadInvalidate组合而成的消息,所以也需要一个Read Response和多个Invalidate Acknoweledge消息;
  • Writeback:CPU将缓存中的数据写回内存消息。处于Modified状态的缓存行是系统中唯一有效的最新副本,其内容与主存不一致。若直接丢弃该缓存行,会导致数据丢失。当缓存空间不足时(例如缓存冲突或容量不足),需要腾出空间加载新数据。此时,若被替换的缓存行处于Modified状态,必须通过writeback消息写回内存。

接下来,我们以一个例子来说明MESI的工作流程:

假设现在有两个CPU:CPU A和CPU B,并且内存中有一个共享变量X,初始值为0。

步骤一:CPU A 发送Read消息读取X的值,内存响应(发送Read Response消息),将 X=0 返回给CPU A。CPU A 缓存X,状态标记为 Exclusive(E)(唯一缓存副本,且未修改)。

txt
CPU A缓存:X=0(状态E)
CPU B缓存:无X
内存:X=0

步骤二:CPU A 修改X(写入)的值,将X的值修改为1,写入后,X的状态变为 Modified(M)(唯一副本,且与内存不一致)。由于修改前CPU A中缓存行的状态是E,则无需通知其他CPU。

txt
CPU A缓存:X=1(状态M)
CPU B缓存:无X
内存:X=0

步骤三:CPU B 发送Read读取变量X,CPU A 监听到总线的Read请求,发现自己的缓存中有X(状态M)。所以CPU A响应Read Response消息,将最新值 X=1 发送给CPU B。并且,若当前持有该地址的缓存行处于 Modified(M)状态时,有其他CPU请求消息,需发送WriteBack消息,将最新的数据写回内存。CPU A 的缓存状态从 M 降级为 Shared(S)(因为现在有多个副本)。CPU B 缓存X=1,状态标记为 Shared(S)

txt
CPU A缓存:X=1(状态S)
CPU B缓存:X=1(状态S)
内存:X=1

步骤四:CPU B 修改X的值,将X的值改为2。由于此时CPU B中X缓存的状态为S,不能直接修改,CPU B 向总线发送 Invalidate 消息,要求其他CPU缓存无效化X的副本。CPU A 监听到Invalidate消息,将本地缓存的X标记为 Invalid(I),并且返回Invalidate Acknowledge消息。CPU B 获得独占权,将X=2写入缓存,状态升为 Modified(M)

txt
CPU A缓存:X=1(状态I)
CPU B缓存:X=2(状态M)
内存:X=0

步骤五:CPU A 再次读取变量X,由于CPU A 的缓存中X处于 Invalid(I) 状态,触发缓存未命中。CPU A 向总线发送 Read 消息。CPU B 监听到Read请求,发现自己有X的最新副本(状态M)。CPU B 将X=2写回内存、发送给CPU A,状态降级为 Shared(S)

txt
CPU A缓存:X=2(状态S)
CPU B缓存:X=2(状态S)
内存:X=2

4.3 Store Buffer和Invalidate Queue

Store Buffer

当某个CPU中缓存行状态是S时,并且该CPU要修改该缓存行中的值,需要发出Invalidate消息,并且等待其他CPU响应Invalidate Acknowledge消息,这个过程是比较影响效率的,所以提出了Store Buffer。当某个CPU需要修改数据时,将新数据写入Store Buffer,并且发送Invalidate消息,就可以继续执行后续指令了,不用阻塞等待其他CPU的响应,等到接收到其他cpu的响应后再将数据从store buffer移到cache line。

Store Forward

回到上面的步骤二和三,如果此时CPU A将x=1写入Store BufferStore Buffer中的最新值还没有刷新回缓存中,CPU B发送Read消息,如果CPU A中的缓存处理器从缓存中的读取X的值发送给CPU B,那么发送的是旧址X=0,所以引入了Store Buffer后,CPU A中的缓存处理器需要先访问Store Buffer,如果Store Buffer中有最新值,则直接返回,如果没有则从缓存中获取,这就是Store Forward

Invalidate Queue

同样的,如果CPU B接收到Invalidate消息,此时可能在执行其他指令,来不及响应Invalidate Acknowledge消息,从而影响其他CPU的执行效率,所以提出了Invalidate Queue(失效队列)。CPU接收到Invalidate消息后,立即响应Invalidate Acknowledge消息,并且将Invalidate消息放入Invalidate Queue中,待有时间的时候再去处理失效队列里的消息,最后通过这种异步的方式,加快了CPU整个修改数据的过程。

image-20250510132319268

经过了 Store Buffer和 Invalidate Queue 优化后的MESI性能的确是有了提升,不过随之而来的也伴随着两个问题,没有优化的MESI虽然整个过程是同步进行的,但是这样可以确保每个操作都真正意义上的执行了,从而保证了数据的强一致性。

但是加入了 Store Buffer 和 Invalidate Queue 后,这就有可能导致修改操看似完成了,但此时还并不能保证最新的数据变更即时同步到了主存里,那么此时就有可能导致其它 CPU 读取到的是脏数据。

上述问题可以通过读屏障和写屏障解决:

  • 读屏障(Load Barrier):强制所有在读屏障指令之后的load(读取数据)指令,都在读屏障指令执行之后被执行,并且一直等到失效队列被该CPU读完才能执行之后的load指令。

    读屏障的意思是,在读取共享变量的指令前,先处理所有在失效队列中的消息,这样就保证了在读取数据之前所有失效的消息都得到了执行,从而保证自己读取到的数据是最新的。

  • 写屏障(Store Barrier):强制所有在写屏障指令之前的 store 指令,都在该写屏障指令执行之前被执行,并把store buffer的数据都刷到CPU缓存。

    结合上面的场景,写屏障其实就是告诉CPU,执行这个指令的时候需要把store buffer的数据都同步到内存中去。

4.4 volatile 原理

volatile 变量时的行为

  • 当一个线程修改一个 volatile 变量时,JMM(Java 内存模型)会确保这个写操作具有“释放语义”(release semantics)。
  • 这通常意味着,在硬件层面,处理器会将写入的值刷新到主内存。
  • 更重要的是,这个写操作会通过总线(bus)发送一个消息(例如,在MESI中可能是 "Invalidate" ),通知其他拥有该变量缓存副本的处理器,它们缓存的这个变量副本已经失效了(变为 I 状态)。

volatile 变量时的行为

  • 当一个线程读取一个 volatile 变量时,JMM 会确保这个读操作具有“获取语义”(acquire semantics)。
  • 在硬件层面,处理器在读取该变量时:
    • 它会检查自己本地缓存中该变量的状态。
    • 如果本地缓存中该变量的缓存行状态是 I (Invalid),那么处理器就不能使用这个无效的缓存数据。它必须通过总线发送一个读取请求,从主内存或其他拥有该数据最新副本(可能是 M 或 E 状态)的CPU缓存中获取数据。
    • 如果其他CPU拥有 M 状态的副本,那么在当前CPU读取之前,那个CPU必须先将数据写回主内存(或者通过缓存间直接转发,如MESI的优化)。
    • 通过这个过程,volatile 读操作就确保了它读取到的是在它这个读操作发生之前,所有对该 volatile 变量的写操作完成后的最新结果。

5. 有序性原理

volatile保证有序性也是通过内存屏障来完成的。

5.1 四种内存屏障

image-20250510160941592

5.2 volatile变量读

当对一个volatile变量进行读操作时,JVM会在读操作之后添加LoadLoadLoadStore屏障:

  • LoadLoad屏障:禁止后面所有的普通读操作在volatile读之前执行;
  • LoadStore屏障:禁止后面所有的普通写操作在volatile读之前执行;

image-20250510161035118

5.3 volatile变量写

当对一个volatile变量写时,JVM会在volatile写之前添加一个StoreStore屏障,在volatile写之后添加一个StoreLoad屏障:

  • StoreStore屏障:禁止之前的普通写在volatile写之后执行;
  • StoreLoad屏障:禁止之后的普通读在volatile写之前执行;

image-20250510161244762

6. 总结

volatile底层原理好复杂,涉及到硬件CPU层面,也涉及到C++层面,能力不足以完全解释清楚,留个遗憾,再慢慢填补知识漏洞吧。

参考资料

[1] JCStress使用:https://juejin.cn/post/7354951908007591948

[2] MESI协议:https://luzhixing12345.github.io/klinux/articles/arch/cache-coherence/

[3] MESI协议:https://zhuanlan.zhihu.com/p/84500221