承接的问题
7.1 拆了 ArrayBlockingQueue 与 LinkedBlockingQueue 的锁和条件,7.2 拆了 SynchronousQueue、PriorityBlockingQueue、DelayQueue 的等待条件。五节的机制讲完,剩下三个绕不开的问题:锁拆开到底买到了什么?元素存成节点要付多少内存?为什么同一组队列在不同机器、不同线程数下测出的快慢会翻转?
这一节不引入新类,只把前面五个类的定量维度摆在一起:锁的拓扑、节点开销、缓存行、容量语义,最后给一组本机实测,并说明这组数字的适用边界。AQS 的排队与 Condition 的唤醒细节第 1、2 章已讲,这里不再展开。
锁拆分:收益与代价
ArrayBlockingQueue 的临界区包含四件事:写 items、推进 takeIndex/putIndex、改 count、调用 signal();如果队列有迭代器,还要维护 Itrs。这四件事共用一把锁,所以即使队列既有空位又有元素,生产者与消费者也只能一个进、一个等。锁的交接本身要付出一次缓存行迁移的成本:锁释放后,下一个线程要把包含锁状态与队列字段的缓存行拿到自己的核心。
LinkedBlockingQueue 把临界区按方向拆开:
| 操作 | 需要持有的锁 |
|---|---|
put / offer | putLock,必要时在释放后拿 takeLock 发信号 |
take / poll | takeLock,必要时在释放后拿 putLock 发信号 |
size | 无锁,读 count |
clear / remove / contains | fullyLock(),两把都拿 |
拆分的收益是:生产者和消费者不再互斥,队列同时有空间和元素时,两条路径可以真正并行。代价有三个。
第一,count 成了新的串行点。7.1 讲过它为什么必须是 AtomicInteger;从性能角度看,它意味着每一次 put 和 take 都要对同一个原子变量做一次读改写。锁虽然分开了,这个变量仍然被两边同时触碰。
第二,跨锁唤醒不能免费。7.1 的 signalNotEmpty()/signalNotFull() 要在释放自己的锁之后去拿对方的锁,只在“空→非空”或“满→非满”的边界发生;一旦工作负载让队列频繁在空和满之间震荡,这笔跨锁开销就会回到每次操作。
第三,批量操作要拿两把锁。clear()、remove(Object)、contains()、drainTo() 都走 fullyLock(),这些操作的并发度并不比 ABQ 高。drainTo 稍微特殊:它只拿 takeLock,用 count.get() 得到可见的元素上界,最后用 count.getAndAdd(-i) 一次性扣减,再在锁外唤醒生产者。这是双锁设计里少见的“批量化摊薄原子操作”的写法。
所以“双锁一定比单锁快”不成立。它快的前提是:生产与消费能并行、跨锁信号稀有、count 的原子竞争没有成为瓶颈。三个条件缺一个,收益就会被抵消。
Node 对象开销与内存
先看结构本身。本机用 JOL 0.17 测得的 shallow size 与 1 万个共享元素(所有槽位引用同一个对象,排除元素本身干扰)的保留集,输出摘录如下(省略了单独的 shared element only 行):
java.util.concurrent.ArrayBlockingQueue shallow=48 B
java.util.concurrent.LinkedBlockingQueue shallow=48 B
java.util.concurrent.SynchronousQueue shallow=32 B
java.util.concurrent.PriorityBlockingQueue shallow=40 B
java.util.concurrent.DelayQueue shallow=32 B
java.util.concurrent.LinkedBlockingQueue$Node shallow=24 B
java.util.concurrent.LinkedTransferQueue$DualNode shallow=32 B
ArrayBlockingQueue(10k shared elements) retained=40,176 B, objects=7
LinkedBlockingQueue(10k shared elements) retained=240,248 B, objects=10,010
PriorityBlockingQueue(10k shared elements) retained=40,144 B, objects=6
ArrayBlockingQueue(empty, cap=10k) retained=40,160 B, objects=6
LinkedBlockingQueue(empty, cap=10k) retained=232 B, objects=9
SynchronousQueue(empty) retained=56 B, objects=2
DelayQueue(empty) retained=200 B, objects=6
把元素本身扣掉,结构开销一目了然:
| 结构 | 1 万元素保留集 | 每元素结构开销 | 空队列 |
|---|---|---|---|
ArrayBlockingQueue | 40,176 B | 约 4.0 B(一个引用槽) | 40,160 B(容量 10k) |
LinkedBlockingQueue | 240,248 B | 约 24.0 B(Node) | 232 B(锁、条件与哨兵) |
PriorityBlockingQueue | 40,144 B | 约 4.0 B(堆数组槽) | 初始 11 槽 |
Node 的 24 字节是对象头 12 字节 + item 引用 4 字节 + next 引用 4 字节,再按 8 字节对齐到 24。链表比数组每元素多出的约 20 字节,就是“24 字节节点减去数组里本来就要占的 4 字节引用槽”。一万个元素多 200 KB 看起来不多,但队列是高频路径:每秒 100 万次 put 就是每秒 24 MB 的节点分配,年轻代会持续被这些短命对象填满;ArrayBlockingQueue 在稳态下每次 put 不分配任何结构对象(元素本身的装箱另算)。
空队列的对比更能说明设计取向:容量 10k 的 ABQ 一创建就占 40 KB,因为它预分配了整个数组;LBQ 空的时候只有 232 字节,节点按需分配。前者是“容量换确定性”,后者是“占用换弹性”。容量规划时要按峰值占用算:100 万元素的 ABQ 约 4 MB 引用数组,LBQ 约 24 MB 节点,差 6 倍。
PriorityBlockingQueue 和 ABQ 一样是数组,所以每元素也是 4 字节引用槽;它的扩容策略是小于 64 时 +2、否则 +50%,不会像 LBQ 那样每元素一个节点。DelayQueue 内部是一个 PriorityQueue 加用户的 Delayed 对象,空队列的 200 字节主要来自 PriorityQueue 的默认 11 槽数组和锁/条件。
伪共享:锁拆开了,缓存行没拆开
比“每元素多少字节”更隐蔽的是字段在对象里的位置。本机 JOL 打印的实例字段偏移(压缩指针开启):
== java.util.concurrent.ArrayBlockingQueue ==
takeIndex offset=12 size=4
putIndex offset=16 size=4
count offset=20 size=4
items offset=24 size=4
lock offset=28 size=4
notEmpty offset=32 size=4
notFull offset=36 size=4
itrs offset=40 size=4
== java.util.concurrent.LinkedBlockingQueue ==
capacity offset=12 size=4
count offset=16 size=4
head offset=20 size=4
last offset=24 size=4
takeLock offset=28 size=4
notEmpty offset=32 size=4
putLock offset=36 size=4
notFull offset=40 size=4
LinkedBlockingQueue 的对象只有 48 字节,count、head、last 三个字段挤在偏移 16–24,takeLock、putLock 与两个条件在 28–40。在对象没有跨 64 字节缓存行放置的常见情况下,这些字段会落在同一行里。后果是:消费者写 head、生产者写 last、两边都写 count,三个写操作都会让同一个缓存行失效,核心之间来回迁移。锁是两把,缓存行只有一条。
这并不是说双锁设计无效。它消掉的是“生产者与消费者不能同时进入临界区”的互斥,收益是真实的;但 count 这条共享行仍然把两边绑在一起,所以吞吐不会随线程数线性上升。ABQ 的所有字段也在 12–40 的同一行里,但它的所有写操作本来就在同一把锁下,缓存行迁移已经包含在锁交接里,不再有“额外的伪共享”。
两个类都没有用 @Contended。@Contended 会给字段前后补 128 字节,适合 LongAdder 的 Cell 这种“每个线程写自己的槽”的结构(8.2 展开);队列的 count 是真正的共享状态,补 padding 只能减少相邻字段的干扰,减少不了 count 本身在多核间的必然同步。JOL 的偏移是对象内偏移,实际缓存行对齐取决于分配地址;上面的结论是“很可能同一行”,不是“必然同一行”。
有界与无界的容量语义
五个类的容量语义完全不同,这是选型时比吞吐更硬的约束:
| 类 | 容量 | 满了的行为 | 内存随什么增长 |
|---|---|---|---|
ArrayBlockingQueue | 构造时固定,必须 > 0 | put 阻塞,offer 返回 false | 容量(构造即分配) |
LinkedBlockingQueue | 构造时固定,默认 Integer.MAX_VALUE | 同上 | 当前元素数 |
SynchronousQueue | 0 | offer 无消费者时 false,put 阻塞 | 不存储元素 |
PriorityBlockingQueue | 无界 | 永不失败(除非 OOM) | 当前元素数 |
DelayQueue | 无界 | 永不失败(除非 OOM) | 当前元素数 |
有界队列的价值不是“省内存”,而是把过载变成调用点上的一个返回值:offer 返回 false,调用方可以降级、拒绝、记录指标;无界队列把同样的过载藏起来,直到堆涨满或 GC 时间失控。remainingCapacity() 只是瞬时估计,不能作为“一定能放进去”的依据。
容量怎么定?用 Little 定律做一个粗算:容量 ≥ 峰值到达率 × 允许的最大排队时延。借还事件峰值 2000 事件/秒,允许积压 2 秒,容量至少 4000,取 4096。这个公式不精确,但它强迫你把“允许排多久”写成一个数字,而不是拍一个 1024。
三队列吞吐对比(本机实测)
用同一个简易探针跑 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue,容量 1024(SQ 忽略容量),每组固定 100 万次元素传递,用 CountDownLatch 对齐起跑线;每个形状重复 3 次,取中位数。运行前负载均值 68.96,10 核:
loadavg=68.95947265625
--- P=1 C=1 total=1,000,000 ---
ABQ samples=12,063,875 / 12,262,500 / 12,269,186 ops/s median=12,262,500
LBQ samples=9,954,799 / 13,573,041 / 16,605,204 ops/s median=13,573,041
SQ samples=386,716 / 419,037 / 433,589 ops/s median=419,037
--- P=4 C=4 total=1,000,000 ---
ABQ samples=32,405,742 / 35,237,711 / 35,650,359 ops/s median=35,237,711
LBQ samples=23,889,725 / 24,346,055 / 27,207,275 ops/s median=24,346,055
SQ samples=703,759 / 706,043 / 707,528 ops/s median=706,043
--- P=8 C=8 total=1,000,000 ---
ABQ samples=24,133,130 / 24,723,980 / 26,390,650 ops/s median=24,723,980
LBQ samples=23,364,122 / 26,222,802 / 28,464,146 ops/s median=26,222,802
SQ samples=854,413 / 861,721 / 886,737 ops/s median=861,721
先把结论说清楚:只有 SQ 与另外两个的差距是数量级差距,ABQ 与 LBQ 之间的差异不能从这个表里读出来。 SQ 的 41.9 万到 86.2 万 ops/s 反映的是“每次交接都要 park/unpark 一个线程”,它的吞吐上限接近上下文切换速率;没有缓冲,生产者必须等到消费者真正接手。ABQ 与 LBQ 在 1P1C 时是 1226 万对 1357 万,4P4C 时是 3524 万对 2435 万,8P8C 时是 2472 万对 2622 万——方向随线程数翻转,而部分样本组内部的波动可达 30%–60%(例如 1P1C 的 LBQ)。
7.1 的 JMH 结果同样如此:4 对 4 时 LBQ 的均值 2900 万高于 ABQ 的 1976 万,但 99.9% 置信区间分别宽达 ±1995 万和 ±3332 万,one/abq 的区间下界甚至是 0。JMH 的误差项已经把“这台机器上分不出胜负”写在了脸上。
为什么数字只能看数量级?至少六个原因:
- 没有 CPU 隔离。本机负载常年 60–110,10 个核上还跑着别的进程;JMH 的 8 个线程会被随时抢占,迭代时间被拉长,吞吐被压低。
- JIT 与测量顺序。简易探针每组按 ABQ、LBQ、SQ 的顺序跑,ABQ 在第一个形状里承担了大部分 JIT 预热成本;3 次重复只能缓解,不能消除。
- 队列状态分布不同。队列是空、半满还是经常满,会改变
signal的跨锁路径、park 的频率和 CAS 失败率;固定容量 1024 但生产/消费速度随调度变化,状态分布并不稳定。 - 线程数与核心数不匹配。8P8C 是 16 个线程抢 10 个核,调度开销已经盖过队列本身的开销;4P4C 是 8 个线程,反而更接近可比较的区间。
- 元素与分配。探针传的是
Integer,小整数走缓存、大整数要分配;真实业务元素的大小和分配率会改变 GC 行为。 - 硬件与运行时。M1 Pro 的缓存层次、macOS 的调度、压缩指针、TLAB 大小都会影响结果,换一台机器排序可能就变了。
还有一个更根本的问题:吞吐不是延迟。上面的 ops/s 是 3 次样本的中位数,它把尾部的长停顿平均掉了。队列真正影响业务的是“一个元素从 put 返回到被 take 取走”的排队时间,以及高负载下的 P99;队列长度、消费者处理时间和锁等待都会进入这个时间,而吞吐表里看不到。卷三第 5 章(5.3 队列长度、拒绝率与降级)展开,这里只强调:不要用吞吐排名代替延迟测量。
复现命令(先按 7.1 的方式编译 jmh.QueueBench1,简易探针的源码见 /tmp/java-conc/juc-ch07/src/ThroughputProbe.java):
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 -d out src/ThroughputProbe.java
java -cp out ThroughputProbe
# 简易探针与 JMH 都要记录当时的负载均值,否则数字没有可比性
uptime
所以正确的用法是:用这组数字排除“SQ 当缓冲”这类数量级错误,用 7.1 的唤醒次数解释“为什么双锁不是每次都快”,然后用容量语义、内存预算和公平性做最终选型。把 1226 万和 1357 万的差异当成“LBQ 比 ABQ 快 10%”写进设计文档,是把噪声当成了结论。
在 libraryd-conc 里落地
把上面的维度固化成两个小工具:一个按 Little 定律算容量,一个按容量/占用估算内存。它们不依赖运行时,放在 conc-core 里,评审时可以直接引用。
package com.plume.conc.core;
/** 队列容量与内存的粗算工具:把拍脑袋变成可复核的数字。 */
public final class QueueSizing {
private QueueSizing() {}
/** capacity >= rate * allowedLatencySeconds,向上取 2 的幂并留 25% 余量。 */
public static int capacityFor(double peakRatePerSecond, double allowedLatencySeconds) {
long raw = (long) Math.ceil(peakRatePerSecond * allowedLatencySeconds * 1.25);
int cap = 1;
while (cap < raw && cap < (1 << 30)) cap <<= 1;
return Math.max(1, cap);
}
/** 数组队列的结构内存:每槽 4 字节引用(压缩指针)。 */
public static long arrayQueueBytes(int capacity) {
return 16L + 4L * capacity;
}
/** 链表队列的结构内存:每元素 24 字节 Node。 */
public static long linkedQueueBytes(int occupancy) {
return 64L + 24L * (long) occupancy;
}
public static void main(String[] args) {
int cap = capacityFor(2000, 2.0);
System.out.println("capacity=" + cap);
System.out.println("ABQ bytes=" + arrayQueueBytes(cap));
System.out.println("LBQ bytes=" + linkedQueueBytes(cap));
}
}
按 2000 事件/秒、允许积压 2 秒算,capacityFor 返回 8192(4000 × 1.25 = 5000,向上取到 8192)。同样的容量,ABQ 的结构内存约 32 KB,LBQ 满员时约 192 KB;如果实际占用只有几百,LBQ 的 64 字节空壳更省。这个对比应该在容量评审时摆出来,而不是等 OOM 了再猜。
第二个工具把“监控什么”写进代码。SynchronousQueue 不能看 size(),DelayQueue 不能只看深度,LBQ 要看 offer 失败率与 count 的趋势:
package com.plume.conc.core;
import java.util.concurrent.BlockingQueue;
/** 队列健康度:不同队列的指标口径不同,别用同一套 dashboard。 */
public final class QueueHealth {
public record Snapshot(int size, int remaining, double fillRatio) {}
public static Snapshot of(BlockingQueue<?> queue, int capacity) {
int size = queue.size();
int remaining = queue.remainingCapacity();
double ratio = capacity <= 0 ? 0.0 : (double) size / capacity;
return new Snapshot(size, remaining, ratio);
}
public static void main(String[] args) {
java.util.concurrent.ArrayBlockingQueue<String> q =
new java.util.concurrent.ArrayBlockingQueue<>(8);
q.offer("a");
System.out.println(of(q, 8));
}
}
fillRatio 只对 ABQ/LBQ 有意义;remainingCapacity() 对 PBQ 恒为 Integer.MAX_VALUE,对 SQ 恒为 0,直接套公式会得到 0% 或 100% 的假象。监控系统应该按队列类型注册不同的指标,而不是所有队列共用一张图。
选型决策表
把四节的结论压成一张按顺序排除的表,从上往下走,第一个满足条件的分支就是答案:
| 需求 | 选择 | 理由 |
|---|---|---|
| 生产者必须把元素直接交给消费者,不能缓冲 | SynchronousQueue | 零容量,配对即交接 |
| 按优先级出队,元素数量可控 | PriorityBlockingQueue | 堆序 O(log n),无界 |
| 按到期时间出队,单机、可丢失 | DelayQueue | 堆 + leader,到期才可取 |
| 有界缓冲,要公平或要确定内存 | ArrayBlockingQueue | 单锁、预分配、可公平 |
| 有界缓冲,生产消费都猛,能接受节点分配 | LinkedBlockingQueue | 双锁,容量必须显式 |
| 只是多线程传递引用,不需要阻塞 | ConcurrentLinkedQueue | 无锁、无界(6.3) |
这张表故意把“吞吐”放在最后:前五行都是语义约束,只有最后一行是“两个都满足时怎么选”。如果两个有界队列都满足语义,优先按内存预算和公平性选;吞吐留到隔离环境里再测。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 拿一台高负载机器上的 ABQ/LBQ 吞吐差做选型 | 把噪声写成结论 | 先看语义与内存,再用隔离环境复测 |
| 认为“双锁 = 两倍吞吐” | 忽略 count 的原子竞争与跨锁信号 | 用 7.1 的唤醒次数确认跨锁是否稀有 |
只看元素数,不算 Node 分配率 | 高吞吐下年轻代 GC 上升 | 100 万 put/s 对应约 24 MB/s 节点分配 |
用 size() 做精确限流 | 读到的只是瞬间值 | 用 offer 返回值或信号量 |
| 给 PBQ/DQ 配“容量” | 它们无界,参数不存在 | 用上游限流或换有界队列 |
| 在 SQ 上做积压监控 | size() 恒 0 | 监控交接延迟与 offer 失败率 |
以为没有 @Contended 就没有伪共享 | 字段同行的缓存行仍然迁移 | 用 JOL 看偏移,用隔离实验验证 |
还有一个边界属于“什么时候不要用队列”:如果一次操作要原子地改两个结构(例如扣库存的同时写预约),不要用两个并发队列拼一个事务;用一把锁把临界区写清楚,或者用数据库事务。队列擅长“传递”,不擅长“复合原子性”。
小结
锁拆分的收益来自生产者与消费者可以并行,代价是 count 的原子竞争、跨锁唤醒和批量操作的双锁;节点存储的收益是空队列几乎不占内存、按需增长,代价是每元素约 24 字节与持续的分配压力;伪共享解释了为什么双锁的吞吐不会随线程数线性上升;容量语义决定了过载时是阻塞、拒绝还是 OOM。本机实测只能确认 SQ 比有缓冲队列低一到两个数量级,ABQ 与 LBQ 的差异在高负载下没有稳定方向。选型顺序应该是:先定容量与失败模式,再算内存与分配率,最后才是在隔离环境里比较吞吐。
下一章从队列转向原子类,8.1 拆 AtomicInteger 家族的 CAS 路径与 VarHandle 实现。
阅读导航:上一节 7.2 SynchronousQueue、Priority、Delay | 下一节 8.1 AtomicInteger 家族的 CAS 路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。