JCSprout项目解析:HashMap并发问题导致的线程池队列堆积问题分析
问题背景
某天凌晨三点,监控系统发出告警邮件,提示某应用的线程池队列达到阈值。这个应用负责从消息队列中取出数据并交由业务线程池处理,对用户体验影响较大。运维团队在发现问题后立即采取了保留现场、dump线程和内存并重启应用的措施。
问题分析过程
线程池配置问题
通过代码审查发现,线程池配置存在明显问题:
ThreadPoolExecutor executor = new ThreadPoolExecutor(coreSize, maxSize,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
这里使用了默认的LinkedBlockingQueue
而没有指定队列大小,导致队列默认大小为Integer.MAX_VALUE
,这为后续问题埋下了隐患。
内存分析
使用MAT工具分析内存dump文件后,发现两个显著的大对象:
- 线程池的任务队列
LinkedBlockingQueue
- 一个
HashSet
集合
队列占用大量内存表明任务处理速度跟不上任务产生速度,导致任务堆积。
线程分析
通过线程快照分析发现,多个业务线程都处于RUNNABLE状态,且堆栈显示它们都在执行HashSet.contains()
操作。更严重的是,这些线程已经持续运行了6-7个小时没有返回。
问题根源定位
并发环境下的HashSet问题
业务代码中存在以下关键问题:
- 共享的
HashSet
没有做任何同步处理 - 在JDK1.7环境下运行
HashSet
本质上是基于HashMap
实现的,在并发环境下会出现两个严重问题:
- 数据在并发写入时被覆盖,导致数据不准确
- 扩容时可能形成环形链表
环形链表导致的死循环
在JDK1.7中,HashMap
扩容时采用头插法转移节点,这在并发环境下可能导致环形链表。当代码执行contains()
查询时,会遍历这个环形链表,由于e.next
永远不为空,导致无限循环。
连带效应
这个死循环问题引发了连锁反应:
- 业务线程全部陷入死循环,无法完成任务
- 新任务不断进入线程池队列
- 队列大小持续增长,最终触发告警
解决方案
针对发现的问题,提出以下改进措施:
-
集合类型替换:
- 将线程不安全的
HashSet
替换为ConcurrentHashMap
- 通过固定value值实现类似Set的功能
- 将线程不安全的
-
初始化优化:
- 合理初始化
ConcurrentHashMap
大小,避免频繁扩容
- 合理初始化
-
数据库优化:
- 对冷数据进行归档处理,降低单表数据量
- 考虑分表策略
- 将直接查询数据库改为先查缓存
-
线程池优化:
- 为线程池设置合理的队列大小
- 配置合适的拒绝策略
- 为线程设置有意义的名称,便于问题排查
-
系统升级:
- 将JDK版本升级至1.8及以上,避免环形链表问题
-
监控告警优化:
- 将关键告警升级为电话通知,确保及时响应
经验总结
- 墨菲定律的体现:即使概率很低的问题,在特定条件下也会发生
- 技术债务的危害:老系统中隐藏的问题可能在业务量暴增时突然爆发
- 全面设计的重要性:从集合类型选择到线程池配置,都需要考虑并发场景
- 监控的价值:完善的监控系统能帮助快速发现问题,但响应机制同样重要
这个案例典型地展示了在并发环境下使用非线程安全集合可能导致的严重后果,特别是在JDK1.7中HashMap
的环形链表问题。通过这次问题排查,我们不仅解决了当前问题,也为系统未来的稳定运行打下了更好基础。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考