ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

深入解析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的compareAndSwapIntJDK 9 之后逐步向VarHandle迁移把过去只有 Unsafe 内部 API 才能办的事情标准化。但无论走哪条路最终落到 CPU 层面都是同一条 CAS 指令。面试能把这个演进链条说出来说明你不仅看过源码还关注了 JDK 的发展脉络这比单纯背方法名有区分度。1.3 原子类的全貌不只有 AtomicInteger把前面的 count 换成原子类之后用法是这样AtomicInteger count new AtomicInteger(0); count.incrementAndGet();它的工作模式是读当前值 v 出来然后尝试把内存上的值从 v 改成 v1。如果 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 之后实现方式换成了VarHandleprivate 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; }翻译成人话读当前值 v尝试 CAS把 offset 指向的内存值从 v 改成 v deltaCAS 失败就说明那个瞬间有别的线程抢先把值改掉了于是重新读取 v回到第 1 步直到某轮 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无竞争情况下的求和基准值cellsCell 数组每个 Cell 是一个独立的计数单元cellsBusy用来控制 cells 扩容和初始化的锁标志位以add(1L)为例执行流程是这样的如果cells数组已经初始化直接尝试对当前线程命中的 Cell 做 CAS如果cells还没初始化先尝试对base做 CAS成功就直接返回如果base的 CAS 失败说明发生了竞争进入longAccumulate流程初始化 Cell 或扩容如果当前线程对应的 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实际做技术选型时我一般只问自己三个问题我依赖返回值做后续逻辑吗如果依赖且要求这个值是我这一下自增之后立刻准确知道总量那必须选 AtomicLong 或 AtomicInteger。LongAdder 不能保证你在任意时刻读到的 sum 是精准值。我需要条件更新吗如果需要当前值等于某个值才改成另一个值这种操作LongAdder 做不到它没有 compareAndSet 方法只能选 AtomicReference 或 AtomicInteger。写入并发度高不高达到十几二十个线程长期抢同一变量能感觉得到 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 的区别之后通常会顺着往下追问这一串LongAdder 的 sum 为什么不加锁——统计场景不要求强一致快照加锁反而破坏并发性能违背设计初衷。LongAdder 的内存占用会高多少——cells 数组从 2 个槽位起步满了按 2 的幂次扩容加上每个 Cell 前后有填充区域深度扩容后内存占用远高于单个 long 字段。为什么不直接全用 LongAdder 替代 AtomicLong——语义不同。LongAdder 没有 compareAndSet不保证 get 的精准递增语义只能用于写入并最终读取总量的场景。LongAdder 还不够用怎么办——上 LongAccumulator它能自定义累加函数支持更广义的聚合操作比如求最大值、求乘积而不只是累加。把这四条追问链准备扎实这一块在面试里基本能打穿。5. 实战避坑ABA、伪共享和自旋风暴5.1 ABA 问题值没变不代表过程没问题先看一个非常经典的 ABA 场景线程 1 读取共享值为 A 线程 1 被调度器挂起 线程 2 把值从 A 改成 B再改回 A 线程 1 恢复执行 CAS发现当前值还是 ACAS 成功问题就出在这里线程 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 差。所以无锁一定比有锁快是条教条真正应该相信的是压测数据。我在实际项目里做过一次付费转换网关的请求量统计原本用 AtomicLong16 个线程并发打点跑长稳压测CPU 占用和 P99 延迟都出现明显劣化。换成 LongAdder 之后代码改动就三行吞吐量提升了近一个量级CPU 占用降下来一大截。这类统计计数器场景LongAdder 几乎是无脑替换。最后一个容易被忽视的实操细节AtomicLong 在 JDK 8 之后有accumulateAndGet、updateAndGet这类高级方法但 LongAdder 没有 compareAndSet。如果你的业务逻辑依赖条件更新比如当前值等于 3 我才改成 5就必须用 AtomicLong 或 AtomicInteger。功能边界的取舍永远先于性能的取舍。
返回列表