Skip to content
Charles Shao
Go back

线程池原理与调优:ThreadPoolExecutor 七参数、执行流程与拒绝策略

views

线程池几乎是每个 Java 后端服务的吞吐上限与事故高发地:newFixedThreadPool 无界队列堆到 OOM、newCachedThreadPool 无限建线程、拒绝策略选错让请求静默丢失、ThreadLocal 在池化线程里泄漏……

这一篇讲清 ThreadPoolExecutor:七个参数、任务进来后的决策顺序、四种拒绝策略、线程数怎么估,以及监控、动态调参、优雅关闭。它和 连接与 I/O 模型 是高并发吞吐的一体两面——一个管线程,一个管连接。

TL;DR

Table of contents

Open Table of contents

1. 为什么要池化线程

线程不是免费的:每个线程约占 512 KB~1 MB 栈内存,创建/销毁要陷内核,线程数超过 CPU 核数数倍后上下文切换开销显著。“来一个请求 new 一个线程”在高并发下会迅速耗尽内存和调度能力(这正是 连接与 I/O 模型 里”一连接一线程”的 C10K 问题)。

线程池的三个价值:复用已创建的线程消除反复创建销毁的开销;限流——用固定线程数 + 有界队列给并发量设上界,避免过载;管理——统一的监控、命名、异常处理、生命周期控制。

2. 七个核心参数

ThreadPoolExecutor 的完整构造器:

public ThreadPoolExecutor(
    int corePoolSize,                     // 1. 核心线程数
    int maximumPoolSize,                  // 2. 最大线程数
    long keepAliveTime,                   // 3. 空闲线程存活时间
    TimeUnit unit,                        // 4. 时间单位
    BlockingQueue<Runnable> workQueue,    // 5. 工作队列
    ThreadFactory threadFactory,          // 6. 线程工厂
    RejectedExecutionHandler handler)     // 7. 拒绝策略
参数作用调参要点
corePoolSize长期保留的线程数,即使空闲也不回收(除非开 allowCoreThreadTimeOut按稳态负载设定
maximumPoolSize线程数上限(核心 + 非核心)应对突发峰值;用 SynchronousQueue 时才真正生效
keepAliveTime + unit非核心线程空闲多久后被回收峰值过后释放资源
workQueue核心线程忙不过来时,任务的缓冲队列必须有界,否则 max 形同虚设
threadFactory创建线程(可自定义命名、守护、优先级)务必自定义线程名,否则排查问题时线程栈全是 pool-1-thread-N
handler队列满且线程达 max 时怎么办见第 4 节

3. 执行流程:队列先于非核心线程

任务 execute(task) 进来后,ThreadPoolExecutor 严格按以下顺序决策:

ThreadPoolExecutor execute 决策树。自上而下五步:提交 task → 线程数小于 core 则建核心线程 → 否则队列未满则入队 → 否则线程数小于 max 则建非核心线程 → 否则触发拒绝策略。侧注强调最反直觉点:先填满队列才会创建非核心线程,无界队列导致 max 形同虚设。

关键点:任务是先塞满队列,才会创建非核心线程,而不是「先把线程加到 max 再排队」。常见误配置:

// 反例:core=10, max=200, 但用了近乎无界的 LinkedBlockingQueue
new ThreadPoolExecutor(10, 200, 60, SECONDS,
    new LinkedBlockingQueue<>());   // 默认容量 Integer.MAX_VALUE

因为队列几乎永远不会满,线程数永远到不了 200——高并发时任务全在队列里堆积,延迟飙升甚至 OOM,maximumPoolSize=200 完全没用。队列容量与 max 要配套设计。

3.1 工作队列怎么选

队列特性适用
ArrayBlockingQueue有界、数组、单锁通用,明确容量上界
LinkedBlockingQueue默认无界(危险)、链表、双锁吞吐高务必显式传容量变有界
SynchronousQueue不存元素,直接手递手配合大 max,任务直接建线程(如 CachedThreadPool)
PriorityBlockingQueue无界、按优先级出队任务有优先级时
DelayedWorkQueue延迟到期才出队ScheduledThreadPoolExecutor 定时任务

4. 四种拒绝策略

队列满 + 线程达 max 时触发。JDK 内置四种:

策略行为后果 / 适用
AbortPolicy(默认)RejectedExecutionException调用方能感知失败;别吞异常
CallerRunsPolicy提交任务的线程自己执行该任务天然背压:提交方被拖慢,上游自然减速。适合不能丢任务的场景
DiscardPolicy静默丢弃新任务,不抛异常任务无声消失,生产上几乎不该用
DiscardOldestPolicy丢弃队列最老的任务,再尝试提交新任务只在「最新数据最有价值」(如实时行情)时用

推荐:可丢弃的旁路任务用 AbortPolicy + 上层降级;不可丢的核心任务用 CallerRunsPolicy 做背压——把压力反推给调用方,比无界队列堆到 OOM 安全得多。也可实现 RejectedExecutionHandler 自定义(落库重试、告警)。

5. 线程数怎么算

没有万能数字,但有经验起点:

举例:8 核机器,某任务平均计算 10ms、等待下游 90ms,则等待/计算 = 9,线程数 ≈ 8 × (1 + 9) = 80。

这本质是 Little’s Law 的应用(并发数 = 吞吐 × 单请求耗时),与 连接与 I/O 模型 里算连接池大小同源。公式给起点,压测定终值——用 性能测试 找到吞吐拐点,别拍脑袋。

6. 生产实践

6.1 手动构造 + 自定义线程工厂

ThreadPoolExecutor bizPool = new ThreadPoolExecutor(
    16, 32,                                        // core / max:按压测定
    60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),                // 有界队列,堵住无限堆积
    new ThreadFactoryBuilder()                     // Guava/自实现:给线程命名
        .setNameFormat("biz-pool-%d")              // 排障时一眼看出是哪个池
        .setUncaughtExceptionHandler((t, e) ->
            log.error("thread {} died", t.getName(), e))
        .build(),
    new ThreadPoolExecutor.CallerRunsPolicy());    // 背压

