泄漏的基本概念:周期性 Work 未清理、资源泄漏类型、泄漏的典型后果
现象识别
周期性 Work 未清理泄漏在运行态表现为:
- 内存持续增长(Heap / RSS 曲线呈阶梯状上升,触发 Full GC 后不回落)
- 线程或连接池耗尽(新增 Work 无法提交,超时或阻塞)
- 应用响应劣化(单次 Work 执行时间正常,但整体吞吐下降)
- 监控告警(CPU 使用率与 GC 频率同步升高,OOM 风险)
🔍 现象口诀:内存只升不降,Work 只增不减,GC 越跑越慢。
数据采集
明确采集点及命令,用于后续分析:
| 采集对象 | 工具/命令 | 关键指标 |
|---|---|---|
| JVM Heap 分布 | jmap -histo:live <pid> | 常驻对象计数、Work 相关类的实例数 |
| 线程堆栈 | jstack <pid> | Work 线程是否卡在等待/阻塞态 |
| GC 日志 | -Xlog:gc* (JDK 11+) | Full GC 后老年代仍不降 |
| Work 队列状态 | JMX / 自定义 MBean | queueSize、completedCount、rejectedCount |
💡 建议在压测阶段每 30s 采集一次快照,保留泄漏爆发前的基准。
原因定位
使用故障树逐步定位根因:
修复动作
修复的核心是保证 Work 的生命周期闭环:
- 强制清理:在 Work 完成回调中显式调用
remove()或cancel()。 - 弱引用防护:对关键资源使用
WeakReference+ReferenceQueue。 - 超时兜底:在 Work 执行超过预期时间后自动释放关联资源。
// 修复示例:周期性 Work 完成后的清理
public class SafeWork implements Runnable {
private final WorkQueue queue;
private final WorkItem item;
@Override
public void run() {
try {
// 业务逻辑
} finally {
queue.remove(item); // 确保从队列清除
item.release(); // 释放文件句柄/连接等
}
}
}
复测
复测手段:
- 稳定压测:运行相同负载 2 小时,Heap 曲线应持平或微波动。
- 对象计数审计:
jmap -histo:live中 Work 相关类实例数应稳定在预期值内。 - GC 日志验证:Full GC 后老年代使用率应回归基线。
⚠️ 复测时需确认修复后队列中出现新 Work 时仍能正常清理,避免因过度清理导致重复提交。
预防
将以下规则纳入代码规范和 CI 流程:
| 防线 | 措施 |
|---|---|
| 静态检查 | Sonar 规则:ThreadPoolExecutor 提交的 Runnable 必须在 finally 中释放资源。 |
| 动态追踪 | 为关键 Work 注册 StateListener,定期上报存活 Work 数量。 |
| 架构约束 | 使用 ScheduledExecutorService 配合 Future 取消,或采用 ScheduledFuture.addListener 模式。 |
| 混沌工程 | 在预发环境注入周期性 Work 模拟,自动检测对象泄漏。 |
额外建议:对资源泄漏类型建立分层清单,便于培训与复盘。常见的泄漏类型包括:
- 内存泄漏(对象引用链不断开)
- 句柄泄漏(文件描述符 / Socket / 数据库连接未归还)
- 线程泄漏(线程被创建后未销毁)
- 缓存泄漏(TTL 或 LRU 策略失效,导致缓存无限膨胀)
周期性 Work 未清理通常同时引发 内存泄漏 和 句柄泄漏,实践中需联合排查。