在Java多线程编程中,线程安全一直是个绕不开的话题。上周我们讨论了竞态条件、原子性操作和线程同步等基础概念,今天要深入探讨一个更隐蔽但同样重要的问题——内存可见性。
先看个简单例子:假设有两个线程,一个负责读取标志位,另一个负责修改标志位。理论上修改后的值应该立即对读取线程可见,但实际情况往往出人意料。这种"一个线程修改了共享变量,另一个线程却看不到变化"的现象,就是典型的内存可见性问题。
让我们用代码还原这个场景:
java复制public class VisibilityDemo {
public static int flag = 0;
public static void main(String[] args) {
Thread reader = new Thread(() -> {
while(flag == 0) {
// 空循环
}
System.out.println("Reader线程退出");
});
Thread writer = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入新flag值:");
flag = scanner.nextInt();
});
reader.start();
writer.start();
}
}
这段代码的逻辑很直观:writer线程等待用户输入来修改flag,reader线程监控flag变化。当用户输入非零值时,预期reader线程应该退出循环。但实际运行时会发现,即使输入了非零值,reader线程仍可能继续运行。
这种现象背后有几个关键因素:
在我们的例子中,while循环内的flag检查被JVM优化为直接从CPU寄存器读取,跳过了实际的内存访问。这就是为什么writer线程修改了内存中的flag值,但reader线程仍然使用寄存器中的旧值。
JIT编译器在发现循环内频繁读取同一个变量且值不变时,会进行"循环不变式外提"优化。具体过程如下:
这种优化在单线程环境下完全合理,但在多线程环境下就会导致可见性问题。因为编译器无法预知其他线程可能修改这个变量。
volatile是Java提供的内存可见性解决方案。只需在变量声明前加上这个关键字:
java复制public volatile static int flag = 0;
volatile通过以下机制保证可见性:
具体来说,当线程A修改volatile变量时:
volatile的读写操作建立了happens-before关系:
这种语义通过内存屏障实现。在x86架构上,写volatile变量相当于插入StoreLoad屏障,读操作相当于插入LoadLoad屏障。
volatile最适合以下场景:
但不适用于:
Java内存模型(JMM)定义了线程如何与内存交互。关键概念包括:
JMM通过happens-before关系定义可见性保证,包括:
不同架构的CPU实现内存屏障的方式不同,但通常包括:
虽然volatile解决了可见性问题,但需要注意:
推荐的使用方式:
java复制volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested) {
// 执行任务
}
}
java复制class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
现代CPU通过MESI协议维护缓存一致性:
volatile通过强制缓存一致性协议生效来保证可见性。
HotSpot虚拟机的具体实现:
经典的线程安全单例模式:
java复制public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这里volatile防止了指令重排序导致的初始化问题。
结合volatile和CAS的计数器:
java复制public class Counter {
private volatile int value;
public int getValue() {
return value;
}
public int increment() {
int oldValue;
do {
oldValue = value;
} while(!compareAndSet(oldValue, oldValue + 1));
return oldValue + 1;
}
private synchronized boolean compareAndSet(int expected, int newValue) {
if (value == expected) {
value = newValue;
return true;
}
return false;
}
}
使用volatile作为轻量级通信机制:
java复制public class ProducerConsumer {
private volatile boolean ready = false;
private volatile String data;
public void produce(String newData) {
data = newData;
ready = true;
}
public String consume() {
while(!ready) {
// 忙等待
}
return data;
}
}
volatile操作比普通变量访问慢,主要体现在:
多个happens-before关系可以组合使用,例如:
final字段在正确构造后对其他线程立即可见,不需要同步:
java复制class FinalFieldExample {
final int x;
public FinalFieldExample() {
x = 42; // 正确构造后对所有线程可见
}
}
新版本Java对内存模型做了优化:
在实际项目中,理解内存可见性和volatile的适用场景非常重要。我曾在一个高并发交易系统中遇到过一个棘手的bug:交易状态更新后,监控系统有时会看到过期的状态。通过分析发现是因为状态标志没有正确使用volatile修饰。添加volatile后问题立即解决,但我们也意识到这增加了内存访问开销。最终我们重新设计了状态监控机制,减少了volatile变量的访问频率,在保证正确性的同时维持了系统性能。