ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【JVM原理详解】55-内存泄漏排查实战

【JVM原理详解】55-内存泄漏排查实战

内存泄漏排查实战

引言

Java 有 Garbage Collection 机制,为什么还会内存泄漏?这是很多开发者的疑问。事实上,GC 能回收的是"不可达对象",而内存泄漏的本质是对象已经不再使用,却仍被 GC Root 可达引用链持有,GC 无法回收。泄漏积累到一定程度,堆耗尽,最终抛出java.lang.OutOfMemoryError: Java heap space

本篇将厘清内存泄漏与内存溢出的关系,梳理生产环境最常见的五类泄漏场景,并给出从jmap导堆到 MAT 分析定位的完整排查流程,最后通过一个 ThreadLocal 泄漏案例演示端到端的排查过程。

内存泄漏 vs 内存溢出

两者常被混用,但概念不同:

  • 内存泄漏(Memory Leak):对象已无用但仍被引用持有,GC 无法回收。特征是堆内存缓慢增长,GC 后内存不降。
  • 内存溢出(OutOfMemoryError,OOM):JVM 无法分配所需内存时抛出的错误。内存泄漏是 OOM 的常见原因之一,但 OOM 也可能由瞬时大对象分配、堆设置过小等引起。
内存泄漏(慢性病) 内存溢出(急性发作) │ │ │ 堆缓慢持续上升 │ 瞬间分配失败 │ GC后内存不回落 │ OOM: Java heap space │ 最终触发 Full GC │ 进程可能崩溃退出 │ │ └──── 长期积累 ────→ 触发 OOM ──┘

核心判断标准:Full GC 后老年代使用量持续不下降,基本可以确认存在内存泄漏。可以通过jstat -gcutil <pid> 1000观察老年代(O 列)的变化趋势来判断。

常见内存泄漏场景

静态集合持有对象

这是最经典的泄漏模式。静态集合的生命周期与 Class 相同,Class 不卸载,集合中的对象就永远无法回收。

// 反例:静态 Map 作为缓存,只 put 不 removepublicclassCacheManager{privatestaticfinalMap<String,byte[]>CACHE=newHashMap<>();publicstaticvoidput(Stringkey,byte[]data){CACHE.put(key,data);// 永远不清理,持续增长}publicstaticbyte[]get(Stringkey){returnCACHE.get(key);}}

修复方式:使用WeakHashMap(key 为弱引用),或引入 Guava Cache / Caffeine 配置过期时间和最大容量。

// 正例:Caffeine 带过期和容量限制privatestaticfinalCache<String,byte[]>CACHE=Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10,TimeUnit.MINUTES).build();

ThreadLocal 未清理

ThreadLocal 的 Entry 继承自WeakReference,key 是弱引用(ThreadLocal 对象本身),但value 是强引用。如果 ThreadLocal 对象被回收(key == null),但 value 仍被 Entry 持有,且线程不退出(线程池场景),这些 value 就永远无法回收——这就是经典的ThreadLocal 泄漏

// 反例:线程池中使用 ThreadLocal 不 removeprivatestaticfinalExecutorServicepool=Executors.newFixedThreadPool(10);privatestaticfinalThreadLocal<LargeObject>tl=ThreadLocal.withInitial(LargeObject::new);publicvoidprocess(){LargeObjectobj=tl.get();// 每个线程持有一个 LargeObject// ... 使用 obj// 没有 tl.remove(),线程复用时 LargeObject 一直留在 ThreadLocalMap 中}

线程池中线程是复用的,ThreadLocalMap 随线程长期存在,不 remove 的 value 不会释放。本篇后续案例会详细演示这种泄漏的排查过程。

监听器 / 回调未注销

注册了事件监听器或回调,但不再需要时忘记注销,发布者仍持有监听器引用,导致监听器及其引用的对象链都无法回收。

// 反例:注册监听器未注销publicclassOrderService{publicvoidinit(){EventBus.register(this);// 注册监听}// 缺少 destroy() 调用 EventBus.unregister(this)// OrderService 实例及它持有的所有对象都无法回收}

内部类持有外部类引用

非静态内部类(包括匿名内部类)隐式持有外部类实例的引用。如果内部类实例被长期持有,外部类实例也无法回收。

// 反例:匿名内部类 Runnable 提交到线程池publicclassTaskRunner{privatebyte[]bigData=newbyte[10*1024*1024];// 10MBpublicvoidsubmit(){// Runnable 匿名内部类隐式持有 this(TaskRunner 实例)executor.submit(newRunnable(){@Overridepublicvoidrun(){// 即使不使用 bigData,TaskRunner 实例也无法回收System.out.println("task running");}});}}

修复方式:将内部类改为静态内部类,或使用 Lambda(Lambda 只捕获用到的变量,不持有外部类引用,除非显式引用了外部类成员)。

资源未关闭

数据库连接、IO 流、Socket 等资源如果不关闭,不仅泄漏内存,还泄漏底层文件描述符。这类泄漏在 GC 时通过 Finalizer / Cleaner 释放(兜底),但不可靠且不及时。

