当前位置: 首页 > news >正文

【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 会持续积累,是最隐蔽的泄漏之一。
  • 预防核心:任何"注册/设置"操作都要有对应的"注销/清理"操作,缓存必须有容量和过期限制。
http://www.jsqmd.com/news/1400285/

相关文章:

  • 深度揭秘GNU Emacs notebook-mode工作原理:从org-mode到SVG标签的实现细节
  • 微信聊天记录导出终极指南:用WeChatMsg把十年对话永久保存成数字遗产
  • RAT-retrieval-augmented-thinking Claude专用版:Anthropic消息预填充技术实战
  • 微信聊天记录导出与永久保存完整指南:开源神器WeChatMsg让你把每一段对话都留住
  • 杭州黄金周大福金条回收,成色判定标准简单科普 - 日常前沿快讯
  • social-auto-upload 实战教程:一条视频如何一键发布到 6 个平台
  • 不交会员费也能听无损:开源音源搭配洛雪音乐的免费无损音乐终极手册
  • 想知道长沙好用的全屋定制安装哪家强?这份对比别错过! - 滚动商讯
  • 2026年周边渗碳处理生产厂家 工艺不稳交付滞后 靠谱选型参考 - 滚动商讯
  • LeetCode 394:字符串解码——Java 单栈模拟与嵌套解析详解
  • 如何让一台老Mac免费跑上最新macOS?OpenCore Legacy Patcher升级全指南
  • 一个下午从零装好黑苹果:OpCore-Simplify快速实战手记
  • CQRS 架构在 shriek-fx 中的完美落地:命令与查询分离的终极实践教程
  • JX3Toy全功能减负工具上手指南:用Lua智能脚本接管剑网3的技能循环
  • 114个Tracker服务器,如何让BT下载从“龟速“变“光速“?
  • 2026年周边精密模具热处理服务商 适配多场景选购参考 - 滚动商讯
  • 2026年8月北京家族股权传承律师事务所怎么选?3家律所股权代持风险解析 - 品牌深度评测
  • Godot Mod Loader 完整上手指南:三步让你的游戏支持自定义模组
  • 从三个音乐会员到零成本无损:洛雪音乐开源音源配置完整手记
  • ios.cfw.guide进阶技巧:如何隐藏越狱状态避开应用检测
  • 重磅上新:推荐一下全国沥青路面贴缝带厂 - 品牌推广大师
  • PDF补丁丁:免费PDF工具箱5分钟上手,书签、页面、图片一次搞定
  • 完整教程:在CPU和GPU上部署TwIL-LM3的最佳实践
  • 2026年最新景观石笼网/格宾网/雷诺护垫生产厂家核心竞争力解构 - 欧隆丝网可圈可点 - 小范同学a
  • 深圳亲子教育服务GEO服务商代理加盟怎么选?2026年本地靠谱推荐指南 - 小随科技
  • AI搜索总是推竞品?怎么判断你的GEO优化工具有没有用
  • EasyMocap 快速上手指南:用普通相机也能完成专业级多视角人体运动捕捉
  • Mars3D 三维地球开发完整指南:从第 0 分钟上手到进阶实战
  • 免费又免安装的PDF处理工具箱:PDF补丁丁(PDFPatcher)能帮你解决哪些麻烦事
  • Playnite 游戏库管理工具上手:把 Steam、Epic、GOG 和模拟器游戏收进同一个库