Java 线程池核心参数及工作原理
提出问题
线程池是 Java 并发编程中最基础也最常用的工具,几乎每个后端项目都在用。但线上事故里,因为线程池配置不当导致的问题占了相当比例——比如无界队列导致 OOM、核心线程数太小导致吞吐上不去、拒绝策略不合适导致关键任务丢失。
面试官问这个问题,不只是想听你背出 7 个参数的名字,而是想看你在生产环境里有没有真正配过线程池、踩过坑。下面从参数解析到执行流程,再到选型策略,一层层说清楚。
分析问题
七参数逐个拆解
ThreadPoolExecutor 的完整构造签名:
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且队列已满时,新提交的任务触发拒绝策略。
任务提交流程:四步走
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 或者核心线程全部超时回收了,队列里还有任务等着,但一个活着的线程都没有,这时候必须新建一个线程来消费。
线程池状态流转
ThreadPoolExecutor 用 ctl 这个 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 访问,耗时比例随业务变化。公式只能给个起点,最后要靠压测校准。
更务实的做法(亲身踩坑后的流程):
- 先设一个保守值(比如
core = 2 * Ncpu),配合有界队列(如ArrayBlockingQueue或容量有限的LinkedBlockingQueue) - 上线后通过
getPoolSize()、getActiveCount()、getQueue().size()采集指标,打成 Prometheus 指标 - 观察队列积压趋势和线程活跃度,配合
setCorePoolSize()动态调整 - 压测到目标 TPS 附近,同时观察 CPU 是否跑到 80% 以上,线程切换是否加剧
真实案例:某订单处理服务,16 核机器,最开始按公式 Ncpu + 1 = 17 设了 corePoolSize,队列容量 500。上线后发现队列持续积压到 400+,CPU 只有 40%。排查发现每个请求里 80% 的时间在等下游 RPC 返回(IO 等待),实际是 IO 密集型。改为 core = 64、max = 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 后,凌晨高峰时虽然偶有拒绝,但应用稳定运行,拒绝的任务被上游重试机制兜底了。
线程池监控:不做监控等于白配
// 自定义 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 方法:
// 动态调整核心线程数
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 小,线程池会先缩小核心线程数再更新最大线程数。
总结
线程池配置的核心思路:用有界队列 + 合理的拒绝策略,让系统在超载时"优雅地失败",而不是"悄悄地崩溃"。
面试话术示例:
"线程池的核心是
corePoolSize和maximumPoolSize配合有界队列的协作机制。配置时我会先根据业务场景估一个corePoolSize,队列用ArrayBlockingQueue并设一个合理的容量上限,拒绝策略用AbortPolicy或CallerRunsPolicy。上线后通过getQueue().size()和getActiveCount()监控实际运行情况,压测时再调优。从来不直接用Executors工厂方法,因为它的默认队列配置在生产环境是高危选择。另外我会对线程池做监控打点,自定义 ThreadFactory 给线程命名,配上动态调参能力,让运维在配置中心就能改参数,不用重启。"
参考:JDK 源码
java.util.concurrent.ThreadPoolExecutor、阿里《Java 开发手册》、Java Concurrency in Practice 第 8 章