一、前言
在上一篇文章中,我们深入剖析了 volatile 的底层原理,知道它通过强化 JMM 交互规则保证可见性,通过插入内存屏障保证有序性,但不具备原子性。
本篇文章将聚焦 volatile 的实战应用,带你掌握它的核心适用场景、避坑指南,以及与其他同步方案的性能对比。
二、核心适用原则
在使用 volatile 之前,我们必须牢记一个核心原则:volatile 仅适用于「单一赋值、多线程读取」的场景,不适合用于需要原子性的复合操作场景。
这个原则是判断 volatile 是否适用的根本依据。简单来说,当共享变量的操作满足以下两个条件时,才能用 volatile :
- **操作是「单一赋值」:**比如 flag = true 、 status = 1 ,而不是 count++ 、 value += 1 这类复合操作;
- **变量不依赖自身的旧值:**赋值时不需要读取变量的旧值来计算新值。
违背这个原则使用 volatile ,必然会导致线程安全问题。
三、典型应用场景
场景 1:状态标志位(最经典场景)
用 volatile 修饰布尔类型或枚举类型的状态变量,作为线程的启停开关、状态标记,是它最常见也最安全的用法。这种场景完全符合「单一赋值、多线程读取」的原则。
实战案例:线程优雅启停
我们在上一篇文章中用到的线程启停开关,就是一个典型案例。这里我们做一个更贴近实战的升级版本 ------ 用 volatile 控制后台任务线程的启停:
java
import java.util.concurrent.TimeUnit;
public class VolatileStatusFlagDemo {
// 用 volatile 修饰状态标志位
private volatile boolean isTaskRunning = false;
private Thread backgroundThread;
// 启动后台任务
public void startBackgroundTask() {
if (isTaskRunning) {
System.out.println("任务已经在运行中");
return;
}
isTaskRunning = true;
backgroundThread = new Thread(() -> {
while (isTaskRunning) {
// 执行业务逻辑:比如定时拉取数据
System.out.println("后台任务正在执行...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("后台任务已停止");
});
backgroundThread.start();
}
// 停止后台任务
public void stopBackgroundTask() {
isTaskRunning = false;
// 唤醒可能处于休眠状态的线程
if (backgroundThread != null) {
backgroundThread.interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
VolatileStatusFlagDemo demo = new VolatileStatusFlagDemo();
// 启动任务
demo.startBackgroundTask();
// 运行 5 秒后停止
TimeUnit.SECONDS.sleep(5);
demo.stopBackgroundTask();
}
}
代码解析 :
-
isTaskRunning 被 volatile 修饰,主线程调用 stopBackgroundTask() 修改其值时,后台线程能立即感知到,从而退出循环;
-
相比 synchronized ,这种方式无需加锁,性能开销极低,是实现线程状态控制的最优解。
场景 2:双重检查锁定(DCL)单例模式(必考点)
DCL 单例模式是面试中的高频考点,而 volatile 是这个模式中必不可少的关键角色。没有 volatile 的 DCL 单例模式,在多线程环境下会存在严重的线程安全问题。
问题版本:无 volatile 的 DCL 单例
java
public class UnsafeSingleton {
private static UnsafeSingleton instance; // 未加 volatile
private UnsafeSingleton() {}
public static UnsafeSingleton getInstance() {
// 第一次检查:避免不必要的锁竞争
if (instance == null) {
synchronized (UnsafeSingleton.class) {
// 第二次检查:防止多线程重复创建实例
if (instance == null) {
instance = new UnsafeSingleton(); // 存在指令重排序风险
}
}
}
return instance;
}
}
风险分析 :
-
instance = new UnsafeSingleton() 会被拆分为「分配内存→初始化对象→赋值引用」三步;
-
JVM 可能对后两步重排序,变成「分配内存→赋值引用→初始化对象」;
-
当线程 A 执行完「赋值引用」后, instance 已经不为 null ,但对象还未初始化;此时线程 B 第一次检查会认为 instance 已创建,直接返回一个未初始化的实例,引发空指针异常。
正确版本:加 volatile 的 DCL 单例
java
public class SafeSingleton {
// 必须加 volatile 禁止指令重排序
private static volatile SafeSingleton instance;
private SafeSingleton() {}
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
核心作用 :
-
volatile 禁止了 instance = new SafeSingleton() 的指令重排序,确保「初始化对象」一定在「赋值引用」之前完成;
-
线程 B 只有在对象完全初始化后,才会看到 instance 不为 null ,彻底避免了半初始化实例的问题。
场景 3:轻量级数据传递(低并发场景)
在低并发场景下,用 volatile 修饰简单的数值类型变量,实现多线程之间的数据传递,也是一种常见用法。比如主线程向工作线程传递配置参数、阈值等。
实战案例:动态调整阈值
java
public class VolatileThresholdDemo {
// 用 volatile 修饰阈值,支持动态调整
private volatile int threshold = 100;
public void startWorker() {
new Thread(() -> {
int count = 0;
while (true) {
count++;
// 读取最新的阈值
if (count > threshold) {
System.out.println("count 超过阈值:" + threshold + ",重置 count");
count = 0;
}
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}).start();
}
// 主线程动态调整阈值
public void updateThreshold(int newThreshold) {
this.threshold = newThreshold;
System.out.println("阈值已更新为:" + newThreshold);
}
public static void main(String[] args) throws InterruptedException {
VolatileThresholdDemo demo = new VolatileThresholdDemo();
demo.startWorker();
// 运行 2 秒后调整阈值
TimeUnit.SECONDS.sleep(2);
demo.updateThreshold(200);
// 再运行 2 秒后调整阈值
TimeUnit.SECONDS.sleep(2);
demo.updateThreshold(50);
}
}
代码解析 :
-
工作线程会实时读取 threshold 的最新值,无需加锁;
-
这种用法仅适用于低并发、无复合操作的场景,若存在多线程同时修改 threshold 的情况,需要搭配 AtomicInteger 使用。
四、常见坑点及规避方案
坑点 1:误用 volatile 修饰需要原子性的变量
**表现 :**用 volatile 修饰 count 等需要累加的变量,执行 count++ 操作,导致最终结果小于预期值。
原因 : count++ 是复合操作, volatile 无法保证其原子性,多线程同时操作会出现数据覆盖问题。
规避方案 :
- 替换为 AtomicInteger 等原子类,利用 CAS 机制保证原子性;
- 或使用 synchronized 修饰操作方法。
优化示例 :
java
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCountDemo {
// 用 AtomicInteger 替代 volatile int
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
public int getCount() {
return count.get();
}
}
坑点 2:volatile 修饰对象引用,误以为能保证对象内部字段的可见性
**表现 :**用 volatile 修饰对象引用 user ,修改 user 的内部字段 user.setName("张三") ,其他线程无法感知到字段变化。
原因 : volatile 仅保证对象引用本身的可见性,不保证对象内部字段的可见性。当对象引用未发生变化时,内部字段的修改不会触发 volatile 的同步机制。
规避方案 :
- 若需要修改对象内部字段,可将字段单独用 volatile 修饰;
- 或使用 synchronized 修饰修改字段的方法;
- 或每次修改字段时,重新创建一个新的对象实例(适用于不可变对象)。
示例说明 :
java
class User {
// 内部字段单独用 volatile 修饰
private volatile String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
public class VolatileObjectDemo {
private volatile User user = new User();
public void updateUserName() {
user.setName("张三"); // 其他线程能感知到 name 的变化
}
}
坑点 3:认为 volatile 可以替代 synchronized
**表现 :**在高并发、复合操作场景下,用 volatile 替代 synchronized ,导致线程安全问题。
原因 : volatile 仅保证可见性和有序性,不保证原子性;而 synchronized 能保证原子性、可见性、有序性三者。
**规避方案 :**牢记两者的适用边界,用表格总结如下:
| 场景类型 | 推荐方案 | 不推荐方案 |
| 状态标志位、单例模式 DCL | volatile | synchronized |
| 复合操作(如 count++) | synchronized / 原子类 | volatile |
| 复杂临界区代码 | synchronized | volatile |
|---|
五、volatile与synchronized的性能对比
为了更直观地看到 volatile 的性能优势,我们做一个简单的性能测试 ------ 对比两者在状态标记场景下的执行效率。
测试代码
java
import java.util.concurrent.CountDownLatch;
public class VolatileVsSyncPerformanceTest {
private static volatile boolean volatileFlag = false;
private static boolean syncFlag = false;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
int threadCount = 1000;
CountDownLatch volatileLatch = new CountDownLatch(threadCount);
CountDownLatch syncLatch = new CountDownLatch(threadCount);
// 测试 volatile
long volatileStart = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
while (!volatileFlag) {}
volatileLatch.countDown();
}).start();
}
volatileFlag = true;
volatileLatch.await();
long volatileEnd = System.currentTimeMillis();
System.out.println("volatile 耗时:" + (volatileEnd - volatileStart) + "ms");
// 测试 synchronized
long syncStart = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
while (true) {
synchronized (lock) {
if (syncFlag) {
break;
}
}
}
syncLatch.countDown();
}).start();
}
synchronized (lock) {
syncFlag = true;
}
syncLatch.await();
long syncEnd = System.currentTimeMillis();
System.out.println("synchronized 耗时:" + (syncEnd - syncStart) + "ms");
}
}
测试结果(仅供参考,不同环境结果不同)
java
volatile 耗时:5ms
synchronized 耗时:32ms
结果分析
- volatile 是无锁机制,仅通过内存屏障保证同步,性能开销极低;
- synchronized 在低并发场景下会升级为偏向锁 / 轻量级锁,但仍有锁竞争的开销;
- 在状态标记这类简单场景下, volatile 的性能远超 synchronized 。
六、总结
通过本文的学习,我们掌握了 volatile 的核心适用场景和避坑指南。那么问题来了:
- 如果在高并发场景下,需要同时保证可见性和原子性,除了 synchronized 和原子类,还有什么更高效的方案?
- volatile 修饰的变量和 Atomic 类的变量,在底层实现上有什么区别?
在下一篇文章中,我们将聚焦 volatile 的选型对比,深入分析它与 synchronized 、 Atomic 类的核心差异,帮你在实战中做出最优的技术选型。