《Java 并发编程·JUC 源码与容器》10.3 无锁 vs 有锁实测

同一混合负载下用 JMH 对比 synchronized 栈、ReentrantLock 栈与 Treiber 栈,再用自写探针统计 CAS 失败次数与 p99/p999:4 线程时无锁吞吐只有有锁的五分之一,但 p999 反而低一个数量级。

承接的问题

10.1 的一组数字一直没解释:4 个生产者、4 个消费者,一百万次 push 加一百万次 pop,Treiber 栈的 CAS 失败了 467 万次——平均每次操作失败 2.3 次。CAS 失败不是免费的,它意味着一次缓存行重新加载加一次循环重来。

这一节把这个现象放到台面上:同一个混合负载(一次 push 加一次 pop 算一个操作),三种实现——synchronized 栈、ReentrantLock 栈、Treiber 栈——在不同竞争度下分别值多少。

先说结论,免得被后面的数字带偏:低竞争时无锁最快,高竞争时无锁的吞吐会崩,但它的尾延迟反而更可控。 “快"这个字在这里至少有三个互不相同的含义。

怎么测:两个探针,各管一件事

JMH 1.37 在本机可用(~/.m2/repository/org/openjdk/jmh/jmh-core/1.37/ 已在本地仓库,mvn -o 离线编译通过),所以吞吐用 JMH,尾延迟用自写探针。

JMH 配置:@BenchmarkMode(Mode.Throughput)、@Fork(1)、2 轮预热加 3 轮测量、每轮 500ms;元素全部取自 Integer 缓存(0–127),排除装箱分配干扰;被测方法是一次 push 加一次 pop,队列空了再补一次 push。

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 2, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class StackBench {

    @Param({"synchronized", "reentrant", "treiber"})
    public String impl;

    @Setup(Level.Trial)
    public void setup() {
        stack = switch (impl) {
            case "synchronized" -> new SynchronizedStack<>();
            case "reentrant" -> new LockedStack<>();
            case "treiber" -> new TreiberStack<>(false);
            default -> throw new IllegalArgumentException(impl);
        };
        for (int i = 0; i < 1024; i++) {
            stack.push(POOL[i & 127]);
        }
    }

    @Benchmark
    public void mixed(ThreadState ts) {
        stack.push(POOL[ts.nextIndex()]);
        if (stack.pop() == null) {
            stack.push(POOL[ts.nextIndex()]);
        }
    }
}

这个负载有个必须承认的偏向:线程 push 之后紧接着 pop,弹出的往往就是自己刚压进去的那个元素——这是 LIFO 局部性最好的情况,缓存行很可能还在自己核上。真实队列(FIFO)没有这个便宜可占。所以本节的数据适合比较"同一负载下三种实现的相对关系”,不适合外推成"某队列能跑到多少 QPS"。

为什么尾延迟不用 JMH?Mode.Throughput 和 Mode.AverageTime 报的是迭代级的统计量,一轮 500ms 的迭代只产出一个数字。它能告诉你"这 500ms 处理了多少操作",不能告诉你"最慢的那 1% 操作等了多久"。要知道 p99,只能在每次操作前后各读一次时钟。

自写探针有两个必须交代的坑。

第一,本机 System.nanoTime() 的刻度是 42ns。macOS 的单调时钟走 24 MHz 计数器,一格 41.7ns。实测一次"读两次 nanoTime 但不做任何事"的空对:

两次 nanoTime 的空对耗时 p50=0ns p99=42ns

中位数是 0——两次读落在同一格里。这意味着探针里出现的 p50=42ns 不能读成"42 纳秒",只能读成"不到两格"。平均值在这个刻度上几乎没有分辨率,这本身就是后面要讲的"尾延迟比平均更有信息量"的第一个理由。

第二,第一轮采样必须丢弃。JIT 没编译完时的解释执行样本会全部堆进尾部,把 p999 抬高一个数量级。探针跑两轮,只采信第二轮。

探针的核心就是一个带计时的循环,每个线程把自己的样本写进独立数组,结束后合并排序取分位:

