news 2026/11/5 21:43:50

深入解析Java原子类:从CAS原理到AtomicInteger与LongAdder选型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析Java原子类:从CAS原理到AtomicInteger与LongAdder选型

最近面试了一个候选人,问了一个我自认为挺基础的并发问题:"AtomicInteger 的 incrementAndGet 为什么是线程安全的?"他脱口而出"CAS 比较交换"六个字,速度倒是很快。但我接着追问"CAS 失败之后会发生什么""它和 synchronized 的性能差异到底在哪里""线上高并发计数器你选 AtomicLong 还是 LongAdder"的时候,明显开始含糊了,聊到最后坦诚说平时只是背过八股,没有真正从原理和场景上捋过。

这个现象其实挺典型。原子类是 Java 并发编程里最常用的工具之一,也是面试 Java 基础、线程安全绕不开的考点,但大多数人的理解停留在"知道有个 CAS"的层面。这篇文章就把 AtomicInteger、LongAdder 这两个核心原子类彻底讲透,从 CPU 指令、源码实现一路聊到适用场景、面试追问方向,以及我实际在项目里踩过的坑。看完之后,你至少能应对面试官围绕"原子类"展开的整套追问链,也能在真实的并发场景里做出没那么拍脑袋的技术选型。

1. 从 volatile 到 CAS:原子类到底是来解决什么问题的

1.1 volatile 能保证什么,不能保证什么

先看一段非常常见的代码:

volatile int count = 0; // 线程 A count++; // 线程 B count++;

count++在字节码层面远不是一条指令,它被拆成三步:读取 count 的当前值、对值做加一、把新值写回。volatile关键字保证了第三步写回后,其他线程能立刻看到新值,也就是可见性。但它完全没有办法保证"读取、加一、写回"这整个复合操作是原子的。

两个线程几乎同时执行 count++ 时,很可能都读到了旧的 0,各自在 CPU 寄存器里加一,再各自写回 1。最终 count 是 1,两次自增丢了一次。

这就是并发编程里"复合操作"问题的典型表现。volatile只能解决可见性,不能解决原子性。而 Java 的原子类被设计出来,目标就是用无锁的方式补齐这一块缺口。

面试里如果被问到"volatile 和 CAS 有什么区别",直接答"volatile 管可见性,CAS 管原子性,两者结合才是原子类",就是一条清晰的主线。

1.2 CAS 的运作机制:一条指令完成比较和交换

CAS 全称 Compare And Swap,中文叫比较并交换。它接受三个核心参数:内存地址、期望值、新值。CPU 在执行 CAS 指令时会做一个动作——把内存地址上当前的真实值和期望值做比较,如果相等,说明期间没人改过,就把新值写入;如果不相等,说明有人抢先改过了,就什么都不做。

最关键的点在于:"比较"和"交换"这两件事是在单条 CPU 指令内完成的,这个粒度不会被线程调度打断,所以从并发语义上它是原子的。x86 平台上对应的指令是带lock前缀的cmpxchgq,在多核环境下通过锁内存总线或锁缓存行保证一致性。

Java 层面对这段历史的封装经历了两个阶段。老版本(JDK 8 及之前)走的是sun.misc.Unsafe的compareAndSwapInt;JDK 9 之后逐步向VarHandle迁移,把过去只有 Unsafe 内部 API 才能办的事情标准化。但无论走哪条路,最终落到 CPU 层面都是同一条 CAS 指令。面试能把这个演进链条说出来,说明你不仅看过源码,还关注了 JDK 的发展脉络,这比单纯背方法名有区分度。

1.3 原子类的全貌:不只有 AtomicInteger

把前面的 count++ 换成原子类之后,用法是这样:

AtomicInteger count = new AtomicInteger(0); count.incrementAndGet();

它的工作模式是:读当前值 v 出来,然后尝试把内存上的值从 v 改成 v+1。如果 CAS 成功,返回新值,完事;如果 CAS 失败,说明这个瞬间有别的线程抢先改了,就重新读取当前值,再试一次。这个"尝试—失败—重试"的循环就叫自旋。

