Java volatile 与内存可见性——happens-before 规则的工程实践
Java volatile 与内存可见性——happens-before 规则的工程实践
适读人群:Java后端开发,对volatile和happens-before感觉模糊的工程师 | 阅读时长:约15分钟 | 核心价值:彻底搞清楚volatile能做什么、不能做什么,以及happens-before规则的实际意义
一个让我困惑了很久的Bug
2021年我写了一个状态检查循环,代码极其简单:
private boolean running = true;
public void startWorker() {
new Thread(() -> {
while (running) {
doWork();
}
System.out.println("worker stopped");
}).start();
}
public void stop() {
running = false;
}调用stop()之后,worker线程死活不停,while (running)永远是true。
我当时以为是doWork()里有什么问题,查了半天,后来一个老同事看了一眼说:"加个volatile"。加上volatile boolean running = true之后,立刻好了。
我当时很懵:这不是同一块内存吗?为什么一个线程写了false,另一个线程看不到?
要回答这个问题,需要从Java内存模型讲起。
Java内存模型:为什么会有可见性问题
现代CPU有多级缓存(L1/L2/L3)。每个CPU核心有自己的缓存,多个核心共享主内存。
核心1 核心2
+--------+ +--------+
| L1缓存 | | L1缓存 |
| running | | running |
| =true | | =true |
+--------+ +--------+
\ /
+----主内存-----+
| running=? |
+---------------+线程1(核心1上)把running改为false,可能只写到了L1缓存,还没刷到主内存。线程2(核心2上)读running,读到的是自己缓存里的true。这就是可见性问题。
Java内存模型(JMM)对这个问题的解答是:除非存在happens-before关系,否则一个线程的写操作对另一个线程不保证可见。
volatile 的两个保证
volatile关键字提供两个保证:
1. 可见性保证
对volatile变量的写操作,会立即刷新到主内存;对volatile变量的读操作,会从主内存读取最新值,而不是使用缓存。
2. 禁止指令重排序
volatile变量的读写操作前后会插入内存屏障,禁止编译器和CPU对其进行指令重排序。
内存屏障的具体位置:
- 写volatile之前:插入StoreStore屏障(前面的普通写不能被重排到volatile写后面)
- 写volatile之后:插入StoreLoad屏障(volatile写不能被重排到后面的读操作前面)
- 读volatile之前:插入LoadLoad屏障
- 读volatile之后:插入LoadStore屏障
这个禁止重排序的特性,是实现正确单例模式和安全发布对象的关键。
happens-before 规则完整列表
happens-before是JMM对程序员的承诺:如果A happens-before B,那么A的所有内存写操作对B可见。
JMM定义了以下天然的happens-before关系:
1. 程序顺序规则 同一线程内,前面的操作happens-before后面的操作。
int a = 1; // A
int b = a + 1; // B
// A happens-before B,B能看到a=12. volatile变量规则 对volatile变量的写,happens-before后续对该变量的读。
volatile int flag = 0;
// 线程1
sharedData = "hello"; // 普通写
flag = 1; // volatile写
// 线程2
if (flag == 1) { // volatile读
use(sharedData); // 能看到 "hello",因为写flag happens-before 读flag
}3. 监视器锁规则 对锁的解锁,happens-before后续对同一个锁的加锁。
4. 线程启动规则thread.start(),happens-before新线程的所有操作。
5. 线程终止规则 线程中的所有操作,happens-before其他线程检测到该线程终止(通过join()或isAlive())。
6. 中断规则 线程A调用B的interrupt(),happens-before B检测到中断。
7. 传递性 如果A happens-before B,B happens-before C,则A happens-before C。
完整代码:volatile 的正确使用场景
import java.util.concurrent.TimeUnit;
/**
* 演示 volatile 的正确使用场景
* 包含:状态标志、单次安全发布、双重检查单例
*/
public class VolatileUsageDemo {
// ============ 场景一:状态标志 ============
/**
* 正确:volatile 用于状态标志
* 只需要可见性,不需要原子性(boolean赋值本身是原子的)
*/
static class WorkerThread extends Thread {
private volatile boolean stopped = false;
@Override
public void run() {
while (!stopped) {
doWork();
}
System.out.println("Worker stopped gracefully");
}
public void stopWorker() {
stopped = true;
}
private void doWork() {
try { TimeUnit.MILLISECONDS.sleep(100); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
// ============ 场景二:安全发布对象(单次写多次读)============
/**
* 正确:volatile 用于确保对象发布的安全性
* 写线程初始化完对象后再写 volatile,保证读线程能看到完整初始化的对象
*/
static class SafePublication {
private volatile Config config; // volatile 确保引用和对象内容都可见
static class Config {
final String host;
final int port;
Config(String host, int port) {
this.host = host;
this.port = port;
}
}
// 写线程调用
public void updateConfig(String host, int port) {
Config newConfig = new Config(host, port); // 先完整创建对象
this.config = newConfig; // 再赋值给 volatile 字段
// 读线程通过 volatile 读能看到完整的 Config 对象
}
// 读线程调用
public Config getConfig() {
return config; // volatile 读
}
}
// ============ 场景三:双重检查锁单例(DCL)============
/**
* 正确:DCL 单例,必须用 volatile 修饰 instance
* 没有 volatile 时,new Singleton() 可能被指令重排序为:
* 1. 分配内存
* 2. 将引用赋给 instance(此时 instance != null,但对象还没初始化完!)
* 3. 执行构造函数
* 如果步骤2和3重排序,另一个线程看到 instance != null 但拿到未初始化的对象
*/
static class Singleton {
private static volatile Singleton instance; // 必须加 volatile
private final String data;
private Singleton() {
// 模拟耗时初始化
this.data = "initialized data";
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(加锁)
instance = new Singleton();
}
}
}
return instance;
}
public String getData() { return data; }
}
// ============ 反例:volatile 不能解决复合操作的原子性 ============
/**
* 错误示范:volatile 不能保证 i++ 的原子性
* i++ 实际是三步:读i → 加1 → 写i,不是原子操作
*/
static class WrongCounter {
private volatile int count = 0;
public void increment() {
count++; // 危险!不是原子操作,并发下会丢失更新
}
// 正确做法:使用 AtomicInteger
// private final AtomicInteger count = new AtomicInteger(0);
// public void increment() { count.incrementAndGet(); }
}
public static void main(String[] args) throws Exception {
// 测试状态标志
WorkerThread worker = new WorkerThread();
worker.start();
Thread.sleep(500);
worker.stopWorker();
worker.join();
System.out.println("测试完成");
// 测试单例
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println("单例验证: " + (s1 == s2));
}
}happens-before 的工程实践案例
案例一:利用 volatile 实现轻量级读写协议
/**
* 利用 volatile 的 happens-before 传递性
* 实现:写线程的普通写,通过 volatile 写,让读线程可见
*/
public class VolatileProtocol {
private int value; // 普通变量
private volatile int version = 0; // 版本号,充当 happens-before 桥梁
// 写线程
public void write(int newValue) {
value = newValue; // 普通写
version++; // volatile写:触发 StoreStore 屏障,确保 value 先刷新
}
// 读线程
public int read() {
int v = version; // volatile读:LoadLoad 屏障,确保后续读到最新值
return value; // 能看到 write() 中对 value 的写
// 原因:version的 volatile写 happens-before version的 volatile读
// value的普通写 happens-before version的 volatile写(程序顺序规则)
// 传递性:value的普通写 happens-before version的 volatile读之后的value读
}
}案例二:CountDownLatch 隐含的 happens-before
CountDownLatch latch = new CountDownLatch(1);
String[] result = new String[1];
Thread writer = new Thread(() -> {
result[0] = "computed value"; // 普通写
latch.countDown(); // countdown happens-before await返回
});
Thread reader = new Thread(() -> {
try {
latch.await(); // 等待countdown
System.out.println(result[0]); // 能看到 "computed value"
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
writer.start();
reader.start();CountDownLatch内部用volatile实现,所以countDown()对await()有happens-before关系,让result[0]的写对读线程可见。
三个踩坑实录
坑一:volatile 修饰数组,不能保证数组元素可见
现象: volatile int[] arr = new int[10],一个线程修改了arr[0] = 1,另一个线程读不到。
原因: volatile只保证数组引用的可见性,不保证数组元素的可见性。修改arr[0]不触发volatile写语义。
// 错误
volatile int[] arr = new int[10];
arr[0] = 1; // 这不是 volatile 写,对其他线程不保证可见
// 正确方案1:用 AtomicIntegerArray
AtomicIntegerArray arr = new AtomicIntegerArray(10);
arr.set(0, 1);
// 正确方案2:整体替换数组
volatile int[] arr = new int[10];
int[] newArr = arr.clone();
newArr[0] = 1;
arr = newArr; // 整体替换,这才是volatile写坑二:以为 volatile 能防止所有重排序
现象: 某段并发代码逻辑上正确,但在特定CPU架构(ARM)上出现数据不一致。
原因: volatile只禁止与它自身相关的重排序,不能禁止两个普通变量之间的重排序。
volatile boolean ready = false;
int a = 0, b = 0;
// 线程1
a = 1; // 普通写
b = 2; // 普通写,a和b之间可能重排序
ready = true; // volatile写,确保a和b在ready之前写入
// 线程2
if (ready) { // volatile读
// 此时能保证看到 a=1, b=2
// 但 a 和 b 被读到的相对顺序不保证
assert a == 1;
assert b == 2;
}坑三:long/double 在32位JVM的可见性问题
现象: 32位JVM上,一个线程写long变量,另一个线程读到了一半更新的值(高32位是新值,低32位是旧值)。
原因: 在32位JVM中,64位的long/double不保证读写的原子性,可能被拆成两次32位操作。
解法: 对多线程共享的long/double变量,加volatile修饰。64位JVM上不存在这个问题,但加volatile是好习惯。
小结
volatile是个精确的工具,用对了很强大,用错了危险:
- 能做的:保证可见性、禁止指令重排序、建立happens-before关系
- 不能做的:保证复合操作(如i++)的原子性
- 最适合的场景:状态标志、单次初始化发布、DCL单例
happens-before规则是整个JMM的核心,理解了它,你就能在不加锁的情况下正确地推导并发程序的行为。
