Java并发笔记

Posted by zjh on April 23, 2025

CPU 内存模型带来的可见性问题

http://www.wowotech.net/kernel_synchronization/Why-Memory-Barriers.html

1. CPU 缓存的引入

为了平衡 CPU 与 内存 速度的差异,现代处理器都会在 CPU 与 内存 之间引入高速缓存结构,CPU Cache 通常包含多级缓存(L1、L2、L3)结构,其中 L1、L2 通常为每个 CPU 私有,各个 CPU 之间数据不共享,L3 一般是多个 CPU 共享。速度上,L1 > L2 > L3 » 内存,容量上,内存 » L3 > L2 > L1。

根据局部性原理,程序在运行时往往会访问到相邻的数据,所以以块为单位传输数据能提高效率。CPU 与 内存 之间以缓存行(Cache Line)为单位进行交互。缓存行的大小通常为 64 字节。

2. 缓存一致性协议

(1)缓存一致性问题

现代 CPU 包含多个物理核心,支持多个线程在多个核心上并行执行,每个核心都有自己独立的 L1、L2 缓存,相同内存地址的缓存行可能同时被多个核心缓存,如果没有合适的同步机制,多个核心可能会在未正确同步的情况下读写同一个内存地址对应的缓存行,导致出现缓存不一致、数据可见性等问题。

现代 CPU 采用缓存一致性协议来确保各 CPU Cache 中相同缓存行的读写能正确的同步数据并保持一致性。其中,MESI 协议是最经典、应用最广泛的缓存一致性协议。

具体而言,缓存一致性协议需要确保 CPU 在修改缓存行内的数据之前,其它 CPU Cache 中相同的缓存行已被置为无效状态,只有这个操作完成之后,当前 CPU 才能安全的写入数据,而不会造成一致性问题。同样,如果其它 CPU 想要访问缓存行的数据,由于缓存行已被置为无效,缓存一致性协议需要确保能从其它 CPU Cache 或 内存 中读取到最新的缓存行数据并保存到当前 CPU Cache 中。

(2)MESI 缓存行状态

MESI 缓存一致性协议是以 缓存行 为单位进行管理的,其将缓存行划分为四种状态:

  • Modified(M):该缓存行仅有效存在于当前 CPU,且已被当前 CPU 修改,数据与内存不一致,后续需写回内存
  • Exclusive(E):该缓存行仅有效存在于当前 CPU,但数据未被修改,与内存一致
  • Shared(S):该缓存行有效存在于多个 CPU,且数据与内存一致,所有 CPU 只能读取,不能修改
  • Invalid(I):该缓存行已失效,不包含有效数据,不能读写

M 和 E 两种状态的缓存行仅有效存在于当前 CPU,且为最新的数据,因此当前 CPU 可以安全的读写缓存行内的数据,而不会造成一致性问题。S 状态的缓存行虽也为最新的数据,但缓存行有效存在于多个 CPU,因此当前 CPU 仅能安全的读取数据,不能修改数据。I 状态的缓存行在当前 CPU 内无效,当前 CPU 不能读写缓存行内的数据。

MESI 缓存行状态转换过程:https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm

当使用 MESI 作为缓存一致性协议时,缓存行除了需要保存数据以及数据在内存中的物理地址以外,还会有一个 2bit 的标志位用来表示缓存行的当前状态。

(3)MESI 协议消息

缓存行的状态转换依赖于 CPU 之间的消息传递,主要通过 总线(Bus) 进行通信。仅考虑所有CPU共享一个总线的情况,MESI协议消息包含以下6种:

  • Read:用于从其它 CPU 或 内存 读取最新的缓存行数据。在缓存行状态为 Invalid(I)且当前 CPU 仅需读缓存行内的数据时使用。
  • Read Response:Read 消息的响应消息,消息可以来自其它 CPU 或 内存。消息内包含最新的缓存行数据。
  • Invalidate:用于将其它 CPU 内的有效缓存行置为 Invalid(I)状态。在缓存行状态为 Shared(S)且当前 CPU 需要修改缓存行内的数据时使用。
  • Invalidate Ack:Invalidate 消息的响应消息。其它 CPU 在收到 Invalidate 消息后,会将自己 Cache 中的缓存行置为 Invalid(I)状态,并回复 Invalidate Ack。
  • Read Invalidate:Read 消息和 Invalidate 消息的结合。用于从其它 CPU 或 内存 读取最新的缓存行数据,并将其它 CPU 内的有效缓存行置为 Invalid(I)状态。
  • WriteBack:用于将 Modified(M)状态的缓存行数据刷回内存。

