《Java 并发编程·JUC 源码与容器》8.1 AtomicInteger 家族的 CAS 路径

拆开 JDK 25 的 AtomicInteger:源码注释为何承认本该用 VarHandle 却仍走 jdk.internal.misc.Unsafe,getAndAddInt、putIntRelease、compareAndSetInt 各自的内存语义,weakCompareAndSet 与 compareAndSet 的实际差别,以及字段更新器的实现与本机字节码和重试数据。

承接的问题

卷一第 8 章从使用者角度讲了 CAS:compareAndSet 怎么用、ABA 怎么防、什么时候该退回锁。本节换到实现者角度:AtomicInteger 只有二十几个公开方法,它究竟把一次自增编译成了什么?

三个问题贯穿全节。第一,字段就是一个 volatile int,getAndIncrement 凭什么比 value++ 多出原子性?第二,源码注释明写着 “This class intended to be implemented using VarHandles”,为什么最后用的却是 jdk.internal.misc.Unsafe?第三,weakCompareAndSet 的 javadoc 说"可能失败",它和 compareAndSet 的实际差别到底在哪?

先给结论,后面逐条给证据。AtomicInteger 是"名字叫原子类、实现是 Unsafe 加内存语义映射"的典型;那句 VarHandle 注释是 JDK 里少见的、公开承认设计妥协的注释;而 weakCompareAndSet 的"弱"是内存序的弱,不是原子性的弱。

需要先划一条线:本节引用的 jdk.internal.misc.Unsafe 是 java.base 里的内部类,被模块系统封装,普通代码连 import 都编译不过。它在这里只作为源码事实陈述,本书不会给出任何照抄它的代码。需要原子语义时,用第 9 章的 VarHandle。

一个 volatile int,一个字段偏移

先看 JDK 25 的 AtomicInteger 骨架:

// JDK 25 源码摘录,已删减
public class AtomicInteger extends Number implements java.io.Serializable {
    /*
     * This class intended to be implemented using VarHandles, but there
     * are unresolved cyclic startup dependencies.
     */
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE
        = U.objectFieldOffset(AtomicInteger.class, "value");

    private volatile int value;

    public final int get() {
        return value;
    }

    public final void set(int newValue) {
        value = newValue;
    }
}

另一半是原子操作:

// JDK 25 源码摘录,已删减
    public final void lazySet(int newValue) {
        U.putIntRelease(this, VALUE, newValue);
    }

    public final int getAndSet(int newValue) {
        return U.getAndSetInt(this, VALUE, newValue);
    }

    public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
    }

    @Deprecated(since="9")
    public final boolean weakCompareAndSet(int expectedValue, int newValue) {
        return U.weakCompareAndSetIntPlain(this, VALUE, expectedValue, newValue);
    }

    public final int getAndIncrement() {
        return U.getAndAddInt(this, VALUE, 1);
    }

四件事值得记。

第一,value 是 volatile,所以 get()/set() 什么都不做,直接读写字段。字节码是 getfield/putfield,JIT 对 volatile 字段的读写保证 acquire/release。这也解释了为什么 AtomicInteger 的读比 synchronized 便宜:它连方法调用都可能被内联掉。

第二,VALUE 是字段偏移,在类初始化时算一次。之后所有原子操作都是"对象引用 + 偏移量"两参数形式。Unsafe 的这套 API 是给 JVM 内部和运行时库用的,它绕过了 Java 的字段访问规则,所以能做到"一次比较并交换";也因为绕过了规则,它不能开放给普通代码。

第三,U 是 jdk.internal.misc.Unsafe,不是很多人以为的 sun.misc.Unsafe。JDK 9 模块化之后,sun.misc.Unsafe 的内存访问方法逐步被 VarHandle 取代,JDK 25 里的原子类早已改用内部那个 Unsafe;第 9.3 节会讲这条迁移路线。

第四,也是本节最值得讲的一处:源码注释承认这个类本该用 VarHandle 实现,只是有"未解决的循环启动依赖"(unresolved cyclic startup dependencies)。注释没有展开是哪一条环。从加载时机看,AtomicInteger 在 VM 启动早期就会被用到,而 VarHandle 的查找要初始化 java.lang.invoke 的方法句柄机制;把这条依赖反向指回原子类就成环。这是基于加载时机的推测,注释本身只承诺"有环"。对比之下,Striped64 是普通 JUC 类,加载晚得多,所以它能安全地在静态块里用 MhUtil.findVarHandle——8.3 会看到那段代码。

一个类为了不破坏启动顺序,宁可继续用内部 Unsafe 二十多年。这是真实的工程取舍,也是读源码时最容易被"标准答案"抹掉的细节。

