java 技术随笔

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());
    }
}

最后记住一条经验:线程池不是万能的,无界队列 + 无限线程只会把问题从“慢”变成“挂”。把队列设成有界、给每个参数一个理由、监控拒绝次数,才能让线程池真正成为稳定性的加分项。

标签
线程池ThreadPoolExecutorJava并发多线程