《Java 并发编程·线程与内存模型》10.2 队列与拒绝策略

队列满、线程到顶之后发生什么,取决于拒绝策略。本节实测四种 JDK 拒绝策略在同一场景下的不同结果,解释 CallerRunsPolicy 如何在调用线程上形成反压、它在什么条件下会失效,并用无界队列实验证明 max 参数如何被架空,最后给出有界队列容量的推导方法与可记录指标的自定义拒绝策略。

承接的问题

10.1 的最后一个动作是 reject(command)。这一行背后是线程池最容易被忽略、也最容易在事故里被放大的决策:任务接不下了,怎么办?

三种选择摆在那里:抛异常让调用方知道系统满了;在调用方线程里跑,用调用方的时间换系统的稳定;或者丢掉,假装没发生过。JDK 把它们做成了四个现成的 RejectedExecutionHandler:抛异常、调用方执行、丢弃、丢最老的。它们各自适合什么场景、默认是哪个、为什么 CallerRunsPolicy 能形成反压、无界队列又为什么让整个机制失效——这是本节要回答的问题。

四种拒绝策略

RejectedExecutionHandler 只有一个方法 void rejectedExecution(Runnable r, ThreadPoolExecutor executor)。JDK 提供四个实现,都是 ThreadPoolExecutor 的静态内部类:

策略行为调用方感知任务去向
AbortPolicy(默认)抛 RejectedExecutionException有异常不执行
CallerRunsPolicy在提交任务的线程里直接 run()被拖慢调用方线程执行
DiscardPolicy静默丢弃无消失
DiscardOldestPolicy丢掉队列头,再重试提交无老任务消失,新任务入队

两个细节藏在实现里:CallerRunsPolicy 和 DiscardOldestPolicy 都会先检查 executor.isShutdown(),已关闭时直接丢弃,不抛异常也不执行。这意味着关闭期间提交的任务,用这两个策略会静默消失。

实测:同一场景,四种结果