原子类的本质是什么?一句话:volatile 的可见性 + CAS 的原子操作。Java 并发包里有一套完整的原子类体系,按用途分五个维度:

  • 基础类型:AtomicInteger、AtomicLong、AtomicBoolean
  • 数组:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
  • 引用:AtomicReference、AtomicStampedReference、AtomicMarkableReference
  • 字段更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater
  • 累加器:LongAdder、LongAccumulator、DoubleAdder、DoubleAccumulator

这套体系覆盖了日常并发编程中几乎所有需要单点原子更新的场景。本文后面的内容会盯住最常考、最常用的 AtomicInteger 和 LongAdder 展开。

2. AtomicInteger 源码拆解:一次自增的完整旅程

2.1 关键字段:valueOffset 与 volatile value

打开AtomicInteger的源码,它核心的字段只有两个:

// JDK 8 及以前的实现方式 private static final long valueOffset; private volatile int value; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } }

value用volatile修饰,解决所有线程对这个值的可见性。valueOffset是value字段在对象内存布局中的偏移量,在静态初始化块里通过Unsafe.objectFieldOffset一次性拿到。后续所有 CAS 操作都拿着这个偏移量直接去操作内存地址,不再需要走一遍字段访问。

JDK 9 之后实现方式换成了VarHandle:

private static final VarHandle VH = MethodHandles.lookup().findVarHandle(AtomicInteger.class, "value", int.class);

可以看到语义没有变,仍然是"volatile 修饰的值 + 一个能对该值做 CAS 的句柄"。面试能说出这一步演进,就不只是背书了,说明真的沿着版本迭代看过源码。

2.2 incrementAndGet 的完整执行链路

以 JDK 8 的代码为例,一次自增的完整链路是:

public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; } public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; }

翻译成人话:

  1. 读当前值 v
  2. 尝试 CAS:把 offset 指向的内存值从 v 改成 v + delta
  3. CAS 失败就说明那个瞬间有别的线程抢先把值改掉了,于是重新读取 v,回到第 1 步
  4. 直到某轮 CAS 成功,返回旧值,外层加 1 后作为新值返回

weakCompareAndSetInt这个命名里有一个隐藏考点:weak版本和强compareAndSet的区别。简单说,weak 版本不保证顺序一致性,只保证原子性,通常用于循环重试的场景;强版本还会附带内存屏障,保证 CAS 前后的访存顺序。实际在 HotSpot 的 C++ 实现里,两者最终都落到 cmpxchg 这条 CPU 指令上。面试被问到这一点,能答出"weak 会快一丁点,但语义更弱,适合自旋重试"基本就到位了。

2.3 自旋 vs 线程切换:为什么无锁反而更快

遇到 CAS 失败时,AtomicInteger 并不会阻塞或挂起线程,而是原地打转重试。这个设计的底气来自临界区极短——里面只有一条 CPU 指令,平均重试次数很少。

线程切换的成本是极高的:涉及操作系统调度、上下文保存与恢复、内核态与用户态切换,一次切换通常是微秒级别。而一次 CAS 失败后的重试是纳秒级别。在高并发度不高、竞争窗口很小的情况下,自旋的无锁方案完胜加锁方案。

但这个方案也有一个致命前提:线程数不能远大于 CPU 核数。如果 64 个线程抢同一个变量,每个核上都在反复刷同一个内存地址,CAS 失败率飙升,CPU 空转严重,性能会比 synchronized 还差。"无锁一定快"是新手最容易踩的观念误区,后面的 LongAdder 就是针对这个瓶颈设计出来的。

3. LongAdder 的分段计数:高并发写热点问题的破局思路

3.1 先看清楚 AtomicLong 在高并发下的瓶颈

假设 32 个线程同时对一个 AtomicLong 执行incrementAndGet。CAS 虽然是一条 CPU 原语,但同一个内存地址在同一时刻只可能有一个线程 CAS 成功,其余 31 个线程全部失败重试。

线程越多,失败率越高。更糟糕的是,每一次 CAS 执行都会去锁内存总线或锁缓存行,导致其他 CPU 核上那些不相关的普通内存访问也跟着被干扰。这就是典型的写热点竞争——所有线程都在同一个内存地址上打架,吞吐量上不去,CPU 白烧。

