Java · #java#jmm#memory-model#happens-before

深入理解Java内存模型(JMM)

2024.11.06 Java 8 min 3.4k
// 目录 · contents

1. 引言

当我们编写多线程程序时,一个看似正确的代码可能在运行时产生意想不到的结果。这并非程序逻辑有误,而是因为现代处理器和编译器为了提升性能会对指令进行重排序,而Java内存模型(Java Memory Model, JMM)正是定义了在这种复杂环境下,多线程程序如何正确地共享数据。

JMM是理解Java并发编程的理论基础。不理解JMM,就很难真正理解volatilesynchronizedfinal这些关键字在并发环境下的行为。

2. JMM的结构与抽象

2.1 主内存与工作内存

JMM将内存抽象为主内存(Main Memory)和工作内存(Working Memory)两个层次:

graph TB
    subgraph 主内存 Main Memory
        MV[共享变量]
    end
    subgraph 线程1工作内存
        WM1[变量副本1]
    end
    subgraph 线程2工作内存
        WM2[变量副本2]
    end
    subgraph 线程3工作内存
        WM3[变量副本3]
    end

    WM1 <-->|read/write| MV
    WM2 <-->|read/write| MV
    WM3 <-->|read/write| MV

    style MV fill:#FFD700,stroke:#333
    style WM1 fill:#87CEEB
    style WM2 fill:#87CEEB
    style WM3 fill:#87CEEB
  • 主内存:所有线程共享的内存区域,存储共享变量的”官方值”
  • 工作内存:每个线程私有,存储该线程使用的共享变量副本

线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接操作主内存。线程间变量值的传递必须通过主内存完成。

2.2 内存交互操作

JMM定义了8种原子操作来完成主内存与工作内存之间的交互:

sequenceDiagram
    participant WM as 工作内存
    participant MM as 主内存

    Note over MM: lock (锁定)
    MM->>WM: read (读取) + load (载入)
    Note over WM: use (使用) - 传给执行引擎
    Note over WM: assign (赋值) - 执行引擎写回
    WM->>MM: store (存储) + write (写入)
    Note over MM: unlock (解锁)
操作 作用域 说明
lock 主内存 将变量标识为线程独占状态
unlock 主内存 释放锁定的变量
read 主内存 将变量值从主内存传输到工作内存
load 工作内存 将read到的值放入工作内存的变量副本
use 工作内存 将工作内存中的值传给执行引擎
assign 工作内存 将执行引擎的值赋给工作内存中的变量
store 工作内存 将工作内存中的变量值传送到主内存
write 主内存 将store传来的值写入主内存变量

3. 重排序

为了提升执行效率,编译器和处理器会对指令进行重排序。重排序分为三种类型:

graph LR
    A[源代码] -->|1.编译器优化重排序| B[编译后指令]
    B -->|2.指令级并行重排序| C[处理器执行序列]
    C -->|3.内存系统重排序| D[最终执行效果]
    style A fill:#E8F5E8
    style D fill:#FFE4E1
  1. 编译器优化重排序:编译器在不改变单线程语义的前提下重新安排语句执行顺序
  2. 指令级并行重排序:现代处理器使用指令级并行技术将多条指令重叠执行
  3. 内存系统重排序:处理器使用写缓冲区、无效化队列,导致加载和存储操作看起来被重排序

3.1 重排序带来的问题

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
/**
* 重排序导致的可见性问题示例
* 理论上result可能为0,违反直觉
*/
public class ReorderingDemo {
private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
int count = 0;
while (true) {
x = 0; y = 0; a = 0; b = 0;
count++;

Thread t1 = new Thread(() -> {
a = 1; // 语句1
x = b; // 语句2
});
Thread t2 = new Thread(() -> {
b = 1; // 语句3
y = a; // 语句4
});
t1.start();
t2.start();
t1.join();
t2.join();

// 如果发生了重排序(语句2在语句1之前执行,语句4在语句3之前执行)
// 那么x == 0 && y == 0 是可能出现的
if (x == 0 && y == 0) {
System.out.println("Reordering detected at iteration: " + count);
break;
}
}
}
}

4. happens-before规则

JMM通过happens-before关系来保证跨线程的内存可见性。如果操作A happens-before操作B,那么A的执行结果对B可见,且A的执行顺序排在B之前(从内存可见性角度)。

4.1 八大happens-before规则

