跳到主要内容
EN
返回

内存泄漏排查实战:用 MAT 和 async-profiler 定位生产问题

Zhou Xunyou 12 分钟阅读 jvm
分享

内存泄漏是 Java 生产环境的常见问题。本文通过一个真实案例,展示如何用 MAT(Memory Analyzer Tool)async-profiler 定位并解决内存泄漏。

什么是内存泄漏?

内存泄漏 = 对象应该被回收但实际没有被回收,导致可用内存越来越少,最终触发 OOM。

正常回收:                              内存泄漏:
  GC Roots                                  GC Roots
     ↓                                        ↓
  A → B → C  → D(引用链正常)           A → B → C → D(存在隐藏引用)
  C 不再被外部引用                       C 仍被一个 HashMap 持有引用
  C 可被回收                             C 无法被回收 → 泄漏

常见的泄漏场景

场景 原因 典型代码
静态集合持有对象 集合只增不减 static Map cache = new HashMap()
监听器未注销 观察者模式中 listener 未移除 addListener(listener) 后无 removeListener
ThreadLocal 未清理 线程池复用导致值对象无法释放 threadLocal.set(obj) 后无 remove()
数据库连接/Stream 未关闭 资源未 finally 释放 ResultSet/Stream 用完未 close
动态类加载 ClassLoader 持有元数据 CGLIB/JVM TI 动态生成大量类

案例:Spring Boot 应用内存持续增长

症状

应用启动后内存从 500MB 逐渐增长到 OOM(约 2GB),GC 后内存不回落:

heap after GC:
  2024-11-20 10:00  used=480M, gc.time=23ms
  2024-11-20 10:05  used=620M, gc.time=31ms    ← 持续上升
  2024-11-20 10:10  used=890M, gc.time=45ms    ← 上升更快
  ...(大约 6 小时后 OOM)

Step 1:Heap Dump 获取

方式一:运行时自动 dump(推荐)

# OOM 时自动生成 heap dump
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/data/heapdump/ \
     -Xms2g -Xmx2g your-app.jar

方式二:手动 jmap(需要 JDK 工具)

# 获取 live heap dump(会触发 Full GC + STW)
jmap -dump:live,format=b,file=heap-20241120-1015.hprof <pid>

# 无 Full GC 的 dump(所有对象,包括即将被回收的)
jmap -dump:format=b,file=heap-all.hprof <pid>

Step 2:MAT 分析泄漏嫌疑

用 MAT(Eclipse Memory Analyzer)打开 .hprof 文件:

1. Leak Suspects Report → 自动分析泄漏嫌疑人
2. Dominator Tree → 按对象 retained heap 排序
3. Histogram → 按类名统计对象数量
4. OQL → SQL 风格查询对象

关键视图解读:

# 视图 1:Leak Suspects(自动报告)
┌──────────────────────────────────────────────────┐
│  Leak Suspect #1                                  │
│  The class "com.example.CacheManager"             │
│  occupies 847MB (42%) of 2GB heap                 │
│  └── java.util.HashMap$Node[8192] × 1           │
│       └── key: String (class name)               │
│       └── value: Object[] (cached result)        │
│                                                      │
│  Reason: Cache never eviction → growing forever    │
└──────────────────────────────────────────────────┘

# 视图 2:Dominator Tree(Retained Heap 排序)
Class Name                     │ Shallow Heap │ Retained Heap
──────────────────────────────┼──────────────┼──────────────
char[] (String内部字符数组)    │    1.2MB     │    520MB      ← 大字符串
byte[][]                      │      890KB    │    310MB      ← 缓存数据
HashMap$Node[]                │      200KB    │    280MB      ← CacheManager.cache
LinkedHashMap$Entry          │      150KB    │    195MB      ← Session 管理

Step 3:GC Roots 追踪引用链

在 MAT 中对着可疑对象右键 → Path to GC Rootsexcludes weak refs

查找引用链:
CacheManager.cache (HashMap)
  ↓ (key)
String "user:12345:profile"
  ↓ (value)
CachedUserProfile[]
  ↓ (reference)
UserService.userCache (static)

GC Roots:
  ↑ Thread Stack: main() → ApplicationContext → userService → ...

Step 4:async-profiler 补充分析

在 MAT 之外,用 async-profiler 做 Allocation 分析(看谁在疯狂分配内存):