3.2 cells 数组:把单点竞争拆成多点并行

LongAdder 的思路说起来很朴素:既然所有人抢一个变量会打架,那就准备一小组变量,让不同的线程分散到不同的变量上更新,最后需要结果的时候再把所有值汇总起来。这就是分段计数,也叫并行累加。

它内部继承自 Striped64,核心只有三个东西:

  • base:无竞争情况下的求和基准值
  • cells:Cell 数组,每个 Cell 是一个独立的计数单元
  • cellsBusy:用来控制 cells 扩容和初始化的锁标志位

以add(1L)为例,执行流程是这样的:

  1. 如果cells数组已经初始化,直接尝试对当前线程命中的 Cell 做 CAS
  2. 如果cells还没初始化,先尝试对base做 CAS,成功就直接返回
  3. 如果base的 CAS 失败,说明发生了竞争,进入longAccumulate流程,初始化 Cell 或扩容
  4. 如果当前线程对应的 Cell 为 null,或者对 Cell 做 CAS 失败,也会进入longAccumulate,重新散列线程到其他槽位

当前线程到底落到哪个 Cell,由一个线程本地随机数getProbe()决定,这样不同线程大概率落在不同槽位上,各自的 CAS 相互独立,并行度从 1 提升到了 N。这个设计本质上是把"所有线程竞争一个内存地址"改写成了"每个线程碰运气竞争多个内存地址",竞争概率被大大稀释。

这里有一个面试加分细节:LongAdder 内部的Cell类被@sun.misc.Contended注解修饰。这个注解会让 JVM 在对象前后填充缓冲区,强制不同 Cell 落在不同的 CPU 缓存行里,避免伪共享(false sharing)。伪共享是指两个核心上的线程各自访问不同的变量,但这两个变量凑巧落在同一条 64 字节缓存行上,A 核修改时整条缓存行失效,B 核被迫重新加载,互相拖累。这个细节单独拿出来问,能刷掉一半候选人。

3.3 sum() 为什么精确不到毫秒级

public long sum() { Cell[] cs = cells; long sum = base; if (cs != null) { for (Cell c : cs) { if (c != null) sum += c.value; } } return sum; }

sum()方法不加锁,直接遍历所有 Cell 累加,全程不阻塞正在执行 add 的线程。这意味着在高并发下,你调用sum()拿到的值可能不是某一瞬间的精确快照——某个 Cell 可能在你累加的过程中被另一个线程更新了。

这个特性决定了 LongAdder 适合的场景类型:统计类需求,比如 QPS 统计、在线人数、网站浏览量、线程池任务累计数。这类场景的特性是不要求全局强一致快照,允许几个纳秒级的偏差,但要求写入吞吐量尽量高。反过来,如果是生成订单号这种要求线程间严格唯一且连续的编号,LongAdder 的语义就完全不合适了。

4. 面试现场:AtomicInteger 和 LongAdder 到底怎么选

4.1 三张表的边界对比

把 AtomicInteger、AtomicLong、LongAdder 放在一起看,边界其实是清晰的:

工具适用场景特点短板
AtomicInteger单点低频、需要强一致的原子操作结构简单、内存占用小、CAS 次数少高并发写热点下吞吐量明显下降
AtomicLong与 AtomicInteger 类似,用于 long 型,范围更广同上同上
LongAdder高并发高频写、允许最终一致内部段落化,吞吐量高sum 非精确快照、内存占用更大、无 compareAndSet

实际做技术选型时,我一般只问自己三个问题:

  1. 我依赖返回值做后续逻辑吗?如果依赖,且要求这个值是"我这一下自增之后立刻准确知道总量",那必须选 AtomicLong 或 AtomicInteger。LongAdder 不能保证你在任意时刻读到的 sum 是精准值。
  2. 我需要条件更新吗?如果需要"当前值等于某个值才改成另一个值"这种操作,LongAdder 做不到,它没有 compareAndSet 方法,只能选 AtomicReference 或 AtomicInteger。
  3. 写入并发度高不高?达到十几二十个线程长期抢同一变量,能感觉得到 AtomicLong 的瓶颈;如果只是偶尔几次打点,老老实实用 AtomicInteger,别为了炫技上 LongAdder——它维护 hash 和数组访问的固定开销比直接 CAS 大,低并发下反而更慢。