总线嗅探(Bus Snooping) 机制:CPU 根据本地缓存状态向总线发送消息,总线排序后严格串行广播。所有 CPU 通过嗅探器监听总线,若地址匹配则按消息类型和本地缓存行状态进行处理。发送方必须等待所有必要响应(如 Invalidate Ack 或数据响应)后,才能安全更新自身缓存行状态或继续后续操作,从而保证缓存一致性协议正确执行。

(4)MESI 转换案例

  1. 初始状态
    • CPU0:x=2,状态为 Modified (M)
    • CPU1:x 状态为 Invalid (I)
  2. CPU1 执行 x++
    • 检测到 x 状态为 Invalid (I) → 触发 Read Invalidate
  3. CPU0 响应
    • 写回 x=2,发送 Read Response,自身状态降为 Invalid (I)
  4. CPU1 完成操作
    • 收到 Read Response,更新 x=2Modified (M),执行 x++x=3

3. store buffer

(1)非独占缓存行写性能降低

有了 MESI 缓存一致性协议后,CPU 对于独占状态(M/E)的缓存行的读写依然是很快的,但是对于非独占状态(S/I)的缓存行的写操作性能会大幅降低,因为 CPU 必须等待其它 CPU 响应 Invalidate Ack 后,才能开始更新自身缓存行状态并执行后续写操作。

为了优化写操作的性能,现代处理器通常会在 CPU 与 Cache 之间再额外引入一层 CPU 私有的 store buffer(写缓冲区)结构,当 CPU 尝试对非独占状态的缓存行进行写操作时,CPU 依然会发送 Invalidate 消息,但并不会等待 Invalidate 消息的返回,而是在将 Invalidate 消息发送出去后,直接将修改内容记录到 store buffer 中,然后就可以转身继续去执行其它命令了,后续在接收到其它 CPU 回复的 Invalidate Ack 后,再将修改内容从 store buffer 中同步到缓存行中即可。

(2)store buffer 引入的本地数据不一致问题

// 假设变量 a = 0 仅在 CPU1 的缓存行中,变量 b = 0 仅在 CPU0 的缓存行中,且都为Exclusive(E)独占状态

// CPU0 执行以下代码
a = 1;
b = a + 1;
assert(b == 2);

执行的可能顺序如下:

  1. CPU0 尝试执行 a = 1,但 Cache Miss,于是发送 Read Invalidate 消息并将 a = 1 记录到 store buffer中
  2. CPU1 接收到 Read Invalidate 消息,将 a = 0 所在缓存行返回并将本地缓存行置为 Invalid(I)状态
  3. CPU0 接收到 Read Response 消息,将 a = 0 所在缓存行保存到本地缓存中
  4. CPU0 执行 b = a + 1,从缓存行中读取旧值 a = 0 ,+ 1 之后将结果 1 赋值给 b
  5. CPU0 接收到 Invalidate Ack 消息,将 store buffer 中的 a = 1 刷新到缓存行中
  6. CPU0 执行 assert(b == 2),但此时缓存行中的 b = 1,断言失败

上述断言失败非常违反软件开发者的直觉,根本原因是因为 CPU0 从缓存行中读取到了脏的 a 值。不过好在这个问题可以在硬件层面直接解决,只需要让 CPU 在读取数据时,不但要看缓存行,还有看 store buffer 是否有内容,如果 store buffer 有该数据,那么就采用 store buffer 中的值,这样即便是 store buffer 中的数据还未刷到缓存行,也不会出现脏读问题。这种设计被称为 “store forwarding“(存储转发)。

(3)store buffer 引入的 CPU 之间的数据不一致问题

a = 0; // 初始 a = 0,且仅在 CPU1 的缓存行中有效
b = 0; // 初始 b = 0,且仅在 CPU0 的缓存行中有效

// CPU0执行
void foo(void)
{
    a = 1;
    b = 1;
}

// CPU1执行
void bar(void)
{
    while (b == 0) continue;
    assert(a == 1);
}

执行的可能顺序如下:

  1. CPU0 执行 a = 1,Cache Miss,发送 Read Invalidate 消息并将 a = 1 写到 store buffer 中
  2. CPU0 执行 b = 1,缓存命中,直接修改缓存行
  3. CPU1 执行 while (b == 0),Cache Miss,发送 Read 消息从 CPU0 中读取到 b = 1,退出循环
  4. CPU1 执行 assert(a == 1),缓存命中,CPU1 缓存行中 a = 0,断言失败
  5. CPU1 接收到 CPU0 在第一步发送的 Read Invalidate 消息,但是为时已晚

可以看到,出现断言失败的根本原因是 CPU0 将 a = 1 写入到 store buffer 对 CPU1 是没办法立即可见的。站在软件开发者的角度来看,a = 1 和 b = 1 的执行好像乱序了,b = 1 跑到 a = 1 之前去执行了。同样的道理,如果 a = 1 后面跟一个读操作,站在软件开发者的角度,读操作好像也跑到 a = 1 之前去执行了。

