上周五上线的新服务在凌晨突然开始疯狂告警,线程池队列积压导致核心接口超时,下游服务雪崩。你猜怎么着?线程池的workQueue用了一个无界队列------这玩意儿在流量突增时就是个定时炸弹。
一、现象:线程池把服务拖垮了
背景是个订单风控服务,QPS平时200左右,高峰期能到800。为了异步处理风控规则,我们用了ThreadPoolTaskExecutor,核心配置如下:
java
@Bean
public ThreadPoolTaskExecutor riskControlExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100); // 你以为这行有用?
executor.setThreadNamePrefix("risk-ctrl-");
executor.initialize();
return executor;
}
上线后风平浪静,直到凌晨促销开始------监控显示队列积压了2万多个任务,线程数却始终卡在5个(核心线程数),最终内存飙到90%触发Full GC。这里有个反直觉的点:你以为QueueCapacity设置了100就能限制队列长度?
二、根因:Spring的线程池配置陷阱
打开ThreadPoolTaskExecutor源码,你会发现这个坑:
java
// 关键代码:如果不显式指定队列类型,默认用LinkedBlockingQueue
private BlockingQueue<Runnable> createQueue(int queueCapacity) {
return (queueCapacity > 0 ? new LinkedBlockingQueue<>(queueCapacity)
: new SynchronousQueue<>());
}
问题出在**queueCapacity的默认值是Integer.MAX_VALUE**!即使你手动设置了setQueueCapacity(100),如果没同时指定setRejectedExecutionHandler,当队列满时,线程池会直接扩容到maxPoolSize,而不会触发拒绝策略。更坑的是,maxPoolSize只在队列满时才会生效------但无界队列永远不会满!
三、正确姿势:线程池必须配拒绝策略
这才是能扛住流量突增的配置:
java
@Bean
public ThreadPoolTaskExecutor riskControlExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 关键!
executor.setThreadNamePrefix("risk-ctrl-");
executor.initialize();
return executor;
}
- 为什么用
CallerRunsPolicy?*
AbortPolicy(默认)直接抛异常,在异步场景容易丢数据DiscardPolicy静默丢弃,排查问题时哭都来不及CallerRunsPolicy让提交任务的线程自己执行,既能限流又能保证不丢数据
压测对比(相同2000突发请求):
| 配置 | 平均耗时 | 最大内存占用 | 任务丢弃率 |
|---|---|---|---|
| 无界队列+默认策略 | 3200ms | 1.2GB | 0% |
| 有界队列+CallerRuns | 850ms | 800MB | 8% |
四、避坑清单:线程池的暗礁
-
队列选型坑
- CPU密集型用
SynchronousQueue(避免任务堆积) - IO密集型用
LinkedBlockingQueue(需明确设置容量) - 定时任务用
DelayedWorkQueue
- CPU密集型用
-
参数动态化
用
ThreadPoolExecutor的setCorePoolSize()方法可以在运行时调整线程数(但最大线程数不支持动态调整,需要重建线程池) -
监控埋点
必须监控三项指标:
javaexecutor.getActiveCount() // 活跃线程数 executor.getQueue().size() // 队列积压数 executor.getCompletedTaskCount() // 已完成任务 -
线程泄漏
永远记得用
try-finally包裹任务代码,否则一个未捕获的异常会让线程直接消失:javaexecutor.execute(() -> { try { doBusiness(); } finally { log.info("Task completed"); // 至少打日志 } });
五、结论
线程池不是配个参数就能闭眼用的工具------maxPoolSize在有界队列前就是个摆设,拒绝策略不配等于自杀。下次你看到线程数不涨但CPU打满时,先摸摸队列是不是又偷偷变成无界的了。
你在项目里还遇到过哪些线程池的骚操作?评论区聊聊你的血泪史。