Skip to content

Java 线程池核心参数及工作原理

提出问题

线程池是 Java 并发编程中最基础也最常用的工具,几乎每个后端项目都在用。但线上事故里,因为线程池配置不当导致的问题占了相当比例——比如无界队列导致 OOM、核心线程数太小导致吞吐上不去、拒绝策略不合适导致关键任务丢失。

面试官问这个问题,不只是想听你背出 7 个参数的名字,而是想看你在生产环境里有没有真正配过线程池、踩过坑。下面从参数解析到执行流程,再到选型策略,一层层说清楚。

分析问题

七参数逐个拆解

ThreadPoolExecutor 的完整构造签名:

java
public ThreadPoolExecutor(
    int corePoolSize,        // 核心线程数
    int maximumPoolSize,     // 最大线程数
    long keepAliveTime,      // 非核心线程空闲存活时间
    TimeUnit unit,           // 时间单位
    BlockingQueue<Runnable> workQueue, // 阻塞队列
    ThreadFactory threadFactory,       // 线程工厂
    RejectedExecutionHandler handler   // 拒绝策略
)
  • corePoolSize:即使空闲也会保留的线程数。allowCoreThreadTimeOut(true) 后核心线程也会过期回收。
  • maximumPoolSize:线程池允许的最大线程数。当队列满且线程数未达上限时,才会创建新线程到该值。
  • keepAliveTime + unit:非核心线程空闲超过这个时间会被终止。核心线程默认不过期。
  • workQueue:任务排队用的阻塞队列,它的类型直接决定了线程池的行为模式。
  • threadFactory:创建线程的工厂,默认用 Executors.defaultThreadFactory()建议自定义:给线程起有业务含义的名字,比如 "order-async-worker",方便 jstack 排查问题时一眼认出。
  • handler:当线程数已达 maximumPoolSize 且队列已满时,新提交的任务触发拒绝策略。

任务提交流程:四步走