总结一下:store buffer 的引入,写操作没办法立即对其它 CPU 可见,会导致出现 写写、写读乱序问题

由于 CPU 无法自动识别变量间的依赖关系,store buffer 引发的写操作可见性延迟(从而导致写写和写读乱序问题)无法在硬件层面直接解决;但 store buffer 显著提升了写性能,而且在大多数实际场景中,这类变量间的紧密相关性较少,在大多数场景下都是利大于弊的。为此,CPU 设计者只能间接的提供一些工具来让软件开发者手动的控制这些变量的相关性,这类工具就是 memory-barrier (内存屏障)指令,具体如下:

a = 0; // 初始 a = 0,且仅在 CPU1 的缓存行中有效
b = 0; // 初始 b = 0,且仅在 CPU0 的缓存行中有效

// CPU0执行
void foo(void)
{
    a = 1;
    smp_mb(); // 添加内存屏障,屏障作用能保证 b = 1 执行之前,a = 1 已被刷新到缓存中
    b = 1;
}

// CPU1执行
void bar(void)
{
    while (b == 0) continue;
    assert(a == 1);
}

smp_mb() 内存屏障的作用就是让 CPU 进入等待状态,直到 store buffer 中的修改都刷到缓存行之后,才能继续执行后续的命令。这样就能保证在 b = 1 执行之前,a = 1 的修改已经刷到缓存行,也即 b = 1 可见之前,a = 1 已经对外可见了。这样就能解决 store buffer 带来的写写和写读乱序问题了。

除了强制让 CPU 等待 store buffer 的刷新以外,也可以在硬件层面就把 store buffer 设计为 FIFO 的队列结构,并让所有写操作都强制进 store buffer,这样也能保证在 b = 1 执行之前,a = 1 的修改已经刷到缓存行,也即 b = 1 可见之前,a = 1 已经对外可见了。不过这种方式只能解决伪写写乱序问题,对于伪写读乱序问题,依然需要通过内存屏障来解决。x86 架构就是这样做的,所以 x86 架构下只会存在伪写读乱序问题,不会出现伪写写乱序问题。

4. invalidate queue

(1)store buffer 写满的问题

store buffer 的大小是有限的(一般为几十个字节),CPU 必须等待其它 CPU 的 Invalidate Ack 消息都返回之后才能将 store buffer 中的修改内容刷到缓存行,所以 store buffer 很容易被写满。在 store buffer 写满了之后,CPU 如果还想往 store buffer 中写入数据,就只能进入等待状态,等待 store buffer 中数据的刷新让出空闲空间后,才能继续执行。

由于硬件层面的限制,store buffer 不能设计的很大,所以为了降低 store buffer 被写满的概率,搞硬件的又为每个 CPU 设计了单独的 invalidate queue 结构,当 CPU 收到来自其它 CPU 的 Invalidate 消息后,并不需要立即去将缓存行置为 Invalid(I)状态,而是将 Invalidate 消息保存到 invalidate queue 之后,就立即给发送消息的 CPU 回复 Invalidate Ack 消息。这样,发送消息的 CPU 收到 Invalidate Ack 的间隔时间就被缩短了,store buffer 也就不容易被写满了。

一旦将一个 invalidate(例如针对变量a的缓存行)消息放入 CPU 的 invalidate queue,该 CPU 就会保证:在处理完该 invalidate 消息之前,不会发送任何相关(即针对变量a的缓存行)的MESI协议消息。

(2)invalidate queue 引入的 CPU 之间的数据不一致问题

a = 0; // 初始 a = 0,且仅在 CPU1 的缓存行中有效
b = 0; // 初始 b = 0,且仅在 CPU0 的缓存行中有效

// CPU0执行
void foo(void)
{
    a = 1;
    smp_mb(); // 添加内存屏障,屏障作用能保证 b = 1 执行之前,a = 1 已被刷新到缓存中
    b = 1;
}

// CPU1执行
void bar(void)
{
    while (b == 0) continue;
    assert(a == 1);
}

还是同样的例子,虽然 smp_mb() 的引入使得 store buffer 的修改能立即可见,但 invalidate queue 引入之后,又会引入新的问题,假设执行的可能顺序如下:

  1. CPU0 执行 a = 1 但 Cache Miss,发送 Invalidate 请求并将 a = 1 记录到 store buffer 中
  2. CPU1 收到 Invalidate 请求,将 Invalidate 请求保存到 invalidate queue 中,并响应 Invalidate Ack
  3. CPU0 接收到 CPU1 的 Invalidate Ack,将 a = 1 刷到缓存行中,内存屏障结束
  4. CPU0 执行 b = 1,直接修改缓存行中 b = 1
  5. CPU1 执行 while (b == 0) 但 Cache Miss,于是发送 Read 请求从 CPU0 读取到 b = 1,退出 while 循环
  6. CPU1 执行 assert(a == 1),此时 CPU1 中的缓存行内 a = 0,于是断言失败
  7. CPU1 开始处理 invalidate queue 中的 invalidate 请求,但已经晚了

