分类:架构与高并发 · 作者:管理员 · 发布于 2026-09-20 · 阅读 0 · 标签:JVM / 内存泄漏 / MAT / Full GC / 性能优化

一、场景:一个"每周准时"的 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 视图

  1. 打开后直接点击 Leak Suspects Report
  2. MAT 会列出最可能的泄漏嫌疑对象及其 retained heap 大小
  3. 查看 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:+UseG1GCG1 垃圾收集器推荐 JDK 8u191+ / JDK 11+ 使用
-XX:MaxGCPauseMillisG1 期望最大暂停100~200ms,根据业务容忍度调整

六、踩坑记录与最佳实践

  1. 不要在生产直接重启:重启丢失问题现场,堆转储是对抗内存泄漏的第一手证据。
  2. HeapDumpOnOutOfMemoryError 必须加:这是投入产出比最高的 JVM 参数,没有之一。
  3. 区分内存泄漏与内存膨胀:泄漏是对象不可达且无法回收;膨胀是高并发下对象短暂暴增但能回收。前者需要修代码,后者只需调大堆或优化流量。
  4. ThreadLocal 用完必 remove:配合线程池使用时,务必在 finally 块中调用 remove()。
  5. 加缓存必须有上限:无大小限制的 Map 缓存等于定时炸弹。Caffeine/Guava Cache 的 maximumSize 和 expireAfterWrite 是标配。
  6. G1 不是万能药:小堆(<4GB)场景下 Parallel GC 可能更优;G1 优势在大堆与低延迟场景。

七、总结

内存泄漏排查的核心思路是:保留现场 → 堆转储分析 → 定位泄漏类 → 代码修复 → 验证回归。MAT、jmap、GCeasy 是 Java 工程师的"三件套"。建议将 GC 日志、堆转储导出作为每个 Java 服务的默认配置,不要等到宕机了才想起它们。