# 采样 30 秒的内存分配
./async-profiler.sh profiler -d 30 \
    -e alloc \
    -f allocation-profile.html \
    <pid>

# 生成火焰图
./async-profiler.sh flames -d 30 \
    -e alloc \
    <pid> > flames.svg
Allocation 火焰图(SVG)片段:

┌─────────────────────────────────────────────┐
│  ██                                      │
│  ██ ██  ██████████                        │
│  ██ ██ ██ ████████ ████  ████████████    │  ← CacheManager.store() 分配最多
│  ██ ██ ██ ████████ ████  ████████████    │
│  ██ ██ ██ ████████ ████  ████████████    │
│  ██ ██ ██ ████████ ████  ████████████    │
│  ██ ██ ██ ████████ ████  ████████████    │
└─────────────────────────────────────────────┘
  main() → CacheManager.store() → CacheManager.rebuild()

定位到的根因

问题代码(简化):

@Service
public class CacheManager {
    private static Map<String, Object> cache = new HashMap<>();

    public void store(String key, Object value) {
        // 不断往 HashMap 塞数据,从不清理
        cache.put(key, value);
    }
}

引用链CacheManager.cache(static HashMap)→ 持有所有缓存对象 → 无法被 GC

Step 5:修复方案

方案 A:添加 LRU eviction(最小改动)

@Service
public class CacheManager {
    // 使用 LinkedHashMap 实现 LRU,maxEntries = 10000
    private static final int MAX_ENTRIES = 10000;
    private static Map<String, Object> cache =
        new LinkedHashMap<String, Object>(MAX_ENTRIES, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > MAX_ENTRIES;
            }
        };

    public void store(String key, Object value) {
        synchronized (cache) {
            cache.put(key, value);
        }
    }
}

方案 B:Caffeine(推荐,性能更好)

@Service
public class CacheManager {
    private static Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(10_000)              // 最多 10000 条
        .expireAfterWrite(Duration.ofMinutes(30))  // 写入 30 分钟后过期
        .expireAfterAccess(Duration.ofMinutes(10)) // 10 分钟无访问则过期
        .recordStats()                    // 开启统计
        .build();

    public void store(String key, Object value) {
        cache.put(key, value);
    }

    // 监控缓存命中率
    public void logStats() {
        CacheStats stats = cache.stats();
        log.info("hitRate={}, evictions={}",
            stats.hitRate(), stats.evictionCount());
    }
}

修复后验证

# 重启后观察内存
jstat -gc <pid> 1000

# 修复后结果:
# heap 稳定在 500MB~800MB,GC 后回落,内存不再持续增长

ThreadLocal 泄漏排查(另一个常见场景)

// 典型泄漏代码
public class UserInterceptor extends HandlerInterceptorAdapter {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public boolean preHandle(HttpRequest request, ...) {
        currentUser.set(parseUser(request));  // ← 设置了但未清理
        return true;
    }
    // ❌ 缺少 afterCompletion() 中的 currentUser.remove()
}

async-profiler 快速定位

# 找出 ThreadLocal.set() 调用最多的栈
./async-profiler.sh profiler -e ClassLoad \
    -f classload.html \
    -I '*ThreadLocal*' \
    <pid>

修复

@Override
public void afterCompletion(HttpRequest request, ...) {
    currentUser.remove();  // ← 无论成功失败都必须清理
}

排查工具一览

工具 用途 特点
MAT (Memory Analyzer) Heap Dump 分析 Eclipse 插件,支持 Leak Suspect、Dominator Tree
async-profiler CPU/Allocation 采样 无需修改代码,生产安全,生成火焰图
jmap + jhat Heap Dump + 快速分析 JDK 内置,jhat 简易但功能有限
Java Mission Control (JMC) 实时 JMX 监控 JDK 内置,记录 Flight Recording
VisualVM 本地连接分析 JDK 内置,轻量级

总结

  • Heap Dump 是关键:OOM 时自动 dump,OOM 前手动 dump
  • MAT + GC Roots 追踪是定位泄漏引用链最有效的方法
  • async-profiler 无侵入,适合生产环境采样
  • 修复方案首选引入成熟缓存库(Caffeine、Guava Cache)而非手写 eviction

下篇将介绍JVM 性能调优实战——结合具体业务场景的参数配置案例。

评论