可以看到,出现断言失败的根本原因是 invalidate queue 保存了 invalidate 消息,导致 CPU0 对 a = 1 的修改,对 CPU1 暂时不可见,CPU1 继续根据本地缓存中的 a = 0 这个旧值来做断言判断,所以断言失败。站在软件开发者的角度来看,b == 0 的判断和 a == 1 的判断好像乱序了,a ==1 跑到 b == 0之前去执行了。同样的道理,如果 while (b == 0) 之后跟一个写操作,站在软件开发者的角度,写操作好像也跑到 b == 0 之前去执行了。

总结一下:invalidate queue 的引入,CPU 会从本地缓存中读到旧值,会导致出现 读读、读写乱序问题

由于 CPU 无法自动识别变量间的依赖关系,invalidate queue 引发的写操作可见性延迟(从而导致读读和读写乱序问题)无法在硬件层面直接解决;但 invalidate queue 显著降低了 store buffer 被写满的可能性,而且在大多数实际场景中,这类变量间的紧密相关性较少,在大多数场景下都是利大于弊的。为此,CPU 设计者只能间接的提供一些工具来让软件开发者手动的控制这些变量的相关性,这类工具就是 memory-barrier (内存屏障)指令,具体如下:

a = 0; // 初始 a = 0,且仅在 CPU1 的缓存行中有效
b = 0; // 初始 b = 0,且仅在 CPU0 的缓存行中有效

// CPU0执行
void foo(void)
{
    a = 1;
    smp_mb(); // 添加内存屏障,屏障作用能保证 b = 1 执行之前,a = 1 已被刷新到缓存中
    b = 1;
}

// CPU1执行
void bar(void)
{
    while (b == 0) continue;
    smp_mb(); // 添加内存屏障,屏障作用能保证 assert(a == 1) 执行之前,invalidate queue 处理完
    assert(a == 1);
}

smp_mb() 内存屏障的作用就是让 CPU 进入等待状态,直到 invalidate queue 中的 invalidate 消息都被处理完之后,才能继续执行后续的命令。这样就能保证在 assert(a == 1) 执行之前,a = 0 的缓存行已被置为 Invalid(I)状态,CPU1 就会去 CPU0 中读取最新的 a = 1 的缓存行。这样就能解决 invalidate queue 带来的伪读读和伪读写乱序问题了。

x86 架构下并没有引入 invalidate queue,所以 CPU 不会从本地缓存中读到旧值。

5. 写屏障、读屏障和全屏障

之前在处理 store buffer 和 invalidate queue 的可见性延迟导致乱序问题的问题时,都是直接使用的 smp_mb() 内存屏障,该屏障既能确保 store buffer 被完全刷新,又能确保 invalidate queue 被完全处理。但在上述例子中,对于 foo() 函数其实没必要让 invalidate queue 被完全处理,bar() 函数也没必要让 store buffer 被完全刷新。因此大部分的 CPU 架构都提供了弱一点的内存屏障指令,如果只需要确保 store buffer 被完全刷新,就添加写屏障(smp_wmb());如果只需要确保 invalidate queue 被完全处理,就添加读屏障(smp_rmb());如果都要限制,那就使用全屏障(smp_mb())。

在有了弱一点的读写屏障之后,之前的例子就可以改写为以下所示:

void foo(void)
{
    a = 1;
    smp_wmb();
    b = 1;
}

void bar(void)
{
    while (b == 0) continue;
    smp_rmb();
    assert(a == 1);
}

6. 总结一下全过程

为了平衡 CPU 与 内存 的性能差异引入了高速缓存,为了解决缓存一致性问题引入了 MESI 缓存一致性协议,因为 MESI 缓存一致性协议的同步处理降低了非独占缓存行的写性能又引入了 store buffer,因为 store buffer 容易被写满又引入了 invalidate queue,store buffer 和 invalidate queue 的引入又会导致可见性或四大伪乱序问题,为了解决可见性或四大伪乱序问题又引入了内存屏障。

可见性、有序性和原子性问题

1. 可见性

可见性:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。

导致可见性的原因有多种,一般我们主要讨论的就是 CPU 内存模型带来的可见性和 编译器 优化带来的可见性问题。CPU 内存模型带来的可见性问题就是 store buffer 和 invalidate queue 的引入导致的,之前已经讨论过,这里主要说编译器优化带来的可见性问题。

(1)寄存器缓存