ThreadFactoryBuilder 来自 Guava;没有依赖时手写一个实现 ThreadFactory 即可。给线程命名是最低成本的可观测性投资

6.2 监控关键指标

线程池的健康度全在这几个数上,务必埋点上报(Micrometer / 自定义):

pool.getActiveCount();          // 正在执行任务的线程数
pool.getPoolSize();             // 当前线程总数
pool.getQueue().size();         // 队列积压 —— 最重要的预警信号
pool.getCompletedTaskCount();   // 已完成任务数
pool.getLargestPoolSize();      // 历史峰值线程数(判断 max 是否够)
// 拒绝次数:JDK 无现成 getter,需在自定义 RejectedExecutionHandler 里自行累计

队列积压持续上升是过载的第一信号;拒绝次数 > 0 说明已经在丢/背压任务,要么扩容要么限流。可参考 可观测性 的思路把这些接入告警。

6.3 动态调参

corePoolSizemaximumPoolSize 支持运行时修改:

pool.setCorePoolSize(24);
pool.setMaximumPoolSize(48);

美团等团队据此做了”动态线程池”:把参数外置到配置中心,结合监控实时调整,无需重启。唯一的遗憾是队列容量(ArrayBlockingQueue)不可动态改,所以有的方案用自定义可变容量队列。

6.4 优雅关闭

服务下线时要让在途任务跑完,别硬杀:

pool.shutdown();                                   // 不再接新任务,已提交的继续执行
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {// 等待在途任务
    List<Runnable> dropped = pool.shutdownNow();   // 超时:中断线程,返回未执行任务
    log.warn("forced shutdown, {} tasks dropped", dropped.size());
}

shutdown() 温和(拒新、跑完存量),shutdownNow() 强硬(中断线程、清空队列并返回未执行任务)。配合容器的 preStop / 优雅停机(见 发布与变更安全)能避免请求被硬切断。

7. 两大隐形坑

7.1 ThreadLocal 在池化线程中的泄漏

线程池的线程是复用的,ThreadLocal 不会随任务结束自动清理。上一个任务写进去的值,会被下一个复用该线程的任务读到——既是数据串味的正确性问题,也是内存泄漏(ThreadLocalMap 的 key 是弱引用但 value 是强引用,线程长存则 value 长存):

private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();

void handle(Request req) {
    try {
        CTX.set(loadContext(req));
        // ... 业务逻辑
    } finally {
        CTX.remove();          // 必须!否则值残留到下一个复用此线程的任务
    }
}

约定:池化线程里用 ThreadLocal,一定在 finally 里 remove()。跨线程传递上下文(如把 trace id 传给线程池任务)要用 TransmittableThreadLocal(阿里 TTL)之类的方案,普通 InheritableThreadLocal 在池化场景下也会失效。

7.2 共用线程池导致的互相拖垮

多个业务共用一个线程池时,若某个下游(比如非核心的日志上报)变慢,会占满线程/队列,把核心业务(比如竞价)一起饿死。

解法:舱壁隔离(Bulkhead)——为不同职责/下游配独立线程池。 在广告竞价服务里,通常至少拆成:

舱壁隔离三池示意图。三个面板并排:竞价主链路(高优先级、严超时、绝不与旁路共享)、特征拉取(画像/特征服务,慢了不影响主链路)、异步上报(计费日志与埋点,可丢可背压)。底部说明共用一池时旁路抖动会饿死核心链路。

一个下游抖动只影响它自己的舱室,核心链路的 P99 不受牵连——与 依赖韧性 的舱壁、连接与 I/O 模型 的独立连接池是同一套原则。

线程池定好并发上界之后,共享数据结构本身也要线程安全——ConcurrentHashMap、阻塞队列是下一层地基。

延伸阅读


views
Share this post on:

Previous Post
并发容器:ConcurrentHashMap 与阻塞队列
Next Post
AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition