AI摘要
在我工作的前两年,我对volatile的理解停留在面试题层面:"保证可见性,禁止指令重排序"。直到遇到一个线上bug——在一个高并发的状态标志判断中,即使我使用了volatile,程序还是出现了诡异的逻辑错误。那次经历让我明白,对volatile的一知半解比完全不懂更危险。
一、 从一次线上诡异bug说起
先重现一下当时的问题场景:我们有一个订单超时取消的定时任务,需要能够被手动紧急停止。
第一版代码(有问题但能工作):
/**
* 订单超时取消任务
*/
@Service
public class OrderTimeoutCancelTask {
private boolean stopped = false; // 停止标志
@Scheduled(fixedRate = 5000)
public void cancelTimeoutOrders() {
if (stopped) {
return; // 如果被停止,直接返回
}
// 执行复杂的订单取消逻辑(耗时操作)
doCancelTimeoutOrders();
}
/**
* 紧急停止任务(通过管理后台调用)
*/
public void emergencyStop() {
this.stopped = true;
log.info("订单取消任务已手动停止");
}
/**
* 重启任务
*/
public void restart() {
this.stopped = false;
log.info("订单取消任务已重启");
}
}在开发环境,这段代码工作正常。但上线后,在某个高峰期,运维同学执行了emergencyStop(),日志显示stopped被设置为true,但任务仍然继续运行了5分钟才停止!
二、 问题分析:可见性问题
问题的根源在于内存可见性。在现代多核CPU架构下:
- 每个线程有自己的工作内存(CPU缓存)
- 主线程执行
emergencyStop()将stopped改为true,这个修改可能只写到了CPU缓存,没有立即刷回主内存 - 定时任务线程在另一个CPU核心上运行,读取的还是自己缓存中的旧值
false
用代码模拟这个延迟:
public class VisibilityDemo {
private static boolean flag = false; // 没有volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!flag) {
// 空循环,等待flag变为true
}
System.out.println("Worker线程看到flag变为true,循环结束");
});
worker.start();
Thread.sleep(1000); // 确保worker线程先运行
flag = true; // 主线程修改flag
System.out.println("主线程已将flag设为true");
worker.join();
}
}运行结果可能让你惊讶:在某些JVM配置下,worker线程可能永远看不到flag的变化,程序无法终止!
三、 volatile的解决方案:保证可见性
这就是volatile的第一个重要作用:保证可见性。
private volatile boolean stopped = false;加上volatile后:
- 当主线程修改
stopped = true时,会立即将修改刷回主内存 - 定时任务线程每次读取
stopped时,会从主内存重新加载最新值
第一版修复:
@Service
public class OrderTimeoutCancelTaskV1 {
private volatile boolean stopped = false; // 添加volatile
@Scheduled(fixedRate = 5000)
public void cancelTimeoutOrders() {
if (stopped) {
return;
}
doCancelTimeoutOrders();
}
// emergencyStop()和restart()方法不变
}问题似乎解决了?但事情没那么简单......
四、 更深层的问题:原子性陷阱
几周后,我们又遇到了新问题:在极端高并发下(手动快速连续点击启动/停止),有时会出现任务"半启动"的诡异状态。
仔细分析代码,我发现了一个隐藏的原子性问题:
public void restart() {
this.stopped = false; // 这不是原子操作!
log.info("订单取消任务已重启");
}看起来简单的stopped = false,在JVM层面可能分为多个步骤。更危险的是,如果restart()和emergencyStop()几乎同时被调用:
- 线程A执行
restart():读取stopped当前值 - 线程B执行
emergencyStop():读取stopped当前值 - 线程A设置
stopped = false - 线程B设置
stopped = true
结果:后执行的操作覆盖了先执行的操作!
五、 volatile不保证原子性
这是很多程序员的误区:volatile能保证单次读写的原子性,但不能保证复合操作的原子性。
哪些操作是原子性的?
- 基本类型(除long/double)的简单读写是原子的
- volatile变量的读写是原子的
哪些操作不是原子性的?
- long/double的读写可能不是原子的(64位系统一般是原子的,但规范不保证)
- 任何需要先读后写的操作:
i++、flag = !flag、判断条件后的设置等
我们的restart()方法虽然只有一个赋值操作,但如果在更复杂的判断逻辑中:
// 这是错误的!不是原子操作!
if (!stopped) {
doSomething(); // 这行代码执行时,stopped可能已经被其他线程修改了!
}六、 正确的解决方案:volatile + 同步机制
对于我们的启停控制,正确的做法是:
方案1:使用AtomicBoolean(推荐)
@Service
public class OrderTimeoutCancelTaskV2 {
private final AtomicBoolean stopped = new AtomicBoolean(false);
@Scheduled(fixedRate = 5000)
public void cancelTimeoutOrders() {
if (stopped.get()) {
return;
}
doCancelTimeoutOrders();
}
public void emergencyStop() {
stopped.set(true);
log.info("订单取消任务已手动停止");
}
public void restart() {
stopped.set(false);
log.info("订单取消任务已重启");
}
}AtomicBoolean内部使用volatile + CAS操作,既保证可见性又保证原子性。
方案2:使用synchronized(更重量级但更安全)
@Service
public class OrderTimeoutCancelTaskV3 {
private boolean stopped = false;
private final Object lock = new Object();
@Scheduled(fixedRate = 5000)
public void cancelTimeoutOrders() {
synchronized (lock) {
if (stopped) {
return;
}
}
doCancelTimeoutOrders();
}
public void emergencyStop() {
synchronized (lock) {
this.stopped = true;
}
log.info("订单取消任务已手动停止");
}
public void restart() {
synchronized (lock) {
this.stopped = false;
}
log.info("订单取消任务已重启");
}
}七、 volatile的另一个作用:禁止指令重排序
这是volatile更微妙但同样重要的特性。看这个经典的单例模式例子:
// 错误的双重检查锁
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题在这里!
}
}
}
return instance;
}
}问题在于instance = new Singleton()在JVM中分为3步:
- 分配内存空间
- 初始化对象
- 将instance指向分配的内存地址
JVM可能进行指令重排序,按1→3→2的顺序执行。这样当线程A执行到第3步时,instance已不为null,但对象还未初始化。此时线程B判断instance != null,直接返回一个未完全初始化的对象!
解决方案:使用volatile
public class Singleton {
private static volatile Singleton instance; // 添加volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 现在安全了
}
}
}
return instance;
}
}volatile通过内存屏障禁止了指令重排序,保证其他线程看到instance时,对象已经完全初始化。
八、 volatile的适用场景
经过这些踩坑经验,我总结出volatile的真正适用场景:
- 状态标志位(最常用)
public class TaskRunner {
private volatile boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) {
// 执行任务
}
}
}- 一次性安全发布(单例模式的双重检查锁)
private volatile static Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (Resource.class) {
if (resource == null) {
resource = new Resource();
}
}
}
return resource;
}- 独立观察:定期"发布"观察结果供程序使用
public class TemperatureSensor {
private volatile double currentTemperature;
// 传感器线程定期更新
private void senseTemperature() {
while (true) {
currentTemperature = readHardware();
Thread.sleep(1000);
}
}
// 其他线程直接读取最新值
public double getTemperature() {
return currentTemperature;
}
}九、 volatile的不适用场景
- 需要原子性的复合操作
// 错误:volatile不能保证count++的原子性
private volatile int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
// 正确:使用AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}- 需要互斥访问的代码块
// 错误:volatile不能替代synchronized
private volatile List<String> list = new ArrayList<>();
public void add(String item) {
list.add(item); // 非线程安全!
}
// 正确:使用同步机制
private final List<String> list = new ArrayList<>();
public synchronized void add(String item) {
list.add(item);
}十、 从JMM角度理解volatile
要真正理解volatile,需要从Java内存模型(JMM)的角度:
- 可见性:写volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;读volatile变量时,JMM会使该线程的工作内存无效,从主内存重新读取。
- 禁止重排序:通过内存屏障实现,确保编译器和CPU不会对指令进行重排序优化。
十一、 实战经验总结
- 不要过度使用volatile:在明确需要可见性且操作是原子的时候才使用
- 优先考虑线程安全类:
AtomicInteger、ConcurrentHashMap等通常比手动使用volatile更安全 - 测量性能影响:volatile的读写比普通变量慢,但比synchronized快
- 理解happens-before关系:volatile写操作happens-before后续的volatile读操作
十二、 最终的正确代码
回到最初的订单取消任务,这是最终的生产级代码:
@Service
public class OrderTimeoutCancelTaskFinal {
private final AtomicBoolean stopped = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(fixedRate = 5000)
public void cancelTimeoutOrders() {
// 检查是否停止
if (stopped.get()) {
return;
}
// 防止任务重叠执行:如果已经在运行,则跳过本次
if (!running.compareAndSet(false, true)) {
log.warn("订单取消任务仍在执行中,跳过本次调度");
return;
}
try {
doCancelTimeoutOrders();
} finally {
running.set(false); // 确保异常时也能重置状态
}
}
public void emergencyStop() {
if (stopped.compareAndSet(false, true)) {
log.info("订单取消任务已手动停止");
}
}
public void restart() {
if (stopped.compareAndSet(true, false)) {
log.info("订单取消任务已重启");
}
}
}这个版本解决了所有问题:
- 使用
AtomicBoolean保证可见性和原子性 - 添加运行状态检查,防止任务重叠执行
- 使用CAS操作避免竞态条件
- 在finally块中确保状态正确重置
总结
volatile关键字就像并发编程中的一把精密手术刀:在正确的场景下使用效果显著,但误用会导致难以调试的bug。真正理解它需要:
- 理解可见性和指令重排序的概念
- 明确volatile的适用边界(不保证原子性)
- 在实际场景中积累经验,知道何时该用volatile,何时该用更强的同步机制
从"知道"到"会用"再到"用好",这是每个Java程序员在并发编程上的必经之路。希望我的踩坑经历能帮你少走一些弯路。