三条路径,三种内存语义

本机 javap -p -c 的真实输出(Temurin 25.0.5+8):

Compiled from "AtomicInteger.java"    // Temurin 25.0.5+8, javap -p -c
public final int getAndIncrement();
  Code:
     0: getstatic     #13   // Field U:Ljdk/internal/misc/Unsafe;
     3: aload_0
     4: getstatic     #17   // Field VALUE:J
     7: iconst_1
     8: invokevirtual #38   // Method jdk/internal/misc/Unsafe.getAndAddInt:(Ljava/lang/Object;JI)I
    11: ireturn

public final void lazySet(int);
  Code:
     0: getstatic     #13   // Field U:Ljdk/internal/misc/Unsafe;
     3: aload_0
     4: getstatic     #17   // Field VALUE:J
     7: iload_1
     8: invokevirtual #21   // Method jdk/internal/misc/Unsafe.putIntRelease:(Ljava/lang/Object;JI)V
    11: return

public final boolean weakCompareAndSetPlain(int, int);
  Code:
     0: getstatic     #13   // Field U:Ljdk/internal/misc/Unsafe;
     3: aload_0
     4: getstatic     #17   // Field VALUE:J
     7: iload_1
     8: iload_2
     9: invokevirtual #35   // Method jdk/internal/misc/Unsafe.weakCompareAndSetIntPlain:(Ljava/lang/Object;JII)Z
    12: ireturn

compareAndSet 的形状与最后一段相同,只是把方法名换成 compareAndSetInt。四条路径对应四种内存语义:

方法Unsafe 调用等价 VarHandle 模式语义
get / set直接读写 volatile 字段getVolatile / setVolatileacquire / release
lazySetputIntReleasesetReleaserelease 存储,无 StoreLoad
getAndSetgetAndSetIntgetAndSet一次读改写,full fence
compareAndSetcompareAndSetIntcompareAndSetvolatile 读 + 条件写
getAndIncrementgetAndAddIntgetAndAdd一次原子加,full fence

注意 getAndIncrement 的字节码里没有循环。失败重读、重试的逻辑要么在 intrinsic 里,要么直接由一条硬件原子指令完成。这一点很关键:手写 CAS 循环在高竞争下比它慢,不是因为 Java 写得不好,而是因为你在 Java 层把失败、重读、重试都做了一遍,而它只有一条指令。

手写循环的成本可以被测出来。本机用 1/2/4/8 个线程各自把 AtomicInteger 自增 100 万次,分别统计"总尝试次数"和"CAS 失败次数"(失败次数记在线程本地,避免统计本身扰动):

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public final class CasRetryProbe {
    static final int OPS = 1_000_000;

    public static void main(String[] args) throws Exception {
        for (int threads : new int[]{1, 2, 4, 8}) {
            AtomicInteger counter = new AtomicInteger();
            long[] fails = new long[threads];
            long[] attempts = new long[threads];
            CountDownLatch start = new CountDownLatch(1);
            Thread[] ts = new Thread[threads];
            for (int t = 0; t < threads; t++) {
                final int id = t;
                ts[t] = Thread.ofPlatform().start(() -> {
                    long f = 0, a = 0;
                    try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                    for (int i = 0; i < OPS; i++) {
                        int prev, next;
                        do {
                            prev = counter.get();
                            next = prev + 1;
                            a++;
                            if (counter.compareAndSet(prev, next)) break;
                            f++;
                        } while (true);
                    }
                    fails[id] = f;
                    attempts[id] = a;
                });
            }
            start.countDown();
            for (Thread th : ts) th.join();
            long totalF = 0, totalA = 0;
            for (int i = 0; i < threads; i++) { totalF += fails[i]; totalA += attempts[i]; }
            long expected = (long) threads * OPS;
            System.out.printf("threads=%d value=%d 期望=%d attempts=%d failures=%d failures/op=%.3f%n",
                    threads, counter.get(), expected, totalA, totalF, totalF / (double) expected);
        }
    }
}

本机输出(load average 84.2,10 核):

threads=1 value=1000000 期望=1000000 attempts=1000000 failures=0 failures/op=0.000
threads=2 value=2000000 期望=2000000 attempts=2678893 failures=678893 failures/op=0.339
threads=4 value=4000000 期望=4000000 attempts=8112958 failures=4112958 failures/op=1.028
threads=8 value=8000000 期望=8000000 attempts=24024406 failures=16024406 failures/op=2.003

失败率随线程数上升:2 线程 0.339,4 线程 1.028,8 线程 2.003。8 线程时每成功自增一次,平均要失败两次、重读两遍。这个数字不能当吞吐结论——本机 load average 84,绝对耗时会漂——但失败次数本身是结构性的:它只取决于有多少线程同时抢同一个地址。

这组数字是 8.2 的引子:AtomicLong 的瓶颈不在"CAS 慢",而在所有线程抢同一个地址。失败重试只是症状,病根是单点。

weakCompareAndSet 的"弱"是内存序的弱

JDK 9 起,weakCompareAndSet 被标了 @Deprecated(since="9"),理由写在 javadoc 里:方法名暗示 volatile 语义,实际却是 plain 语义。替代品有两个:weakCompareAndSetPlain(plain)和 weakCompareAndSetVolatile(强)。字节码已经证明前者落到 weakCompareAndSetIntPlain,后者落到 weakCompareAndSetInt。

“弱"包含两层,而且都不是"写一半”:

  • 允许伪失败:即使当前值等于期望值,也允许返回 false。所以调用方必须放在重试循环里,不能拿一次 false 当"值不匹配"的证据。
  • 不提供排序保证:plain 模式下,CAS 前后的普通读写可以越过它重排。

原子性本身仍然成立:不会出现"比较成功但只写了一半"的中间态。这一点常被误解,值得强调。

我做了两个实验找差别。第一个:单线程连续做 1000 万次"期望值必然正确"的 CAS,统计失败次数。第二个:一个 200 万轮的 litmus 测试,写者先写普通字段 data = 42,再用 weak/strong CAS 发布 flag = 1,读者自旋读 flag 后检查 data,统计"看到 flag 却看不到 data"的次数。

A 单线程 10000000 次:weak 失败=0  strong 失败=0
B litmus 2000000 轮:weakCASPlain 观测到 data==0 的次数=0
B litmus 2000000 轮:compareAndSet   观测到 data==0 的次数=0

两组都是零。诚实的结论是:在本机 Temurin 25 上观测不到 weak 与 strong 的差别。最可能的解释是 HotSpot 对 plain 与 volatile 的 CAS 在本平台落成了同一条带序指令;但这是实现细节,JMM 明确允许实现不同。所以两件事都不能做:不能因为"实测一样"就把 weakCompareAndSetPlain 当 compareAndSet 用(那是赌实现),也不能因为名字里有 weak 就以为它更快(没有证据)。

真正该用 weak 的地方很窄:CAS 失败后本来就要重试,且这次 CAS 不承担发布/获取的同步职责。满足这两条时它不会更慢;不满足时,省下的屏障会变成可见性 bug。

字段更新器:省一个对象,换一组约束

AtomicIntegerFieldUpdater 解决的是"我已经有一个对象,不想为它的一个 int 字段再包一个 AtomicInteger“的问题。它不复制字段,只在构造时保存字段偏移和访问检查所需的类信息。

约束有四条,违反直接抛异常:字段必须是 volatile、不能是 static、类型必须是 int、调用者必须有访问权限。本机实测:

C 可更新 volatile 字段: stock=995
C 非 volatile 字段: Must be volatile type
C 错误接收者类型: ClassCastException

更隐蔽的约束是原子性的范围:compareAndSet 只对"同一个 updater 的 compareAndSet 和 set“保证互斥。别的线程直接写 stock++,或者用另一个 updater 实例操作同一字段,原子性立刻破掉。所以字段更新器适合"字段私有、所有写入都走同一个 updater"的类。

实现上,AtomicIntegerFieldUpdaterImpl 每次调用先 accessCheck(obj)(cclass.isInstance),再走 U.compareAndSetInt(obj, offset, ...)。本机 javap -p -c 看它的 weakCompareAndSet:

public final boolean weakCompareAndSet(T, int, int);
  Code:
     0: aload_0
     1: aload_1
     2: invokevirtual #141   // Method accessCheck:(Ljava/lang/Object;)V
     5: getstatic     #76    // Field U:Ljdk/internal/misc/Unsafe;
     8: aload_1
     9: aload_0
    10: getfield      #86    // Field offset:J
    13: iload_2
    14: iload_3
    15: invokevirtual #144   // Method jdk/internal/misc/Unsafe.compareAndSetInt:(Ljava/lang/Object;JII)Z

它和 compareAndSet 的字节码完全一样,都是 compareAndSetInt;但接口 javadoc 仍写着 “may fail spuriously and does not provide ordering guarantees”。接口承诺与实现不一致,读源码时以字节码为准,写代码时以 javadoc 为准——因为你换一个 JDK 实现,字节码可能就变了。

在 libraryd-conc 里落地

库存槽位天然是"一个对象带一个 int"。书目目录里每个 ISBN 对应一个 InventorySlot,如果再加一个 AtomicInteger 字段,就是每个槽位多一次分配、读库存时多一次指针追逐。用字段更新器把 stock 保持成普通 volatile int:

package com.plume.conc.core;

import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;

/** 单个 ISBN 的库存槽位;所有写入都必须走 STOCK。 */
public final class InventorySlot {
    private static final AtomicIntegerFieldUpdater<InventorySlot> STOCK =
            AtomicIntegerFieldUpdater.newUpdater(InventorySlot.class, "stock");

    private final String isbn;
    private volatile int stock;

    public InventorySlot(String isbn, int stock) {
        this.isbn = isbn;
        this.stock = stock;
    }

    /** 扣减 n 本;库存不足返回 false,库存永不为负。 */
    public boolean tryReserve(int n) {
        if (n <= 0) throw new IllegalArgumentException("n must be positive");
        int prev;
        do {
            prev = stock;
            if (prev < n) return false;
        } while (!STOCK.compareAndSet(this, prev, prev - n));
        return true;
    }

    public void restock(int n) {
        if (n <= 0) throw new IllegalArgumentException("n must be positive");
        STOCK.addAndGet(this, n);
    }

    public int stock() { return stock; }
    public String isbn() { return isbn; }
}

tryReserve 的循环就是本节 javap 里那段"Java 层重试”:读、判断、CAS,失败重来。库存不足时直接返回 false,不能先扣再检查,否则并发下会出现负库存。restock 用 addAndGet,因为补货不需要读旧值做判断。

下面这段可以独立运行,验证 8 个线程抢 1000 本库存不会超卖:

import com.plume.conc.core.InventorySlot;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public final class ReserveProbe {
    public static void main(String[] args) throws Exception {
        InventorySlot slot = new InventorySlot("978-7-111-00001-1", 1000);
        int threads = 8;
        AtomicInteger ok = new AtomicInteger();
        CountDownLatch start = new CountDownLatch(1);
        Thread[] ts = new Thread[threads];
        for (int t = 0; t < threads; t++) {
            ts[t] = Thread.ofPlatform().start(() -> {
                try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                for (int i = 0; i < 1000; i++) if (slot.tryReserve(1)) ok.incrementAndGet();
            });
        }
        start.countDown();
        for (Thread th : ts) th.join();
        System.out.println("成功扣减=" + ok.get() + " 剩余=" + slot.stock() + " 合计=" + (ok.get() + slot.stock()));
    }
}

本机输出:

成功扣减=1000 剩余=0 合计=1000

什么时候不要用字段更新器?字段需要被序列化框架、反射工具或别的类直接写的时候;字段类型不是 int/long/Object 的时候;以及你其实需要的是一个独立计数器对象(比如要传给别的组件)的时候。前两种会破坏原子性,第三种是硬套。

常见错误与边界

第一,把 weakCompareAndSet 当 compareAndSet 用。plain 语义不建立 happens-before,用它发布对象会出现"引用可见、字段没写完”。

第二,把 lazySet 当普通 set 用。它是 release 存储,读者必须用 volatile/acquire 读配对;用普通读去轮询一个 lazySet 写的标志,可能永远看不到。

第三,以为 getAndIncrement 和手写 CAS 循环性能差不多。低竞争下差不多,高竞争下手写循环多出的失败重读会放大成本,见本节 4 线程 1.03 次/操作的失败率。

第四,用字段更新器却保留了对字段的直接写入。原子性只覆盖 updater 的调用,stock-- 一行就能破坏它。

第五,试图 import jdk.internal.misc.Unsafe。模块系统会拒绝;即使加 --add-exports 也不该在业务代码里这么做,第 9 章有正确的替代品。

第六,把 AtomicInteger 当高并发累加器。本节的重试数据已经说明单点是瓶颈,8.2 给出分片方案。

小结

AtomicInteger 的实现可以压缩成一句话:一个 volatile int 加一个预先算好的字段偏移,所有原子操作都映射到 jdk.internal.misc.Unsafe 的对应方法。源码注释承认它本该用 VarHandle,但启动期的循环依赖让这条路走不通,于是这个"临时方案"活到了 JDK 25。lazySet、compareAndSet、getAndAddInt 分别对应 release、volatile CAS、原子读改写三种语义;weakCompareAndSet 弱在内存序和允许伪失败,不弱在原子性。字段更新器则把这套机制复用到已有的 volatile 字段上,代价是四条约束和"所有写入必须走同一个 updater"。

下一节离开单点,看 LongAdder 如何用一个 base 加一组 Cell 把竞争摊开。

阅读导航:上一节 7.3 队列的锁粒度、吞吐与内存 | 下一节 8.2 LongAdder 的 Cell 分片

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收