一个生产环境线程池问题解决与调优实践
问题发现:Netty消息丢失
在一次生产环境监控中,我们观察到Netty消息处理出现异常,部分消息似乎丢失了。深入日志后发现,问题的根源在于线程池的处理能力不足。
线程池初始配置
我们最初为SCADA客户端消息消费设置的线程池配置如下:
java
/** 最大线程数空闲时间 */
private final int keepAliveSeconds = 120;
/**
* scada client消息消费线程池
* @return
*/
@Bean(name = "scadaCommonTaskExecutor")
public ThreadPoolTaskExecutor scadaCommonTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(40); // 核心线程数
executor.setMaxPoolSize(60); // 最大线程数
executor.setQueueCapacity(4000); // 任务队列容量
executor.setKeepAliveSeconds(keepAliveSeconds);
// 拒绝策略:抛出异常
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
这个配置意味着:
- 正常情况下有40个线程处理任务。
- 线程不够时,最多可以扩展到60个线程。
- 任务暂时处理不过来时,可以排队最多4000个。
- 如果核心线程空闲超过120秒,会被回收。
new ThreadPoolExecutor.CallerRunsPolicy()
是 Java 中ThreadPoolExecutor
提供的一种拒绝策略。当线程池的任务队列已满,并且线程池中的线程数也达到了最大值(maxPoolSize
)时,如果还有新的任务提交,线程池就需要决定如何处理这个"过载"的任务。这就是拒绝策略的作用。CallerRunsPolicy
的具体作用是: 让提交任务的线程(调用者线程)自己去执行这个任务。 换句话说,当线程池无法处理新任务时,它不会抛出异常(不像AbortPolicy
),也不会直接丢弃任务(不像DiscardPolicy
或DiscardOldestPolicy
),而是把这个任务"踢回"给提交它的那个线程去执行。 工作原理:
- 线程池判断:新任务提交时,线程池发现队列已满,且线程数已达上限。
- 执行策略:线程池不创建新线程,也不将任务加入队列,而是调用
CallerRunsPolicy
的rejectedExecution
方法。 - 调用者执行:
CallerRunsPolicy
的实现会中断当前任务提交流程,让提交任务的线程(比如你的主线程、某个业务线程等)自己执行这个被拒绝的任务。 - 执行时机:由于是调用者线程自己在执行任务,这会暂时阻塞调用者线程,直到任务执行完毕。这会使得调用者线程的执行速度变慢。 优点:
- 有界性: 它提供了一种"自我调节"的机制。因为调用者线程在执行任务时会变慢,这实际上会间接限制任务提交的速度,防止任务无限堆积,有助于系统在过载时保持稳定,而不是崩溃。
- 任务不丢失: 相比
DiscardPolicy
和DiscardOldestPolicy
,它确保了任务最终会被执行(只要调用者线程还在运行)。 - 简单实现: 相对于需要外部处理(如放入单独队列、记录日志等)的策略,它的实现相对简单。 缺点:
- 调用者阻塞: 最明显的缺点是会阻塞提交任务的线程。如果提交任务的线程是关键业务线程(比如处理用户请求的线程),这可能导致请求响应变慢,甚至超时。
- 可能影响调用者逻辑: 调用者线程原本可能有自己的业务逻辑,现在需要处理被拒绝的任务,可能会打乱原有的执行流程。
- 不一定适合所有场景: 如果调用者线程本身非常关键且不允许被阻塞,或者任务的执行时间很长,那么这种策略可能并不合适。 适用场景:
CallerRunsPolicy
适用于那些可以容忍调用者线程短暂阻塞,并且不希望任务丢失的场景。它特别适合用于"温和"地限制任务提交速率,防止系统被压垮。例如,在某些后台任务处理、日志收集、或者对实时性要求不是极端高的场景下。
这个拒绝策略我怀疑是阻塞了,并不是消息丢失了,所以我从 CallerRunsPolicy
(直接抛出异常,可能导致消息丢失)切换到 AbortPolicy
当线程数达到上限且队列已满时,新任务会被拒绝,并抛出AbortPolicy
异常。
java
/** 最大线程数空闲时间 */
private final int keepAliveSeconds = 120;
/**
* scada client消息消费线程池
* @return
*/
@Bean(name = "scadaCommonTaskExecutor")
public ThreadPoolTaskExecutor scadaCommonTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(40); // 核心线程数
executor.setMaxPoolSize(60); // 最大线程数
executor.setQueueCapacity(4000); // 任务队列容量
executor.setKeepAliveSeconds(keepAliveSeconds);
// 拒绝策略:抛出异常
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
线上错误日志分析
再次运行关键错误日志如下:
log
2025-07-02 14:35:16 [nioEventLoopGroup-5-1] WARN io.netty.channel.DefaultChannelPipeline An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
org.springframework.core.task.TaskRejectedException: Executor [java.util.concurrent.ThreadPoolExecutor@202ed590[Running, pool size = 60, active threads = 60, queued tasks = 4000, completed tasks = 5850]] did not accept task: ...
Caused by: java.util.concurrent.RejectedExecutionException: Task ... rejected from java.util.concurrent.ThreadPoolExecutor@202ed590[Running, pool size = 60, active threads = 60, queued tasks = 4000, completed tasks = 5850]
...
核心问题 :RejectedExecutionException
表明线程池已无法接受新任务。日志中明确指出:
- 线程池当前状态:
Running
。 - 活动线程数已达上限:
pool size = 60, active threads = 60
。 - 任务队列已满:
queued tasks = 4000
。 - 这意味着线程池的处理能力(60线程 + 4000队列)在峰值时被耗尽,新消息无法被处理,导致了"消息丢失"的表象。
问题排查过程
1. 线程池容量不足
从错误日志可以直观看出,线程池的配置(核心线程40,最大线程60,队列4000)在消息高峰期不足以处理所有到达的任务。这可能是配置不当,也可能是实际负载超出了预期。
2. 深入线程堆栈分析
用JMC获取了线程堆 dump,发现大量线程处于 WAITING (parking)
状态,例如:
text
"scadaCommonTaskExecutor-248" #412 prio=5 os_prio=0 tid=0x0000000074a56800 nid=0x7700 waiting on condition [0x00000008442fe000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000817462e0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209)
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285)
at com.cogy.slp.scada.MessageAfterHandlerData.handlerCarStatus(MessageAfterHandlerData.java:1536)
...
分析:
WAITING (parking)
表示线程调用了LockSupport.park()
,主动放弃了CPU,在等待某个条件。parking to wait for <0x00000000817462e0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
明确指出它在等待一个ReentrantLock
的锁。- 调用栈最终指向了
MessageAfterHandlerData.handlerCarStatus
方法中的lock()
调用。
3. 锁竞争问题定位
查看 handlerCarStatus
方法的代码,它使用了 ReentrantLock
来保护共享资源(model.getCsLock()
):
java
public void handlerCarStatus(...) {
model.getCsLock().lock(); // 加锁
try {
// 临界区:处理小车状态逻辑(更新Redis、发布消息、计算占用率、推送MQ等)
} finally {
model.getCsLock().unlock(); // 确保释放锁
}
}
结论:
- 线程
scadaCommonTaskExecutor-248
处于WAITING (parking)
状态,是因为它尝试获取model.getCsLock()
这把锁,但锁被其他线程持有。 - 大量线程堆栈显示都在等待同一个
ReentrantLock
实例,表明这是一个严重的锁竞争问题。在高并发下,大量线程争抢这把锁,导致许多线程阻塞,无法有效执行任务,进一步加剧了线程池的压力。
4. 验证锁竞争
通过进一步检查线程堆栈,确认了确实有多个线程在等待同一个 ReentrantLock
实例,证实了锁竞争是导致线程阻塞和处理能力下降的重要原因。
解决方案与调优
1. 增加线程池容量并调整拒绝策略
需要提升线程池的处理能力,以应对峰值负载。同时,修改拒绝策略,避免直接丢弃任务。
java
@Bean(name = "commonTaskExecutor")
public ThreadPoolTaskExecutor commonTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(200); // 大幅增加核心线程数
executor.setMaxPoolSize(300); // 增加最大线程数
executor.setQueueCapacity(6000); // 增加队列容量
executor.setKeepAliveSeconds(keepAliveSeconds);
// 改用 CallerRunsPolicy:由提交任务的线程执行任务,缓解线程池压力,且不会丢失任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
调整说明:
- 线程数增加:将核心线程数从40增加到200,最大线程数从60增加到300。这能显著提升线程池的并发处理能力。
- 队列容量增加:将队列容量从4000增加到6000,为突发流量提供更大的缓冲。
- 拒绝策略变更 :从
AbortPolicy
(直接抛出异常)改为CallerRunsPolicy
。CallerRunsPolicy
会让提交任务的线程自己去执行任务。这有几个好处:- 不会丢失任务。
- 当线程池过载时,提交任务的线程(通常是Netty的I/O线程)会被占用,从而自然地降低任务提交速度,起到限流的作用。
- 相比直接丢弃任务,更能保证系统的稳定性。 效果验证: 调整后,监控显示活动线程数增加,且不再出现因线程池拒绝任务导致的错误。消息丢失问题得到缓解。
2. 细化线程池配置
根据实际监控和负载测试,我们进一步调整了线程池大小。发现峰值并发量大约在80左右,因此将核心线程数调整为100,以更经济地利用资源:
java
executor.setCorePoolSize(100);
executor.setMaxPoolSize(150); // 保留一定的弹性空间
3. 优化任务执行逻辑(针对锁竞争)
锁竞争是本次问题的核心之一。虽然增加了线程池容量,但根本解决之道在于减少锁的争用。 可能的优化方向:
- 减小锁粒度 :如果
handlerCarStatus
方法中只有部分代码需要同步,尝试将其拆分为更细粒度的锁,只保护必要的共享数据。 - 使用并发集合 :如果锁保护的是集合类操作,考虑使用
ConcurrentHashMap
、CopyOnWriteArrayList
等并发集合替代同步块。 - 优化业务逻辑 :检查
handlerCarStatus
中的业务逻辑,看是否有可以并行处理的部分,或者是否可以减少对共享资源的访问。 - 引入分布式锁(如果适用) :如果
model
是跨JVM共享的资源,可能需要考虑分布式锁方案,但这会增加复杂性。

