线程池是 Java 面试中必考且最能拉开差距的知识点。老练的 Java 工程师不仅能讲清楚参数,还能结合源码执行流程、生产调优经验、监控与坑点进行深入阐述。下面我用"核心原理 → 参数拆解 → 工作流程 → 实战案例 → 调优与监控 → 常见陷阱"这条线,帮你彻底吃透。
一、为什么必须用线程池?
- 降低资源消耗:复用已创建的线程,减少线程创建、销毁的开销。
- 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
- 提高线程的可管理性:统一分配、监控和调优,防止无限制创建线程导致 OOM。
- 提供更强大的执行控制:执行、排队、拒绝策略、定时执行等。
二、线程池的核心构造参数(ThreadPoolExecutor)
java
public ThreadPoolExecutor(int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 空闲线程存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler) // 拒绝策略
2.1 核心参数详解
- corePoolSize :常驻线程数,即使空闲也不回收(除非
allowCoreThreadTimeOut(true))。 - maximumPoolSize :线程池允许的最大线程数。
线程数 = core + 当队列满后额外创建的线程,但总量 ≤ max。 - keepAliveTime + unit:超出 core 的线程如果空闲超过此时间,会被回收。
- workQueue:存储等待执行的任务,是线程池吞吐量的关键。
- threadFactory:自定义线程命名、守护、优先级等,便于监控排错。
- handler:当线程数到达 max 且队列满时,对新任务的拒绝策略。
三、线程池的工作流程(源码级理解)
当提交一个任务 execute(Runnable command) 时:
- 当前线程数 < corePoolSize
→ 直接创建新线程执行任务(即使有空闲核心线程也会优先创建达到 core)。 - 当前线程数 ≥ corePoolSize
→ 尝试将任务放入 workQueue 排队。 - 队列已满
→ 创建新线程(非 core)执行任务,直到达到 maximumPoolSize。 - 线程数 = max 且队列满
→ 执行拒绝策略。
面试官追问细节:
- 为什么核心线程满了不立即创建非核心线程,而是先入队?
答:为了缓冲突发流量,减少线程创建销毁的开销,除非队列设定为容量极小的(如 SynchronousQueue)则不等。 - 什么时候会回收非核心线程?
答:当getPoolSize() > corePoolSize且空闲超过keepAliveTime,回收直到线程数回到 core。
四、阻塞队列选型与实战案例
4.1 常见队列对比
| 队列 | 结构 | 容量 | 适用场景 |
|---|---|---|---|
| SynchronousQueue | 无存储,一对一交接 | 0 | 请求量平稳,把任务直接交给线程,拒绝策略容易触发 |
| LinkedBlockingQueue | 链表 | Integer.MAX_VALUE(默认) | 固定线程数 + 无界缓冲,但可能 OOM |
| ArrayBlockingQueue | 数组 | 必须指定容量 | 有限缓冲,配合有界队列 + 拒绝策略更安全 |
| DelayQueue | 优先级堆 | 无界 | 延时任务调度 |
| PriorityBlockingQueue | 优先级堆 | 无界 | 按优先级执行,需任务实现 Comparable |
血泪教训 :
Executors.newFixedThreadPool(10) 底层用的是 LinkedBlockingQueue 无界队列,队列无限增长会 OOM。所以生产严禁直接使用 Executors 的四个工厂方法。
4.2 案例一:自定义线程池(安全高效)
java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // core
8, // max
60L, TimeUnit.SECONDS, // 空闲回收
new ArrayBlockingQueue<>(200), // 有界队列,防止 OOM
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-pool-" + count.getAndIncrement());
t.setDaemon(false); // 非守护,确保任务执行完
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:交给主线程执行,防止丢任务
);
五、拒绝策略与场景选择
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException |
必须通知上游,记录异常 |
| CallerRunsPolicy | 由提交任务的线程执行 | 防丢任务,可降低流量 |
| DiscardPolicy | 静默丢弃新任务 | 不重要的日志、统计 |
| DiscardOldestPolicy | 丢弃队列头部最旧任务,重试提交 | 只保留最新数据,如实时性高的任务 |
| 自定义策略 | 实现 RejectedExecutionHandler,可记录日志、告警、降级到 MQ 等 |
需要精细控制时 |
案例:自定义拒绝策略 + 告警
java
public class AlertRejectedHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
log.error("线程池任务被拒绝,当前活跃线程: {}, 队列大小: {}",
executor.getActiveCount(), executor.getQueue().size());
// 尝试重新入队列或降级到 MQ
// 如果仍失败则丢弃或抛异常
}
}
六、如何合理设置线程数?
6.1 经典公式
- CPU 密集型 :线程数 = CPU 核心数 + 1
例:加密解密、复杂计算。 - I/O 密集型 :线程数 = CPU 核心数 * 2 或
线程数 = CPU核心数 * (1 + 平均等待时间/平均处理时间)
例:数据库查询、网络调用。 - 混合型:拆分成两个线程池,根据任务耗时比例分配。
6.2 实际案例
在订单处理微服务中,既有 CPU 密集的规则计算,也有大量 DB 和远程调用。
我们拆分两个池:
- 计算池:
core = Ncpu,max = Ncpu+2,SynchronousQueue。 - 业务池:
core = 10,max = 50,ArrayBlockingQueue(500),处理大部分 I/O 操作。
最终都需要压测验证,公式只是起点。
七、线程池的监控(生产必备)
7.1 指标采集
通过 ThreadPoolExecutor 提供的 getter 方法暴露给 Prometheus 或日志。
java
public void printPoolStats(ThreadPoolExecutor pool) {
log.info("核心线程数: {}, 最大线程数: {}, 当前线程数: {}, 活跃线程数: {}, " +
"队列中任务数: {}, 已完成任务数: {}, 拒绝任务数: {}",
pool.getCorePoolSize(), pool.getMaximumPoolSize(), pool.getPoolSize(),
pool.getActiveCount(), pool.getQueue().size(),
pool.getCompletedTaskCount(),
// 需要自定义包装才能获取拒绝计数
);
}
7.2 监控指标面板
- 队列积压:队列中等待任务数持续上升,说明消费能力不足,需扩容。
- 活跃线程占比:长期接近 max,且队列不空,考虑增加 max 或提升机器。
- 拒绝次数:发生拒绝说明线程池容量不足或下游出了问题。
7.3 实战案例:动态调整线程池
使用 Apollo/Nacos 等配置中心动态更新 core/max,结合 setCorePoolSize() 和 setMaximumPoolSize() 实时生效。
八、线程池的优雅关闭
java
public void shutdownGracefully(ThreadPoolExecutor pool) {
pool.shutdown(); // 不再接收新任务,已提交的任务继续执行
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 尝试中断所有任务
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
log.error("线程池未关闭成功,部分任务可能丢失");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
注意 :shutdownNow 不保证能停止正在执行的任务,只是尝试中断,业务代码需响应中断。
九、常见陷阱与血泪史
-
用 Executors 创建线程池导致 OOM
newFixedThreadPool/newSingleThreadExecutor无界队列,newCachedThreadPool最大线程数为Integer.MAX_VALUE,都危险。 -
线程池中线程的异常被吞掉
execute提交的Runnable异常会导致线程消亡,任务丢失,必须用submit+Future.get()或自定义UncaughtExceptionHandler。javat.setUncaughtExceptionHandler((thread, ex) -> log.error("线程异常", ex)); -
submit后不get异常被吞
submit返回的Future,如果不调用get且任务抛出异常,你完全不知道。 -
线程池里用 ThreadLocal
线程复用导致值污染,必须在任务结束时remove()。 -
tomcat / web 容器共享线程池误区
不要随意使用 servlet 容器线程池处理长耗时任务,应自定义线程池剥离。
十、面试串联话术(建议背诵)
"线程池的核心是一个
ThreadPoolExecutor,我理解它的工作流程是优先创建核心线程、满核心入队列、队列满开最大线程、最后拒绝。生产上我绝对不用Executors的快捷方法,而是用ArrayBlockingQueue做有界缓冲、自定义ThreadFactory命名线程、CallerRunsPolicy或自定义拒绝策略保证任务不丢失。线程数设置上,CPU 密集型用
CPU核数+1,I/O 密集型适当放大并压测验证。监控方面,我会定期采集队列大小、活跃线程数、拒绝次数到 Prometheus 并配置告警。实际项目中我遇到过因
LinkedBlockingQueue无界导致 OOM 的事故,后来全部替换为有界队列并动态调整参数。也踩过submit异常被吞、ThreadLocal残留的坑,所以现在任务异常全部捕获记录,线程工厂设置UncaughtExceptionHandler,并在 finally 块清理ThreadLocal。"
这套话术结合了理论、源码流程、调优和真坑,能让面试官确认你是真正的线程池专家。