Java 线程池 ThreadPoolExecutor 详解:七大参数、执行流程与拒绝策略
“线程池的参数怎么设置?”、“为什么阿里巴巴规范不让用 Executors 的快捷方法?”、“线程池提交的任务抛异常会怎样?”——这三个问题几乎是 Java 面试必考,也是线上事故的高发点。本文围绕 ThreadPoolExecutor 的七个参数、任务执行流程、四种拒绝策略和常见使用坑展开,帮你彻底搞懂线程池。
一、为什么需要线程池
线程的创建和销毁是有代价的:每次 new Thread 都要触发系统调用申请内核资源。如果高并发下每个请求都新建线程,系统会快速耗尽内存和句柄。线程池的核心思路是复用固定数量的工作线程,同时用队列削峰填谷,从而降低资源消耗、提升响应速度、便于统一管理(这也是“池化”思想,和数据库连接池同理)。
二、ThreadPoolExecutor 的七大核心参数
public ThreadPoolExecutor(
int corePoolSize, // 1. 核心线程数:常驻线程,即使空闲也不回收(除非 allowCoreThreadTimeOut)
int maximumPoolSize, // 2. 最大线程数:线程池允许创建的最大线程数
long keepAliveTime, // 3. 空闲存活时间:非核心线程空闲超过该时长会被回收
TimeUnit unit, // 4. keepAliveTime 的时间单位
BlockingQueue<Runnable> workQueue, // 5. 任务队列:核心线程忙时,任务先排队
ThreadFactory threadFactory, // 6. 线程工厂:用于创建线程,可自定义线程名
RejectedExecutionHandler handler // 7. 拒绝策略:队列满且线程达上限时的处理方式
) { ... }
注意一个容易混淆的点:线程池默认不会预先创建核心线程,是任务提交时按需创建;prestartAllCoreThreads() 可以预热。另外 1.6 之后线程池执行完任务,空闲的非核心线程会退出,而核心线程会一直阻塞等待新任务。
三、任务执行流程(面试必背)
// 提交任务后,execute() 内部的决策逻辑(伪代码)
if (workerCount < corePoolSize) {
// ① 工作线程 < 核心线程数:直接新建核心线程执行任务
addWorker(command, true);
} else if (workQueue.offer(command)) {
// ② 线程数已满核心:任务放入队列等待
// (队列满了 offer 返回 false,走下一步)
if (workerCount == 0 && ...) addWorker(null, false);
} else if (workerCount < maximumPoolSize) {
// ③ 队列也满了:新建非核心线程(临时扩员)执行任务
addWorker(command, false);
} else {
// ④ 线程数也达到 maximumPoolSize:执行拒绝策略
reject(command);
}
把它记成一句口诀:先核心 → 再队列 → 后扩到最大 → 满了就拒绝。注意很多人以为“核心满了直接扩到最大线程”,实际上是先塞队列,队列满了才会扩线程,这也是为什么“使用无界队列时最大线程数形同虚设”(任务永远进队列,永远触达不到扩容和拒绝)。
四、任务队列怎么选
| 队列 | 特点 | 适用场景 |
|---|---|---|
| ArrayBlockingQueue | 有界数组队列,FIFO,容量固定 | 需要控制积压量的业务,推荐首选 |
| LinkedBlockingQueue | 链表队列,可无界也可有界 | Executors.newFixedThreadPool 默认用无界队列(有 OOM 风险) |
| SynchronousQueue | 不存任务,直接交接给线程 | Executors.newCachedThreadPool 默认,适合任务短且多、可弹性扩线程 |
| PriorityBlockingQueue | 支持按优先级出队 | 需要按重要程度执行的场景 |
| DelayQueue | 延迟到期才出队 | 定时/延迟任务调度 |
五、四种拒绝策略
当线程池已满(队列满且工作线程 = maximumPoolSize)时触发拒绝策略,JDK 内置四种,均可实现 RejectedExecutionHandler 自定义:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy(默认) | 直接抛 RejectedExecutionException | 任务丢失 + 调用方异常,需要业务捕获兜底 |
| CallerRunsPolicy | 让提交任务的调用线程自己执行 | 相当于降速反馈,不会丢任务,适合对一致性要求高的场景 |
| DiscardPolicy | 静默丢弃新任务 | 任务直接丢失无感知,慎用 |
| DiscardOldestPolicy | 丢弃队列头(最老的)任务再尝试提交 | 会丢老任务,适合“最新优先”的实时性场景 |
线上常见做法是自定义策略:将拒绝的任务写入本地缓冲/重试队列或直接告警,避免静默丢失。
六、execute 和 submit 的区别(高频考点)
execute(Runnable):无返回值,异常直接抛给线程(通常被打进日志),调用方拿不到;submit(Callable/Runnable):返回Future,可拿到执行结果或异常;但要注意——submit 的任务异常被封装在 Future 里,调用 get() 时才会以 ExecutionException 抛出,如果从不调用 get(),异常会被“静默吞掉”,线上排查时日志里什么都看不到。
// submit + get 的正确姿势
Future<Integer> future = pool.submit(() -> 1 / 0); // 不会立刻抛异常
try {
System.out.println(future.get()); // 这里才抛出 ExecutionException
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // 真正的异常在这里
log.error("任务执行失败", cause);
}
七、Executors 快捷方法的坑
阿里巴巴《Java 开发手册》明确禁止使用 Executors 创建线程池,原因如下:
newFixedThreadPool/newSingleThreadExecutor:使用无界 LinkedBlockingQueue,任务无限堆积会耗尽内存(OOM);newCachedThreadPool:maximumPoolSize = Integer.MAX_VALUE,任务量突增会创建海量线程,导致 OOM 或线程切换风暴;newScheduledThreadPool:同样使用无界队列。
正确姿势是手动 new ThreadPoolExecutor,明确核心数、队列容量和拒绝策略,这样参数可配置、行为可预期。
八、线程数到底设置多少
没有银弹,只有估算模型:
- CPU 密集型(大量计算):核心线程数 ≈ CPU 核数 + 1;
- IO 密集型(大量等待网络/磁盘):核心线程数 ≈ CPU 核数 × (1 + 等待时间/计算时间),工程上常取 CPU 核数 × 2。
关键观察点:监控线程池的 ActiveCount / QueueSize / 拒绝次数,用真实压测数据反推,而不是拍脑袋。
九、线程池的几个高频“坑”
坑 1:核心线程也被回收? 默认核心线程空闲不回收;只有调用了 allowCoreThreadTimeOut(true) 后,核心线程才会按 keepAliveTime 退出,此时 corePoolSize 实际可降到 0。
坑 2:线程复用导致 ThreadLocal 串数据。 池里的线程会反复执行不同任务,如果任务 A 往 ThreadLocal 里放了值却没 remove,任务 B 复用同一线程时会读到残留值,造成严重的业务串号。
try {
ThreadLocalHolder.set(userId); // 业务使用
doSomething();
} finally {
ThreadLocalHolder.remove(); // 必须在 finally 里清理!
}
坑 3:任务吞异常。 execute 的任务如果 run() 内部 try-catch 掉了异常,日志里完全不可见;submit 的异常又藏在 Future 里。建议:自定义 ThreadFactory 时给线程设置 setUncaughtExceptionHandler,同时包裹 Runnable 统一打日志。
坑 4:优雅停机。 进程退出时若直接 System.exit,队列里未执行的任务全丢。正确顺序是 shutdown()(不再接受新任务,存量任务跑完)→ awaitTermination(超时) → 仍没结束再 shutdownNow() 并处理返回值里的未完成任务。
// 优雅关闭线程池的标准三段式
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> dropped = pool.shutdownNow(); // 返回尚未开始的任务
log.warn("还有 {} 个任务未执行完", dropped.size());
}
十、一个可直接落地的自定义线程池
public class BizThreadPools {
private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor(
8, // 核心线程
16, // 最大线程
60L, TimeUnit.SECONDS, // 非核心线程空闲 60s 回收
new ArrayBlockingQueue<>(2000), // 有界队列,防止 OOM
new ThreadFactory() { // 自定义线程名,方便日志排查
private final AtomicInteger seq = new AtomicInteger(1);
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-worker-" + seq.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时退回调用线程执行
);
public static void execute(Runnable task) { POOL.execute(task); }
// 监控用:活跃线程、队列积压、已完成任务数
public static String stats() {
return String.format("active=%d, queued=%d, completed=%d",
POOL.getActiveCount(), POOL.getQueue().size(), POOL.getCompletedTaskCount());
}
}
最后记住一条经验:线程池不是万能的,无界队列 + 无限线程只会把问题从“慢”变成“挂”。把队列设成有界、给每个参数一个理由、监控拒绝次数,才能让线程池真正成为稳定性的加分项。