4. 其他优化
- 限流:在消息进入线程池前,引入限流机制(如令牌桶、漏桶算法),防止瞬时流量过大压垮线程池。
- 任务分批处理:如果单个任务处理的数据量很大,考虑拆分成多个小任务分批提交。
- 异步处理:将任务中非关键路径的耗时操作(如日志记录、非实时统计等)改为异步执行,缩短单个任务的执行时间。
- 监控与告警 :利用
ThreadPoolExecutor
提供的API或集成监控工具(如Prometheus + Grafana),实时监控线程池状态(队列长度、活跃线程数、任务完成数等),设置告警阈值,做到早发现早处理。 - 动态调整:在允许的情况下,可以开发动态调整线程池参数的功能,根据实时负载自动伸缩。
总结
本次生产环境Netty消息丢失问题,根源在于线程池配置不足以及任务执行过程中的严重锁竞争。通过以下步骤成功解决:
- 分析日志:定位到线程池拒绝任务是直接原因。
- 排查堆栈:发现大量线程因锁竞争而阻塞,导致线程池实际可用线程数远低于配置值。
- 扩容线程池 :大幅增加线程池容量,利用拒绝策略
AbortPolicy
排查错误日志问题。 - 优化锁逻辑:认识到锁竞争的严重性,规划后续优化方向。
- 精细化调整:根据实际负载,进一步优化线程池参数。 这次经历提醒我们:
- 线程池配置并非一成不变,需要根据实际负载进行调优。
- 锁竞争是并发编程中的常见陷阱,需要仔细设计同步策略。
- 健全的监控体系对于快速发现和定位问题至关重要。