// 反例:Connection 未关闭publicUserfindById(Longid){Connectionconn=dataSource.getConnection();// 借出连接// ... 查询逻辑中抛出异常// conn 未 close,连接泄漏,连接池耗尽后阻塞returnuser;}// 正例:try-with-resources 自动关闭publicUserfindById(Longid){try(Connectionconn=dataSource.getConnection();PreparedStatementps=conn.prepareStatement(sql)){// ...returnuser;}}

排查流程:jmap → MAT → 定位

第一步:导出堆转储

内存泄漏排查需要堆转储(Heap Dump),包含此时堆中所有对象和引用关系。

# 方式一:jmap 手动导出(JDK 8/11/17 通用)jmap-dump:format=b,file=heap.hprof<pid># 方式二:jcmd(推荐,JDK 9+)jcmd<pid>GC.heap_dump /data/dump/heap.hprof# 方式三:OOM 时自动导出(提前配置 JVM 参数)-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/data/dump/

生产环境注意事项

  1. jmap -dump会触发 STW(Full GC),对线上服务有短暂影响,建议在低峰期或摘流后操作。
  2. 堆转储文件约等于堆大小(8G 堆 ≈ 8G 文件),确保磁盘空间充足。
  3. 转储文件包含对象数据,注意脱敏,不要直接外传。

第二步:MAT 分析

MAT(Eclipse Memory Analyzer Tool)是分析堆转储的专业工具,能从数十 GB 的堆中快速定位泄漏点。

打开 hprof 文件后,MAT 提供几个核心分析视图:

视图用途
Leak Suspects自动分析疑似泄漏点,生成报告
Dominator Tree按对象保留内存(Retained Heap)排序,找最大对象
Histogram按类统计对象数量和内存占用
GC Roots查看对象到 GC Root 的完整引用路径

第三步:Dominator Tree 找大户

Dominator Tree按保留内存(Retained Heap)降序排列。Retained Heap 表示对象被回收后能释放的总内存(包括它支配的所有对象),是定位泄漏的关键指标。

Dominator Tree(示意) ┌─────────────────────────┬──────────┬────────────┐ │ Class Name │ % │ Retained │ ├─────────────────────────┼──────────┼────────────┤ │ java.util.HashMap │ 42.3% │ 1.2 GB │ ← 最大保留内存 │ java.util.ArrayList │ 15.1% │ 430 MB │ │ byte[] │ 12.8% │ 365 MB │ │ ... │ │ │ └─────────────────────────┴──────────┴────────────┘

如果某个 HashMap 占了 42% 的堆,且其内部存储了大量业务对象,这个 HashMap 很可能就是泄漏源。

第四步:GC Root 路径确认

找到可疑对象后,右键选择Path To GC Roots → exclude weak/soft references,查看该对象被谁持有。排除弱引用和软引用是因为它们不阻止 GC 回收(在内存不足时会被清除),真正导致泄漏的是强引用链

GC Root 路径(示意) java.lang.Thread @ 0x7f8a01c3a000 (线程局部变量,GC Root) └── com.app.TaskRunner$1 @ 0x7f8a02b1c000 (匿名内部类 Runnable) └── com.app.TaskRunner @ 0x7f8a02b1a000 (外部类实例) └── byte[10485760] @ 0x7f8a02b1d000 (10MB bigData)

这条路径说明:线程池线程持有 Runnable,Runnable 持有 TaskRunner,TaskRunner 持有 10MB 的 bigData——正是前面"内部类持有外部类引用"的泄漏。

实战案例:ThreadLocal 泄漏排查

现象

某 Web 应用运行数天后老年代持续增长,Full GC 后内存不下降,最终 OOM 重启。jstat监控显示:

# 观察 30 秒,老年代(O)从 68% 涨到 74%,Full GC 后仍不下降$ jstat-gcutil12345300010S0 S1 E O M CCS YGC YGCT FGC FGCT0.0045.2362.1068.3492.1588.421423.21582.8410.0045.2378.4570.1292.1588.421433.23882.841...0.0045.2391.2074.5592.1588.421453.28993.102← FGC +10.0045.2312.3074.1092.1588.421463.30193.102← GC 后 O 仍74%

导出与分析

# 导出堆转储jmap-dump:format=b,file=/data/dump/heap_oom.hprof12345

用 MAT 打开后,Leak Suspects 报告直接给出疑似泄漏点:

Leak Suspect 1: Problem Suspect 1: The class "java.lang.ThreadLocal$ThreadLocalMap", loaded by "<system class loader>", occupies 1,234,567,890 (58.3%) bytes. The memory is accumulated in one instance of "java.lang.ThreadLocal$ThreadLocalMap$Entry[]" loaded by "<system class loader>".

ThreadLocalMap 占了 58% 的堆。展开 Dominator Tree 查看 ThreadLocalMap 的 Entry 数组:

Thread @ 0x7f8a01c3a000 (线程: pool-1-thread-3) └── ThreadLocal$ThreadLocalMap @ 0x7f8a02b1c000 └── ThreadLocal$ThreadLocalMap$Entry[] @ 0x7f8a02b1d000 ├── Entry @ 0x7f8a02b1e000 │ └── UserContext @ 0x7f8a02b1f000 (5.2 MB) │ └── byte[5242880] @ 0x7f8a02b20000 (用户头像缓存) ├── Entry @ 0x7f8a02b21000 │ └── UserContext @ 0x7f8a02b22000 (4.8 MB) ... └── Entry @ 0x7f8a02b50000 (共 1200+ 个 Entry)