public class VisibilityExample {
    private static boolean flag = false;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (!flag) {
                // do nothing
            }
            System.out.println("Flag changed!");
        });

        Thread t2 = new Thread(() -> {
            flag = true; // 
        });

        t1.start();
        t2.start();
    }
}

对于 while (!flag),编译器可能在第一次读取 flag 之后,将 flag = false 的值缓存在寄存器中,导致后续读操作直接从寄存器读取旧值,而非从缓存行加载新值,导致 t1 线程一致死循环无法退出

(2)写操作合并

int x = 0;
// 线程 1(非 volatile 变量)
x = 1;  // 可能被编译器优化为无效操作(若后续有 x=2)
x = 2;  // 直接写入 x=2

编译器可能合并多次写操作为一个写操作,导致其他线程无法及时看到 x = 1 的中间状态。

2. 有序性

有序性:程序的执行顺序应该符合代码的书写顺序,即使在多线程环境中,也要防止由于编译器优化或 CPU 指令重排导致的乱序执行。

导致有序性的原因有多种,一般我们主要讨论的就是 CPU 和 编译器 对指令进行重排序带来的有序性问题。CPU 和 编译器 进行指令重排序的目的是优化性能,但会导致程序执行顺序与代码编写顺序不一致,可能会导致并发问题。

  • 编译器重排序:编译器在生成代码时调整指令顺序。
  • CPU 重排序:CPU 动态调整指令执行顺序。

指令重排序不会随意进行,必须遵循 数据依赖、as-if-serial语义和内存一致性模型约束。

  • 数据依赖原则:如果指令 A 计算出的值被指令 B 依赖,指令 B 不能被重排序到 A 之前。

    int a = 1;
    int b = a + 1;  // 依赖 a 的值,CPU 和 编译器 不能调换顺序
    
  • as-if-serial语义:无论如何优化,在单线程下,程序执行的结果必须与按顺序执行的结果一致。

  • 内存一致性模型约束:通过内存屏障指令,能强制 CPU 在某些关键时刻禁止指令重排序。

数据依赖原则和as-if-serial语义仅能保证在单个线程内的执行结果不被改变,但是在多线程环境下,由于 CPU 和 编译器 无法自动识别变量间的依赖关系,还是会出现有序性问题。多线程环境下,就需要通过手动添加 内存屏障 的方式禁止掉这种重排序。

3. 原子性

原子性:一个操作或一组操作要么全部执行完,要么全部不执行,中间不会被其他线程打断。

CPU 会保证单条指令的执行是不可中断的(例如 cmpxchgl),单个指令的执行具备原子性。但是对于多条指令的组合执行,由于线程上下文切换等因素,执行过程并不是原子的,中间可能会穿插各种指令的执行,就可能会导致线程安全性问题。同时在多核环境下,多个 CPU 内同时执行相同指令也是没办法保证原子性的,还是以 cmpxchgl 为例,该条指令的执行至少包含三个过程:

  1. 读取当前值
  2. 比较当前值和期望值
  3. 修改当前值

虽然单个指令的执行不可中断,但是多个 CPU 核心内同时执行以上操作依然可能会导致值被覆盖等线程安全性问题。

Java 内存模型

1. 为什么需要 Java 内存模型

不同 CPU 架构(如 x86、ARM)之间的内存模型差异很大,例如 store buffer、invalidate queue、缓存一致性协议等机制的实现方式各不相同。这些差异决定了在不同硬件平台上实现线程安全所需的代码也不尽相同,这显然违背了 Java 所倡导的 “一次编写,到处运行” 的核心理念。

此外,底层复杂的内存交互机制也使得 Java 程序员编写线程安全代码变得更加困难。因此,为了屏蔽底层硬件差异、简化并发编程模型,Java 需要在这些复杂的 CPU 内存模型之上抽象出一个统一、简洁的模型。

这个模型就是 Java 内存模型(Java Memory Model, JMM)。它规定了多线程程序中各个变量的可见性、原子性和有序性,并由 JVM 负责将这些高层规则映射到底层不同平台的具体实现中,从而实现跨平台的并发语义一致性。

JMM 解决以下问题:

  • 可见性:一个线程对共享变量的修改,何时对其他线程可见?
  • 有序性:代码的执行顺序是否会被编译器或 CPU 重排?
  • 原子性:哪些操作是不可分割的?

2. Java 内存模型核心概念

(1)主内存与工作内存

  • 主内存(Main Memory):所有线程共享的内存区域,存储全局变量(堆中的对象、静态变量等)。
  • 工作内存(Working Memory):每个线程私有的内存区域,存储线程操作的共享变量副本(可能来自寄存器、L1/L2 缓存等)。
  • 关键规则:线程不能直接操作主内存中的变量,必须通过工作内存的副本来完成。

(2)内存交互的原子操作

JMM 定义了 8 种原子操作(需满足特定顺序和规则):

