Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏

Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏

服务跑了几天,老年代内存像爬楼梯一样一路涨,Full GC 越来越频繁,回收后还是降不下来,最后 java.lang.OutOfMemoryError: Java heap space 把进程干掉。重启能续命一天,但根本问题没解决。这就是典型的内存泄漏------对象本该被回收,却被某个还活着的引用死死拽住,GC 够不着。

这篇不讲理论,直接走一遍从「确认是泄漏不是内存不够」到「拿到堆快照定位到具体那行代码」的完整流程,并把四类最常见的泄漏点摆出来。

第一步:先确认是泄漏,不是单纯堆太小

内存涨不代表就是泄漏。先看 GC 行为。开启 GC 日志(JDK 11+ 用统一日志参数):

bash 复制代码
# 启动参数加上,把 GC 情况打到文件
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20M

判断标准很简单:

  • 正常:每次 Full GC 后,老年代能回落到一个稳定的基线,基线长期不涨。
  • 泄漏 :每次 Full GC 后的基线持续抬高,像锯齿一样一级级往上爬,最终撑爆。

如果基线稳定只是偶尔飙高,那可能只是堆偏小或有大对象,加内存或调 -Xmx 就行,不用大动干戈。确认是基线持续抬高,才按泄漏来查。

第二步:抓堆快照(heap dump)

有两种时机拿快照。首选是让 JVM 在 OOM 时自动导,这样抓到的就是「案发现场」:

bash 复制代码
# 启动参数:OOM 时自动 dump,并指定路径
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof

如果服务还没崩、但你怀疑在泄漏,可以手动导(注意:jmap 导堆会 STW 暂停进程,几百 MB 到几 GB 的堆可能卡几秒到几十秒,生产要挑低峰期):

bash 复制代码
# 先拿到进程 pid
jps -l

# live 参数会先触发一次 Full GC 再 dump,只保留存活对象,文件更小、更聚焦泄漏
jmap -dump:live,format=b,file=/var/log/heap.hprof <pid>

live 很关键:它先做一次 Full GC,只导「GC 之后还活着」的对象。既然做了 Full GC 还活着,那这些就是真正被引用拽住、值得怀疑的对象。

第三步:用 MAT 找到「谁拽着它」

拿到 .hprof 后,用 Eclipse MAT(Memory Analyzer Tool) 打开分析。核心思路是回答两个问题:哪类对象占了最多内存?它们被谁引用着回收不掉?

  1. 打开 Leak Suspects 报告:MAT 加载后会自动给一份泄漏嫌疑报告,直接告诉你哪个对象/哪个类占了异常大的比例。这一步往往就能锁定大方向。

  2. 看 Dominator Tree(支配树) :按 Retained Heap(该对象被回收后能连带释放的总内存)排序。排在最前面的,就是「拽着最多内存」的元凶。比如你看到一个 HashMap 的 Retained Heap 有 800MB,基本就是它了。

  3. 右键 → Path to GC Roots → exclude weak/soft references:这是最关键的一步。它告诉你「这个本该被回收的对象,到底被哪条强引用链拽住了」。排除掉弱/软引用(那些本来就允许被回收),剩下的强引用链末端,就是你代码里那个不肯松手的地方。

顺着 GC Roots 路径往上看,通常会停在一个静态字段、一个线程的 ThreadLocal、或一个长生命周期的单例上------这就是泄漏源头。

四类最常见的泄漏点

排查多了会发现,90% 的 Java 内存泄漏就这几类。

1. 静态集合只加不删

最经典。一个 static 的 Map/List 当缓存用,只往里塞、从不清理:

java 复制代码
public class UserCache {
    // static 意味着这个 Map 的生命周期和整个应用一样长
    private static final Map<Long, User> CACHE = new HashMap<>();

    public static void put(Long id, User user) {
        CACHE.put(id, user);  // 只进不出,用户越多内存越大,永远回收不掉
    }
}

修法:别自己用裸 HashMap 当缓存。用带淘汰策略的缓存(如 Caffeine),设最大容量和过期时间:

