承接的问题
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 393 | 17.4 ns |
synchronized 栈 | 49 954 | 20.0 ns |
ReentrantLock 栈 | 36 279 | 27.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 中位数:
| 线程数 | synchronized | ReentrantLock | Treiber |
|---|---|---|---|
| 1 | 49 954 | 36 279 | 57 393 |
| 4 | 28 486 | 24 879 | 4 566 |
| 16 | 7 844 | 23 056 | 3 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 万次操作,第二轮采样):
| 线程 | 实现 | p50 | p99 | p999 |
|---|---|---|---|---|
| 4 | synchronized | 208–250 ns | 1.5–2.4 µs | 66–82 µs |
| 4 | ReentrantLock | 42 ns | 1.4–11.8 µs | 35–61 µs |
| 4 | Treiber | 417–667 ns | 2.8–3.8 µs | 5.1–7.5 µs |
| 16 | synchronized | 125–250 ns | 2.3–2.5 µs | 338–645 µs |
| 16 | ReentrantLock | 42 ns | 1.5–55.7 µs | 135–235 µs |
| 16 | Treiber | 1 333–2 209 ns | 18.8–26.3 µs | 38–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():
| 线程 | 实现 | p50 | p99 | p999 |
|---|---|---|---|---|
| 1 | AtomicLong | 0 ns | 42 ns | 125 ns |
| 1 | LongAdder | 0 ns | 83 ns | 125 ns |
| 4 | AtomicLong | 208 ns | 500 ns | 625 ns |
| 4 | LongAdder | 125 ns | 500 ns | 625 ns |
| 16 | AtomicLong | 375 ns | 1 167 ns | 1 542 ns |
| 16 | LongAdder | 375 ns | 1 125 ns | 1 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 万次,比单点栈低 |
| 库存扣减计数器 | 吞吐 | LongAdder | 8.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 生命周期
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。