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