十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java开发中的内存泄漏排查思路与实战案例

Java开发中的内存泄漏排查思路与实战案例 内存泄漏在Java世界里从来不是一道显性的报错而是一场缓慢的失血。当GC日志显示堆占用曲线像台阶一样只升不降当应用在运行数天后从秒级响应退化到持续Full GC你才会意识到那些看不见的对象引用已经像野草一样缠住了新生代和老年代。真正的排查不是背诵八股文而是沿着引用链一寸一寸地挖出那个本该被回收却始终存活的对象。看似“被释放”的内存其实还攥在手里很多开发者以为只要把对象置为null或者让局部变量走出作用域内存就会安全归还。这种直觉在绝大多数情况下成立但偏偏在集合、缓存、线程池、类加载器这些“容器型”结构中失效。一个最经典的场景是静态集合持有短生命周期对象有人为了方便把用户Session塞进一个static HashMap却忘了在会话销毁时remove。这个Map自己永远不会被回收于是它里面的所有键值对都成了老年代的钉子户。你去看堆dump能发现成千上万个看似无关的对象被同一个Map的Entry引用着根节点是一条静态字段。还有一种隐蔽的形态出现在ThreadLocal的使用中。ThreadLocalMap的生命周期依附于Thread而线程池里的线程是复用的不会退出。如果你在请求处理中往ThreadLocal里写入了数据又没有调用remove那么下一次请求复用这个线程时上一次的数据仍然挂在线程上。更严重的是ThreadLocalMap的Entry是弱引用key、强引用value一旦value指向了一个重量级对象比如数据库连接、业务上下文即使key已经被GC回收value依然被强引用链钉死。线程池不死ThreadLocal里的残留对象就永不超生。如果抱着“内存泄漏就是对象太多”的想法去排查你可能会试图调大堆内存但那只会把失血变成一次更漫长的崩溃。真正有效的做法是先承认泄漏点一定藏在一个不经意的“强引用”里然后借助工具把每一个存活对象的GC根路径画出来。从GC日志里嗅出异常的信号排查的第一步从来不是马上抓dump而是先观察现象。打开GC日志如果发现老年代占用在每次Full GC后并没有回落到接近基线水位而是呈锯齿状缓慢爬升同时Full GC的频率越来越高那么基本可以判定存在持续的对象堆积。另一个信号是堆内存使用率即使在高并发低谷期也降不下来——正常情况下Minor GC过后新生代应该有明显腾空而老年代应该保持稳定。如果老年代在低峰期的占用依然高于启动初期的两倍以上先别调堆去找谁在持有它们。接下来需要确认是“真正泄漏”还是“内存增长”。两者在GC日志上的区别是内存增长往往随着流量波动而起伏高峰期上去低谷期能恢复真正的泄漏则是“只升不降”的单调趋势。为了让特征更明显你可以故意让系统在低负载下运行一段时间连续做几次Full GC然后比较堆占用。如果Full GC之后老年代占用依然纹丝不动地维持在高位那泄漏就实锤了。拿到这个判断后不要急着上去抓dump因为大数据dump在复杂生产环境中往往超过几个GB分析起来极其痛苦。更优雅的姿势是先利用jstat实时观测各区间的变化确认哪个区域在持续膨胀。比如jstat -gcutil pid 1000每一秒打印一次Eden、Survivor、Old区的使用比例。如果你看到Old区不断上升而Eden区上下波动正常说明大量长期存活对象正从新生代晋升到老年代但这些对象本不该长寿。结合GC日志中的晋升耗时和暂停时间能进一步锁定“晋升潮”发生的时段再回去追溯那个时段内到底处理了什么类型的业务请求。抓dump要讲究时机和姿势抓heap dump有几种方式各自适用不同场景。如果进程还活着并且JVM还没完全卡死建议用jmap -dump:live,formatb,fileheap.hprof pid。其中的-live参数会先触发一次Full GC再dump存活对象——这样一来临时垃圾都被过滤掉剩下的都是“真正存活”的对象分析时噪声更小。但要记住使用live模式会触发一次完整的STW暂停在高峰期操作极有可能引起线上告警所以最好安排在流量低峰或者维护窗口。另一种思路是用jcmd GC.heap_dump代替jmap因为jcmd不经过JDI接口在某些高版本JVM上更稳定。如果你怀疑泄漏与堆外内存或DirectBuffer有关还得额外抓NMTNative Memory Tracking数据先启动时加-XX:NativeMemoryTrackingsummary然后通过jcmd pid VM.native_memory对比运行前后的差值。拿到dump后分析工具首选Eclipse MAT。打开一个几个GB的hprof文件时你第一眼看到的是Histogram但别急着点类名排列。最关键的视图是“Histogram - List objects - with outgoing references”它能展示某个类的所有实例以及它们各自引用了什么。比如你发现一个DTO对象有100万个实例每个实例都指向一个业务Service这明显不正常——因为Service应该是单例不该被DTO持有。顺着outgoing reference往下钻你会看到一条引用链GC根 - 线程对象 - ThreadLocalMap - Entry - DTO。至此元凶浮出水面。一个真实的内存泄漏排查记录以下案例来自一个保险平台的核保服务现象是每运行约36小时接口平均响应延迟从30ms恶化到3秒Full GC从一天两次暴增到每小时15次。首先用jstat观察发现老年代使用率从启动时的800MB持续攀升即使深夜无人使用也保持每小时200MB的速度增加。值班团队一度认为是规则引擎加载的费率表太多但关闭了一部分规则后攀升趋势并未改变。通过MAT分析live dump发现一个名为UnderwritingContext的类占据了堆内存的42%实例数量高达19万。它内部有一个MapString,Object attributes这个Map的键是保单号值是庞大的核保因子对象。正常情况下一个保单处理完这个Context就应该被丢弃。为什么会积攒这么多查看该实例的GC根引用MAT显示每个UnderwritingContext都被一个java.lang.ThreadLocal所引用而Thread的引用链指向了一个名为threadPool-rule-engine-3的线程。可以断定代码在规则引擎的执行线程里用ThreadLocal缓存了Context来避免重复传参但在任务结束时的finally块中只清空了业务主流程里显式持有的一个引用却忘了执行ThreadLocal对象的remove方法。由于线程池复用了核心线程每个线程的ThreadLocalMap中始终残留上一次任务塞进去的Context而ThreadLocalMap的value持有强引用于是每处理一单内存里就多一粒永存种子。修复方式很简单在finally块里调用contextThreadLocal.remove()而不是仅仅设置为null。注意先remove再置null才是正确姿势因为remove会同时清理ThreadLocalMap的Entry而置null只是断开了你手上的引用线程内部还持有它。上线后观察48小时老年代曲线变得平滑Full GC回归正常。那类你永远猜不到的泄漏类加载器与字符串驻留不是所有内存泄漏都那么“符合直觉”。有一种高难度的泄漏发生在应用服务器或热部署场景下每一次重新部署JVM都会创建一个新的类加载器而如果你不慎让旧加载器加载的对象被全局静态变量引用那整个类加载器连同它加载的所有类就永远无法被卸载。用MAT排查这类问题时类名会成为最显眼的线索——你会看到同一份业务类的全限定名出现了成百上千个不同版本每个版本后面带着不同的ClassLoader地址。如果你还同时使用了反射/字节码增强CGLib或ASM动态生成的类这些东西又反过来引用了触发定义它的那个类加载器泄漏就会滚雪球一样越滚越大。另一个隐蔽场景是String.intern()的滥用。JDK 8及以后字符串常量池保存在堆内。如果业务代码对无穷多的UUID、订单号或长文本执行了intern()这些字符串就进入常量池并永远不会被GC回收。一次查询误用了intern就可能让堆在一天内多出数GB无法回收的数据。排查这种问题时MAT的Histogram里String数量异常庞大且每个String的逻辑值五花八门但没有任何非String对象引用它们——因为它们被常量池这个内置数据结构所持有。针对类加载器泄漏建议直接用jmap -clstats pid打印出所有类加载器的存活数量、加载类数量如果发现某个第三方插件的加载器活跃对象持续增长而业务不增长就重点看这个加载器。针对intern泄漏没有捷径只能通过Code Cache的异常膨胀反推或者通过jcmd pid VM.stringtable查看字符串表的统计观察数量是否单调递增。如何把排查变成一种条件反射如果你问十年经验的JVM调优专家内存泄漏排查有没有固定的方法论他多半会告诉你真正重要的是形成一套“怀疑顺序”。拿到一个可疑的堆形态先问三个问题有没有大量生命周期应该很短的对象晋升到了老年代谁在引用它们引用者的期望生命周期是多长第一个问题让GC日志和jstat回答第二个问题让MAT回答第三个问题则需要你自己梳理业务代码逻辑。排查时千万不要被业务复杂性牵着走。看到一个对象实例数很多就以为是业务量本身过大——那只是表象。内存泄漏与高并发本质区别在于高并发下对象虽多但年轻代可以回收老年代曲线保持水平泄漏时老年代上升曲线跟业务流量无关。你可以做一个简单压力实验用固定QPS打半小时半小时后停止流量等三次Full GC后看老年代是否回落到实验前水平。回落了就不是泄漏不回落再看dump。如果你使用的框架是Spring Boot还要额外检查CGLIB生成的代理类数量以及Async线程池中的提交队列是否无界。一个无界的线程池队列本身就是一种结构性内存泄漏因为所有排队中的Callable和Runnable都持有完整业务对象图。这种情况下dump不会给你任何指向明确的GC根你看到的是大量Task对象排成一列。修复方式不是换GC而是改造成有界队列并设置合理的拒绝策略。最后别忘了为你的诊断动作留痕。在排除问题后建议用Arthas或Byteman在线上临时对某个类的getter方法做一次耗时采样通过观察调用链上对象创建频次来验证修复效果。真正的内存泄漏排查不是一次冒险而是一场系统性考证你必须交替使用工具、日志和代码推理直到引用链上的每一个环都有了确切的解释。每当拿到一个看起来不可能的存活对象不要急着怀疑JVM的GC实现先问自己它到底被谁攥着而答案往往就藏在你某个习以为常的静态字段或线程局部变量中。
返回列表