多线程编程的混乱
现代计算机CPU有多级缓存,每个核心有自己的缓存,且编译器/处理器会进行指令重排序优化。这导致:
- 可见性问题:线程A修改了共享变量,但修改后的值可能只写回了自己的CPU缓存,没有及时同步到主内存,线程B因此看不到最新值。
- 有序性问题:代码的编写顺序不等于最终的执行顺序,可能导致意想不到的结果。
JMM
这就需要Java内存模型了,JMM 主要定义了对于一个共享变量,当一个线程执行写操作后,该变量对其他线程的可见性。
它不是指Java程序运行时真实的物理内存结构(如堆、栈等),而是一套抽象的规范。这套规范定义了:
- Java程序中各种变量(线程共享变量)的访问规则。
- 以及在JVM中,将变量存储到内存 和从内存读取变量的底层细节。
其存在目的是解决在多线程并发环境下,由于CPU缓存、指令重排序等问题导致的内存可见性、原子性、有序性 问题,为多线程编程提供一个一致的、可预测的内存访问视图。
JMM如何抽象线程和主内存之间的关系
JMM通过一个简化的三层抽象模型来定义线程、工作内存和主内存之间的关系:
-
主内存(Main Memory) :
- 存储所有共享变量(实例字段、静态字段、数组元素)
- 是线程间共享的内存区域
- 相当于硬件内存的抽象
-
工作内存(Working Memory) :
- 每个线程私有的存储区域
- 存储该线程使用的变量的主内存副本拷贝
- 包含栈帧中的局部变量表、操作数栈、动态链接、方法出口等信息
- 相当于CPU寄存器+高速缓存的抽象
-
线程(Thread) :
- 程序执行的最小单位
- 所有变量操作都发生在工作内存中
- 不能直接读写主内存中的变量
JMM定义了8种原子操作来完成线程、工作内存、主内存之间的交互:
- lock(锁定) : 作用于主内存变量,将其标识为线程独占状态
- unlock(解锁) : 作用于主内存变量,释放锁定状态
- read(读取) : 作用于主内存,将变量值传输到线程工作内存
- load(载入) : 作用于工作内存,将read得到的值放入变量副本
- use(使用) : 作用于工作内存,将变量值传递给执行引擎
- assign(赋值) : 作用于工作内存,将执行引擎接收的值赋给变量
- store(存储) : 作用于工作内存,将变量值传输到主内存
- write(写入) : 作用于主内存,将store传输的值放入变量
线程要读取变量x: [主内存] --read--> [数据传输] --load--> [工作内存] --use--> [执行引擎]
线程要修改变量x: [执行引擎] --assign--> [工作内存] --store--> [数据传输] --write--> [主内存]
案例1:工作内存与主内存的数据不一致的案例
arduino
public class MemoryVisibilityDemo {
private static boolean ready = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread readerThread = new Thread(() -> {
// 工作内存中:ready的初始值是false
while (!ready) {
// 空转,等待ready变为true
// 问题:线程可能永远读取不到主内存中最新的ready值
}
// 如果看到ready为true,打印number
System.out.println("Number: " + number);
});
Thread writerThread = new Thread(() -> {
// 在工作内存中修改number
number = 42;
// 在工作内存中修改ready
ready = true;
// 注意:此时修改可能还停留在工作内存,没有刷新到主内存
});
readerThread.start();
writerThread.start();
readerThread.join();
writerThread.join();
}
}
以上代码的执行流程: 时间线:
-
writerThread启动:
- 从主内存read-load number副本到工作内存
- assign number=42到工作内存副本
- 从主内存read-load ready副本到工作内存
- assign ready=true到工作内存副本
- (没有立即store-write回主内存!)
-
readerThread启动:
- 从主内存read-load ready副本到工作内存(此时主内存中ready=false)
- 循环检查工作内存中的ready副本(始终为false)
- 永远无法看到writerThread的修改!
结果:可能死循环,也可能正常结束(依赖具体JVM实现和硬件)
案例2:使用volatile改进
arduino
public class VolatileVisibilityDemo {
// 使用volatile修饰,保证可见性
private static volatile boolean ready = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread readerThread = new Thread(() -> {
while (!ready) {
// 由于ready是volatile,每次循环都会从主内存重新读取
}
System.out.println("Number: " + number);
});
Thread writerThread = new Thread(() -> {
number = 42;
// volatile写操作:1. 立即将工作内存的值刷新到主内存
// 2. 使其他CPU缓存中该变量的缓存行无效
ready = true;
});
readerThread.start();
Thread.sleep(100); // 确保reader先运行
writerThread.start();
readerThread.join();
writerThread.join();
}
}
以上代码的执行过程: writerThread的volatile写操作:
- assign ready=true (工作内存)
- store ready (工作内存 -> 传输)
- write ready (传输 -> 主内存) [立即执行,插入StoreStore内存屏障]
- 发送缓存失效信号给其他CPU
readerThread的volatile读操作:
- 发现本地缓存失效
- read ready (主内存 -> 传输)
- load ready (传输 -> 工作内存) [插入LoadLoad内存屏障]
- use ready (工作内存 -> 执行引擎)
案例3:synchronized的完整内存语义
typescript
public class SynchronizedMemoryDemo {
private int sharedValue = 0;
private final Object lock = new Object();
public void writer() {
synchronized(lock) {
// 1. 进入同步块:清空工作内存,从主内存重新加载所有共享变量
sharedValue = 100;
// 2. 修改只发生在工作内存
}
// 3. 退出同步块:将工作内存的修改刷新到主内存
}
public void reader() {
synchronized(lock) {
// 4. 进入同步块:清空工作内存,从主内存重新加载所有共享变量
System.out.println("Value: " + sharedValue); // 保证看到最新值
}
}
public static void main(String[] args) {
SynchronizedMemoryDemo demo = new SynchronizedMemoryDemo();
Thread t1 = new Thread(demo::writer);
Thread t2 = new Thread(demo::reader);
t1.start();
t2.start();
}
}
上述代码的执行过程:
monitorenter(获取锁):
- 清空工作内存中所有共享变量的副本
- 从主内存重新加载所有需要的共享变量
- 执行同步块内的代码
同步块内执行:
- 所有操作都在工作内存中进行
- 修改不会立即写回主内存
monitorexit(释放锁):
- 将工作内存中修改的所有共享变量刷新到主内存
- 释放锁
内存屏障(Memory Barrier)
内存屏障,也可以称为内存栅栏(Memory Fence),它是在程序中对内存访问操作(读/写)施加的一种特殊约束。
核心作用:
- 保证特定操作的顺序 :确保屏障前的某些操作必须在屏障后的某些操作之前完成。
- 保证内存的可见性:确保一个线程对共享变量的修改能立即对其他线程可见。
JMM通过在适当位置插入内存屏障来保证可见性和有序性:
csharp
public class MemoryBarrierExample {
private int x = 0;
private volatile boolean v = false;
public void writer() {
x = 1; // 普通写
// StoreStore屏障:确保x=1先于v=true刷新到主内存
v = true; // volatile写(隐含内存屏障)
}
public void reader() {
if (v) { // volatile读(隐含内存屏障)
// LoadLoad屏障:确保读取x之前,v的读取已完成
int r = x; // 看到x=1
}
}
}
happends-before原则
Happens-Before 原则 是 Java 内存模型(JMM)中定义的一组跨线程操作间的可见性保证规则 。它规定了在什么情况下,一个线程对共享变量的修改结果必须对另一个线程可见。
如果一个操作 A happens-before 操作 B,那么:
- A 操作对内存的影响(如写入的变量值)在 B 操作执行时是可见的
- 编译器和处理器可以对指令重排序,但必须遵守 happens-before 规则
它是为了解决编译器和处理器会进行指令重排序优化的问题
问题示例:
csharp
// 问题示例
public class VisibilityProblem {
private boolean flag = false;
private int value = 0;
public void writer() {
value = 42; // 操作1
flag = true; // 操作2 ← 可能被重排序到操作1之前
}
public void reader() {
if (flag) { // 可能看到true,但value仍为0!
System.out.println(value);
}
}
}
没有 happens-before 时的问题:
- 编译器/CPU 可能重排序操作1和操作2
- 线程B可能看到
flag=true但value=0 - 不同线程对操作执行顺序可能有不同视角
8条happens-before原则
规则1:程序顺序规则(Program Order Rule)
在同一个线程中,按照程序代码顺序,前面的操作 happens-before 后面的操作。
arduino
public class ProgramOrderRule {
int x = 0;
boolean ready = false;
// 线程A执行
public void writer() {
x = 1; // 操作1
ready = true; // 操作2
// 在同一个线程内:操作1 happens-before 操作2
// 单线程视角下,操作顺序固定
}
}
规则2:监视器锁规则(Monitor Lock Rule)
对一个锁的解锁 happens-before 随后对这个锁的加锁。
typescript
public class MonitorLockRule {
private final Object lock = new Object();
private int sharedData = 0;
public void writer() {
synchronized(lock) { // 加锁
sharedData = 42; // 操作1
} // 解锁
// 解锁 happens-before 后续对这个锁的加锁
}
public void reader() {
synchronized(lock) { // 加锁(看到writer的解锁)
System.out.println(sharedData); // 保证看到42
}
}
}
规则3:volatile变量规则(Volatile Variable Rule)
对一个 volatile 变量的写操作 happens-before 后续对这个变量的读操作。
csharp
public class VolatileRule {
private volatile boolean flag = false;
private int data = 0;
public void writer() {
data = 42; // 操作1
flag = true; // 操作2(volatile写)
// 操作1 happens-before 操作2(程序顺序)
// volatile写 happens-before 后续的volatile读
}
public void reader() {
if (flag) { // volatile读
// 保证能看到 data = 42
System.out.println(data);
}
}
}
规则4:线程启动规则(Thread Start Rule)
线程的 start() 方法调用 happens-before 该线程的任何操作。
arduino
public class ThreadStartRule {
private int data = 0;
public void test() {
data = 100;
Thread thread = new Thread(() -> {
// 这里能看到 data = 100
System.out.println("Thread sees data: " + data);
});
// main线程中data的赋值 happens-before thread.start()
thread.start();
}
}
规则5:线程终止规则(Thread Termination Rule)
线程中的所有操作 happens-before 其他线程检测到该线程已经终止(如 join() 返回)。
arduino
public class ThreadJoinRule {
private int result = 0;
public void test() throws InterruptedException {
Thread thread = new Thread(() -> {
result = 42; // 线程中的操作
});
thread.start();
thread.join(); // 等待线程结束
// 线程中的所有操作 happens-before join()返回
System.out.println(result); // 保证看到42
}
}
规则6:线程中断规则(Thread Interrupt Rule)
对线程 interrupt() 的调用 happens-before 被中断线程检测到中断。
arduino
public class InterruptRule {
public void test() {
Thread thread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 工作
}
// 能感知到中断状态
});
thread.start();
thread.interrupt(); // happens-before 线程检测到中断
}
}
规则7:对象终结规则(Finalizer Rule)
对象的构造方法结束 happens-before finalize() 方法的开始。
typescript
public class FinalizerRule {
private final Object resource;
public FinalizerRule() {
this.resource = new Object();
// 构造函数中的操作 happens-before finalize()
}
@Override
protected void finalize() {
// 这里能访问构造函数初始化的resource
cleanup(resource);
}
}
规则8:传递性规则(Transitivity Rule)
如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
Happens-Before 原则的本质 :Java 内存模型为程序员提供的一组最小保证,在这些保证下,即使存在指令重排序和内存可见性问题,程序的行为也是可预测的。