线程池优雅停机与核心线程数估算
线上发版,日志里突然冒出一批 RejectedExecutionException,或者任务跑到一半进程被 kill,数据只写了一半。这类问题基本都出在停机姿势和参数估算上。这篇就顺着这条线,把线程池从"能跑"推到"敢上生产"。
手把手实操:写一个能优雅停机的线程池
先看问题。下面这段是很多人第一版写法:
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> doSomething());
// 进程要退出了,直接 System.exit 或容器 kill
容器发 SIGTERM 后,正在跑的任务被硬中断,队列里排队的任务直接丢。要解决的是:先停止接收新任务,再等存量任务跑完,超时了才强杀。
ExecutorService pool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger(1);
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-pool-" + n.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy());
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
pool.shutdown(); // 不再收新任务
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 超时强杀,返回未执行任务
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}));
关键点在三处。shutdown() 只是把状态置为 SHUTDOWN,已提交任务照跑;awaitTermination 返回 false 才轮到 shutdownNow(),它会把队列里没跑的任务作为 List 返回,同时给运行中的线程发中断。第二处是线程工厂必须给线程起名,否则线上 jstack 里全是 pool-1-thread-1,排查时你根本认不出是谁。第三处 CallerRunsPolicy 让提交方自己跑,天然形成背压,比直接丢弃安全。
踩坑提醒:shutdownNow() 靠 Thread.interrupt() 生效,如果你的任务里有 catch (InterruptedException e) {} 把异常吞了,线程根本停不下来,只能等进程被 kill。
看源码及解析:submit 和 execute 的异常语义差在哪
execute(Runnable) 是 Executor 接口的方法,任务抛异常时,异常会一路冒到 ThreadPoolExecutor.runWorker 里,最终由 afterExecute 或线程的 UncaughtExceptionHandler 处理。默认情况下线程会死掉,然后线程池补一个新线程——异常是"可见"的。
submit 走的是 AbstractExecutorService.submit,它把任务包成 FutureTask:
public Future<?> submit(Runnable task) {
FutureTask<Void> ftask = new FutureTask<>(task, null);
execute(ftask);
return ftask;
}
FutureTask.run 内部用 try/catch 把异常存进 outcome 字段,不往外抛。所以任务炸了,线程池毫不知情,线程也不会死。异常被"藏"起来了,只有调用 future.get() 时才会以 ExecutionException 形式抛出,真正的业务异常在 getCause() 里。
这就是为什么很多人 submit 之后不 get,线上任务静默失败几个月没人发现。要么用 execute,要么 submit 后必须处理 Future,或者重写 afterExecute 统一兜底。
顺带说 Executors 四个工厂方法的坑:newFixedThreadPool 和 newSingleThreadExecutor 用的是无界 LinkedBlockingQueue,任务堆积会把内存撑爆;newCachedThreadPool 的 maximumPoolSize 是 Integer.MAX_VALUE,高并发下会疯狂建线程;newScheduledThreadPool 的队列同样无界。生产上更推荐手写 ThreadPoolExecutor,把队列容量和拒绝策略显式写出来。
验证方法:怎么确认参数和停机都对
核心线程数估算,先判断任务类型。CPU 密集任务,线程数接近核数,Runtime.getRuntime().availableProcessors() 拿到的值加 1 左右即可,多了只会增加上下文切换。IO 密集任务,线程大部分时间在等,可以用 核数 × (1 + 等待时间/计算时间) 粗估,但这个比值要靠压测校准,别拍脑袋。
验证手段:压测时用 jstack 看线程状态分布,如果大量线程卡在 WAITING 且 CPU 打满,说明线程数偏少或锁竞争严重;如果 CPU 利用率低但队列在涨,说明线程数不够。停机验证更直接——发 SIGTERM 后看日志,确认存量任务全部跑完、没有 RejectedExecutionException、awaitTermination 在预期时间内返回 true。
监控上至少暴露这几个指标:getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount()。队列持续增长就是扩容或降级的信号。动态调参可以用 setCorePoolSize、setMaximumPoolSize,但注意队列容量改不了,LinkedBlockingQueue 的 capacity 是 final 的。
面试速答
- 优雅停机:
shutdown停收新任务,awaitTermination等存量,超时再shutdownNow强杀并返回未执行任务。 execute异常冒到线程,submit包成 FutureTask 吞掉异常,必须get()才以ExecutionException抛出。Executors的坑:无界队列撑爆内存、CachedThreadPool线程数无上限。- 核心线程数:CPU 密集接近核数,IO 密集按等待/计算比估算,必须压测校准。
- 动态调参改不了队列容量,监控盯
activeCount和队列长度。
核心收获:线程池上生产的关键不是参数调优,而是停机时存量任务不丢、异常不静默。下一步,把你项目里所有 Executors.newXxx 换成手写 ThreadPoolExecutor,补上线程名和拒绝策略。
本文关键词:优雅停机、submit 与 execute、核心线程数估算、拒绝策略、动态调参