volatile 关键字可以保证原子性吗?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
-
三大特性辨析:面试官不仅仅是想知道 "能不能" 这三个字的答案,更是想确认你是否能清晰地区分并发编程的三大特性——可见性、原子性、有序性,以及
volatile分别能保证哪些。 -
JMM 理解深度:这道题背后考察的是你对 Java 内存模型(JMM)的理解。如果只知道 "volatile 能保证可见性" 这个结论,却说不出内存屏障、主内存与工作内存的交互过程,面试官会觉得你只是背了八股文。
-
实战避坑能力:考察你是否知道在什么场景下
volatile不够用,需要用锁或原子类来替代。这个在实际开发中真的会踩坑,我就见过有同事用volatile修饰计数器,结果线上数据对不上。
核心答案
不能。volatile 关键字不能保证原子性。
它只能保证以下两个特性:
| 特性 | volatile 是否保证 | 说明 |
|---|---|---|
| 可见性 | ✅ 保证 | 一个线程修改了变量,其他线程立刻能看到最新值 |
| 有序性 | ✅ 部分保证 | 禁止指令重排序(通过内存屏障) |
| 原子性 | ❌ 不保证 | 复合操作(如 i++)依然可能出现并发问题 |
深度解析
一、为什么 volatile 不能保证原子性——用 i++ 说话
直接上代码,这是最经典的反例:
public class VolatileAtomicTest {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
count++; // 看似一行代码,实际不是原子操作
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
// 期望 20000,实际结果几乎每次都小于 20000
System.out.println("count = " + count);
}
}
你觉得加了 volatile 结果就是 20000?跑一下就知道了,几乎每次都小于 20000。为什么?
因为 count++ 不是一步操作,它实际包含了三步:
上图展示了 count++ 的完整执行过程,它由三个独立的操作组成。虽然 volatile 保证了每次读取都从主内存拿最新值、每次写入都立刻刷回主内存,但它无法保证这三步作为一个整体不被打断。来看一个经典的竞态场景:
上图展示了一个典型的 "丢失更新" 问题。整个过程的关键在于:
- T1:Thread-1 从主内存读取
count=0,此时volatile保证它拿到的是最新值,没问题 - T2:Thread-2 也从主内存读取
count=0,注意此时 Thread-1 还没写回,所以 Thread-2 拿到的依然是 0 - T3-T4:两个线程各自在自己的工作内存中做
0+1=1的计算 - T5:Thread-1 把结果 1 写回主内存
- T6:Thread-2 也把结果 1 写回主内存,覆盖了 Thread-1 写入的 1
两次自增,结果只增加了 1。volatile 确实保证了每次写入都能立刻对其他线程可见,但它管不了 "读-改-写" 之间被别的线程插队。这就是为什么 volatile 不能保证原子性。
二、那什么场景下 volatile 够用?
volatile 适用于 一个线程写、多个线程读 的场景,或者状态标记位这种 "纯写入" 的场景。
// 场景一:状态标记位(完全 OK)
private volatile boolean running = true;
public void stop() {
running = false; // 单纯的赋值操作,本身就是原子的
}
public void doWork() {
while (running) {
// 业务逻辑
}
}
// 场景二:双重检查锁定(DCL)单例模式
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;
}
}
DCL 单例中 volatile 的作用不是保证原子性,而是防止 new Singleton() 的指令重排序。对象创建分三步:分配内存 → 初始化对象 → 将引用指向内存地址。如果不加 volatile,JVM 可能把步骤 2 和 3 重排序,导致其他线程拿到了一个还没初始化完的 "半成品" 对象。
三、需要原子性怎么办?
既然 volatile 不行,那该用什么?
| 方案 | 适用场景 | 示例 |
|---|---|---|
AtomicInteger 等原子类 | 单个变量的原子操作 | AtomicInteger.incrementAndGet() |
synchronized | 代码块的原子性保护 | 包裹复合操作 |
ReentrantLock | 需要更灵活的锁控制 | tryLock()、超时获取等 |
// 方案一:用 AtomicInteger(推荐)
private static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 底层 CAS,无锁原子操作
// 方案二:用 synchronized( heavier,但通用)
private static int count = 0;
synchronized (VolatileAtomicTest.class) {
count++;
}
AtomicInteger 底层用的是 CAS(Compare And Swap),这是一种无锁的原子操作,性能比 synchronized 好很多,是计数器场景的首选。
面试高频追问
-
追问一:volatile 是怎么保证可见性的?
通过内存屏障(Memory Barrier)实现。写操作前插入
StoreStore屏障,写操作后插入StoreLoad屏障;读操作前插入LoadLoad屏障,读操作后插入LoadStore屏障。这些屏障强制 CPU 将写缓冲区的数据刷新到主内存,并使其他 CPU 缓存行失效,从而保证可见性。 -
追问二:什么是 CAS?ABA 问题怎么解决?
CAS 是 "比较并交换",包含三个操作数:内存值 V、预期值 A、新值 B。只有当 V == A 时,才将 V 更新为 B,否则重试。ABA 问题是指值从 A 变成 B 又变回 A,CAS 无法感知中间的变化。解决方案是用
AtomicStampedReference(带版本号)或AtomicMarkableReference(带标记位)。 -
追问三:volatile 和 synchronized 的区别?
volatile是轻量级的同步机制,只能修饰变量,保证可见性和有序性,不保证原子性;synchronized是重量级的,能修饰方法和代码块,同时保证可见性、有序性和原子性。能用volatile解决的就用volatile,解决不了再用synchronized。
常见面试变体
- "volatile 能保证线程安全吗?"
- "volatile 和 synchronized 有什么区别?"
- "i++ 是原子操作吗?加了 volatile 之后呢?"
- "什么场景下用 volatile 就够了?"
记忆口诀
volatile 三板斧:可见性 ✅、有序性 ✅、原子性 ❌。复合操作还得靠锁或 CAS。
总结
volatile 不能保证原子性,它只管两件事——可见性(改了立刻让别人看到)和有序性(禁止指令重排)。遇到 i++ 这种 "读-改-写" 的复合操作,volatile 无能为力,得用 AtomicInteger(CAS)或 synchronized 来保证原子性。面试中把这个区别说清楚,基本就不会在这道题上翻车了。