每个线程的 ThreadLocalMap 中有大量 UserContext 对象,每个 5MB 左右,10 个线程 × 1200 个 ≈ 60GB 的引用(部分已被 Full GC 回收 key,但 value 仍在)。

定位代码

查看 GC Root 路径确认是 ThreadLocal 持有:

Path To GC Roots (exclude weak/soft references): java.lang.Thread @ 0x7f8a01c3a000 [Thread, pool-1-thread-3] (GC Root: JavaThread) └── ThreadLocal$ThreadLocalMap @ 0x7f8a02b1c000 └── Entry[] @ 0x7f8a02b1d000 └── Entry @ 0x7f8a02b1e000 └── UserContext @ 0x7f8a02b1f000 ← 泄漏对象

回到代码中搜索ThreadLocal<UserContext>,找到问题代码:

publicclassUserContext{privatestaticfinalThreadLocal<UserContext>CONTEXT=newThreadLocal<>();privatebyte[]avatarCache;// 用户头像缓存,可达数 MBprivateUseruser;publicstaticvoidset(UserContextctx){CONTEXT.set(ctx);}publicstaticUserContextget(){returnCONTEXT.get();}// 缺少 remove() 方法!}// 拦截器中设置上下文publicclassUserInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestreq,...){UserContext.set(buildContext(req));// 每次请求设置returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestreq,...){// 忘记调用 UserContext.remove()!// 线程池线程复用,UserContext 永远留在 ThreadLocalMap 中}}

每个 HTTP 请求在拦截器中设置 UserContext(含 MB 级头像缓存),但afterCompletion中没有 remove。线程池的 10 个线程不断复用,ThreadLocalMap 中积累了大量 UserContext。虽然 ThreadLocal 的 key 是弱引用,Full GC 时 key 被回收(Entry 的 key == null),但value(UserContext)是强引用,仍然被 Entry 持有,无法回收。

修复

publicclassUserContext{privatestaticfinalThreadLocal<UserContext>CONTEXT=newThreadLocal<>();publicstaticvoidset(UserContextctx){CONTEXT.set(ctx);}publicstaticUserContextget(){returnCONTEXT.get();}// 增加 remove 方法publicstaticvoidremove(){CONTEXT.remove();}}// 拦截器中清理publicclassUserInterceptorimplementsHandlerInterceptor{@OverridepublicvoidafterCompletion(HttpServletRequestreq,...){UserContext.remove();// 请求结束务必清理}}

修复后上线,老年代使用量恢复正常,Full GC 后内存回落到 20% 左右,不再持续增长。

实践要点

预防优于排查

  1. ThreadLocal 规范try-finally或拦截器中确保remove(),将 set 和 remove 配对使用。
  2. 资源关闭:统一使用try-with-resources,杜绝手动 close 遗漏。
  3. 缓存设限:任何缓存必须有容量上限和过期策略,禁止使用无限制的 HashMap 做缓存。
  4. 监听器注销:注册和注销成对出现,在@PreDestroy或销毁方法中统一注销。

排查技巧

  1. 对比多次 dump:在不同时间点导出两次堆转储,用 MAT 的Compare Basket对比对象增长,比单次 dump 更容易定位泄漏。
  2. 关注 Shallow vs Retained:Shallow Heap 是对象自身大小,Retained Heap 是对象被回收后释放的总大小。定位泄漏看 Retained Heap
  3. 排除弱/软引用:查看 GC Root 路径时勾选exclude weak/soft references,只关注强引用链。
  4. 线上长期监控:通过 Prometheus + JMX exporter 持续监控堆内存趋势,设置老年代持续增长的告警,在 OOM 前发现问题。

常见误区

  • “Java 有 GC 不会有内存泄漏”:GC 回收不可达对象,泄漏恰好是"不该可达但可达"的情况。
  • “Full GC 后内存一定会降”:有泄漏时 Full GC 后老年代不下降,这正是判断泄漏的依据。
  • “堆转储没有风险”:堆转储包含对象数据(可能含密码、个人信息),传输和存储需脱敏处理。

小结

  • 泄漏本质:对象已无用但被 GC Root 的强引用链持有,GC 无法回收,堆缓慢增长直至 OOM。
  • 五大常见场景:静态集合、ThreadLocal 未清理、监听器未注销、内部类持有外部类、资源未关闭。
  • 排查四步法jmap/jcmd导堆 → MAT 打开 → Dominator Tree 找大户(Retained Heap) → GC Root 路径定位引用链。
  • ThreadLocal 泄漏:key 是弱引用会被 GC 回收,但 value 是强引用,线程池中不 remove 会持续积累,是最隐蔽的泄漏之一。
  • 预防核心:任何"注册/设置"操作都要有对应的"注销/清理"操作,缓存必须有容量和过期限制。
返回列表