尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java内存泄漏排查指南:从GC原理到MAT实战

Java内存泄漏排查指南:从GC原理到MAT实战 1. 内存泄漏到底是什么一次GC之后的“赖着不走”做Java开发很多人第一反应是“Java不是有GC吗怎么还会内存泄漏”。说实话这个反应我太熟悉了因为我在面试候选人的时候十个里有八个都会先问出这个问题。但GC解决的是“该回收的能回收”它管不了“本该回收却被某些引用链牢牢拽着”的对象。这就像小区的垃圾分类保洁员每天会定时清运GC但如果你把垃圾绑在自己腰上走到哪儿带到哪儿强引用保洁员再勤快也不可能从你身上把垃圾拿走。内存泄漏的本质就是有些对象已经完成了它的使命按理说该被回收腾出空间但因为还有从GC Roots出发的引用链能到达它JVM只能判定它“还活着”于是一个个本该退休的对象就这么赖在堆内存里。日积月累堆空间被这些“僵尸对象”越占越多直到触发了那句让所有Java程序员头皮发麻的java.lang.OutOfMemoryError: Java heap space。这里要先区分两个特别容易混淆的概念内存泄漏Memory Leak和内存溢出Memory Overflow。内存泄漏是“该释放的没释放”是内存被无意义占用内存溢出是“需要的内存超过能用的内存”是空间不够。两者关系是长期的内存泄漏会不断蚕食堆空间最终引发内存溢出但内存溢出也可能只是一次流量高峰、一个超大List导致的跟泄漏没关系。排查的时候如果一上来就认定是泄漏方向就容易跑偏。那么怎么判断一个应用是不是真的在泄漏我一般看三个信号内存监控曲线持续上升触发Full GC之后也只是短暂回落随后继续爬升整体呈锯齿状上升趋势。老年代Old Gen占用率居高不下GC日志里Full GC频繁但回收效果很差回收后占用率仍在90%以上。压测或者长时间运行后应用响应变慢最终抛OutOfMemoryError重启后恢复但过一段时间又复发。理论上Java的泄漏点是有规律可循的逃不出几个典型场景。下面我从最常见的“隐形持有者”讲起再讲偏冷门但线上更容易踩雷的进阶场景。2. Java里最常见的三类“隐形持有者”2.1 静态集合类生命周期错配的典型如果说要排一个Java内存泄漏Top 1静态集合类当之无愧。这类泄漏的原理非常朴实static修饰的字段属于类生命周期和JVM进程一致而存进集合里的业务对象生命周期可能只有一次请求、一次会话。当这两个生命周期错配的对象被绑到一起短命的对象就只能陪着长命的集合一起耗到进程结束。我一个真实经历有个老项目里有一段缓存用户信息的代码写的是private static MapString, UserInfo userCache new HashMap();每次用户登录就往里put一条。当时逻辑很简单用户量也不大运行了半年都没出事。后来接入了一个定时任务系统凌晨会批量模拟登录几千个账号不到一个月每晚任务跑到一半就OOM。就是因为这个userCache里的key永远在增长没有任何清理机制。这类泄漏的隐蔽性在于它不会立刻爆雷而是像温水煮青蛙一样等你的业务量涨到某个临界点才突然爆发。排查的时候还要注意一种衍生的坑如果集合里存的对象又引用了其他大对象那泄漏的量会成倍放大。预防手段其实很粗暴能用WeakHashMap、ConcurrentHashMap搭配过期策略的就不要用原生HashMap当缓存。真要手动维护缓存务必配套容量上限和淘汰策略。涉及周期任务、批量操作使用前后检查集合的size必要时执行clear()。2.2 非静态内部类那个被忽略的外层this非静态内部类是另一个高频泄漏点而且比静态集合更“阴”。它阴在语法上你压根看不出来它持有了外部类的引用。Java为了保证非静态内部类能访问外部类的成员变量和方法编译器在生成字节码时会给内部类添加一个this$0字段指向外部类的实例。这个引用是隐式的、强引用而且你看代码的时候根本感知不到。举一个我实际处理过的案例。有一个UI工具类里面定义了一个非静态的内部类TaskHandler这个内部类被提交到了一个全局线程池执行周期性任务。表面上看TaskHandler对象自身很小一个内部类能占多少内存但问题在于每创建一个TaskHandler实例它都会顺手拽住创建它的外部类实例。如果外部类实例内部又持有一大堆缓存数据、数据库连接池、IO流那这一个内部类对象导致的间接泄漏可能就是几十MB甚至几百MB。怎么判断自己的代码有没有这个问题一个很实用的排查思路用javap -c反编译内部类字节码看构造函数里是不是多了一个this$0赋值操作。或者直接在IDE里用调试模式查看内部类实例的字段列表里有没有指向外部类的引用。如果你发现一个业务对象的生命周期根本不需要依赖外部类直接用static修饰内部类问题立刻消失。2.3 监听器、回调与缓存注册容易注销难监听器和回调在GUI开发、事件驱动架构里非常常见。这套机制本身就是“让别人持有你”的代名词你把一个监听器对象注册到某个主题Subject上主题就会在自己的监听器列表里保存这个对象的引用。如果主题是长生命周期的而你注册进去的监听器是短生命周期的比如某个Activity、某个页面、某个Session那注销这一步如果忘了做或者没做干净泄漏就悄然滋生。我之前看过一个项目的代码Activity在onCreate里注册了一个LocationListener但onDestroy里漏掉了反注册。每次用户进入又退出地图页面系统里就多一个监听器携带着整个Activity的引用。用户来回操作几十次之后内存里堆了几十个Activity实例直接OOM。这种问题定位起来还不难Memory Analyzer里一看Activity实例数量就知道。难的是有些业务代码里监听器的注册和注销分散在不同模块排查链路很长。缓存的道理也一样。很多项目引入Caffeine、Guava Cache之后以为万事大吉但有些同学图省事直接拿HashMap当缓存用又没有容量上限和过期时间存进去的对象永远出不来。这其实和第一类“静态集合”的问题是同源的缓存的使用者必须明确一个问题——缓存里的对象到底该活多久针对监听器和缓存我的经验是立三条规矩注册监听器的地方务必成对出现注销逻辑要么在同一方法块里try-finally要么在对应的销毁生命周期里调用。缓存组件必须配置最大容量以及过期策略禁止裸用HashMap做生产缓存。定期用dump文件检查那些“看起来不该活这么久”的业务对象数量从数据上反推代码问题。3. 进阶场景容易踩雷的“冷门泄漏点”如果说上一章的问题属于“新手村任务”那下面这几个场景更像是“隐藏Boss”。它们不会出现在教科书开篇但在线上环境里踩中一个就是大事故。3.1 ThreadLocal的完美陷阱弱引用也挡不住的value泄漏ThreadLocal原本是解决线程内变量共享问题的利器但它在“线程池复用线程”的场景下是公认的泄漏重灾区。要讲清楚这个问题得先看一眼ThreadLocalMap的设计它的Entry继承自WeakReferenceThreadLocal?也就是说Entry的keyThreadLocal对象本身是弱引用而value是强引用。问题就出在这个“key弱、value强”的不对称设计上。当某个ThreadLocal对象不再被外部强引用比如业务方法执行完局部变量出栈GC之后key就会被回收但Entry并没有被移除只是变成了一个key为null的脏Entry而value依然被线程强引用着。如果这个线程是普通线程线程死了Entry也一起没了但如果这个线程来自线程池线程长期存活那这些脏Entry就会一直赖在线程的ThreadLocalMap里。最典型的应用场景就是接口层用ThreadLocal存储用户上下文、TraceID每次请求进来set一次但请求结束忘了remove。在高并发线程池环境下跑个几天每个线程的ThreadLocalMap里堆积了成千上万个无法回收的value对象内存升高肉眼可见。我的建议非常简单粗暴凡是使用ThreadLocal的代码在finally块里必须显式调用remove()。另外有一个兜底的排查技巧dump完堆内存后搜索ThreadLocalMap$Entry的数量如果数量远超业务并发量基本就能锁定问题再把value对象的类型列出来直接就能定位到是哪个业务代码往里塞了东西。3.2 类加载器的长期“站岗”热部署场景的隐藏雷区这一类泄漏比ThreadLocal更隐蔽因为问题往往不在业务代码里而在框架层。它涉及的关键组件是类加载器ClassLoader。每个Java类在被加载时JVM会记录它是由哪个类加载器加载的这个关系本身也是一个从“类”到“类加载器”的强引用链。如果加载器的生命周期和进程一样长而它加载的类对象、静态字段又被业务代码长期引用那整个类元数据都无法卸载。最常见的触发场景是Java应用服务器的热部署。每次你修改代码触发自动重部署应用服务器会创建新的类加载器来加载新版本理论上旧类加载器应该被回收。但如果某个地方——比如全局缓存、静态字段、ThreadLocal——还持有旧类加载器加载出来的对象那整批旧的类定义、类静态变量、甚至被JIT编译后的代码都会跟着一起无法回收。热部署几十次之后PermGenJDK 7或MetaspaceJDK 8被占满。在实际排查中这类泄漏在堆dump里很难直接发现因为业务对象数量可能不大但类的数量极多。排查工具建议使用jmap -clstats pid查看类加载器统计信息看有没有大量相同包名的类被不同的类加载器重复加载。如果发现问题优先检查框架层是否有全局变量存储了旧版本对象引用。这块属于框架级问题普通业务开发能做的预防有限但至少要认识到热部署环境下的内存管理比常规运行环境要敏感得多。3.3 资源对象与线程池的“假关闭”这一类严格来说不算是“纯Java对象泄漏”但在实践里造成的后果和内存泄漏一模一样。典型代表是创建了数据库连接、IO流、Socket、Statement、ResultSet在使用完之后没有调用close()。这些资源对象背后是操作系统的原生内存和文件句柄如果一直不释放最终耗尽的是进程的本地内存和句柄数症状同样是崩溃。有一个细节值得单独提醒很多人在写finally块时会写connection.close()但忘了在关闭前处理中间的异常。一旦SQL执行抛异常后面的连接和IO流根本没机会走到close那一行。我见过一个定时任务因为某条SQL在特定数据下抛了异常连接池连接被持续创建又无法归还最后把整个数据库的连接数打满。后来改成try-with-resources写法Java 7自动关闭之后再也没出过事。线程池的管理同理。用Executors.newFixedThreadPool()创建线程池而不是手动指定参数本身就埋了一个隐患默认的LinkedBlockingQueue是无界队列任务提交速度超过处理速度时任务会在队列里无限积压导致内存暴涨。这不是线程池对象本身泄漏但只要你没在应用关闭时显式调用shutdown()线程池对象和它的队列就会一直存活。因此涉及线程池的地方应用关闭钩子Shutdown Hook里一定要有池子的优雅关闭逻辑。我把这几个场景按“危害程度”和“隐蔽程度”做了一个对照方便大家按图索骥泄漏场景危害程度隐蔽程度典型触发业务静态集合持有高低缓存、名单、全局数据非静态内部类中高UI回调、线程任务内部类监听器未注销中中事件监听、生命周期回调ThreadLocal未移除高高请求上下文、TraceID类加载器无法卸载高很高热部署、插件化框架资源未关闭高低数据库操作、IO读写4. 用一套方法论在项目里定位内存泄漏4.1 从现象到假设先分清楚方向很多同学遇到OOM第一反应是打开Java代码逐行找。我的建议恰恰相反——先别看代码先看现象。内存问题分三类处理路径完全不同一次性OOM可能是某次操作加载了超大集合救急手段是调大堆内存-Xmx但更要紧的是找到谁在一次操作里加载了这么多数据。持续缓慢增长这就是典型的“慢性泄漏”适合用Heap Dump分析逐步排查。周期性突刺先判断是不是定时任务批量处理、大促流量等正常波动如果和业务高峰无关则按泄漏处理。拿我印象很深的一个项目举例。那是一个Spring Boot的Web服务测试环境一切正常但上线后内存以每天5%的速度增长跑到大约第20天准点OOM然后被K8s重启第二天又回到5%周而复始。一开始我先入为主认为是缓存配置问题查了Redis、本地缓存都没毛病。后来我干脆写了一个脚本每天凌晨采样一次堆内存使用量连续采了7天数据发现增长曲线几乎是一条完美直线——这说明对象只进不出铁定是某处引用了没有释放。4.2 物理取证Heap Dump三大分析思路确认是泄漏之后核心工作就是拿到一份Heap Dump然后用工具分析。我常用的采集方式有两个轻量场景jmap -dump:live,formatb,fileheap.bin pid。加了live参数会先触发一次Full GC排除掉可回收的“临时对象”留下的都是“真正活着但又没那么必要”的东西这正是我们要找的。压测场景在压测脚本里触发OOM加上JVM参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/logsJVM抛OOM之前自动生成dump文件。采集完Dump文件以后我习惯用Eclipse MATMemory Analyzer Tool来解剖它在分析大堆文件时的稳定性和图表直观度我个人觉得很顺手。沿着这个流程走第一步打开MAT的Dominator Tree按照Retained Heap降序排列。Retained Heap代表“如果这个对象被回收能释放多少内存”数值最大的那几个基本就是泄漏源头或者离源头很近。第二步找到Retained Heap最大的对象后右键选择Path to GC Roots - exclude all phantom/weak/soft etc. references。这一步会展示从GC Root到该对象的完整引用链。去掉弱引用、软引用之后还能到达GC Roots说明就是强引用链拽住了这个对象。引用链上每一层都是嫌疑对象逐层展开通常在几层之内就能看到业务代码里的类名。第三步切到Leak Suspects视图。MAT会自动生成“泄漏嫌疑报告”它会标出哪些对象占用了大量堆内存并给出可能的根因描述。虽然MAT的自动报告偶尔会指错方向比如把正常的大集合误判成泄漏但它能帮你快速圈定搜索范围。4.3 一个基于MAT的完整排查案例回到前面那个“每天5%”的案例。我拿到第7天的dump文件后打开Dominator Tree排在第一位的是一个java.util.concurrent.ConcurrentHashMapRetained Heap占了整个堆的70%。顺着引用链往下点发现这个Map里存的全是com.example.trace.TraceRecord类型的对象。看到这个类名我立刻想起来了项目里有个AOP切面会在每个接口方法执行时用ThreadLocal记录一个TraceRecord包含请求路径、耗时、用户ID然后在切面的AfterReturning和AfterThrowing里打印日志。问题就出在代码只写了traceRecorder.set()但所有异常和返回路径上都没有remove()。线程池线程复用时每次请求的TraceRecord对象都残留在ThreadLocalMap里下一次请求又开始往里追加。一个TraceRecord对象本身不大但每天几百万次请求累积下来的量相当可观。修复方式就是一个在finally里加一行traceRecorder.remove()。我改完之后重新上线内存曲线从锯齿上升变成了一条平稳直线再也没有出现过周期性的OOM。这个案例再次印证了一个朴素的道理内存泄漏的核心不是“内存不够”而是“引用没断”。4.4 排查工具的选型与对比经常有同学问我排查Java内存泄漏用什么工具最好我的回答是别迷信某个所谓“神器”工具在不同场景下各有优劣按情况选工具适用场景上手难度优点短板jmap/jstat/jstack线上快速摸底低JDK自带无需额外安装信息维度有限偏统计无法深挖引用链VisualVM本地开发调试低可视化能实时看堆、GC、线程对大型远程生产环境不友好Eclipse MAT离线深度分析中Dominator Tree和引用链分析极强大文件分析吃内存需调大MAT自身堆JProfiler本地/压测全链路中高实时分配追踪能定位对象在哪个方法里被创建商业授权对团队有成本Arthas在线排查疑难杂症中高不重启进程可实时查看对象、反编译、监控方法不是专门的Heap分析工具需要组合使用JFRJava Flight Recorder生产环境长时间采样中低开销能记录对象分配、GC、锁等信息官方出品需要通过JMC解析分析门槛略高另外热词里提到了WinDbg能不能检测内存泄漏。这里顺便说清楚WinDbg是Windows平台的本地代码调试和转储分析工具主要用于C/C原生程序对Java的堆内存分析并不直接适用。Java侧的内存泄漏分析还是优先以JVM自带的工具和MAT这类JVM生态工具为准。对于线上环境我个人的组合方案是先用jstat -gcutil看GC和堆分区概貌再用jmap拿dump最后用MAT做深度分析。如果遇到那种“本地复现不了、线上重启就恢复”的问题就挂JFR做一段时间的连续采样拿到数据后一次性分析。5. 避免泄漏从源头建立防御习惯定位和修复固然重要但更高阶的能力是不让它发生。结合我多年的项目经验可以从下面四个层面来建立防线这也是我在带团队时坚持推广的实践。5.1 容器化部署时如何提前发现与兜底现在大家基本都在容器或者K8s环境里部署Java应用。容器环境下内存问题的破坏半径会比单机更大因为OOM导致容器被Kill重启不会像以前那样打出一份报错日志然后进程还在。所以需要提前把监控和自动取证能力部署好。我一般在每个Java容器里都固定加上这几行JVM参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/logs/heapdump.hprof -XX:ExitOnOutOfMemoryError解释一下为什么是ExitOnOutOfMemoryError与其让一个内存已经不健康的进程继续处理请求不如直接退出让编排系统拉起重启同时保留dump文件等着分析。很多人只加HeapDumpOnOutOfMemoryError不加ExitOnOutOfMemoryError结果OOM后进程苟延残喘Dump出来的文件也不完整。与之配套的另一个习惯是设置JVM容器的内存上限明确配置-Xmx。如果你不给堆设置上限容器内存上限又小于JVM默认的最大堆大小通常是物理内存的四分之一OOM发生时会先被cgroup干掉导致看不到任何Java层面的报错排查直接变成地狱模式。务必要保证三者的关系清晰容器内存上限 堆内存 元空间 线程栈 直接内存 JVM overhead。具体到实践容器内存4G时-Xmx一般设置为2G至2.5G留出足够的非堆内存空间。5.2 代码层的防御性编程清单代码层面我给自己和团队列了一份“内存安全检查清单”每次Code Review对照执行。这里分享几个最重要、也是踩坑概率最高的条目第一静态字段和集合的使用必须能回答一个问题“这个数据什么时候可以删”答不上来的要么加生命周期管理要么换弱引用容器。第二所有非静态内部类除非明确需要访问外部类成员否则一律用static修饰。尤其线程任务、回调、事件处理器这些经常作为参数传递的类外部类this引用很容易在不知不觉中被带出去。第三资源对象强制使用try-with-resources。Java 7之后这是语法级支持的没有任何理由再写手动close。如果团队还在用Java 6也必须在finally里逐个关闭而且关闭动作本身要做空指针判断。第四往集合里放对象之前问一句“它会不会被remove”。如果答案是“不会”那这就是一个潜在的泄漏点。第五使用WeakReference、SoftReference时需要理解它们的GC行为差异。WeakReference在下一次GC时就会回收适合缓存key等场景SoftReference会对内存敏感在堆内存充足时可以长期存活内存紧张时回收适合做内存缓存。但不要指望弱引用解决所有问题一个对象如果本身被强引用链持有再给它包一层弱引用也没用。5.3 面试中如何把这个问题讲出深度既然热词里大量出现“Java面试”和“八股文”说明很多人是为了面试准备这个问题。那我也顺便聊聊这个话题在面试中的呈现。如果面试官问“Java哪些情况会导致内存泄漏”很多候选人回答到“静态集合”“未关闭连接”就停了。这个回答不算错但只能拿个及格分。有经验的候选人会从底层机制讲起先讲GC Roots与引用链的概念再说“泄漏的本质是强引用链无法断开”然后用这个框架来解释各个场景为什么泄漏。比如回答ThreadLocal泄漏时如果能讲出“Entry的key是WeakReference、value是强引用、线程池线程复用”这三层就已经说明了你是真正理解原理而不是背答案。再进一步如果还能主动提到排查方法论——先看GC日志确认老年代持续增长再拿Heap Dump用MAT的Dominator Tree结合Path to GC Roots定位引用链——那基本上这个问题的回答就完整了。面试官一听就知道你是实际处理过线上问题的人而不是只会背八股文。八股文记的是骨架真正拉开差距的是骨架之上的血肉和实战细节。6. 写在最后的几点实操体会做了这么多年Java开发和性能优化我的最大感受是内存泄漏问题永远不会消失它只会随着技术栈的演进换一副面孔出现。早年的内存泄漏集中在字符串截断、HashMap滥用、重写finalize这些老场景现在的主流战场转移到了线程池复用、热部署类加载器、框架内部缓存和异步链路。但无论场景怎么变分析思路永远是那套找到不该被强引用持有的对象断开那条引用链。最后分享一个我常跟团队说的小技巧每次修完一个内存泄漏问题不要急着关工单顺手做两件事。第一在技术文档里记录下这次的泄漏类型、定位链路和修复方案形成一个“内存泄漏案例库”下次再遇到同类问题直接按图索骥。第二在代码里加一条针对性的监控指标——比如当前ThreadLocalMap里的脏Entry数量、某个静态集合的size——一旦指标超过阈值就报警。内存泄漏最可怕的地方是发现得晚等你肉眼看到曲线异常的时候它往往已经跑了很久。而这两件顺手的事恰恰能帮你把发现时间点往前挪一大截。
返回列表