4.2 性能数据的参考基准

很多人在面试里只能说出"高并发用 LongAdder",这是没有任何说服力的。得能补上数据支撑:在 JDK 8 环境下,16 线程高竞争压力跑基准测试,LongAdder 的吞吐量大约是 AtomicLong 的 4 到 6 倍;压到 64 线程,差距能拉到 8 倍以上。但线程数降到 4 以下时,两者差距迅速缩小,甚至 AtomicLong 偶尔会反超,因为 LongAdder 每次 add 都要多走一层 hash 计算和数组索引。

这个"临界并发度"的直觉很重要。没有任何一个工具是绝对更优的,"高竞争选 LongAdder、低竞争选 AtomicLong"这句话必须讲出前提条件和数据支撑,才算是真正理解了。

4.3 面试官的追问链与隐藏考点

以我的经验,面试官问完"AtomicInteger 和 LongAdder 的区别"之后,通常会顺着往下追问这一串:

  1. "LongAdder 的 sum 为什么不加锁?"——统计场景不要求强一致快照,加锁反而破坏并发性能,违背设计初衷。
  2. "LongAdder 的内存占用会高多少?"——cells 数组从 2 个槽位起步,满了按 2 的幂次扩容,加上每个 Cell 前后有填充区域,深度扩容后内存占用远高于单个 long 字段。
  3. "为什么不直接全用 LongAdder 替代 AtomicLong?"——语义不同。LongAdder 没有 compareAndSet,不保证 get 的精准递增语义,只能用于"写入并最终读取总量"的场景。
  4. "LongAdder 还不够用怎么办?"——上 LongAccumulator,它能自定义累加函数,支持更广义的聚合操作,比如求最大值、求乘积,而不只是累加。

把这四条追问链准备扎实,这一块在面试里基本能打穿。

5. 实战避坑:ABA、伪共享和自旋风暴

5.1 ABA 问题:值没变不代表过程没问题

先看一个非常经典的 ABA 场景:

线程 1 读取共享值为 A 线程 1 被调度器挂起 线程 2 把值从 A 改成 B,再改回 A 线程 1 恢复执行 CAS,发现当前值还是 A,CAS 成功

问题就出在这里:线程 1 以为自己独占了一段无竞争窗口,实际上中间已经有人把值改头换面又改回来了。对单纯的计数器来说,ABA 造成的后果通常可以忽略,因为数值本身不承载"过程信息"。但在依赖结构完整性的场景——比如用 CAS 实现无锁链表栈——ABA 会让栈顶引用被错误替换,轻则逻辑错误,重则内存泄漏。

解决方案是AtomicStampedReference,它除了维护对象引用之外,还维护一个整数版本号。每次修改引用时同时修改版本号,CAS 时不仅比较引用相等,还比较版本号相等。代价是每次操作要多维护一个额外变量,内存翻倍,操作开销上升。所以只在确实需要防止 ABA 的场景(无锁数据结构、非阻塞算法)里使用,普通计数场景不需要。

还有一个变体AtomicMarkableReference,它只标记"是否被修改过"这个布尔位,语义比版本号更弱,适用场景也更窄,面试里能主动提出来说一句是加分项。

5.2 伪共享:多核下的隐形性能杀手

前面提到 LongAdder 靠@Contended注解解决伪共享。这个概念值得展开,因为它不只在原子类里存在,也是分布式缓存、数据库缓冲池设计中共同的心理模型。

CPU 读写内存的单位不是单个字节,而是 64 字节一条的缓存行(cache line)。如果两个 CPU 核各自频繁访问变量 A 和变量 B,但 A、B 恰好落在同一条缓存行里,那么 A 核每修改一次 A,整条缓存行失效,B 核必须重新从内存加载整行数据。两个核就这样互相踩踏,明明访问的是不同变量,性能却比竞争同一个变量还差。