private static long[] measure(ConcurrentStack<Integer> stack, int threads) throws Exception {
    long[][] samples = new long[threads][OPS];
    CountDownLatch start = new CountDownLatch(1);
    Thread[] ts = new Thread[threads];
    for (int i = 0; i < threads; i++) {
        final long[] mine = samples[i];
        final int seed0 = 99 + i;
        ts[i] = Thread.ofPlatform().start(() -> {
            try {
                start.await();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            int seed = seed0;
            for (int k = 0; k < OPS; k++) {
                seed = seed * 1103515245 + 12345;
                Integer v = POOL[(seed >>> 16) & 127];
                long t0 = System.nanoTime();
                stack.push(v);
                if (stack.pop() == null) {
                    stack.push(v);
                }
                mine[k] = System.nanoTime() - t0;
            }
        });
    }
    start.countDown();
    for (Thread th : ts) {
        th.join();
    }
    long[] all = new long[threads * OPS];
    for (int i = 0; i < threads; i++) {
        System.arraycopy(samples[i], 0, all, i * OPS, OPS);
    }
    return all;
}

CountDownLatch 保证所有线程同时起跑,否则先启动的线程会在别人还没就位时把样本采完,测出来的是单线程数据。种子按线程编号错开,避免所有线程以相同的节奏访问同一批元素——那样会人为制造同步效应。

还有一点关于 JMH 的 Error 列:3 轮测量时它按 t 分布算 99.9% 置信区间,样本少导致系数极大。本机某次 4 线程 synchronized 的报告是 27105.781 ± 50086.490,区间下界直接到 0。这个 Error 不能当"结果不可信"读,也不能当"误差范围"读;要看每轮迭代的具体数值(Iteration 1/2/3 那三行)。本文所有 JMH 数字都取 3 轮迭代的中位数,并额外标注重跑的差异。

环境交代:Apple M1 Pro、10 核、32 GB、Temurin 25.0.5+8;测试期间 uptime 的 load average 在 77–104 之间波动。以下所有数字都是单次运行、随调度波动、不作基准,只用来看结构。

低竞争:一次 CAS 对一次轻量级锁

单线程(-t 1),单位 ops/ms,一个 op 是一次 push 加一次 pop,取 3 轮迭代的中位数:

实现ops/ms折算每 op
Treiber 栈57 39317.4 ns
synchronized 栈49 95420.0 ns
ReentrantLock 栈36 27927.6 ns

无锁版比监视器版快约 15%,比 ReentrantLock 快约 58%。没有竞争时,Treiber 的 push 就是"分配节点 + 一次 CAS",pop 是"一次 CAS",没有进入/退出锁的簿记。

这里必须提一句偏向锁。很多旧资料会说"无竞争时监视器会偏向最后一个线程,几乎零成本",进而得出"无锁在低竞争下没有优势"。JDK 25 已经彻底移除偏向锁,本机实测:

$ java -XX:+UseBiasedLocking -version
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.

锁升级路径现在只有"无锁 → 轻量级锁 → 重量级锁"。单线程下监视器走轻量级锁,每次进出仍是一次 CAS 加一次解锁写,所以它比无锁慢是结构性的,不是实现质量问题。

ReentrantLock 比 synchronized 再慢 36%,这部分是 AQS 的排队检查、state 的 CAS 和 unlock 的唤醒判断。单线程时这些全是纯开销。

高竞争:CAS 失败的重试放大

把线程数拉到 4 和 16,JMH 中位数:

线程数synchronizedReentrantLockTreiber
149 95436 27957 393
428 48624 8794 566
167 84423 0563 105

4 线程时 Treiber 掉到有锁版的五分之一,16 线程时掉到七分之一。这不是抖动:4 线程这一档我重跑了一次,Treiber 是 4 343 对 4 566,两次差 5%;ReentrantLock 是 25 527 对 24 879,差 2.6%。无锁那一列是稳定地崩,不是噪声。

崩的原因在重试次数。探针在 4 线程、每线程 10 万次操作下统计到的 CAS 失败:

impl          threads    p50(ns)    p99(ns)   p999(ns)  casFailures
treiber             4        417       2833       5125       602501
treiber            16       1333      23208      47125      6133960

4 线程 40 万次操作失败 60 万次,平均每次操作 1.5 次失败;16 线程 160 万次操作失败 613 万次,平均 3.8 次。三次运行分别在 1.5–2.2 和 3.8–4.9 之间,随负载浮动。

为什么 4 个线程就能把性能打掉 80%?因为CAS 失败的代价不是"重来一次",而是"重读一次已经被别人改脏的缓存行"。量级上,成功的 CAS 在独占状态下是十几纳秒级,失败则意味着这条缓存行已经落在别的核上,重新取回要跨核通信,高一个数量级(这是机制解释,不是本文测出来的数字)。4 个线程同时抢一个 top,每次成功都会让其余 3 个线程的副本失效,于是失败者付跨核代价、成功者付让别人失效的代价——总吞吐随线程数下降。

把这个过程算一遍就明白为什么是"下降"而不是"持平"。N 个线程抢同一个地址,每次成功的 CAS 都会让其余 N-1 个线程的缓存副本失效。稳态下每个线程的成功率大约是 1/N,失败一次就要经历一次跨核重取;N 从 1 涨到 4,单次操作的平均代价从"一次本地 CAS"变成"若干次跨核往返",而总成功次数并没有增加——单位时间内做成的有用功不变,花的钱变多了。

10.1 的队列数据可以当对照组。同样是两百万次操作,ConcurrentLinkedQueue 式的 MS 队列在 4 生产者 4 消费者下的失败次数是 192 万,Treiber 栈是 467 万,差 2.4 倍。原因不是队列更"无锁",而是它的竞争被摊到了 head 和 tail 两个地址上,同一条缓存行上的冲突密度降下来了。这正好印证了分片的思路:降低争抢密度比优化单次 CAS 更有效。

有锁实现为什么没崩?因为它在竞争激化时走的是另一条路:synchronized 膨胀成重量级监视器,ReentrantLock 进 AQS 队列后 park。park 的线程不消耗 CPU,也不产生缓存行流量。 失败重试被换成了排队等待,单位时间内对共享地址的写次数被压到最低。代价是唤醒延迟,这部分在下一节的尾部数据里现形。

顺带解释 16 线程时 synchronized 那档为什么只有 7 844:监视器在激化的竞争下每次唤醒都要走操作系统,而 ReentrantLock 的 AQS 在同一临界区里做了更多忙等优化,两者在这个点位上分道扬镳。反过来,4 线程时 synchronized 又跑到 28 486,两次运行的全部迭代从 7 203 晃到 28 887——监视器的行为对调度极其敏感,这一列的数字最不该被当成常数。

尾延迟:两种完全不同的"慢"

吞吐说无锁输了。但把同一批操作的耗时排序,故事就变了。下表是三次运行的范围(每线程 10 万次操作,第二轮采样):

线程实现p50p99p999
4synchronized208–250 ns1.5–2.4 µs66–82 µs
4ReentrantLock42 ns1.4–11.8 µs35–61 µs
4Treiber417–667 ns2.8–3.8 µs5.1–7.5 µs
16synchronized125–250 ns2.3–2.5 µs338–645 µs
16ReentrantLock42 ns1.5–55.7 µs135–235 µs
16Treiber1 333–2 209 ns18.8–26.3 µs38–53 µs

读这张表要先记住 42ns 是计时器刻度:p50=42ns 的意思是"一半操作在两格之内完成",不是"42 纳秒"。

真正有信息量的是三件事。

第一,中位数撒谎。 4 线程的 synchronized 栈 p50 只有 208–250ns,看着比 Treiber 的 417–667ns 还快;但它的 p999 是 66–82µs,比 Treiber 的 5–7.5µs 高一个数量级。同一个实现在同一批样本里,中位数赢、尾部输。用平均值做选型,会得到和用 p999 做选型完全相反的结论。

第二,两种尾部的成因不同。 有锁的尾部来自 park/unpark 和操作系统调度:线程被唤醒后要等 CPU,负载高时这一等就是几十到几百微秒。这类尾部没有上界——16 线程时 synchronized 的 p999 到了 338–645µs,而且随机器负载剧烈漂移(三次运行 373µs / 338µs / 645µs)。无锁的尾部来自重试次数:最坏情况是"每次 CAS 都被抢",重试次数受同时竞争的线程数约束,16 线程时 p999 也只是 38–53µs,而且三次运行相当稳定。

第三,p99 和 p999 会给出不同的赢家。 4 线程时 p99 是 synchronized 略胜(1.5–2.4µs 对 Treiber 的 2.8–3.8µs),p999 反过来是 Treiber 大胜(5.1–7.5µs 对 66–82µs)。如果你的 SLO 是"p99 小于 5ms",三个实现都合格;如果 SLO 是"任何请求不超过 10ms",只有 Treiber 稳得住。

这就是"尾延迟比平均吞吐更能说明问题"的实际含义:平均吞吐告诉你系统在稳态下的产能,尾延迟告诉你最坏情况下用户会等多久。无锁的 p99.9 有结构性上界,有锁的没有。在这个意义上,4 线程时吞吐只有五分之一的无锁版,对延迟敏感的接口反而是更安全的选择。

反过来也要说清楚:如果只看吞吐,或者 p999 不在 SLO 里,那这组数据就是无锁输。不要用一个指标替另一个指标做决定。

LongAdder 式分片能不能救

10.1 的失败次数说明问题出在"所有线程写同一个地址"。8.2 讲过 LongAdder 的解法:把一次写拆成 N 个地址的写。同样的思路能不能搬到栈上?

先看计数场景的直接对照。同一个探针,AtomicLong.incrementAndGet() 对 LongAdder.increment():

线程实现p50p99p999
1AtomicLong0 ns42 ns125 ns
1LongAdder0 ns83 ns125 ns
4AtomicLong208 ns500 ns625 ns
4LongAdder125 ns500 ns625 ns
16AtomicLong375 ns1 167 ns1 542 ns
16LongAdder375 ns1 125 ns1 375 ns

必须如实说:这组数据没有复现 8.2 的吞吐差距。 8.2 用 JMH 测到 8 线程时 LongAdder 比 AtomicLong 快 36 倍,这里 16 线程的 p99 只差 3.6%。我判断有三个原因:探针本身有 40ns 级的计时开销,等于在每次自增之间插了一段"不竞争"的间隙;每线程 20 万次的窗口太短,LongAdder 的 Cell 数组还没扩容到位;本机负载 77–104,线程被调度得七零八落,争抢根本没形成。

所以这一格我只能给出"未取得可复现的分片收益",不能给出数字。要复现吞吐差距,看 8.2 的 JMH 数据,不要看这个探针。

那分片思路能不能用到栈上?能,但形态不同。栈不能有 N 个栈顶(那就不是栈了),可行的做法是批量合并:每个线程先在本地攒一批节点,串成一条链,再用一次 CAS 把整条链挂到 top 上。CAS 次数降到 1/N,失败一次损失 N 个节点的进度而不是 1 个。

代价很明确:

  • 出队侧仍然是单点。批量只优化了 push,pop 还得逐个别 CAS。
  • 语义变了。“刚 push 的元素立刻可被其他线程看到"不再成立,最坏要等一个批次窗口。
  • 内存峰值上去了。每个线程手里都可能攥着一批还没发布的节点。

这就是典型的"用延迟换吞吐”,和 LongAdder 用"读不精确"换"写不争抢"是同一类交易。分片从来不是免费的加速,是把竞争成本挪到别的地方。

在 libraryd-conc 里落地

回到借阅事件队列。10.1 用它做了 MPSC 的审计流,那里我建议直接用 ConcurrentLinkedQueue。选型的依据就是这一节的数据——但要按场景分开看:

场景关注点选择原因
审计事件投递吞吐、不能丢ConcurrentLinkedQueue帮助推进把竞争摊到两个指针上,本机 1M 入队失败 192 万次,比单点栈低
库存扣减计数器吞吐LongAdder8.2 已测,8 线程 36 倍
库存快照的"最新值"延迟、单次操作AtomicReference 直接换一次 CAS,不需要分片
秒杀时的等待名单尾延迟有界队列 + 拒绝无界队列会把排队时间转成内存

如果确实要自己写一个高频投递的队列,先量再写。测量顺序建议是:先用 JMH 测吞吐确认瓶颈在竞争上,再用探针看 p99/p999 确认尾部是不是调度造成的,最后才决定要不要上无锁。反过来——先写无锁再找场景——是本末倒置。

复现本文全部数字的命令(CP 由 target/classes 加三个 jar 拼成,见第 2 章的依赖说明):

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
cd /tmp/java-conc/juc-ch10/bench
mvn -o -q -DskipTests compile                       # 离线编译,生成 META-INF/BenchmarkList

# 吞吐:线程数 -t 分别取 1、4、16,各跑一次
java -Djava.io.tmpdir=$PWD/tmp -cp "$CP" org.openjdk.jmh.Main \
     'jucch10.bench.StackBench.mixed' -t 4 -rf text -rff jmh-t4.txt

# 尾延迟与 CAS 失败次数
cd /tmp/java-conc/juc-ch10
javac --release 25 -d out src/jucch10/*.java
java -cp out jucch10.LatencyProbe
java -cp out jucch10.CounterTail

-Djava.io.tmpdir 是必须的:JMH 默认在系统临时目录下建 jmh.lock,同一台机器上跑多个 JMH 会互相抢锁,报 “Another JMH instance might be running”。我第一次跑就撞上了这个锁,换一个私有临时目录才跑起来。

一个可以直接用的判断:如果你的临界区里除了改指针还有别的活(比如校验库存、写审计日志),那把活搬进无锁区只会让 CAS 窗口变长,失败率更高。有锁在这里几乎总是更好的选择,因为锁把"排队"和"干活"分开了。

常见错误与边界

第一,用单次运行的吞吐数字做选型。本机 synchronized 在 4 线程同一配置下两次跑出 28 486 和 7 414,差 3.8 倍。没有多次运行和方差,就没有结论。

第二,把 p50 当性能。42ns 是计时器刻度;即使刻度更细,中位数也会掩盖 p999 上两个数量级的差距。

第三,认为无锁一定快。4 线程时 Treiber 的吞吐是有锁版的 1/5,失败次数每次操作 1.5–2.2 次。

第四,认为有锁一定稳。16 线程时 synchronized 的 p999 到 645µs,且随负载漂移;park 的唤醒延迟没有上界。

第五,在负载高的机器上做微基准还相信绝对值。本文所有数字都在 load 77–104 下取得,只能看相对结构,不能当基准线。

小结

低竞争时 Treiber 栈最快(57 393 ops/ms,比监视器版快 15%),因为偏向锁已从 JDK 25 移除,监视器的每次进出都是实打实的 CAS。4 线程时竞争把 CAS 失败率推到每次操作 1.5–2.2 次,Treiber 吞吐掉到有锁版的五分之一;16 线程时失败率 3.8–4.9 次,差距扩大到七分之一。但尾部完全反过来:4 线程时 Treiber 的 p999 是 5–7.5µs,synchronized 是 66–82µs,16 线程时是 38–53µs 对 338–645µs。有锁的尾部来自 park 的调度延迟,没有上界;无锁的尾部来自重试次数,受线程数约束。

LongAdder 式分片在这个探针下没有复现出 8.2 的吞吐优势,原因写在上面,不给数字。想用分片思路救栈,只能做批量合并,代价是可见性延迟和内存峰值。

下一章进入线程池:ctl 那 32 位里怎么同时塞下运行状态和线程数。

阅读导航:上一节 10.2 ABA 与内存回收 | 下一节 11.1 ctl 与 Worker 生命周期

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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