固定 core=1, max=1, queue=ArrayBlockingQueue(1),任务睡 400 ms 并记录执行它的线程名。依次提交 A、B、C:A 占住唯一的工作线程,B 进队列,C 触发拒绝。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public final class RejectionProbe {

    static final ConcurrentLinkedQueue<String> RAN = new ConcurrentLinkedQueue<>();

    public static void main(String[] args) throws Exception {
        runPolicy("AbortPolicy", new ThreadPoolExecutor.AbortPolicy());
        runPolicy("CallerRunsPolicy", new ThreadPoolExecutor.CallerRunsPolicy());
        runPolicy("DiscardPolicy", new ThreadPoolExecutor.DiscardPolicy());
        runPolicy("DiscardOldestPolicy", new ThreadPoolExecutor.DiscardOldestPolicy());

        CountingHandler counting = new CountingHandler();
        runPolicy("Counting+CallerRuns", counting);
        System.out.println("metrics rejected=" + counting.rejected.get()
                + " ranInCaller=" + counting.ranInCaller.get()
                + " lastThread=" + counting.lastThread);

        unboundedQueue();
    }

    static void runPolicy(String label, RejectedExecutionHandler handler) throws Exception {
        RAN.clear();
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                1, 1, 0, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1), r -> new Thread(r, "loan-worker"), handler);
        String outcome = "none";
        try {
            submit(pool, "A");
            submit(pool, "B");
            submit(pool, "C");
        } catch (RejectedExecutionException e) {
            outcome = e.getClass().getSimpleName();
        }
        pool.shutdown();
        boolean done = pool.awaitTermination(10, TimeUnit.SECONDS);
        System.out.printf("%-19s outcome=%-24s ran=%s terminated=%s%n",
                label, outcome, RAN, done);
    }

    static void submit(ThreadPoolExecutor pool, String id) {
        pool.execute(() -> {
            RAN.add(id + "@" + Thread.currentThread().getName());
            try {
                Thread.sleep(400);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }

    static final class CountingHandler implements RejectedExecutionHandler {
        final AtomicInteger rejected = new AtomicInteger();
        final AtomicInteger ranInCaller = new AtomicInteger();
        volatile String lastThread = "-";

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            rejected.incrementAndGet();
            lastThread = Thread.currentThread().getName();
            if (!executor.isShutdown()) {
                ranInCaller.incrementAndGet();
                r.run();
            }
        }
    }

    static void unboundedQueue() throws Exception {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2, 8, 30, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(), r -> new Thread(r, "loan-unbounded"));
        for (int i = 0; i < 20; i++) {
            submit(pool, "T" + i);
        }
        Thread.sleep(300);
        System.out.println("unbounded core=2 max=8 submit=20 poolSize=" + pool.getPoolSize()
                + " queue=" + pool.getQueue().size()
                + " largest=" + pool.getLargestPoolSize());
        pool.shutdownNow();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

编译运行:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac --release 25 -d classes RejectionProbe.java
$JAVA_HOME/bin/java -cp classes RejectionProbe

本机输出:

AbortPolicy         outcome=RejectedExecutionException ran=[A@loan-worker, B@loan-worker] terminated=true
CallerRunsPolicy    outcome=none                     ran=[C@main, A@loan-worker, B@loan-worker] terminated=true
DiscardPolicy       outcome=none                     ran=[A@loan-worker, B@loan-worker] terminated=true
DiscardOldestPolicy outcome=none                     ran=[A@loan-worker, C@loan-worker] terminated=true
Counting+CallerRuns outcome=none                     ran=[C@main, A@loan-worker, B@loan-worker] terminated=true
metrics rejected=1 ranInCaller=1 lastThread=main
unbounded core=2 max=8 submit=20 poolSize=2 queue=18 largest=2

逐条读:

  • AbortPolicy:outcome 是 RejectedExecutionException,A、B 正常执行,C 没有出现。异常抛在 execute 调用处,不处理就一路抛到栈顶;如果提交发生在裸线程的 run() 里,这个线程会随之终止。
  • CallerRunsPolicy:C 出现在 ran 里,线程名是 main——它在提交任务的线程上跑完了,主线程在这 400 ms 里被完全占住。C@main 排在 A 之前只是因为 A 的工作线程还没来得及记录,顺序取决于调度,不可依赖。
  • DiscardPolicy:没有异常,ran 里只有 A、B。如果 ran 不是测试代码而是业务侧效果,这个任务就是彻底消失了,日志里一个字都没有。
  • DiscardOldestPolicy:ran 是 A、C——B 被丢掉了。丢的是队列头,也就是等得最久的那个任务。
  • Counting+CallerRuns:自定义策略记到了 1 次拒绝,并且在 main 上执行。
  • unbounded:无界队列下,core=2, max=8 的池提交 20 个任务,poolSize 停在 2,队列涨到 18,largest 是 2。max=8 一次都没用上。

这些是结构性观测,不依赖 CPU 速度,因此在本机 load averages 140 以上的环境里仍然可以引用。任务里的 400 ms 只影响总时长,不影响判定。

DiscardOldestPolicy 还值得多想一步:FIFO 里最早入队通常也是最接近超时的任务,丢掉它等于把「快要过期」换成「刚刚提交」,延迟指标可能变好,业务上是随机丢失。

CallerRunsPolicy 的反压原理

反压(backpressure)的本质是:消费者处理不过来时,把压力传回生产者,让生产变慢。

CallerRunsPolicy 用最直接的方式实现了这一点——它不排队、不新建线程,而是在提交者的线程里同步执行任务。于是提交者「提交一个任务」的耗时从微秒级变成了「任务执行时间」,提交速率自然被压到处理速率,队列不再增长。

这和消息队列里的「生产者限流」是同一个闭环:上游请求 → 提交任务 → 队列满 → 调用线程亲自执行 → 上游请求变慢 → 提交速率下降。它成立需要三个前提:

前提说明违反后的后果
提交者是请求处理线程拖慢它能直接减少上游流量如果提交者是定时器/巡检线程,只是拖慢了自己
任务不持有调用方的锁任务在调用线程里跑,若调用方持锁,锁被延长到任务结束临界区膨胀,其他线程排队
任务本身不调用同一个池否则是同池递归提交,仍可能再次被拒绝并再次内联递归加深,最坏栈溢出

三个典型失效场景:在持锁代码里提交,锁的持有时间从「提交」变成「提交 + 任务」,吞吐断崖式下跌,还可能和任务内部的另一把锁形成死锁;调用方是 Web 容器的 IO 线程,内联执行占住 IO 线程,反压变成连接堆积;池已经关闭,策略直接丢弃,任务静默丢失。

因此 CallerRunsPolicy 适合「提交者就是业务请求线程、任务短、无锁」的场景;写操作、长任务、持锁路径都不适合。

无界队列的危害

实测最后一行是本节最该记住的数字:poolSize=2 queue=18 largest=2。

无界队列(LinkedBlockingQueue 无参构造,容量是 Integer.MAX_VALUE)带来三个连锁后果:

  1. maximumPoolSize 永远不生效。队列永远 offer 成功,第三段扩容分支永远走不到,池的线程数被钉死在 core。
  2. 没有反压,只有延迟。任务不会失败,只会越排越久。上游看到的是「接口越来越慢」,而不是「系统满了」,于是继续加压,直到超时雪崩。
  3. 内存不可控。每个排队任务都持有闭包、上下文、请求体;队列涨到十万级时,OOM 只是时间问题。而且 OOM 发生时栈里看到的是业务代码,很难联想到队列。

Executors 的三个快捷工厂里有两个踩了这个坑:newFixedThreadPool(n) 是 core = max = n 加无界 LinkedBlockingQueue;newSingleThreadExecutor() 同上,n = 1,外面还包了一层代理。newFixedThreadPool 不仅是无界队列,还把 core 和 max 设成相等——就算队列有界,也不会有扩容。newCachedThreadPool 是另一个极端:core=0、max=Integer.MAX_VALUE、SynchronousQueue,队列不存任务,来一个建一个线程,高并发下线程数可以冲到几千。

生产代码的默认答案因此是:自己 new,队列必须有界。

有界队列容量怎么定

容量不能拍脑袋,可以从排队论最基本的公式推。Little 定律说,稳定系统里「平均排队任务数 = 到达速率 × 平均停留时间」;落到线程池上,用峰值而不是均值,就是 capacity ≈ 峰值 QPS × 可接受的排队秒数 - 并发线程数。

举例(示例推导,不是本机测量):借阅查询峰值 400 QPS,一次查询平均 200 ms。按 Little 定律,需要的并发是 400 × 0.2 = 80;如果池里只有 8 个线程,无论队列多大都撑不住,应该先扩容或优化查询。假设下游能承受 80 并发、max=80,正常时队列接近空,容量只需覆盖抖动和下游短暂变慢:

场景计算建议容量
抖动 100 ms400 × 0.1 = 4064
下游变慢到 500 ms400 × 0.5 - 80 = 120128
下游变慢到 2 s400 × 2 - 80 = 720不排队,直接拒绝

最后一行是关键判断:当排队时间已经超过上游超时时间时,排队毫无意义。上游 1 秒超时,任务在队列里等 2 秒,执行完也没人要结果,还占着线程和内存。这种情况下队列应该小、拒绝应该早。

容量的三条约束一起看:

  • 延迟:容量 / 处理速率 ≤ 上游超时;
  • 内存:容量 × 单任务持有内存 ≤ 可接受增量;
  • 恢复:池被打满后,队列里的任务在多久内能清空。

队列类型也会影响容量语义:

队列容量特点适用
ArrayBlockingQueue构造时固定数组、单锁两条件、可选公平容量确定的常规池
LinkedBlockingQueue有参时固定,无参近似无界链表、吞吐略高必须显式传容量
SynchronousQueue0(手递手)没有消费者就必然扩容newCachedThreadPool 式弹性池
PriorityBlockingQueue无界按优先级出队有优先级且另有总量控制
DelayQueue无界到时间才能取重试/定时,不适合做工作队列

PriorityBlockingQueue 和 DelayQueue 都是无界的,用它们做工作队列时,必须有别的机制兜住总量,否则同样会堆到 OOM。

自定义拒绝策略记录指标

AbortPolicy 抛异常是对的,但异常本身不告诉你「满了多久、排了多长、哪些任务被拒」。生产环境需要一个既记录指标、又保留快速失败的策略:

package com.plume.conc.core;

import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public final class MetricsRejectionHandler implements RejectedExecutionHandler {

    private final String poolName;
    private final LongAdder rejected = new LongAdder();
    private final AtomicLong lastRejectNanos = new AtomicLong();
    private final AtomicLong lastQueueSize = new AtomicLong(-1);

    public MetricsRejectionHandler(String poolName) {
        this.poolName = poolName;
    }

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        rejected.increment();
        lastRejectNanos.set(System.nanoTime());
        lastQueueSize.set(executor.getQueue().size());
        if (executor.isShutdown()) {
            return;                       // 关闭期间不再抛,避免关闭流程被异常打断
        }
        throw new java.util.concurrent.RejectedExecutionException(
                poolName + " saturated, queue=" + executor.getQueue().size()
                        + " pool=" + executor.getPoolSize());
    }

    public long rejectedCount() {
        return rejected.sum();
    }

    public long lastRejectNanos() {
        return lastRejectNanos.get();
    }

    public long lastQueueSize() {
        return lastQueueSize.get();
    }
}

四条记录原则:

  • 计数器用 LongAdder,拒绝路径可能被高频调用,AtomicLong 在高竞争下会自旋。
  • 不要在拒绝策略里打日志。队列满时往往是流量峰值,每个拒绝打一行日志会把磁盘和日志框架一起打爆;记录计数和最后一次的时间戳,由监控线程定期采样。
  • 不要在拒绝策略里阻塞。有人会写 executor.getQueue().put(r) 来「一定要接住」,这在池已满时会把提交线程永久阻塞,且 put 抛 InterruptedException 时任务直接丢失。
  • 关闭期间区别对待。关闭时继续抛异常会让 shutdown 流程里的清理代码不断失败;返回即可,调用方本来就不该在关闭后提交。

实测里的 CountingHandler 是最小版本,输出是 metrics rejected=1 ranInCaller=1 lastThread=main:一次拒绝、一次内联执行、发生在主线程。真实系统里这三个数字应该进监控,而不是进标准输出。

在 libraryd-conc 里落地

借阅是写操作,满了就该快速失败,不该让请求线程替它写库。PooledLoanService 的队列与拒绝策略定成这样:

// 领域方法 reserve/returnBook 与 close() 同 10.1,这里只给差异
public final class PooledLoanService implements LoanService, AutoCloseable {

    private final BookRepository books;
    private final LoanRepository loans;
    private final ThreadPoolExecutor pool;
    private final MetricsRejectionHandler rejection;

    public PooledLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
        this.rejection = new MetricsRejectionHandler("loan-pool");
        AtomicInteger seq = new AtomicInteger();
        this.pool = new ThreadPoolExecutor(
                4, 16, 60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(32),
                r -> {
                    Thread t = new Thread(r, "loan-pool-" + seq.incrementAndGet());
                    t.setUncaughtExceptionHandler((thread, error) ->
                            System.err.println("uncaught in " + thread.getName() + ": " + error));
                    return t;
                },
                rejection);
    }

    private <T> T submitAndWait(Callable<T> task, String what) {
        try {
            return pool.submit(task).get();
        } catch (RejectedExecutionException e) {
            throw new LoanException(ErrorCode.CONFLICT,
                    "loan pool saturated, rejected=" + rejection.rejectedCount()
                            + " lastQueue=" + rejection.lastQueueSize());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LoanException(ErrorCode.INTERRUPTED, what + " interrupted");
        } catch (ExecutionException e) {
            if (e.getCause() instanceof LoanException loan) {
                throw loan;
            }
            throw new IllegalStateException(what + " failed", e.getCause());
        }
    }
}

参数背后的判断:

  • core=4, max=16:查询型负载可以给大 max,写型负载 max 受数据库连接池约束,16 是「连接池 20 减去其他用途」的结果。
  • queue=32:按「32 / 16 线程 ≈ 2 轮」估算,峰值时最多等两轮任务的时间;再多就该让上游收到 409。
  • 拒绝策略用「记录指标 + 抛异常」:写操作快速失败,调用方拿到 ErrorCode.CONFLICT 后可以重试或降级,而不是无声等待。
  • 异常消息里带上 rejectedCount 和 lastQueueSize,日志里能直接看到拒绝次数和当时的队列长度。

如果是只读查询(比如书目检索),可以换成 CallerRunsPolicy:让请求线程亲自查一次库,把压力传回上游,比直接报错更平滑。写路径不要这么做——请求线程内联写库会让事务边界和超时控制全部失控。

常见错误与边界

错误后果改法
用无界队列max 失效、内存无上限、只有延迟没有反压ArrayBlockingQueue 或带容量的 LinkedBlockingQueue
在拒绝策略里打日志峰值时日志风暴,磁盘打满只记计数,由监控采样
在拒绝策略里 queue.put提交线程被永久阻塞快速失败或 offer(timeout)
用 DiscardPolicy 处理写请求任务静默消失,数据不一致写路径用 AbortPolicy
依赖 CallerRunsPolicy 但持有锁锁被延长到任务结束,吞吐暴跌提交移出临界区
关闭后继续提交AbortPolicy 抛异常打断关闭流程拒绝策略里判断 isShutdown
queue=0(SynchronousQueue)配小 max稍有波动就拒绝弹性池要把 max 设大,或换有界队列
队列容量按平均值算峰值直接打满按峰值 QPS × 可接受排队时间算

