Java线程池这破玩意,差点让我周末加班排查到凌晨

文章来源声明: 原文作者:阿橙的百宝箱; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690779187615809536; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

线程池配置不是小事:无界队列会掩盖容量问题,有界队列+合理拒绝策略才能把故障挡在门外。适合后端与中间件开发者排查线上线程池隐患时参考。

上周五上线的新服务在凌晨突然开始疯狂告警,线程池队列积压导致核心接口超时,下游服务雪崩。你猜怎么着?**线程池的`workQueue`用了一个无界队列**——这玩意儿在流量突增时就是个定时炸弹。

一、现象:线程池把服务拖垮了

背景是个订单风控服务,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突发请求):

配置平均耗时最大内存占用任务丢弃率
无界队列+默认策略3200ms1.2GB0%
有界队列+CallerRuns850ms800MB8%

四、避坑清单:线程池的暗礁

  1. 队列选型坑

    • CPU密集型用SynchronousQueue(避免任务堆积)
    • IO密集型用LinkedBlockingQueue(需明确设置容量)
    • 定时任务用DelayedWorkQueue
  2. 参数动态化
    用ThreadPoolExecutor的setCorePoolSize()方法可以在运行时调整线程数(但最大线程数不支持动态调整,需要重建线程池)

  3. 监控埋点
    必须监控三项指标:

    executor.getActiveCount()      <span>// 活跃线程数</span>
    executor.getQueue().size()     <span>// 队列积压数</span>
    executor.getCompletedTaskCount() <span>// 已完成任务</span>
    
    
  4. 线程泄漏
    永远记得用try-finally包裹任务代码,否则一个未捕获的异常会让线程直接消失:

    executor.execute(() -> {
        <span>try</span> {
            doBusiness();
        } <span>finally</span> {
            log.info(<span>"Task completed"</span>); <span>// 至少打日志</span>
        }
    });
    
    

五、结论

线程池不是配个参数就能闭眼用的工具——maxPoolSize在有界队列前就是个摆设,拒绝策略不配等于自杀。下次你看到线程数不涨但CPU打满时,先摸摸队列是不是又偷偷变成无界的了。

你在项目里还遇到过哪些线程池的骚操作?评论区聊聊你的血泪史。