线程之间共享变量,却看不到彼此的修改?别急,JMM 有解。
你有没有遇到过这样的情况:明明代码里写的是 flag = true;但另一个线程却一直没看到这个变化?这就是典型的 内存可见性问题,而 Java 内存模型(JMM)就是用来解决这个问题的。
JMM 是 Java 虚拟机中定义的一套规范,它抽象了硬件内存架构的复杂性。换句话说,JMM 是 Java 程序员和底层硬件之间的桥梁,它决定了 Java 程序中变量访问的顺序和可见性。如果你不理解 JMM,就可能在多线程代码中陷入一些看似无解的“逻辑陷阱”。
内存分区模型:主内存 vs 工作内存
JMM 将 Java 程序中的内存分为两个部分:主内存(Main Memory) 和 每个线程的私有工作内存(Working Memory)。主内存是所有线程共享的区域,而工作内存则是线程私有的,保存了主内存中变量的副本。
这意味着,当我们对一个变量进行读写操作时,实际上是先在工作内存中进行操作,再通过内存屏障(Memory Barrier)将数据同步到主内存。这种设计虽然提高了性能,但也带来了问题:线程之间无法直接看到彼此的工作内存中的变量。
比如,一个线程修改了某个变量的值,但另一个线程的工作内存中仍然保存的是旧值,这就可能导致一些逻辑错误。这种现象在 Java 程序中非常常见,尤其是在并发场景下。
与硬件内存架构的关系
现代计算机的硬件架构通常包括多个处理器核心,每个核心都有自己的缓存。当一个线程运行在某个核心上时,它对变量的修改可能会被缓存,不会立即写回主内存。其他线程如果读取的是缓存中的变量,就可能读取到旧值。
这也解释了为什么 JMM 要对内存访问进行规范。它试图模拟硬件行为,让 Java 程序员在不深究底层细节的情况下,也能写出稳定的多线程代码。
典型场景:共享变量的可见性问题
举个例子,如果你写了类似下面的代码:
public class VisibilityExample {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!flag) {
// 线程 1 在这里循环等待 flag 变为 true
}
System.out.println("线程 1 发现 flag 变为 true 了!");
}).start();
Thread.sleep(1000); // 确保线程 1 先启动并进入循环
new Thread(() -> {
flag = true; // 线程 2 更新 flag 的值
System.out.println("线程 2 已将 flag 设置为 true!");
}).start();
}
}
你可能会困惑:为什么线程 1 一直没看到 flag 的变化?这正是 JMM 所导致的 可见性问题。线程 1 工作内存中的 flag 值是 false,它并没有主动去主内存重新获取,所以即使 flag 被线程 2 修改了,线程 1 也无法感知到。
这个问题看似简单,但却容易在实际开发中引发严重后果。比如,你可能需要让一个线程等待另一个线程完成任务,但因为可见性问题,导致程序陷入死循环或逻辑错误。
指令重排序:看似合理却可能出错
编译器和处理器为了提高性能,常常会对指令进行重排序。这听起来像是一个好主意,但有时候却会导致 不可预测的内存可见性问题。
比如,下面这个例子:
public class ReorderExample {
private static int x = 0;
private static int y = 0;
private static int a = 0;
private static int b = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
a = 1; // 操作 1
x = b; // 操作 2
});
Thread thread2 = new Thread(() -> {
b = 1; // 操作 3
y = a; // 操作 4
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("x = " + x + ", y = " + y);
}
}
你可能会期望 x 和 y 的值都为 1,但实际运行中,它们可能都是 0。为什么会这样?因为指令重排序让操作 2 优先于操作 1 执行,同时操作 4 优先于操作 3。线程 1 和线程 2 的工作内存没有同步到主内存,导致最终输出错误。
解决方案:volatile 和 synchronized
为了解决这些问题,Java 提供了 volatile 和 synchronized 这两个关键字。
volatile 保证了变量的可见性,即当一个线程修改了 volatile 变量的值,其他线程会立即看到这个变化。它禁止编译器和处理器对 volatile 变量的读写进行指令重排序,从而避免了潜在的可见性问题。
比如,将 flag 声明为 volatile:
private static volatile boolean flag = false;
这样,线程 1 在每次检查 flag 的值时,都会直接读取主内存中的最新值,而不是依赖于工作内存中缓存的旧值。
synchronized 则提供了更强大的保障。当一个线程进入 synchronized 代码块时,它会将工作内存中的变量值写回主内存。当它退出代码块时,又会从主内存中读取最新的变量值。这种机制确保了线程之间对共享变量的可见性。
happens-before 原则:内存可见性的终极判断标准
为了更系统地判断内存可见性,JMM 引入了 happens-before 原则。这个原则定义了两个操作之间是否具有可见性。如果 A happens-before B,那么 A 的结果会对 B 可见。
happens-before 的规则包括:
- 程序顺序规则:一个线程中,前一个操作 happens-before 后一个操作。
- 锁定规则:对一个锁的解锁操作 happens-before 后续对同一个锁的加锁操作。
- volatile 变量规则:对 volatile 变量的写操作 happens-before 后面对该变量的读操作。
- 传递性规则:如果 A happens-before B,B happens-before C,那么 A happens-before C。
- 线程启动规则:start() 方法调用 happens-before 线程的每一个动作。
- 线程终止规则:线程中的所有操作 happens-before 对该线程的终止检测。
- 线程中断规则:interrupt() 方法调用 happens-before 被中断线程的代码检测到中断事件。
- 对象终结规则:一个对象的初始化完成 happens-before 它的 finalize() 方法的开始。
通过这些规则,我们可以更清晰地判断哪些操作是“可见”的,哪些是“不可见”的。这在编写并发代码时尤为重要。
实战经验:避免可见性问题的几个技巧
虽然 JMM 和 happens-before 原则提供了理论保障,但在实际开发中,我们仍然需要关注一些细节:
- 避免在循环中使用 volatile 变量:volatile 虽然能保证可见性,但无法保证循环中的变量状态及时刷新。如果要实现类似“等待某个条件满足”这样的功能,建议使用 synchronized 或 Lock 机制。
- 合理使用 synchronized:synchronized 虽然能解决可见性问题,但可能会带来性能损失。因此,在不需要同步的场景中要避免滥用。
- 理解指令重排序的代价:虽然指令重排序提高了性能,但会导致内存可见性问题。我们可以在代码中使用 内存屏障(Memory Barrier) 来阻止重排序。
总结与思考
JMM 是 Java 并发编程的基石,它决定了我们如何理解和处理多线程环境下的内存可见性问题。掌握 JMM 不仅能帮助我们写出更安全的代码,还能让我们对底层机制有更深的认识。
但你有没有想过:如果 JMM 被彻底重构,我们是否还能用现有的并发工具和模式?这个问题或许值得我们在未来深入探讨。
关键字:Java 内存模型, 内存可见性, volatile, synchronized, happens-before, 多线程, 并发编程, 指令重排序, 线程安全, JVM, 高并发