一、场景:一个"每周准时"的 OOM 告警
某电商中台在灰度上线后,每周四下午准时收到 OOM 告警:java.lang.OutOfMemoryError: Java heap space。重启后恢复正常,但一周后再次告警。这是一个典型的内存泄漏——GC 无法回收的"常驻垃圾"在堆中越积越多,最终撑爆堆容量。
本文从实战角度,记录一次完整的 JVM 内存泄漏排查过程,涵盖堆转储获取、MAT 分析、代码定位与修复,以及 Full GC 调优策略。
二、第一步:保留现场——获取堆转储
收到告警时,第一反应不是直接重启,而是保留现场。
方式一:JVM 启动参数自动导出
# 在 JVM 启动参数中加入以下配置
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/app/dumps/
-XX:+ExitOnOutOfMemoryError
当 OOM ��生时,JVM 会自动将堆转储(.hprof 文件)写入指定路径,并自动退出,避免进程僵死。
方式二:运行时手动触发
# 先用 jps 找到进程 PID
jps -l
# 用 jmap 手动导出堆转储(不会触发 Full GC,避免影响数据)
jmap -dump:live,format=b,file=/tmp/heap-20260920.hprof <PID>
注意:-dump:live 会先触发一次 Full GC,对生产环境有短暂影响。如果要求零干扰,用 jmap -dump:format=b 不带 live 参数,但文件会包含可回收对象,体积更大。
三、第二步:MAT 分析——锁定"罪魁祸首"
将 .hprof 文件下载到本地,用 Eclipse MAT(Memory Analyzer Tool)打开。
3.1 看概览:Suspect 视图
- 打开后直接点击 Leak Suspects Report
- MAT 会列出最可能的泄漏嫌疑对象及其 retained heap 大小
- 查看 Details → Accumulated Objects by Class,找到占用最大的内部对象
3.2 用 OQL 做精确查找
MAT 的 OQL(对象查询语言)很强大。以下是一些常用查询:
-- 查询所有 HashMap 实例
SELECT * FROM java.util.HashMap
-- 查询指定类的所有存活实例
SELECT * FROM com.example.service.OrderCacheService
-- 查字符串长度超过 1000 的 String 对象
SELECT toString(s) FROM java.lang.String s WHERE s.count > 1000
3.3 真实案例:ThreadLocal 引发的线程级泄漏
通过 MAT 发现 java.lang.ThreadLocal 的 ThreadLocalMap.Entry 中有大量 HttpClientConnection 对象未被释放。每个请求创建一个新的 HttpClient 实例并放入线程局部变量,但请求结束后未调用 remove(),导致连接对象在线程池的复用线程中持续累积。
四、第三步:代码层面的常见泄漏模式
4.1 集合类静态缓存无限增长
// ❌ 错误写法:静态缓存没有淘汰策略
public class OrderCache {
private static final Map<String, Order> cache = new HashMap<>();
public Order getOrder(String orderId) {
return cache.computeIfAbsent(orderId, this::loadFromDB);
}
}
// ✅ 正确写法:使用 Guava Cache 或 Caffeine,设置最大容量和过期时间
public class OrderCache {
private static final Cache<String, Order> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build();
public Order getOrder(String orderId) {
return cache.get(orderId, this::loadFromDB);
}
}
4.2 未关闭的资源:IO 流与 HTTP 连接
// ❌ 错误写法:InputStream 未关闭
public void processFile(String path) throws IOException {
InputStream is = new FileInputStream(path);
byte[] data = is.readAllBytes();
// is.close() 缺失 —— 泄漏了文件句柄和 native memory
}
// ✅ 正确写法:try-with-resources 自动关闭
public void processFile(String path) throws IOException {
try (InputStream is = new FileInputStream(path)) {
byte[] data = is.readAllBytes();
}
}
4.3 自定义线程池的 Task 泄漏
使用线程池时,如果提交的任务持有外部对象的隐式引用,任务积压会导致对象无法回收。典型场景:异步日志、MQ 生产确认回调。
// ✅ 推荐做法:使用独立的、有界队列的线程池,并设置拒绝策略
ExecutorService executor = new ThreadPoolExecutor(
4, 8,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
五、Full GC 调优:从日志到参数优化
5.1 开启 GC 日志(JDK 8 / JDK 11+)
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/opt/app/logs/gc.log
# JDK 11+(统一的 -Xlog 语法)
-Xlog:gc*:file=/opt/app/logs/gc.log:time,uptime,level,tags
5.2 用 GCeasy 分析日志
将 gc.log 上传到 GCeasy.io(在线工具),它会自动识别:
- GC 暂停平均时长与峰值
- 堆使用率趋势(是否持续上升直至触发 Full GC)
- 各 GC 事件(Young GC / Full GC)的触发频率
- 推荐 JVM 参数优化建议
5.3 参数优化实例
| 参数 | 说明 | 推荐值 |
|---|---|---|
-Xms / -Xmx | 堆初始与最大值 | 设为相同值,避免动态调整带来的停顿 |
-XX:MetaspaceSize | 元空间初始大小 | 256m,避免频繁触发元空间扩容 GC |
-XX:+UseG1GC | G1 垃圾收集器 | 推荐 JDK 8u191+ / JDK 11+ 使用 |
-XX:MaxGCPauseMillis | G1 期望最大暂停 | 100~200ms,根据业务容忍度调整 |
六、踩坑记录与最佳实践
- 不要在生产直接重启:重启丢失问题现场,堆转储是对抗内存泄漏的第一手证据。
- HeapDumpOnOutOfMemoryError 必须加:这是投入产出比最高的 JVM 参数,没有之一。
- 区分内存泄漏与内存膨胀:泄漏是对象不可达且无法回收;膨胀是高并发下对象短暂暴增但能回收。前者需要修代码,后者只需调大堆或优化流量。
- ThreadLocal 用完必 remove:配合线程池使用时,务必在 finally 块中调用
remove()。 - 加缓存必须有上限:无大小限制的 Map 缓存等于定时炸弹。Caffeine/Guava Cache 的 maximumSize 和 expireAfterWrite 是标配。
- G1 不是万能药:小堆(<4GB)场景下 Parallel GC 可能更优;G1 优势在大堆与低延迟场景。
七、总结
内存泄漏排查的核心思路是:保留现场 → 堆转储分析 → 定位泄漏类 → 代码修复 → 验证回归。MAT、jmap、GCeasy 是 Java 工程师的"三件套"。建议将 GC 日志、堆转储导出作为每个 Java 服务的默认配置,不要等到宕机了才想起它们。