graph TD
    HB[happens-before规则] --> R1[1. 程序顺序规则]
    HB --> R2[2. 监视器锁规则]
    HB --> R3[3. volatile变量规则]
    HB --> R4[4. 线程启动规则]
    HB --> R5[5. 线程终止规则]
    HB --> R6[6. 线程中断规则]
    HB --> R7[7. 对象终结规则]
    HB --> R8[8. 传递性规则]

    R1 -->|"同一线程中前面的操作<br/>hb后面的操作"| E1[单线程内有序]
    R2 -->|"unlock hb<br/>后续的lock"| E2[锁的可见性]
    R3 -->|"volatile写 hb<br/>后续的volatile读"| E3[volatile可见性]

    style HB fill:#FFD700,stroke:#333,stroke-width:2px
规则 说明
程序顺序规则 一个线程中每个操作happens-before该线程中后续操作
监视器锁规则 对一个锁的unlock操作happens-before后续对同一个锁的lock操作
volatile变量规则 对一个volatile变量的写操作happens-before后续对该变量的读操作
线程启动规则 Thread.start() happens-before 该线程中的任何操作
线程终止规则 线程中的所有操作happens-before其他线程检测到该线程终止(join/isAlive)
线程中断规则 对线程interrupt()的调用happens-before被中断线程检测到中断事件
对象终结规则 对象的构造函数执行完毕happens-before finalize()方法
传递性 如果A happens-before B,B happens-before C,则A happens-before C

4.2 happens-before与实际执行顺序

需要注意的是,happens-before并不意味着实际的执行顺序。JMM允许在不影响happens-before语义的情况下进行重排序:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 虽然语句1 happens-before 语句2(程序顺序规则),
* 但因为两者之间没有数据依赖,编译器/处理器可能重排序它们的实际执行顺序。
* 只要最终结果与顺序执行一致即可。
*/
public class HappensBeforeDemo {
public void example() {
int a = 1; // 语句1
int b = 2; // 语句2 -- 可能与语句1重排序
int c = a + b; // 语句3 -- 依赖语句1和2,不能被重排到它们之前
}
}

5. volatile的内存语义

5.1 volatile的两大特性

volatile关键字提供了两个语义保证:

  1. 可见性:对volatile变量的写操作会立即刷新到主内存,读操作会从主内存重新读取
  2. 有序性:禁止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
public class VolatileDemo {
private volatile boolean flag = false;
private int data = 0;

/**
* 线程A执行
* 由于flag是volatile的:
* - data = 42 不会被重排到 flag = true 之后
* - flag = true 会立即刷新到主内存
*/
public void writer() {
data = 42; // 普通写
flag = true; // volatile写 -- 充当内存屏障
}

/**
* 线程B执行
* 由于flag是volatile的:
* - 读取flag会从主内存刷新
* - 读取data不会被重排到读取flag之前
* - 如果看到flag == true,则必然能看到data == 42
*/
public void reader() {
if (flag) { // volatile读 -- 充当内存屏障
System.out.println(data); // 保证输出42
}
}
}

5.2 volatile的内存屏障

JMM通过插入内存屏障(Memory Barrier)来实现volatile的语义:

graph TB
    subgraph volatile写操作
        SW1[StoreStore屏障] --> VW[volatile写]
        VW --> SW2[StoreLoad屏障]
    end
    subgraph volatile读操作
        VR[volatile读] --> SR1[LoadLoad屏障]
        SR1 --> SR2[LoadStore屏障]
    end

    style VW fill:#FF6347
    style VR fill:#4169E1,color:#fff
    style SW1 fill:#FFD700
    style SW2 fill:#FFD700
    style SR1 fill:#FFD700
    style SR2 fill:#FFD700
屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 确保Load1在Load2之前执行
StoreStore Store1; StoreStore; Store2 确保Store1在Store2之前刷新到主内存
LoadStore Load1; LoadStore; Store2 确保Load1在Store2之前执行
StoreLoad Store1; StoreLoad; Load2 确保Store1刷新到主内存后才执行Load2,开销最大

5.3 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
/**
* volatile不能保证复合操作的原子性
* count++不是原子操作:read-modify-write
*/
public class VolatileNotAtomic {
private volatile int count = 0;

// 这个方法在多线程下是不安全的
public void unsafeIncrement() {
count++; // 非原子操作:读取 -> 加1 -> 写回
}

// 正确做法1:使用synchronized
public synchronized void safeIncrement1() {
count++;
}

// 正确做法2:使用AtomicInteger
private final java.util.concurrent.atomic.AtomicInteger atomicCount =
new java.util.concurrent.atomic.AtomicInteger(0);

public void safeIncrement2() {
atomicCount.incrementAndGet();
}
}

