《Java 并发编程·性能诊断与事故》10.1 队列堆积与 OOM

无界队列不会避免过载,只会把过载从拒绝变成堆增长。本节用 2 个 worker、1000/s 到达、64 KB 消息体在 -Xmx256m 下真实跑出 OOM,给出堆积速率与堆增长的算式,并用 jcmd GC.class_histogram 定位到占着约 145 MiB 的 byte[],最后给出有界队列与水位拒绝的落地写法。

承接的问题

构造场景: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();
    }
}

四个设计点:

  1. 容量由排队预算反推,不由「感觉」决定。 queueBudget.toMillis() / serviceTime.toMillis() 是「每个线程能排几个」,再乘线程数。50 ms 服务时间、100 ms 排队预算、4 个线程 → 8 个槽位;这个池最多让请求等 100 ms,多出来的直接拒绝。
  2. 水位拒绝比队列满拒绝早。 highWatermark = 3/4 × capacity 让系统在还有余量时就开始拒绝,给正在排队的请求留出排空时间,避免「队列满 → 全部拒绝 → 队列空 → 又来一批」的锯齿。
  3. allowCoreThreadTimeOut(true)。默认情况下核心线程永不退出(10.3 会展开),空闲期的线程栈也是内存。开了以后 keepAliveTime 对核心线程生效,低峰期能把线程数降下来。
  4. 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 拒绝策略引发的雪崩

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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