操作 描述
lock 锁定主内存的变量(如进入 synchronized 块)。
unlock 解锁主内存的变量。
read 从主内存读取变量到工作内存。
load read 得到的值放入工作内存的变量副本。
use 将工作内存中的变量传递给执行引擎(如 CPU)。
assign 将执行引擎的结果赋给工作内存中的变量(如 a = 1)。
store 将工作内存的变量传回主内存。
write store 的值写入主内存的变量。

规则

  • readloadstorewrite 必须成对出现,且顺序不可打乱。
  • 不允许线程丢弃最近的 assign 操作(即变量修改后必须同步到主内存)。
  • 未发生 assign 的变量不能同步到主内存。
  • lock 操作会清空工作内存中的变量副本,强制重新从主内存加载。

3. happens-before 规则

happens-before 是 JMM 的核心规则,定义了操作之间的可见性和顺序性。若操作 A happens-before 操作 B,则 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。具体规则包括:

规则名称 描述
程序顺序规则 单线程内,代码书写顺序决定执行顺序(看似顺序执行)。
锁规则 解锁操作(unlock)happens-before 后续的加锁操作(lock)。
volatile 规则 volatile 变量的写操作 happens-before 后续的读操作。
线程启动规则 Thread.start() happens-before 该线程的所有操作。
线程终止规则 线程的所有操作 happens-before 其他线程检测到该线程终止(如 join())。
传递性规则 若 A happens-before B,且 B happens-before C,则 A happens-before C。

4. 内存屏障

JMM 通过内存屏障指令限制编译器和 CPU 的重排序,分为以下四类:

屏障类型 作用
LoadLoad 确保 Load1 的加载操作在 Load2 及后续加载操作之前完成。
StoreStore 确保 Store1 的写入对其他处理器可见后,再执行 Store2 及后续写入。
LoadStore 确保 Load1 的加载操作在 Store2 及后续写入操作之前完成。
StoreLoad 确保 Store1 的写入对所有处理器可见后,再执行 Load2 及后续加载操作。

实际硬件实现

  • x86:仅需 StoreLoad 屏障(对应 mfence 指令),因为其内存模型较强(如保证写操作按顺序对其他 CPU 可见)。
  • ARM:需要更多的屏障指令,因为其内存模型较弱。

5. 不看了 还是直接看 CPU 内存模型吧

volatile

先说结论,使用 volatile 能保证被修饰变量的读写操作具有可见性和有序性。为此,volatile 必须同时解决以下四个问题:

  1. CPU 内存模型带来的可见性问题
  2. 编译器 优化带来的可见性问题
  3. CPU 指令重排带来的有序性问题
  4. 编译器 指令重排带来的有序性问题

1. x86 架构下的可见性与有序性问题

由于 x86 架构采用强内存模型(TSO,全局存储顺序),规定大部分内存操作必须按照代码顺序执行。可见性和有序性问题存在以下情况:

  • CPU 可见性问题:
    • x86 没有 invalidate queue,不会导致读操作读到旧数据,因此不会出现读读、读写可见性问题。
    • x86 有 store buffer,但强制所有写都进 store buffer,所以不会出现写写可见性问题,只会出现写读可见性问题。
  • CPU 指令重排问题:
    • x86 仅允许 Store-Load 重排序,其他类型的重排序(Load-Load、Load-Store、Store-Store)被硬件禁止。
  • 编译器 可见性问题:
    • 编译器可能会优化为直接使用寄存器缓存数据,而非每次都从缓存行中加载数据,会出现 读 可见性问题。
    • 编译器可能合并或延迟写操作,导致中间状态丢失,会出现 写 可见性问题。
  • 编译器 指令重排问题:
    • 编译器在 x86 下默认遵循 TSO 模型,不会主动引入违反硬件保证的重排序,仅允许 Store-Load 重排序。

2. linux_x86架构下volatile的实现

(1)volatile 读操作

根据 x86 架构下的可见性和有序性问题情况,JVM 对于 volatile 读只需禁用掉编译器优化导致的可见性问题(例如编译器可能降变量缓存到寄存器,每次都从寄存器中读取过期值)。所以直接使用 C/C++ 的 volatile 关键字即可。

具体 volatile 读操作函数(load_acquire())处理如下:

// 使用 C/C++ volatile 修饰符,强制每次都从缓存行中读取数据,禁用寄存器缓存,解决编译器优化带来的可见性问题。
inline juint OrderAccess::load_acquire(volatile juint*p) { return *p; }

(2)volatile 写操作

根据 x86 架构下的可见性和有序性问题情况,JVM 需要处理:

  1. store buffer 导致的 写读 可见性问题
  2. CPU 对 Store-Load 重排序 导致的有序性问题
  3. 编译器 对 Store-Load 重排序 导致的有序性问题
  4. 编译器可能合并或延迟写操作,导致中间状态丢失的可见性问题