6. 双重检查锁定(DCL)与volatile

双重检查锁定是volatile最经典的应用场景之一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
// 必须用volatile修饰,禁止指令重排序
private static volatile Singleton instance;

private Singleton() {
// 私有构造函数
}

public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton(); // 关键点
}
}
}
return instance;
}
}

为什么必须用volatile?因为instance = new Singleton()不是原子操作,它实际上包含三个步骤:

graph LR
    S1["1. 分配内存空间"] --> S2["2. 初始化对象"]
    S2 --> S3["3. 将引用指向内存地址"]

    S1b["1. 分配内存空间"] --> S3b["2. 将引用指向内存地址<br/>(对象尚未初始化!)"]
    S3b --> S2b["3. 初始化对象"]

    subgraph 正常顺序
        S1
        S2
        S3
    end
    subgraph 重排序后
        S1b
        S3b
        S2b
    end

    style S3b fill:#FF6347,color:#fff

如果发生重排序,步骤2和3交换,另一个线程可能在第一次检查时看到instance != null,但获取到的是一个尚未完成初始化的对象,导致程序出错。volatile通过禁止重排序解决了这个问题。

7. final字段的内存语义

JMM对final字段也有特殊的内存语义保证:

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
public class FinalFieldExample {
private final int x;
private final int[] arr;
private int y; // 普通字段,无特殊保证

public FinalFieldExample() {
x = 42; // final字段写入
arr = new int[]{1, 2, 3}; // final字段写入(引用类型)
y = 100; // 普通字段写入
}
// 构造函数结束时,会插入StoreStore屏障
// 确保final字段在对象引用发布前已初始化完成

/**
* JMM保证:
* 1. 在构造函数内对final字段的写入,与对象引用赋值不会重排序
* 2. 其他线程通过对象引用首次读到final字段时,能看到正确的初始化值
* 3. final引用类型指向的对象(如数组),其成员在构造函数中的初始化也有保证
*
* 前提条件:对象引用不能在构造函数中逸出(this引用不能泄露)
*/
public static void reader(FinalFieldExample obj) {
int localX = obj.x; // 保证读到42
int[] localArr = obj.arr; // 保证读到已初始化的数组
int localY = obj.y; // 不保证读到100(普通字段无保证)
}
}

this引用逸出的反面示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 错误示例:构造函数中this引用逸出
* final字段的可见性保证将失效
*/
public class ThisEscapeExample {
private final int value;
private static ThisEscapeExample instance;

public ThisEscapeExample(int v) {
instance = this; // 危险!this引用在构造函数完成前逸出
value = v; // final字段可能还未初始化
}
// 其他线程通过instance读取value时,可能看到默认值0而非v
}

8. 最佳实践

  1. 正确使用volatile:仅适用于一写多读的简单标志位场景,不能替代锁来保护复合操作。
  2. 理解happens-before:这是分析并发正确性的根本工具,遇到并发问题时先从happens-before关系入手。
  3. 不要依赖指令执行顺序:除非有happens-before关系保证,否则不要假设任何执行顺序。
  4. 安全发布对象:使用volatile、synchronized、final或并发容器来安全地发布共享对象。
  5. 避免构造函数中的this逸出:否则final字段的语义保证将失效。
  6. 使用高层并发工具:优先使用java.util.concurrent包提供的线程安全容器和同步工具,而非自己实现底层同步。

9. 总结

Java内存模型是连接Java语言层面并发语义和底层硬件内存架构的桥梁。它通过以下核心机制保证了多线程程序的正确性:

  • happens-before规则定义了操作之间的可见性保证,是并发正确性的理论基础
  • volatile通过内存屏障实现了可见性和有序性,是轻量级的同步手段
  • synchronized通过Monitor机制提供了原子性、可见性和有序性的完整保证
  • final通过特殊的内存语义保证了不可变对象的安全发布

理解JMM不仅有助于编写正确的并发程序,更能帮助我们理解各种并发工具和设计模式的底层原理,是成为高级Java开发者的必经之路。

作者 · authorzt
发布 · date2024-11-06
篇幅 · length3.4k 字 · 8 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论