深入理解 Java 线程池:从 7 个核心参数到线上事故排查
线程池几乎是大厂面试的必考点,也是线上问题的高发地。这篇文章不讲 API 怎么调,只讲原理、坑和排查手段。
一、为什么需要线程池
每来一个任务就 new Thread() 有三个致命问题:
- 创建销毁开销大:线程栈默认 1MB,频繁创建会加剧 GC 与上下文切换;
- 无法控制并发量:流量突增时线程数可能被打到几千个,直接拖垮机器;
- 缺少统一管理:没有队列、没有拒绝策略、没有监控指标。
线程池本质是一个生产者—消费者模型:任务进队列,固定数量的 Worker 循环取任务执行。
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. 核心线程已满,入队
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. 入队失败(队列满),尝试扩容到 maximumPoolSize
else if (!addWorker(command, false))
reject(command); // 4. 仍然失败,走拒绝策略
}
这段源码是理解线程池的唯一关键,记住一条结论:
只有当队列满了之后,线程池才会把线程数从 corePoolSize 扩到 maximumPoolSize。
二、7 个参数的真实含义
| 参数 | 作用 | 易错点 |
|---|---|---|
corePoolSize |
核心线程数,默认常驻 | 设置 allowCoreThreadTimeOut(true) 才可回收 |
maximumPoolSize |
最大线程数 | 队列没满时它是摆设 |
keepAliveTime + unit |
非核心线程空闲存活时间 | 只对超出 core 的线程生效 |
workQueue |
任务排队队列 | 用无界队列等于 maximumPoolSize 失效 |
threadFactory |
线程工厂 | 必须自定义命名,否则 dump 全是 pool-1-thread-1 |
handler |
拒绝策略 | 默认 AbortPolicy 直接抛异常 |
队列选择决定行为
LinkedBlockingQueue(默认容量Integer.MAX_VALUE):等于无界队列,任务会无限堆积直至 OOM,maximumPoolSize永远用不上;ArrayBlockingQueue:有界队列,可实现"先扩线程、再拒绝"的预期行为;SynchronousQueue:不存储元素,来一个任务必须立刻有线程接,配合大 max 就是CachedThreadPool的模型;DelayedWorkQueue/PriorityBlockingQueue:前者给ScheduledThreadPoolExecutor用,后者任务需实现Comparable。
三、四种拒绝策略怎么选
new ThreadPoolExecutor.AbortPolicy(); // 抛 RejectedExecutionException(默认)
new ThreadPoolExecutor.CallerRunsPolicy(); // 由提交任务的线程自己执行,天然反压
new ThreadPoolExecutor.DiscardPolicy(); // 静默丢弃,几乎不要用
new ThreadPoolExecutor.DiscardOldestPolicy();// 丢弃队首最老任务,再重试
生产建议:默认用 AbortPolicy,并且必须捕获异常或使用自定义策略打日志。 DiscardPolicy 是最危险的选择——任务凭空消失,排查时毫无线索。
自定义拒绝策略模板:
public class LoggingRejectPolicy implements RejectedExecutionHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingRejectPolicy.class);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.error("task rejected, pool={}, active={}, queue={}, completed={}",
e.getPoolSize(), e.getActiveCount(),
e.getQueue().size(), e.getCompletedTaskCount());
throw new RejectedExecutionException("thread pool is saturated");
}
}
四、五个高频线上事故
1. 无界队列导致内存溢出
// 反面教材
Executors.newFixedThreadPool(10);
// 等价于 new ThreadPoolExecutor(10, 10, 0L, MILLISECONDS,
// new LinkedBlockingQueue<>()); // 容量 = Integer.MAX_VALUE
任务生产速度 > 消费速度时,队列会一直增长,最终 OutOfMemoryError: Java heap space。必须换成有界队列,并在队列满时让上游感知到压力(拒绝 + 重试/降级)。
2. 提交的任务异常被静默吞掉
Future<?> f = pool.submit(() -> { throw new RuntimeException("boom"); });
// 不调用 f.get(),异常永远不会出现在日志里
submit() 会把异常封装进 Future,只有 get() 才抛出;而 execute() 会交给线程的 UncaughtExceptionHandler。
解决方案:execute() 提交,或重写 afterExecute 统一处理。
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?> future && future.isDone()) {
try {
future.get();
} catch (ExecutionException e) {
log.error("task failed", e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
3. 父子任务同池 → 死锁
父任务占用全部线程并阻塞等待子任务,子任务在队列里永远排不到线程。不同语义的任务必须用不同线程池(IO 池、计算池、定时任务池物理隔离)。
4. ThreadLocal 在线程复用下残留数据
try {
USER_CONTEXT.set(user);
doWork();
} finally {
USER_CONTEXT.remove(); // 必须写在 finally 里
}
线程池中的线程不会销毁,ThreadLocalMap 的 key 是弱引用而被回收,value 是强引用却滞留,形成内存泄漏。更严重的是数据串号——下一个请求可能读到上一个用户的信息。
5. shutdown() 之后任务还在跑
shutdown():不接受新任务,执行完队列中的任务;shutdownNow():中断所有线程,返回未执行的任务列表;awaitTermination(timeout, unit):等待收敛,关闭时必须成对使用,否则主线程先退出、任务被静默丢弃。
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> dropped = pool.shutdownNow();
log.warn("force shutdown, {} tasks dropped", dropped.size());
}
五、线程数到底设多少
- CPU 密集型:
N + 1(N 为核数),多出来的 1 用于偶发缺页中断; - IO 密集型:
N × (1 + 等待时间 / 计算时间),工程上常取2N起步,再压测微调; - 不要拍脑袋:线程数取决于下游 RT。数据库连接池只有 20 个连接,线程开到 200 只会让请求排队在连接池上,并把超时错误放大。
六、一个可直接用的生产级工厂
public final class ThreadPools {
public static ThreadPoolExecutor build(String name, int core, int max,
int queueCapacity, long keepAliveSec) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
core, max, keepAliveSec, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new NamedThreadFactory(name),
new LoggingRejectPolicy());
pool.allowCoreThreadTimeOut(true); // 低峰期回收,节省资源
return pool;
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger seq = new AtomicInteger(1);
NamedThreadFactory(String prefix) { this.prefix = prefix; }
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement());
t.setDaemon(false);
t.setUncaughtExceptionHandler((th, e) ->
log.error("uncaught exception in {}", th.getName(), e));
return t;
}
}
}
关键点只有三条:有界队列 + 有名字的线程 + 拒绝时打日志。
七、监控看什么
| 指标 | 含义 | 报警阈值参考 |
|---|---|---|
getActiveCount() |
正在执行的线程数 | 持续 ≈ poolSize |
getQueue().size() |
排队任务数 | 持续 > 容量的 80% |
getCompletedTaskCount() |
已完成任务数 | 增长停滞即异常 |
getLargestPoolSize() |
历史峰值线程数 | 判断 max 是否合理 |
最小可用方案:ScheduledExecutorService 每 10s 采集一次,打点到 Micrometer/Prometheus。
Gauge.builder("executor.queue.size", pool, p -> p.getQueue().size())
.tag("pool", "order-async").register(meterRegistry);
结语
线程池的所有坑,归根到底都来自同一个误解:把线程池当成"能自动扩容的万能池"。它的容量由队列与 max 共同决定,它的失败必须被显式处理,它的线程是有状态的(ThreadLocal、上下文、局部缓存都需清理)。
- 点赞
- 收藏
- 关注作者
评论(0)