java 复制代码
// Caffeine:超过容量按 LRU 淘汰,过期自动移除,内存有上界
Cache<Long, User> cache = Caffeine.newBuilder()
        .maximumSize(10_000)                       // 最多 1 万个,超了淘汰旧的
        .expireAfterWrite(Duration.ofMinutes(30))  // 写入 30 分钟后过期
        .build();

2. ThreadLocal 用完不 remove(尤其线程池场景)

ThreadLocal 的 value 挂在 Thread 对象上。线程池里的线程不会销毁、反复复用,你 set 了不 remove,value 就跟着线程一直活着:

java 复制代码
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();

public void handle(Request req) {
    CTX.set(buildContext(req));
    try {
        doBusiness();
    } finally {
        CTX.remove();  // 必须在 finally 里清,否则线程复用时旧 context 泄漏
    }
}

漏了这个 remove(),不仅泄漏内存,下一个请求复用同一线程时还可能读到上一个请求的脏 context------数据串了,比泄漏更可怕。

3. 监听器/回调注册了不注销

把对象注册成事件监听器,却在对象「逻辑上该销毁」时忘了反注册,发布者那个长生命周期的列表就一直拽着它:

java 复制代码
eventBus.register(this);   // 注册进去了
// ... 对象用完了,但没调 eventBus.unregister(this)
// eventBus 是单例,它的监听器列表一直持有 this,this 永远回收不掉

修法 :注册和注销成对出现,在生命周期结束的钩子里(如 Spring 的 @PreDestroy)反注册。

4. 资源流/连接没关

InputStream、数据库 ConnectionStatement 这些底层往往关联着堆外内存或本地资源,不关不仅泄漏堆内存,还可能耗尽文件句柄或连接池:

java 复制代码
// 用 try-with-resources,编译器帮你保证 close,别再手写 finally 关流
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    // 用完自动按声明逆序 close
}

一个能落地的排查清单

bash 复制代码
# 1. 确认是泄漏:GC 日志里 Full GC 后老年代基线是否持续抬高
-Xlog:gc*:file=/var/log/gc.log:time,uptime

# 2. 让它在 OOM 时留下现场
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof

# 3. 或低峰期手动导存活对象
jmap -dump:live,format=b,file=/var/log/heap.hprof <pid>

# 4. MAT 打开 → Leak Suspects → Dominator Tree 按 Retained Heap 排序
#    → 对头号对象 Path to GC Roots(exclude weak/soft)找强引用链源头

# 5. 对照四类常见泄漏点定位到代码,修完再观察 GC 基线是否回归平稳

小结

  • 先用 GC 日志区分「泄漏」和「堆太小」:Full GC 后基线持续抬高才是泄漏,偶尔飙高多半是堆不够。
  • 抓快照优先让 JVM 在 OOM 时自动 dump;手动导用 jmap -dump:live,先 GC 再导,只留存活对象更聚焦。
  • MAT 的杀手锏是 Dominator Tree 找占用大户 + Path to GC Roots 找拽住它的强引用链
  • 四类高频泄漏:静态集合只增不减、ThreadLocal 不 remove、监听器不注销、资源流不关------记住这四个,能覆盖绝大多数线上案例。

记忆点:内存泄漏不是「内存被用光」,是「该死的对象没死」------排查的全部意义就是顺着 GC Roots 找到那只不肯松手的手。

相关推荐
码匠许师傅1 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试
我不是疯子是傻子2 小时前
Qt CAN通信周期发送抖动?实测定时器精度校准与时间戳补偿方案
开发语言·数据库·qt
IT爱学堂3 小时前
尚硅谷 - 2025年3月Java+AI大模型应用开发
java·开发语言·人工智能
有点。3 小时前
C++认识数
开发语言·c++
小贤plus3 小时前
SpringBoot 三大核心注解精讲:@ControllerAdvice、@RestControllerAdvice、@Validated 分组校验(实战)
java
吠品4 小时前
Java byte数组与String互转:编码细节与踩坑记录
java·linux·服务器
W_326004 小时前
Python文件进阶:一维数据与 CSV 文件读写
开发语言·python
晚风醉蝶4 小时前
1-6-插入排序-InsertionSort
java·数据结构·排序算法
Tyler_TXZ4 小时前
C++C语言之——二叉树
c语言·开发语言·数据结构·c++·二叉树