2565 字
约 8 分钟
17
S05-X21 · 线程池的参数、拒绝策略与容量估算

ThreadPoolExecutor 的构造器把七个参数一次性塞给你;真正决定它行为的只有两个:队列有没有界、拒绝策略是什么。绝大多数线上事故不是参数选错,而是选了一个让某些参数彻底失效的组合——最典型的就是 Executors.newFixedThreadPool:看似"固定大小",实际上把 maximumPoolSize 和 handler 同时废掉了。


1. 七个参数:谁牵制谁

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
参数 语义 被谁牵制
corePoolSize 常驻线程数下限 被 allowCoreThreadTimeOut(true) 打破
maximumPoolSize 线程数上限 队列有界才可能生效
keepAliveTime 超出核心数的空闲线程存活 只作用于 > corePoolSize 的线程
workQueue 排队缓冲 容量决定 maximumPoolSize 是否可达
threadFactory 线程创建方式 线程名、优先级、是否守护
handler 无法接收任务时的动作 队列满且线程满才触发

一句话概括耦合关系:扩容能力是队列满之后才被解锁的权限。硬约束:maximumPoolSize >= corePoolSize、keepAliveTime >= 0,否则构造器抛 IllegalArgumentException。


2. 执行顺序:四步,顺序不能记错

// 骨架,原文见 JDK 17 ThreadPoolExecutor#execute
if (workerCountOf(c) < corePoolSize)
    if (addWorker(command, true)) return;   // ① 核心未满 → 建核心线程
if (isRunning(c) && workQueue.offer(command)) {  // ② 核心满 → 入队
    if (!isRunning(recheck) && remove(command)) reject(command); // 入队后复查防丢
} else if (!addWorker(command, false))     // ③ 队列满 → 扩到最大
    reject(command);                       // ④ 最大也满 → 拒绝
阶段 判断条件 动作 常见误解
① 线程数 < corePoolSize 新建线程直接跑 以为先填队列
② 核心已满 queue.offer() 以为队列只是缓冲
③ 队列满 扩到 maximumPoolSize 无界队列时永不发生
④ 扩容失败 调 handler 以为会一直等着

2.1 "无界队列让 maximumPoolSize 失效"

LinkedBlockingQueue 的默认容量是 Integer.MAX_VALUE,newFixedThreadPool 与 newSingleThreadExecutor 都用它。于是第②步 offer 永远成功、第③步永不执行——maximumPoolSize 写多少都没用,handler 也永不触发。

后果不是"线程少一点",而是任务无界堆积到 OOM——每个排队任务都持有一份业务对象引用。

// 危险:看起来固定 8 线程,实际最大也是 8,队列可无限增长
ExecutorService bad = Executors.newFixedThreadPool(8);

// 安全:显式有界队列 + 命名线程 + 明确的拒绝行为
ThreadPoolExecutor good = new ThreadPoolExecutor(
        8, 16, 60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(200),
        r -> new Thread(r, "order-worker-" + SEQ.incrementAndGet()),
        new ThreadPoolExecutor.AbortPolicy());

2.2 队列选型

队列 容量 交接语义 适用
ArrayBlockingQueue 构造必须指定 有界 FIFO、单锁 通用首选,内存可预测
LinkedBlockingQueue 默认 Integer.MAX_VALUE 双锁(入/出分离) 必须显式传容量
SynchronousQueue 0 直接交接,不排队 配合大 maximumPoolSize 跑短任务
PriorityBlockingQueue 无界 按优先级出队 有优先级且量可控
DelayQueue 无界 到期才可取 定时重试、延迟任务

SynchronousQueue 必须配合足够大的 maximumPoolSize,否则拒绝率高得离谱。newCachedThreadPool(core=0、max=Integer.MAX_VALUE)就这么组合,代价是线程数可能失控。


3. 拒绝策略:四种内置 + 一种自定义

策略 行为 抛出 适用
AbortPolicy(默认) 抛 RejectedExecutionException 是 调用方需感知失败
CallerRunsPolicy 提交任务的线程自己跑 否 天然背压,会阻塞调用方
DiscardPolicy 静默丢弃 否 等于吞数据
DiscardOldestPolicy 丢最老任务后重试 否 只保留最新值

自定义拒绝策略至少要回答四个问题,缺一个就是埋坑:记录什么、怎么降级、抛还是返、连续拒绝要不要熔断上游。

class MeteredRejectPolicy implements RejectedExecutionHandler {
    private final MeterRegistry meter;
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        meter.counter("pool.rejected").increment();      // 1) 记录
        log.warn("reject task={} active={} queue={} max={}",  // 2) 打点留证
                r.getClass().getSimpleName(), e.getActiveCount(),
                e.getQueue().size(), e.getMaximumPoolSize());
        throw new RejectedExecutionException("order-worker saturated"); // 3) 反馈
    }
}

CallerRunsPolicy 有个隐蔽行为:池已 shutdown 时它不执行任务,直接静默丢弃,停机窗口内的任务会无声消失。


4. 容量估算:唯一的关键输入是等待占比

两条等价公式,前者来自 Little's Law,后者是它的工程化变形:

线程数 ≈ 目标 QPS × 单任务耗时(秒)
线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)

"等待占比"是唯一需要实测的输入,其余都是算术:

