承接的问题
构造场景:libraryd-conc 的借阅入口用 Executors.newFixedThreadPool(4) 接请求,下游是库存服务,单次调用约 50 ms。活动开始后请求从 300/s 涨到接近 1000/s,线程池一次都没有拒绝——因为它的队列根本没有上限。五分钟内老年代一路涨,监控上看到的是「队列深度」曲线缓慢爬升、「P99 时延」从 50 ms 爬到十几秒,最后以 java.lang.OutOfMemoryError: Java heap space 结束。重启之后同样的事情又发生了一次。
5.3 讲的是「拒绝率」这条路径:有界队列满了以后,AbortPolicy 如何把过载变成可观测的异常,各种拒绝策略在过载下分别让调用方付出什么代价。本节讲的是它的反面——堆积而不拒绝。这条路径在监控上最安静,也最危险:拒绝率为 0,错误率为 0,只有堆内存在涨。
这一节要回答四个问题:无界队列为什么等于没有背压、堆积速率怎么算、堆什么时候会满、OOM 之后如何用堆转储和类直方图找到真正的占用者。
无界队列把「拒绝」推迟成「OOM」
Executors.newFixedThreadPool 的实现很稳定:JDK 25 的 src.zip 里,Executors.java 第 88–92 行是:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
没有传容量。而 LinkedBlockingQueue 的无参构造器在同一个 src.zip 的第 244–246 行写得很直白:
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
本机打印出来就是 queue.remainingCapacity()=2147483647。也就是说,这个池的「队列长度」在语义上等于「不限制」,在实现上等于「直到堆用完为止」。ThreadPoolExecutor 认为自己在正常工作:getQueue().size() 一直增长,getActiveCount() 稳定在 4,getRejectedExecutionCount() 永远是 0。
有界队列与无界队列的区别不在于「会不会满」,而在于满的时候系统做什么:
| 队列 | 过载时的表现 | 监控上的信号 | 故障形态 |
|---|---|---|---|
有界 + AbortPolicy | 立即抛 RejectedExecutionException | 拒绝率、错误率上升 | 快速失败,可降级 |
有界 + CallerRunsPolicy | 提交线程被拉去干活 | 提交耗时上升 | 上游被压慢 |
| 无界 | 全部接受,排队 | 队列深度、堆占用上升 | 时延膨胀,直到 OOM |
无界队列不是「更宽容」,它是把一次本该在入口发生的拒绝,推迟成一次发生在整个 JVM 上的 OOM。前者的爆炸半径是一个请求,后者是整个进程。
堆积速率与堆增长公式
设稳定到达率为 λ_in(请求/秒),稳定处理率为 λ_out(任务/秒),每个排队任务平均固定占用 s 字节(消息体、闭包、队列节点之和)。只要 λ_in > λ_out,队列深度 Q 和堆占用 H 就按下面的速率线性增长:
dQ/dt = λ_in − λ_out (单位:任务/秒)
dH/dt ≈ (λ_in − λ_out) × s (单位:字节/秒)
T_oom ≈ (Xmx × 可用比例 − 基线) / (dH/dt) (单位:秒)
这三个式子里,λ_out 才是真正的容量,它由「线程数 ÷ 单任务耗时」决定,和队列长度无关。队列长度只决定「什么时候开始满」,不决定「能处理多少」。这是 5.1 的 L = λW 在故障方向上的直接推论。
本机实测(2 个 worker、parkNanos(50ms)、到达 1000/s、每个任务 64 KB 数组、-Xmx256m)的采样点:
queue.remainingCapacity()=2147483647
t= 0.5s queue= 488 submitted= 507 completed= 17 heapUsed= 36MB
t= 1.0s queue= 986 submitted= 1024 completed= 36 heapUsed= 68MB
t= 1.5s queue= 1478 submitted= 1534 completed= 54 heapUsed=102MB
t= 2.0s queue= 1966 submitted= 2040 completed= 72 heapUsed=133MB
t= 2.5s queue= 2450 submitted= 2542 completed= 90 heapUsed=156MB
t= 3.1s queue= 2942 submitted= 3052 completed= 108 heapUsed=190MB
t= 3.6s queue= 3426 submitted= 3554 completed= 126 heapUsed=222MB
把数字代进公式(实测值,不是假设):
λ_in ≈ 3554 / 3.6 ≈ 987/s,名义 1000/s,差的 13/s 是投递线程自己被调度拖慢的部分。λ_out ≈ 126 / 3.6 ≈ 35/s,名义2 / 0.05 = 40/s,差的 5/s 是parkNanos的唤醒开销。- 队列斜率:
(3426 − 488) / 3.1 ≈ 948/s,与987 − 35 = 952/s吻合。 - 堆增长:
(222 − 36) / 3.1 ≈ 60.0 MiB/s;按公式948 × 64 KB ≈ 59.3 MiB/s,多出来的部分是闭包、LinkedBlockingQueue$Node和字符串常量。 - 按公式估算
T_oom ≈ 256 / 59.3 ≈ 4.3s;实测 OOM 落在 3.6 s 到 4.1 s 之间,因为堆里还有基线对象和 GC 预留。
注意一个容易被忽略的事实:这 60 MiB/s 全是活对象。它们挂在队列的链表上,被 LinkedBlockingQueue.Node.item 引用,Full GC 也回收不掉。所以这不是「GC 压力大」,而是「存活集合在稳定增长」——年轻代收集器救不了,G1 的并发标记也救不了,唯一的出路是不让它们进队列。
构造堆积并真实触发 OOM
复现代码只要三件事:无界队列、慢消费者、大消息体。为了不让 JVM 靠年轻代收集把数组提前回收,payload 必须被提交的任务闭包引用住,直到任务执行完。
import java.util.Arrays;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.LockSupport;
/** 复现:无界队列 + 生产快于消费 -> 堆增长 -> OOM。 */
public final class QueueOom {
static final int PAYLOAD_BYTES = 64 * 1024;
static final long SERVICE_NANOS = TimeUnit.MILLISECONDS.toNanos(50);
static final int ARRIVAL_PER_SEC = 1000;
static final int WORKERS = 2;
public static void main(String[] args) throws Exception {
// 故意不传容量:LinkedBlockingQueue 的默认容量是 Integer.MAX_VALUE
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
System.out.println("queue.remainingCapacity()=" + queue.remainingCapacity());
ThreadPoolExecutor pool = new ThreadPoolExecutor(
WORKERS, WORKERS, 0, TimeUnit.MILLISECONDS, queue,
r -> new Thread(r, "loan-worker"),
new ThreadPoolExecutor.AbortPolicy());
LongAdder submitted = new LongAdder();
LongAdder completed = new LongAdder();
long start = System.nanoTime();
Thread meter = new Thread(() -> {
try {
while (true) {
Thread.sleep(500);
Runtime rt = Runtime.getRuntime();
System.out.printf("t=%5.1fs queue=%6d submitted=%7d completed=%6d heapUsed=%3dMB%n",
(System.nanoTime() - start) / 1e9, queue.size(),
submitted.sum(), completed.sum(),
(rt.totalMemory() - rt.freeMemory()) >> 20);
}
} catch (InterruptedException ignored) {
}
}, "meter");
meter.setDaemon(true);
meter.start();
long interval = 1_000_000_000L / ARRIVAL_PER_SEC;
long seq = 0;
try {
while (true) {
long wait = start + seq * interval - System.nanoTime();
if (wait > 0) {
LockSupport.parkNanos(wait);
}
long id = seq++;
byte[] payload = new byte[PAYLOAD_BYTES];
Arrays.fill(payload, (byte) id);
pool.execute(() -> {
LockSupport.parkNanos(SERVICE_NANOS);
if (payload[0] == Byte.MIN_VALUE && payload.length == 0) {
System.out.print("");
}
completed.increment();
});
submitted.increment();
}
} catch (OutOfMemoryError e) {
Runtime rt = Runtime.getRuntime();
System.out.println("OOM queue=" + queue.size() + " heapUsed="
+ ((rt.totalMemory() - rt.freeMemory()) >> 20) + "MB");
System.exit(2);
}
}
}
跑之前必须先给堆设上限,并且把 dump 路径指向一个你一定会删掉的目录:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p /tmp/conc10
javac -d /tmp/conc10/classes QueueOom.java
java -Xmx256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/conc10/queue-oom.hprof \
-cp /tmp/conc10/classes QueueOom
本机一次真实运行的结尾(Apple M1 Pro、Temurin 25.0.5+8、load average 40 上下,单次运行、随调度波动、不作基准):
t= 3.6s queue= 3426 submitted= 3554 completed= 126 heapUsed=222MB
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/conc10/queue-oom.hprof ...
Heap dump file created [255254109 bytes in 0.155 secs]
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "meter"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
注意最后两行:catch (OutOfMemoryError) 确实接住了第一次 OOM,但 catch 里拼接那行 "OOM queue=..." 还要再分配一个 String,堆里已经一个字节都挤不出来,于是第二次 OOM 直接漏到默认处理器。OOM 现场的兜底日志不能分配内存——要么事先预分配一个 StringBuilder,要么就接受「只能靠最后一个采样点」这个现实。本例的最后状态因此以 t=3.6s 那一行为准:队列 3426、已提交 3554、已完成 126、堆 222 MB。
-Xmx256m 的堆,dump 出来是 243 MiB(255 254 109 字节),在 /tmp 里只停留了不到一分钟就被删掉了。在磁盘紧张的机器上,OOM 实验的 dump 必须当场清理:三个 GB 级的 hprof 足以把 CI 或开发机塞满,然后再制造一次「磁盘 OOM」。如果只是想看聚合数据,用 jcmd GC.class_histogram 就够了,不需要 dump。
直方图:谁占着堆
jcmd <pid> GC.class_histogram 会先做一次 Full GC,再按类聚合存活对象。在同一个实验跑到 2.2 s 时对进程执行,得到(原文,节选):
num #instances #bytes class name (module)
-------------------------------------------------------
1: 13599 151532760 [B (java.base@25.0.5)
2: 11214 269136 java.lang.String (java.base@25.0.5)
3: 1757 232008 java.lang.Class (java.base@25.0.5)
4: 1146 155928 [Ljava.lang.Object; (java.base@25.0.5)
5: 3337 106784 java.util.HashMap$Node (java.base@25.0.5)
...
8: 2304 55296 QueueOom$$Lambda/0x0000078001042000
9: 2303 55272 java.util.concurrent.LinkedBlockingQueue$Node (java.base@25.0.5)
关键不是 [B 的实例数,而是字节数:13 599 个 byte[] 里,约 2 300 个是 64 KB 的 payload,合计约 145 MiB,正好对上同一时刻 heapUsed 的量级。QueueOom$$Lambda 与 LinkedBlockingQueue$Node 的实例数几乎相等(2 304 对 2 303),说明每一个排队任务都同时挂着一个闭包和一个链表节点——这就是「队列里的东西全是活对象」的直接证据。
命令的三种用法,按侵入性排序:
| 手段 | 命令 | 得到什么 | 代价 |
|---|---|---|---|
| 类直方图 | jcmd <pid> GC.class_histogram | 按类的实例数/字节数 | 触发一次 Full GC |
| 类直方图(不 GC) | jcmd <pid> GC.class_histogram -all | 含不可达对象 | 更快,但混入垃圾 |
| 堆转储 | jcmd <pid> GC.heap_dump /tmp/x.hprof 或 jmap -dump:live,format=b,file=/tmp/x.hprof <pid> | 完整引用链 | 暂停 + 磁盘空间 |
直方图能回答「谁占着堆」,回答不了「谁引用它」。要看清 ThreadPoolExecutor → workQueue → Node → Lambda → byte[] 这条链,需要把 hprof 放进 MAT 或 VisualVM 看 dominator tree。本机没有安装 MAT,本节没有做 dominator tree 分析,只做到类级定位;对「无界队列堆积」这个形态,类级证据已经足够——LinkedBlockingQueue$Node 和 payload 数组数量同步增长,指向非常明确。
如果堆还没 OOM、但你想在不重启的情况下确认趋势,jcmd <pid> GC.class_histogram 前后各做一次,比较 [B 的字节数增量,再除以两次采样的间隔,就是 dH/dt 的实测值。它应该接近 (λ_in − λ_out) × s;如果明显更高,说明还有别的对象在泄漏,那是 10.3 的话题。
在 libraryd-conc 里落地
修复不是「把队列改小」这么简单,而是把三件事一起做:按 SLO 算容量、按水位提前拒绝、把队列深度变成指标。
package com.plume.conc.ops;
import java.time.Duration;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/** 借阅入口的有界池:容量按排队预算算,超水位先拒绝,拒绝与队列深度可观测。 */
public final class BoundedBorrowPool {
private final ThreadPoolExecutor pool;
private final int highWatermark;
private final LongAdder rejected = new LongAdder();
private final AtomicLong peakQueued = new AtomicLong();
public BoundedBorrowPool(int threads, Duration queueBudget, Duration serviceTime) {
long capacity = Math.max(1, queueBudget.toMillis() / Math.max(1, serviceTime.toMillis()))
* threads;
int queueCapacity = (int) Math.min(Integer.MAX_VALUE, capacity);
this.pool = new ThreadPoolExecutor(
threads, threads, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
r -> new Thread(r, "borrow-pool"),
new ThreadPoolExecutor.AbortPolicy());
this.pool.allowCoreThreadTimeOut(true);
this.highWatermark = Math.max(1, queueCapacity * 3 / 4);
}
/** 返回 false 表示没有接住,调用方必须走降级分支。 */
public boolean submit(Runnable task) {
int queued = pool.getQueue().size();
peakQueued.accumulateAndGet(queued, Math::max);
if (queued >= highWatermark) {
rejected.increment();
return false;
}
try {
pool.execute(task);
return true;
} catch (RejectedExecutionException e) {
rejected.increment();
return false;
}
}
public int queueDepth() {
return pool.getQueue().size();
}
public long peakQueueDepth() {
return peakQueued.get();
}
public long rejectedCount() {
return rejected.sum();
}
public void shutdown() {
pool.shutdown();
}
}
四个设计点:
- 容量由排队预算反推,不由「感觉」决定。
queueBudget.toMillis() / serviceTime.toMillis()是「每个线程能排几个」,再乘线程数。50 ms 服务时间、100 ms 排队预算、4 个线程 → 8 个槽位;这个池最多让请求等 100 ms,多出来的直接拒绝。 - 水位拒绝比队列满拒绝早。
highWatermark = 3/4 × capacity让系统在还有余量时就开始拒绝,给正在排队的请求留出排空时间,避免「队列满 → 全部拒绝 → 队列空 → 又来一批」的锯齿。 allowCoreThreadTimeOut(true)。默认情况下核心线程永不退出(10.3 会展开),空闲期的线程栈也是内存。开了以后keepAliveTime对核心线程生效,低峰期能把线程数降下来。peakQueueDepth是容量规划的证据。 只看瞬时queueDepth()会错过尖峰;峰值水位配合拒绝数,才能判断「是队列太短」还是「消费者太慢」。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
用 newFixedThreadPool 接外部流量 | 队列无界,过载变成 OOM | 显式构造 ThreadPoolExecutor + 有界队列 |
| 队列元素是大对象(图片、报文) | 几十个槽位就能吃掉整个堆 | 队列里放引用或 ID,别放 payload |
| 只看拒绝率,不看队列深度 | 无界队列下拒绝率永远为 0 | 队列深度、峰值、增速都要有指标 |
用 -Xmx 很大来「扛住」堆积 | 只是把 OOM 推迟几分钟 | 限制队列,限制单请求内存 |
| OOM 后不保留 dump 就重启 | 同一故障反复发生 | HeapDumpOnOutOfMemoryError + 定期清理策略 |
什么时候不该用无界队列:几乎没有正当理由。唯一接近的场景是「任务的到达速率天然受限于某个更慢的上游」,此时队列再长也不会持续增长——但这需要证明,而不是假设。
什么时候有界队列也不够:当任务本身持有一个不可复用的大对象、且消费者会被下游无限期阻塞时,64 个槽位也可能是 64 × 10 MB。这时候要做的不是调小队列,而是先把消息体瘦身,再给下游加超时(11.1 会讲连接超时与中断的边界)。
小结
Executors.newFixedThreadPool 的队列是 new LinkedBlockingQueue<Runnable>(),容量 Integer.MAX_VALUE——本机打印的 remainingCapacity() 就是 2147483647。它不产生拒绝,只产生堆积。
本机在 -Xmx256m、2 个 worker、1000/s 到达、64 KB 消息体下实测:队列以 948/s 增长,堆以 60.0 MiB/s 增长(公式预测 59.3 MiB/s),3.6 s 时 heapUsed=222MB,随后抛出 OutOfMemoryError: Java heap space,HeapDumpOnOutOfMemoryError 写下 243 MiB 的 dump。jcmd GC.class_histogram 显示 13 599 个 byte[] 占约 145 MiB(151 532 760 字节),LinkedBlockingQueue$Node 与闭包实例数一一对应,占用者一目了然。
处置办法只有一条主线:把「无限缓冲」换成「有限队列 + 提前拒绝 + 可观测水位」。下一节看拒绝之后如果上游重试,会把系统推到什么地步。
阅读导航:上一节 9.3 从事故到 happens-before 归因 | 下一节 10.2 拒绝策略引发的雪崩
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。