java
public void execute(Runnable command) {
    if (command == null) throw new NullPointerException();
    int c = ctl.get();
    // 步骤1:工作线程数 < corePoolSize → 新建核心线程
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    // 步骤2:已到 corePoolSize → 尝试入队
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (!isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    // 步骤3:入队失败 → 尝试创建非核心线程
    else if (!addWorker(command, false))
        // 步骤4:创建失败 → 执行拒绝策略
        reject(command);
}

执行顺序图解(文字时序)

提交任务

  ├─ 线程数 < corePoolSize? → 创建核心线程直接执行
  │      (就算有空闲线程,也优先建新线程,这是 JDK 的设计选择)

  ├─ 已到 corePoolSize → 尝试入队
  │      │
  │      ├─ 入队成功 → double-check
  │      │     ├─ 线程池已 shutdown? → 移除任务并拒绝
  │      │     └─ 工作线程数为 0? → 创建非核心线程(保活消费)
  │      │
  │      └─ 入队失败(队列满)→ 尝试创建非核心线程

  └─ 创建非核心线程失败 → 触发拒绝策略

关键细节:步骤 2 入队成功后的 double-check 不是可有可无的。如果线程池在入队后 shutdown,不检查的话任务会永远留在队列里不被执行。另外,workerCountOf(recheck) == 0 那条分支是兜底——如果 corePoolSize 设了 0 或者核心线程全部超时回收了,队列里还有任务等着,但一个活着的线程都没有,这时候必须新建一个线程来消费。

线程池状态流转

ThreadPoolExecutorctl 这个 AtomicInteger 同时存了工作线程数和线程池状态,高 3 位存状态,低 29 位存线程数。

RUNNING     → 接收新任务,处理队列中的任务

  ├─ shutdown() → SHUTDOWN
  │     不再接收新任务,但继续处理队列中的任务

  ├─ shutdownNow() → STOP
  │     不再接收新任务,也不处理队列中的任务,中断正在执行的任务

  SHUTDOWN → TIDYING → TERMINATED
  STOP     → TIDYING → TERMINATED

生产事故案例:一次发布时,运维先停了应用再切流量,导致 shutdownNow() 被调用,正在执行中的数据库批量写入任务被中断,数据写了一半。正确做法是调 shutdown() 等待队列任务 drain 完,再确认超时后强制关闭。awaitTermination(60, TimeUnit.SECONDS) 是必须加的。

四种拒绝策略怎么选

策略行为适用场景
AbortPolicy(默认)抛出 RejectedExecutionException任务不可丢,必须感知拒绝
CallerRunsPolicy提交任务的线程自己执行降级:让调用者慢下来,自然限流
DiscardPolicy静默丢弃允许丢任务(如日志)
DiscardOldestPolicy丢弃队列头的任务,再尝试提交优先级任务:保新弃旧

生产建议:默认 AbortPolicy 适合大多数场景,让上层及时感知异常。CallerRunsPolicy 用于流量削峰——提交线程(通常是 Tomcat 线程)自己执行,等于把压力传导回去,让上游自动减速。但注意:如果提交线程是 Tomcat 的请求处理线程,CallerRunsPolicy 会导致该请求超时,并且 Tomcat 线程被占用后新请求进不来,要评估这个副作用是否符合预期。

核心线程数估算:经验公式不是万能药

业界常说的公式:

  • CPU 密集型Ncpu + 1(+1 是为了补偿页缺失等中断造成的暂停)
  • IO 密集型Ncpu * (1 + 等待时间 / 计算时间)

问题在于:业务系统很少有纯粹的 CPU 密集型或 IO 密集型请求,一个请求里可能包含本地计算、网络调用、数据库查询、Redis 访问,耗时比例随业务变化。公式只能给个起点,最后要靠压测校准

更务实的做法(亲身踩坑后的流程):

  1. 先设一个保守值(比如 core = 2 * Ncpu),配合有界队列(如 ArrayBlockingQueue 或容量有限的 LinkedBlockingQueue
  2. 上线后通过 getPoolSize()getActiveCount()getQueue().size() 采集指标,打成 Prometheus 指标
  3. 观察队列积压趋势和线程活跃度,配合 setCorePoolSize() 动态调整
  4. 压测到目标 TPS 附近,同时观察 CPU 是否跑到 80% 以上,线程切换是否加剧

真实案例:某订单处理服务,16 核机器,最开始按公式 Ncpu + 1 = 17 设了 corePoolSize,队列容量 500。上线后发现队列持续积压到 400+,CPU 只有 40%。排查发现每个请求里 80% 的时间在等下游 RPC 返回(IO 等待),实际是 IO 密集型。改为 core = 64max = 128、队列容量 200 后,同样 500 TPS 下队列稳定在 30 左右,CPU 升到 70%,P99 从 1.2s 降到 400ms。

队列类型决定生死

队列特点生产风险
LinkedBlockingQueue(无界)默认 Integer.MAX_VALUE队列积压不触发 maxPoolSize,任务堆积到 OOM
ArrayBlockingQueue(有界)必须指定容量最推荐,但容量要合理
SynchronousQueue不存任务,直接转交线程相当于 newCachedThreadPool,高峰时线程数无上限
PriorityBlockingQueue优先级排序任务可能被优先级反转,较少用

这就是为什么阿里规约禁止用 Executors 工厂方法Executors.newFixedThreadPool(10) 内部用的是无界 LinkedBlockingQueue,高峰时队列无限膨胀,迟早 OOM。Executors.newCachedThreadPool()SynchronousQueue,如果任务提交速度超过处理速度,线程数会无限增长,同样危险。

真实踩坑:某次监控告警说应用 OOM 了,dump 分析发现 LinkedBlockingQueue 里有 800 多万个任务,每个任务持有一个大 JSON 对象。原因是上游一个定时任务凌晨 3 点批量推送数据,而线程池处理速度跟不上,队列一路涨到撑爆堆内存。改成 ArrayBlockingQueue(1000) + CallerRunsPolicy 后,凌晨高峰时虽然偶有拒绝,但应用稳定运行,拒绝的任务被上游重试机制兜底了。

线程池监控:不做监控等于白配

java
// 自定义 ThreadPoolExecutor,暴露关键指标
public class MonitoredThreadPoolExecutor extends ThreadPoolExecutor {
    private final String poolName;
    private final MeterRegistry registry;

    public MonitoredThreadPoolExecutor(String poolName, int core, int max,
                                        long keepAlive, TimeUnit unit,
                                        BlockingQueue<Runnable> queue,
                                        MeterRegistry registry) {
        super(core, max, keepAlive, unit, queue,
              new NamedThreadFactory(poolName));
        this.poolName = poolName;
        this.registry = registry;

        // 注册指标采集
        Gauge.builder(poolName + ".active.threads", this, ThreadPoolExecutor::getActiveCount)
             .register(registry);
        Gauge.builder(poolName + ".queue.size", this, tpe -> tpe.getQueue().size())
             .register(registry);
        Gauge.builder(poolName + ".completed.tasks", this, ThreadPoolExecutor::getCompletedTaskCount)
             .register(registry);
    }

    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        // 记录任务开始时间,用于计算任务耗时
        ThreadPoolTaskTracker.start(poolName);
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        ThreadPoolTaskTracker.end(poolName);
        // 如果任务抛异常,记一条告警
        if (t != null) {
            log.error("ThreadPool[{}] task failed", poolName, t);
        }
    }
}

职责链要清晰:线程池只负责调度和执行,任务本身的异常处理由任务内 try-catch 负责。afterExecute 里拿到的是 Throwable,但如果任务用了 Future.get() 来获取结果,异常会被包装在 ExecutionException 里,afterExecute 拿不到,需要用 submit() 时的 Future 去 get 才能感知。

动态调参:不用重启改线程池

生产环境不可能每次调参都重启应用。ThreadPoolExecutor 提供了 setter 方法:

java
// 动态调整核心线程数
threadPool.setCorePoolSize(newCoreSize);
// 动态调整最大线程数
threadPool.setMaximumPoolSize(newMaxSize);
// 动态调整空闲存活时间
threadPool.setKeepAliveTime(newKeepAlive, TimeUnit.SECONDS);

配合配置中心(Nacos/Apollo/Spring Cloud Config),运维人员可以在控制台改配置,应用监听变更后实时调参,不需要重启。

一个完整的动态调参链路

Nacos 配置变更
  → 应用监听器收到新配置
  → 调用 threadPool.setCorePoolSize(newValue)
  → 同时更新队列容量(如果队列支持 resize 或重建)
  → 打印变更日志 + 记一条业务审计
  → 将新参数推送到 Prometheus 作为 gauge 指标

注意:setMaximumPoolSize() 如果设得比当前 corePoolSize 小,线程池会先缩小核心线程数再更新最大线程数。

总结

线程池配置的核心思路:用有界队列 + 合理的拒绝策略,让系统在超载时"优雅地失败",而不是"悄悄地崩溃"

面试话术示例:

"线程池的核心是 corePoolSizemaximumPoolSize 配合有界队列的协作机制。配置时我会先根据业务场景估一个 corePoolSize,队列用 ArrayBlockingQueue 并设一个合理的容量上限,拒绝策略用 AbortPolicyCallerRunsPolicy。上线后通过 getQueue().size()getActiveCount() 监控实际运行情况,压测时再调优。从来不直接用 Executors 工厂方法,因为它的默认队列配置在生产环境是高危选择。另外我会对线程池做监控打点,自定义 ThreadFactory 给线程命名,配上动态调参能力,让运维在配置中心就能改参数,不用重启。"

参考:JDK 源码 java.util.concurrent.ThreadPoolExecutor、阿里《Java 开发手册》、Java Concurrency in Practice 第 8 章

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。