" />
文章

JMM 中 volatile 修饰的 i++ 会有并发问题吗?

JMM 中 volatile 修饰的 i++ 会有并发问题吗?

面试指北 · 淘天

题意

这道题表面在问 i++,实际是在考 Java 内存模型(JMM) 里的几个核心概念:可见性、原子性、有序性,以及 volatile 到底能做什么、不能做什么。

面试官通常想确认三件事:

  1. 你是否知道 volatile 的语义边界;
  2. 你是否能把 i++ 拆成“读-改-写”三步,并解释为什么会丢更新;
  3. 你是否知道 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 的关键语义有两条:

  1. 可见性:对 volatile 变量的写,会立即对其他线程可见;对 volatile 变量的读,会从主内存(或最新同步状态)读取,而不是一直使用线程本地旧副本。JMM 用 happens-before 描述就是:对 volatile 变量的写 happens-before 后续对同一个变量的读。
  2. 一定的有序性:volatile 读写前后会插入内存屏障,禁止特定类型的指令重排序,避免出现“代码顺序没变,但执行顺序被重排”导致的可见性问题。

但 volatile 没有锁的互斥语义,不能保证复合操作的原子性。这是本题的根。

2. 为什么 i++ 不是原子操作

i++ 看起来是一行代码,实际包含三个动作:

  1. 读取 i 的当前值;
  2. 把值加 1;
  3. 把新值写回 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 保证计数最终一致 保证 不阻塞 高并发统计计数

几个常见误区:

  1. “volatile 是轻量级 synchronized”:不对。volatile 没有互斥能力,不能保护复合操作。
  2. “i++ 和 ++i 有区别,所以一个安全一个不安全”:两者都安全不了,都是读-改-写,区别只是返回旧值还是新值。
  3. “用了 AtomicInteger 就万事大吉”:AtomicInteger 只保证它自己的单次方法调用原子,像“先判断再自增”“先读两个变量再一起更新”这种组合操作,仍然需要锁或别的同步手段。
  4. “volatile 能保证 32 位 int 的读写原子,所以 i++ 也原子”:单个 int 读/写本身通常是原子的,但 i++ 是三个动作的组合,组合不原子。
  5. “AtomicInteger 一定比 synchronized 快”:低竞争通常更轻,但高竞争下 CAS 自旋可能很耗 CPU,这时 LongAdder 或锁可能更合适。

小结

回到面试题:volatile 修饰的 i++ 仍然有并发问题。 因为 volatile 只保证可见性和一定的有序性,不保证原子性;i++ 是读-改-写复合操作,多线程下会互相覆盖,造成丢失更新。要保证线程安全,可以用 synchronized / Lock 做互斥,也可以用 `AtomicInteger

License:  CC BY 4.0