AI摘要

文章通过线上踩坑案例揭示:volatile仅保可见性与禁重排,不保原子性;高并发状态标志需改用AtomicBoolean或synchronized,并给出最终生产级代码。
在我工作的前两年,我对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架构下:

  1. 每个线程有自己的工作内存(CPU缓存)
  2. 主线程执行emergencyStop()stopped改为true,这个修改可能只写到了CPU缓存,没有立即刷回主内存
  3. 定时任务线程在另一个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()几乎同时被调用:

  1. 线程A执行restart():读取stopped当前值
  2. 线程B执行emergencyStop():读取stopped当前值
  3. 线程A设置stopped = false
  4. 线程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步:

  1. 分配内存空间
  2. 初始化对象
  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的真正适用场景:

  1. 状态标志位(最常用)
public class TaskRunner {
    private volatile boolean running = true;
    
    public void stop() { running = false; }
    
    public void run() {
        while (running) {
            // 执行任务
        }
    }
}
  1. 一次性安全发布(单例模式的双重检查锁)
private volatile static Resource resource;
public static Resource getInstance() {
    if (resource == null) {
        synchronized (Resource.class) {
            if (resource == null) {
                resource = new Resource();
            }
        }
    }
    return resource;
}
  1. 独立观察:定期"发布"观察结果供程序使用
public class TemperatureSensor {
    private volatile double currentTemperature;
    
    // 传感器线程定期更新
    private void senseTemperature() {
        while (true) {
            currentTemperature = readHardware();
            Thread.sleep(1000);
        }
    }
    
    // 其他线程直接读取最新值
    public double getTemperature() {
        return currentTemperature;
    }
}

九、 volatile的不适用场景

  1. 需要原子性的复合操作
// 错误:volatile不能保证count++的原子性
private volatile int count = 0;
public void increment() {
    count++; // 这不是原子操作!
}

// 正确:使用AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet();
}
  1. 需要互斥访问的代码块
// 错误: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)的角度:

  1. 可见性:写volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;读volatile变量时,JMM会使该线程的工作内存无效,从主内存重新读取。
  2. 禁止重排序:通过内存屏障实现,确保编译器和CPU不会对指令进行重排序优化。

十一、 实战经验总结

  1. 不要过度使用volatile:在明确需要可见性且操作是原子的时候才使用
  2. 优先考虑线程安全类AtomicIntegerConcurrentHashMap等通常比手动使用volatile更安全
  3. 测量性能影响:volatile的读写比普通变量慢,但比synchronized快
  4. 理解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("订单取消任务已重启");
        }
    }
}

这个版本解决了所有问题

  1. 使用AtomicBoolean保证可见性和原子性
  2. 添加运行状态检查,防止任务重叠执行
  3. 使用CAS操作避免竞态条件
  4. 在finally块中确保状态正确重置

总结

volatile关键字就像并发编程中的一把精密手术刀:在正确的场景下使用效果显著,但误用会导致难以调试的bug。真正理解它需要:

  1. 理解可见性和指令重排序的概念
  2. 明确volatile的适用边界(不保证原子性)
  3. 在实际场景中积累经验,知道何时该用volatile,何时该用更强的同步机制

从"知道"到"会用"再到"用好",这是每个Java程序员在并发编程上的必经之路。希望我的踩坑经历能帮你少走一些弯路。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:Volatile关键字:你以为你懂了,但可能只懂了皮毛
▶ 本文链接:https://www.huangleicole.com/backend-related/62.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