对于编译器优化导致的可见性问题,依然可以直接使用 C/C++ 的 volatile 关键字处理。具体 volatile 写操作函数(release_store())处理如下:

// 使用 C/C++ 的 volatile 修饰符,确保编译器不会做写操作合并等操作,解决编译器优化带来的可见性问题。
inline void OrderAccess::release_store(volatile juint* p, juint v) { *p = v; }

对于剩余的三个问题,则在 volatile 写操作函数执行之后,额外执行的 storeload() 函数来处理:

// storeload() 函数体中会调用 fence() 函数
inline void OrderAccess::storeload() { fence(); }

inline void orderaccess::fence() {
    if (os::is_MP()) {
        // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
        __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
        __asm__ volatile ("lock; andl $0,0(%%esp)" : : : "cc", "memory");
#endif
    }
}

这里,lock 前缀会充当一个全屏障(smp_mb())的作用:

  • 执行 lock 指令后,强制刷新 store buffer(使写操作对其他 CPU 立即可见)到缓存行,解决写读可见性问题。
  • 插入 StoreLoad 屏障,禁止 Store-Load 重排序(x86 唯一允许的 CPU 指令重排),保证该指令前的所有 读/写 操作 不会被 CPU 重排序到该指令之后,指令之后的 读/写 操作 不会被 CPU 重排序到该指令之前。解决写读指令重排序问题。

同时,汇编语句中的 "memory" 禁止编译器将屏障前后的内存操作重排序,禁止 Store-Load 重排序,解决编译器指令重排序问题。

CAS

1. CAS 原理

CAS 全称 Compare And Swap(比较并交换),是一种用于在多线程环境下实现轻量级无锁同步机制。CAS 读取变量当前值,与期望值比较,若相等则原子性更新,否则更新失败。在 JUC 并发包中,像 AQS、Atomic 原子类等工具内部都大量使用了CAS。

// AQS 设置 state 值的代码
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

// Atomiclnteger 给变量设置值的代码
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

Java 中 CAS 的功能都是通过 unsafe 类实现的,其内部的 compareAndSwapXXX 等方法都是 native 方法,以 compareAndSwapInt 方法为例,hotspot linux_x86 下实现 CAS 的核心源码如下所示:

#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

// 参数1:修改值
// 参数2:属性的地址
// 参数3:比较值
inline jint  Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint  compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

源码核心点:

  1. C/C++ 的 volatile:禁止编译器优化,解决编译器优化带来的可见性问题
  2. cmpxchgl:原子指令,实现比较并交换的功能
  3. LOCK_IF_MP:多核 CPU 下,给 cmpxchgl 指令添加 lock 前缀
  4. memory:禁止编译器将屏障前后的内存操作重排序

lock 前缀的作用如下:

  1. 保证 cmpxchgl 的原子性:通过锁缓存行或锁总线的方式,避免后续 cmpxchgl 命令的执行被其它 CPU 干扰,保证 cmpxchgl 命令执行的原子性

    CPU 会优先使用锁缓存行的方式,如果实在锁不了再采用锁总线的方式,尽可能的减小锁的粒度
    1. 优先通过缓存一致性协议尝试获取变量所在缓存行的独占权限(M/E),如果获取成功,锁住缓存行,避免其它 CPU 获取缓存行的独占权限,然后再执行 cmpxchgl,保证命令执行的原子性
    2. 如果变量所在缓存的独占权限在多个 CPU 中竞争比较激烈,或者变量保存跨多个缓存行,CPU 会锁住总线,其它 CPU 的任何读写请求所发出的缓存一致性消息都会被阻塞,直到 cmpxchgl 命令执行完成,保证命令执行的原子性
    
  2. 添加全屏障(smp_mb()):保证 cmpxchgl 指令前后的内存操作顺序,防止重排序,并确保可见性。确保 cmpxchgl 执行前,当前 CPU 的无效化队列(Invalidate Queue)被清空(即加载最新值);确保 cmpxchgl 执行后,当前 CPU 的写缓冲区(Store Buffer)被刷新到缓存行(即其他 CPU 可见修改)。

总结 Java CAS 在 hotspot linux_x86 的实现:

  1. 通过 lock 前缀保证后续 cmpxchgl 命令在多核 CPU 内执行的原子性
  2. 通过 lock 前缀保证后续 cmpxchgl 命令执行的可见性和有序性
  3. 通过 C/C++ 的 volatile 禁止编译器优化(例如直接从寄存器中读),解决编译器层面的可见性问题
  4. 通过内联 memory 汇编指令,禁止编译器将屏障前后的内存操作重排序,解决编译器层面的有序性问题