LongAdder 内部用注解填充,让每个 Cell 单独占一条缓存行,就是为了根治这个问题。业务代码里如果自己写数组、分组计数器,也可以手动在字段后面补齐填充位来模拟这个效果,或者在 JDK 8 里对自研类使用@sun.misc.Contended,但注意需要加上-XX:-RestrictContendedJVM 参数才真正生效。这里提醒一句,日常业务代码里别为了这个过度设计,只有确认压测确实因为伪共享劣化了再做处理。

5.3 自旋风暴与"无锁一定快"的误区

AtomicInteger 在高竞争下的核心风险是自旋风暴:CAS 失败率过高时,CPU 全部在做无意义的空转,白白消耗算力。两个层面的应对思路:

  • 应用层:把竞争分散到多个槽位,换 LongAdder,也就是前面讲的分段计数。
  • JVM 层:HotSpot 对 synchronized 做了大量优化,偏向锁、轻量级锁、自旋、锁膨胀一整套组合拳,在临界区很短时,synchronized 的性能不一定比 CAS 差。所以"无锁一定比有锁快"是条教条,真正应该相信的是压测数据。

我在实际项目里做过一次付费转换:网关的请求量统计原本用 AtomicLong,16 个线程并发打点跑长稳压测,CPU 占用和 P99 延迟都出现明显劣化。换成 LongAdder 之后,代码改动就三行,吞吐量提升了近一个量级,CPU 占用降下来一大截。这类统计计数器场景,LongAdder 几乎是无脑替换。

最后一个容易被忽视的实操细节:AtomicLong 在 JDK 8 之后有accumulateAndGet、updateAndGet这类高级方法,但 LongAdder 没有 compareAndSet。如果你的业务逻辑依赖条件更新,比如"当前值等于 3 我才改成 5",就必须用 AtomicLong 或 AtomicInteger。功能边界的取舍,永远先于性能的取舍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 21:43:50

OpenAI Codex CLI 编程智能体:安装、配置与实战指南

Codex、OpenAI、编程智能体——这三个词最近几乎成了技术社区里绕不开的话题。如果你和我一样,每天要在编辑器、终端和ChatGPT之间来回切换,一遍遍复制粘贴代码、贴报错、再贴改好的结果,那Codex确实值得你花一个下午搞清楚。它是OpenAI官方推…

作者头像 李华
网站建设 2026/10/23 3:52:34

Django入门与实战:从MTV架构到ORM,搞懂Python最强Web框架

很多人问过我一个问题:Django到底是什么?尤其在Python圈子里,这个框架几乎绕不开——找工作看JD,上面写着"熟悉Django者优先";刷技术社区,满屏都是"Django项目实战";随手搜…

作者头像 李华
网站建设 2026/10/24 12:53:14

Hadoop+Spark游戏推荐系统:从数据清洗到可视化大屏实战

1. 毕业设计选题:为什么是“HadoopSpark游戏推荐系统”1.1 这个项目到底在做什么先说人话:这是一个典型的大数据方向毕业设计项目,核心目标是搭一套能够根据用户行为自动推荐游戏的系统。它不是简单地写个推荐算法跑通就完事,而是…

作者头像 李华
网站建设 2026/10/23 8:07:06

Xilinx FPGA实战:用XADC实现高精度电源电压监测(Vivado配置教程)

Xilinx FPGA实战:用XADC实现高精度电源电压监测(Vivado配置教程)做FPGA板卡调试这几年,我吃过最大的暗亏之一,就是板子跑着跑着电源电压悄悄漂了,最后查出来是某路供电纹波超标,芯片都在异常状态…

作者头像 李华
网站建设 2026/11/2 13:19:33

树莓派5加Hailo-8L实战:YOLOv8实时AI摄像头从训练到部署

1. 为什么选择树莓派5加Hailo-8L这条路线 1.1 从需求倒推硬件选型 做AI摄像头这件事,核心诉求其实就三条:第一,视频流要实时处理,不能卡成幻灯片;第二,功耗和体积要控制住,毕竟是要挂在墙角或者…

作者头像 李华