任务类型 等待占比 取值 说明
纯计算(加解密、序列化) 接近 0 ≈ 核数 再多只增切换开销
常规 DB 查询 中 核数 × 2 ~ 4 按 JDK 17 核对 + 压测
远程调用为主 高 核数 × 8 以上 先确认下游能承受
混合 — 分池 拆成计算池与 IO 池
int cpu = Runtime.getRuntime().availableProcessors();
double waitRatio = 0.9;                               // 唯一实测输入
int ioBound = (int) Math.ceil(cpu / (1 - waitRatio)); // 8 核 → 80

两个必须写进文档的约束:线程数受下游容量约束(池加到 200 而 DB 连接池只有 20,多出的线程只是在等连接);队列容量是内存预算(容量 × 单任务持有对象大小 即最坏情况堆占用,必须算出来)。


5. 动态调整与上下文传递

5.1 setCorePoolSize 的运行时行为

setCorePoolSize(n) 运行时做两件事:调大时补建线程,调小时中断多余 idle 线程。它不改已在线程的存活语义,也不清空队列。

操作 运行时效果 边界
setCorePoolSize 立即补建/回收 不得大于 maximumPoolSize
setMaximumPoolSize 只影响后续扩容 不得小于 corePoolSize
setKeepAliveTime 对 idle 线程生效 需下一次超时判断
setRejectedExecutionHandler 立即替换 无状态策略可热换

配置中心热更新的姿势是只调数值、不动结构:队列类型与容量属于结构,改它必须换池;核心/最大/存活时间属于数值,可热改。同时要校验区间,避免配置推错把池推到 0。

5.2 ThreadLocal/MDC 丢失

线程复用意味着 ThreadLocal 的值会串到下一个任务,新任务也可能读不到提交方的上下文(traceId、用户身份)。解法是装饰器:提交时快照 MDC.getCopyOfContextMap(),任务内还原,finally 里恢复或 clear();ThreadLocal 同理,快照、还原、remove() 三步缺一不可。

5.3 关闭:shutdown 与 shutdownNow

方法 新任务 队列中任务 运行中任务
shutdown() 拒绝 继续执行完 不中断
shutdownNow() 拒绝 作为返回值取出 尝试 interrupt
awaitTermination(t) 拒绝 等待 等待

优雅停机是两段式:

pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    List<Runnable> dropped = pool.shutdownNow();   // 返回未执行的任务,必须记录
    log.warn("force shutdown, dropped={}", dropped.size());
    pool.awaitTermination(5, TimeUnit.SECONDS);
}

shutdownNow 的 interrupt 只对响应中断的任务有效。任务里 while(true) 不检查中断标志,awaitTermination 就永不返回——这是任务设计问题,不是线程池问题。


6. 监控与四个高频坑

四个必看指标都来自 ThreadPoolExecutor 自身:

指标 取值方式 判读
活跃线程 getActiveCount() 长期顶到 max → 饱和
队列深度 getQueue().size() 单调上升 → 消费不足
拒绝次数 拒绝策略内计数 非零即告警
任务耗时 装饰器埋点 p99 分任务是变慢还是并发不够

Micrometer 的 ExecutorServiceMetrics 可直接把池大小、活跃数、队列长度、完成数绑到 registry,不必自己造轮子。

坑清单:

坑 表现 根因 修法
无界队列 堆缓慢上涨直至 OOM LinkedBlockingQueue 默认容量 显式传容量
submit 吞异常 任务失败但日志干净 异常包进 Future 无人 get() 改用 execute 或统一 get
池内阻塞重试 线程全卡在重试 重试占着工作线程 交给调度器或 MQ
多业务共用一个池 慢业务拖垮快业务 无隔离 按业务分池 + 各自拒绝策略

submit 吞异常值得单说:execute 的任务抛异常会经 afterExecute 打到 UncaughtExceptionHandler;submit 包成 FutureTask 后异常存进 Future,既不打日志也不抛出。要么取结果,要么重写 afterExecute 兜底。


7. 边界:这些情况该换方案

场景 首选 理由
任务有父子依赖 ForkJoinPool / 结构化并发 普通池会死锁
大量短延迟任务 SynchronousQueue + 大 max 排队无意义
需要背压 CallerRunsPolicy 或信号量 拒绝要能传导
定时/延迟 ScheduledThreadPoolExecutor 普通池无调度语义
停机不能丢任务 先落库再提交 内存队列不持久

线程池不是"并发开关",而是资源配额 + 排队策略 + 失败语义的组合。调参之前先把这三件事写成文档,参数自然有答案。


8. 自测

  1. newFixedThreadPool(8) 里把 maximumPoolSize 改成 100,为什么不生效?
  2. 执行顺序的四步中,哪一步会触发拒绝策略?它的前置条件是什么?
  3. CallerRunsPolicy 在池已 shutdown 时对任务做了什么?
  4. 容量估算里唯一需要实测的输入是什么?为什么不能直接抄别人的线程数?
  5. shutdown() 与 shutdownNow() 对队列中任务的处理差异在哪?
  6. submit 提交的任务抛出异常后,为什么日志里可能什么都没有?
S05-X21 · 线程池的参数、拒绝策略与容量估算
https://www.clxhxhhr.top/posts/5925/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。