什么时候不要用拒绝策略兜底:当你还没有监控、没有容量评估时,拒绝策略只是把「慢慢崩」变成「立刻崩」。先把队列长度、拒绝次数、任务耗时的指标做出来(10.3 节),再决定用哪一种。

小结

  • 默认策略是 AbortPolicy;CallerRunsPolicy 在调用线程内联执行,DiscardPolicy 静默丢弃,DiscardOldestPolicy 丢队列头再重试。
  • 本机实测同一场景:Abort 抛异常且 C 不执行;CallerRuns 的 C 在 main 上执行;Discard 的 C 消失;DiscardOldest 丢掉 B、执行 C。
  • CallerRunsPolicy 用「拖慢提交者」形成反压,前提是提交者就是请求线程、任务短、不持锁、不递归提交同一池。
  • 无界队列让 max 彻底失效:实测 core=2/max=8 提交 20 个任务,poolSize 停在 2、队列堆到 18。
  • 有界队列容量按「峰值 QPS × 可接受排队秒数 - 并发线程数」估,并用上游超时和内存上限双向约束。
  • 自定义拒绝策略只做三件事:计数、记录最后一次队列长度、快速失败;日志和阻塞都不要放进去。

下一节把线程池从「配好」推进到「跑起来之后」:指标怎么读、setCorePoolSize 改下去会发生什么、shutdown 与 shutdownNow 的两段式关闭怎么写。

阅读导航:上一节 10.1 线程池七参数与执行流程 | 下一节 10.3 监控、动态调参与关闭

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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