通过上诉四个功能就实现 CAS 的线程安全性。

原子性的保证,本质上就是:cmpxchgl 命令是原子的,单核 CPU 下随便怎么执行都没问题,因为一定可以保证同一时间只会有一个 cmpxchgl 命令在执行,但是在多核 CPU 下同时执行就会打破其原子性,那就在缓存或总线层面加锁,保证多核 CPU 下同时只能有一个 cmpxchgl 命令在执行就可以了。

问题:

  1. 单核单线程单指令、单核单线程多指令、单核多线程单指令、单核多线程多指令会有原子性问题吗?

    答:对于单核单线程,无论是单指令还是多指令都不存在原子性问题,因为 CPU 指令是顺序执行的,不存在被其它线程打断。对于单核多线程单指令,由于单条指令的执行不可中断(硬件保证),所以不存在原子性问题。对于单核多线程多指令,由于时间片轮转,线程内执行多条指令(例如i++)的过程中可能会被中断转而去执行其它线程的指令,所以存在原子性问题。

  2. 既然单核多线程会有原子性,为什么上述 CAS 源码仅在多核下才加 lock 前缀?

    答:CPU 以指令为单位执行操作,单条指令的执行不可中断(硬件保证),所以 cmpxchgl 指令本身在单个 CPU 内的执行就是原子的,因此仅在多核下才需要加 lock 前缀(多个核心执行 cmpxchgl 修改同一个数据,最终覆盖掉彼此的修改,造成原子性破坏)

  3. 既然底层能保证线程安全性,为什么 Java 代码层面还要对修改变量添加 volatile 修饰?

    答:底层仅能保证多个线程并发 CAS 写时的线程安全性。对于普通读操作,如果不加 volatile 修饰,可能会因 invalidate queue 中的 invalidate 消息还未被处理,导致读到旧数据

2. ABA 问题

CAS 只能保证比较并交换操作的原子性,但在执行 CAS 之前,需要先获取当前值。在获取当前值到执行 CAS 之间的时间窗口,其他线程仍然可以修改该共享变量,导致 CAS 无法感知变量的历史变更,从而产生 ABA 问题。

例如,假设共享变量 i = 1,两个线程并发修改 i,执行顺序如下:

线程A                                 线程B
1. 获取 i = 1                        
                                      2. 获取 i = 1
                                      3. CAS(i = 1 → 100),成功
                                      4. 获取 i = 100
                                      5. CAS(i = 100 → 1),成功
6. CAS(i = 1 → 2),成功

尽管线程 A 的 CAS 操作成功,但 i 其实经历了 1 → 100 → 1 的变化,线程 A 误以为 i 始终未变,从而错误地更新了值。这就是ABA 问题。

ABA 问题的本质不是 “CAS 失败” ,而是 “CAS 误判成功”! 线程 A 误以为变量值未变,但实际上它已经被其他线程修改过,只不过又改回了原值而已。

如果业务逻辑不允许 ABA 问题的发生,常见的解决办法就是引入一个版本号(或时间戳)。每次读取变量时,不仅要获取当前值,还要获取当前版本号。CAS 更新时,必须保证值和版本号都相等,才能更新,否则更新失败。 同时,CAS 更新时,除了要修改变量值以外,还需要更新版本号,确保历史变更不会被忽略。

JDK 提供了 AtomicStampedReference 来解决 ABA 问题:

public class ABADemo {
    public static void main(String[] args) {
        AtomicStampedReference<Integer> atomicStamped = new AtomicStampedReference<>(1, 0);

        int[] stampHolder = new int[1];
        int oldValue = atomicStamped.get(stampHolder);  // 获取当前值 + 版本号
        System.out.println("oldValue = " + oldValue);
        int oldStamp = stampHolder[0];
        System.out.println("oldStamp = " + oldStamp);
		
        // CAS 更新值为2,同时版本号 + 1
        boolean success = atomicStamped.compareAndSet(oldValue, 2, oldStamp, oldStamp + 1);
        System.out.println("success = " + success);
    }
}

3. 循环开销问题

CAS 本身只是一次比较并交换的操作,它不涉及循环逻辑,因此单次 CAS 操作并不会带来循环开销。

但在实际应用中,由于 CAS 只能在值匹配时更新成功,如果多个线程竞争同一个变量,可能会出现某些线程 CAS 失败的情况。为了保证最终一定能 CAS 成功,通常会使用 自旋重试,即不断尝试 CAS 直到成功。如果竞争激烈,可能会导致某些线程自旋多次,从而消耗 CPU 资源,这就是循环开销问题。

例如 AtomicIntegergetAndIncrement() 方法,会调用 Unsafe 类的 getAndAddInt() 方法,方法逻辑如下:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);  // 读取当前值
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // 失败则自旋重试
    return var5;
}