JMM 中 volatile 修饰的 i++ 会有并发问题吗?
JMM 中 volatile 修饰的 i++ 会有并发问题吗?
面试指北 · 淘天
题意
这道题表面在问 i++,实际是在考 Java 内存模型(JMM) 里的几个核心概念:可见性、原子性、有序性,以及 volatile 到底能做什么、不能做什么。
面试官通常想确认三件事:
- 你是否知道
volatile的语义边界; - 你是否能把
i++拆成“读-改-写”三步,并解释为什么会丢更新; - 你是否知道
synchronized、AtomicInteger等方案的区别和适用场景。
一句话回答:会有并发问题。volatile 能保证可见性和一定的有序性,但保证不了 i++ 这种复合操作的原子性。
结论
volatile修饰的变量,在 JMM 抽象意义上读写直接与主内存交互,一个线程写入后,后续线程读取能看到最新值。volatile还会插入内存屏障,禁止特定重排序,提供 happens-before 关系。- 但
i++不是一步操作,而是“读i→ 计算i+1→ 写回i”的复合操作。 volatile只能让每一步读/写本身可见,不能把这三步打包成一个原子动作。多个线程同时执行时仍会互相覆盖,出现丢失更新。- 解决方式:
synchronized/Lock保证互斥,或AtomicInteger用 CAS 保证单变量自增原子性;高并发计数可考虑LongAdder。
要点展开
1. JMM 与 volatile 的语义
JMM 是 Java 内存模型,它规定了多线程之间共享变量的可见性和有序性规则。可以把它理解成一种抽象:每个线程有自己的“工作内存”,共享变量放在“主内存”;线程读写变量时,可能先操作工作内存中的副本,再与主内存同步。注意,这里的“工作内存”是 JMM 的抽象概念,不等同于某一块具体的 CPU 缓存。
volatile 的关键语义有两条:
- 可见性:对
volatile变量的写,会立即对其他线程可见;对volatile变量的读,会从主内存(或最新同步状态)读取,而不是一直使用线程本地旧副本。JMM 用 happens-before 描述就是:对volatile变量的写 happens-before 后续对同一个变量的读。 - 一定的有序性:
volatile读写前后会插入内存屏障,禁止特定类型的指令重排序,避免出现“代码顺序没变,但执行顺序被重排”导致的可见性问题。
但 volatile 没有锁的互斥语义,不能保证复合操作的原子性。这是本题的根。
2. 为什么 i++ 不是原子操作
i++ 看起来是一行代码,实际包含三个动作:
- 读取
i的当前值; - 把值加 1;
- 把新值写回
i。
从字节码角度看,大致是 getfield、iconst_1、iadd、putfield 这样的读-改-写组合。即使 i 是 volatile,getfield 和 putfield 是 volatile 读写,但“读”和“写”之间仍然可能被其他线程插入。
举例,初始 i = 5:
线程 A:读 i = 5
线程 B:读 i = 5
线程 A:计算 6,写回 i = 6
线程 B:计算 6,写回 i = 6
两个线程都做了加一,最终却只加了 1,期望的 7 变成了 6。这就是典型的丢失更新。原文中的场景也类似:A 读到 5,B 把 i 改成 6,A 再把自己基于 5 算出的 6 写回去,覆盖了 B 的结果。
所以,volatile 保证的是“每次读能看见最新写、每次写对别人可见”,而不是“读-改-写这一整段不被打断”。可见性不等于原子性。
3. 解决方案
方式 1:使用 synchronized 关键字
private volatile int i = 0;
public synchronized void increment() {
i++;
}
synchronized 给 increment 方法加锁,同一时刻只有一个线程能进入,读-改-写被串行化,因此不会丢失更新;同时锁的释放和获取也建立了 happens-before,保证可见性。
注意:如果 i 的所有访问都经过这个同步方法,volatile 其实是多余的,可以去掉;保留也不会影响正确性,只是多了一层可见性语义。若还有其他地方无锁读取 i,volatile 能保证读到较新的值,但读取和自增之间仍可能不一致,需要结合业务判断。
方式 2:使用 AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;
private AtomicInteger i = new AtomicInteger(0);
public void increment() {
i.incrementAndGet(); // 原子性自增
}
AtomicInteger 内部维护一个 volatile 的 value,通过 CAS(Compare-And-Swap)实现原子更新。incrementAndGet() 的逻辑可以粗略理解为:
读取当前值 old
计算 next = old + 1
如果当前值仍等于 old,就 CAS 更新为 next
否则说明被别人改过,重试直到成功
CAS 是一种无锁原子操作,低到中等竞争下通常比 synchronized 更轻量。但高竞争时 CAS 会不断失败重试,自旋开销也会上来;这种计数场景可以考虑 LongAdder,它把计数分散到多个单元格,最后汇总,吞吐量更高。
方式 3:Lock 等显式锁
ReentrantLock 也能解决,本质和 synchronized 一样是互斥。简单场景优先 synchronized,需要可中断、超时、公平锁等能力时再考虑显式锁。面试里答出 synchronized 和 AtomicInteger 通常已经覆盖核心。
易错点与对比
| 方案 | 原子性 | 可见性 | 是否阻塞 | 适用场景 |
|---|---|---|---|---|
volatile |
不保证复合操作原子性 | 保证 | 不阻塞 | 状态标志、单写多读、安全发布 |
synchronized |
保证临界区原子性 | 保证 | 会阻塞 | 多变量、复杂不变式、需要互斥 |
AtomicInteger |
保证单变量 CAS 操作原子 | 保证 | 不阻塞(CAS 自旋) | 计数器、单一变量原子更新 |
LongAdder |
保证计数最终一致 | 保证 | 不阻塞 | 高并发统计计数 |
几个常见误区:
- “volatile 是轻量级 synchronized”:不对。
volatile没有互斥能力,不能保护复合操作。 - “
i++和++i有区别,所以一个安全一个不安全”:两者都安全不了,都是读-改-写,区别只是返回旧值还是新值。 - “用了 AtomicInteger 就万事大吉”:
AtomicInteger只保证它自己的单次方法调用原子,像“先判断再自增”“先读两个变量再一起更新”这种组合操作,仍然需要锁或别的同步手段。 - “volatile 能保证 32 位 int 的读写原子,所以 i++ 也原子”:单个
int读/写本身通常是原子的,但i++是三个动作的组合,组合不原子。 - “AtomicInteger 一定比 synchronized 快”:低竞争通常更轻,但高竞争下 CAS 自旋可能很耗 CPU,这时
LongAdder或锁可能更合适。
小结
回到面试题:volatile 修饰的 i++ 仍然有并发问题。 因为 volatile 只保证可见性和一定的有序性,不保证原子性;i++ 是读-改-写复合操作,多线程下会互相覆盖,造成丢失更新。要保证线程安全,可以用 synchronized / Lock 做互斥,也可以用 `AtomicInteger