图 1:本文封面——两个线程同时读写共享变量,i++ 被拆成三步。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「volatile 保证了什么」几乎人人会答「可见性」,面试官真正的落点是下一句:「那用它做计数器行不行?为什么?」
想象面试现场,面试官问:「说说 volatile 的作用。既然它保证可见性,为什么计数器还要用 AtomicLong?」
第一步,审题:题目在问三件事。
volatile 保证了什么、没保证什么、没保证的那部分由谁补上。把答案组织成这三段,结构就立住了。
第二步,基础回答:volatile 的两个保证。
保证一,可见性。Java 语言规范说得很克制:字段声明为 volatile 后,Java 内存模型保证所有线程看到这个变量的一致的值。一个线程写完,其他线程立刻读得到,读操作不会被本地缓存蒙住。保证二,有序性。volatile 变量的读写禁止与周边的内存操作发生特定重排,典型应用是双重检查锁单例——没有 volatile,别的线程可能看到一个「引用已赋值但构造没完成」的半成品对象。
但它的边界同样清楚:不保证原子性。规范建议的默认做法仍然是加锁互斥,volatile 只是某些场景下比加锁更方便的第二种机制。
第三步,进阶回答:i++ 为什么丢更新。
关键在于 i++ 不是一步。它实际是三个动作:读出当前值、加一、写回结果。volatile 只保证每个线程读到的是最新值,但两个线程可以同时读到同一个值——A 读到 0,B 也读到 0,各自加一写回,两次自增只生效了一次。读、加、写这个复合操作中间有缝隙,缝隙里别人也能插进来。这就是「复合操作」与「单次读写」的区别:开关、标志位是单次读写,volatile 够用;计数器是复合操作,volatile 管不了。
第四步,CAS 与原子类补上原子性。
AtomicLong 所在的 atomic 包,官方定位就是「支持在单个变量上进行无锁线程安全编程的小工具集」。核心招式是 compareAndSet(CAS):拿着「期望值」去和当前值比较,相等才写入新值,整个过程由硬件保证一步完成;失败说明有人抢先改了,重读新值再试。incrementAndGet 这类自增方法就是在循环里反复 CAS,直到成功为止——全程不加锁,也不会丢更新。
图 2:volatile 的两个保证、i++ 丢更新的三步拆解、CAS 补原子性,以及选型口诀。
第五步,用实验把道理坐实。
Java 之外,同一套道理在 Node.js 里也能复现:SharedArrayBuffer 是普通共享内存(对应 volatile 的处境),Atomics.add 是原子操作(对应 AtomicLong)。下面这份代码用 4 个 worker 各做 20 万次自增,Node.js 22.15.0 直接运行(node volatile-vs-atomic.js):
'use strict';
import { Worker, isMainThread, workerData } from 'node:worker_threads';
const THREADS = 4;
const ROUNDS = 200000;
if (!isMainThread) {
const { sab, mode } = workerData;
const view = new Int32Array(sab);
for (let i = 0; i < ROUNDS; i++) {
if (mode === 'plain') {
const tmp = view[0]; // 第一步:读
view[0] = tmp + 1; // 第二、三步:加一、写回(三步整体不是原子的)
} else {
Atomics.add(view, 0, 1); // 原子读-改-写,一步完成
}
}
} else {
const run = (mode) => {
const sab = new SharedArrayBuffer(4);
new Int32Array(sab)[0] = 0;
const workers = [];
for (let t = 0; t < THREADS; t++) {
workers.push(
new Promise((resolve, reject) => {
const w = new Worker(new URL(import.meta.url), { workerData: { sab, mode } });
w.on('exit', resolve);
w.on('error', reject);
})
);
}
return Promise.all(workers).then(() => new Int32Array(sab)[0]);
};
const expected = THREADS * ROUNDS;
const plain = await run('plain');
console.log(`plain 模式:期望 ${expected},实际 ${plain},丢失 ${expected - plain} 次更新`);
const atomic = await run('atomic');
console.log(`atomic 模式:期望 ${expected},实际 ${atomic},丢失 ${expected - atomic} 次更新`);
}
笔者的一次实测输出如下(plain 模式每次运行数值不同,但必然丢失):
plain 模式:期望 800000,实际 476060,丢失 323940 次更新
atomic 模式:期望 800000,实际 800000,丢失 0 次更新
80 万次自增,非原子写法丢了 32 万次;原子写法一次不丢。可见性管「读得到新值」,管不了「三步不被人插队」——这就是 volatile 与 AtomicLong 的分界线。
易错点清单。
一,把可见性当成原子性——「读得到最新值」不等于「读-改-写不被打断」。二,认为 volatile 能保证 i++ 正确,面试现场被追问就露馅。三,双重检查锁单例漏掉 volatile,讲不出半成品对象的风险。四,所有场景一律上 AtomicLong——写少读多的开关位用它纯属浪费,口诀是:开关与标志位用 volatile,计数器与复合操作用原子类或加锁。
回到面试现场,建议的答题节奏:先答两个保证(可见性、有序性,各配一个例子),再拆 i++ 的三步说明原子性缺口,最后讲 CAS 的「比较再写、失败重试」。推理服务的指标计数、降级开关、无锁队列,全是这套知识点的直接应用。
如果面试官追问「高并发下 AtomicLong 也会成为热点,JDK 8 引入了什么类来缓解」,你能说出它的名字和思路吗?欢迎写出你的答案。
参考资料
- The Java Language Specification, Java SE 21 Edition §8.3.1.4 volatile Fields(Java 语言规范:声明为 volatile 的字段,Java 内存模型保证所有线程看到一致的值;volatile 是加锁之外的第二种机制) — https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.3.1.4
- java.util.concurrent.atomic(JDK 21 官方文档:支持在单个变量上进行无锁线程安全编程的工具包,提供 compareAndSet 等读-改-写原子操作与原子自增方法) — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/atomic/package-summary.html
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
worker_threads、Atomics等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/