深蓝黑背景品牌封面:居中大标题两行「volatile 保证了什么,为什么还要 AtomicLong」,副标题「可见性、有序性,与不保证的原子性」;下方装饰——两个线程圆点同时读写一个共享变量方块,变量上的 i++ 被拆成读、加、写三格小方块;顶部标注「经典题解析 · Java 后端」与「Day 08 · 09-26」 图 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,直到成功为止——全程不加锁,也不会丢更新。

三栏结构图:左栏 volatile 的两个保证——可见性(写后立即对其他线程可见,读绕过本地缓存)与有序性(禁止指令重排,典型应用是双重检查锁单例),但它不保证原子性。中栏演示 i++ 为什么丢更新:i++ 实为读、加一、写回三步,线程 A 与线程 B 同时读到 0,各自加一写回,两次自增结果只有 1。右栏 AtomicLong 用 CAS 补上原子性:compareAndSet 拿着「期望值」去比较,相等才写入新值,失败就重读重试,全程无锁。底部选型口诀:开关与标志位用 volatile 足够,计数器与复合操作用原子类或加锁 图 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/