ARTICLE DETAIL

资讯详情

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

一次 Java 内存泄漏排查过程:从告警到定位修复

一次 Java 内存泄漏排查过程:从告警到定位修复 摘要本文以一次线上 Java 服务频繁 Full GC、内存占用持续升高的真实事故为主线系统讲解 Java 内存泄漏的概念、JVM 内存模型、常见泄漏场景、排查工具链jstat、jmap、jstack、MAT、Arthas 等以及完整的分析定位流程并结合静态集合、ThreadLocal、连接未关闭、监听器未注销等典型案例给出修复方案与工程化预防规范。文章适合 Java 后端开发、SRE 以及需要处理线上 JVM 稳定性问题的同学阅读。一、事故背景一个周三凌晨的告警周三凌晨两点监控群里弹出一条告警支付网关服务 memory_used 达到容器内存上限的 85%且持续上升。紧接着第二条告警Full GC 次数在最近 10 分钟内达到 37 次GC 停顿时间显著拉长。凌晨值班的同学先做了一轮应急处理临时扩容、重启服务。重启之后指标短暂回落半小时后内存曲线又像爬山一样重新涨了上去。这说明问题不是偶发的流量尖峰而是典型的内存泄漏随着请求不断处理本应被垃圾回收器回收的对象一直没有被回收堆内存被持续占用最终把系统推向频繁 Full GC 甚至 OutOfMemoryError 的深渊。本文要还原的就是这次事故完整的排查思路、工具使用与修复过程。在开始之前我们需要先花一点篇幅把「内存泄漏」这个概念说清楚。很多人一听到内存泄漏会理所当然地想到 C 语言里的 malloc 之后忘记 free但在 Java 这种带垃圾回收的语言里内存泄漏的表现形式并不一样。二、Java 内存泄漏到底是什么2.1 先纠正一个常见的误解在 C/C 中内存泄漏指分配出去的内存没有释放进程再也无法使用这块内存。而在 Java 中JVM 自带垃圾回收器Garbage CollectorGC正常情况下不再被引用的对象会被自动回收。因此 Java 的内存泄漏并不是「申请了内存忘了释放」这么直白而表现为某些对象在逻辑上已经不再被使用但由于存在一条或多条仍然有效的引用链垃圾回收器认为它们仍然存活因此永远无法回收。换句话说Java 内存泄漏的本质是对象的生命周期被意外延长。这些对象就像被遗忘在缓存里的快递仓库管理员GC永远不会去清空它们仓库堆内存只会越来越满。2.2 内存泄漏与内存溢出的关系这是两个经常被混淆的概念简单区分如下内存泄漏Memory Leak是造成内存浪费的原因之一表现为无用对象无法被回收堆占用随时间增长。内存溢出OutOfMemoryErrorOOM是最终的表现结果当堆内存或元空间等区域被用完时JVM 抛出的错误。两者关系可以理解为内存泄漏是「病因」内存溢出是「症状」。当然内存溢出也可能由其他原因造成比如单个对象过大、堆本身配置过小、元空间不足等但持续上涨后稳定不回落、重启后复现的内存问题首要怀疑方向就是内存泄漏。2.3 什么样的曲线像内存泄漏观察内存曲线是判断泄漏的首要手段。典型的内存泄漏曲线有几个特征锯齿状上升每次 Young GC 之后内存会回落到一个「波谷」但这个波谷的高度一次比一次高整体呈阶梯式上涨。周期性 Full GC 但无法下降Full GC 的次数越来越多停顿越来越长但每次 GC 后释放的内存非常有限。重启可短期缓解但会复发服务重启后内存从低位开始但运行一段时间后又爬上去周期与业务量的关系并不完全对应。相反地如果内存曲线呈现健康的锯齿状波谷长期稳定在一条水平线上即使业务高峰期内存升高低谷也能降回来那就属于正常的对象创建与回收节奏不必过度紧张。三、JVM 内存模型与垃圾回收基础要真正看懂排查过程必须对 JVM 内存布局和垃圾回收机制有基本认识。这一节不求深入到底层源码但求建立一张清晰的「内存地图」。3.1 JVM 运行时数据区Java 虚拟机规范把运行时内存划分为多个区域对于排查内存泄漏我们重点关注三个区域堆Heap几乎所有对象实例和数组都在这里分配是内存泄漏的主战场。堆又被分代 GC 划分为新生代Young Generation和老年代Old Generation。元空间MetaspaceJDK 8 之后替代永久代存放类的元数据主要受-XX:MaxMetaspaceSize控制。类的元数据如果不合理地在运行时动态加载、卸载不掉的类也会造成元空间泄漏。线程栈Java Virtual Machine Stack / Native Method Stack每个线程私有存放栈帧中的局部变量、方法参数等。局部变量对堆中对象持有强引用方法调用结束后栈帧被销毁引用随之解除。其中堆内存是最常见的泄漏区域。需要注意的是分析堆时会用到可达性分析其起点被称为GC Roots包括栈帧中的局部变量、类的静态字段、常量池引用、JNI 引用、活跃线程等。只要对象从 GC Roots 可达它就会被视为存活。3.2 对象从生到死一个对象的生命周期大致如下使用new在 Eden 区分配内存。经历若干次 Minor GCYoung GC仍然存活的对象会被晋升到 Survivor 区再经过一定次数轮换后晋升到老年代。老年代空间不足时触发 Major GC / Full GC若不存活才被回收。内存泄漏对象由于始终被强引用无论经过多少轮 GC 都无法被回收。它们可能在年轻代时由于频繁复制而很快进入老年代随后长期驻留这也是为什么泄漏对象分析时常常能在老年代 Histogram 里看到它们占据大量空间。3.3 引用类型与泄漏的关系Java 提供四种引用理解它们对解决泄漏很有帮助强引用Strong Reference最常见的Object obj new Object()。只要强引用存在对象就不会被回收。大部分泄漏都是「过长的强引用链条」造成的。软引用Soft Reference内存不足时会被回收适合做缓存。软引用本身要配合对象生命周期理解不能无脑缓解泄漏。弱引用Weak Reference只要被 GC 发现无论内存是否充足都会被回收适合用于WeakHashMap、监听器等场景。虚引用Phantom Reference用于对象回收前的通知处理通常配合引用队列做清理工作。在修复部分我们会看到把缓存从强引用改为弱引用、软引用是常见手段但这只是「缓解」真正要做的是从根源上解除没有必要的引用。四、Java 常见内存泄漏场景盘点在动手排查之前先建立一份「嫌疑名单」能显著提升效率。下面是根据工程经验总结的高频泄漏场景几乎每一类都能在真实事故中找到对应案例。4.1 静态集合类的无界增长这是最经典、最容易被忽视的泄漏来源。比如把数据放入static HashMap、static List或static ConcurrentHashMap只进不出。只要服务不重启这些集合就永远可达收集的对象也随之永驻堆中。public class CacheHolder { private static final MapString, Object CACHE new HashMap(); public static void put(String key, Object value) { CACHE.put(key, value); // 只有 put没有 remove也没有容量上限 } }代码看起来无害但如果 key 是用户 id、订单号或请求流水号随着业务运转Map 会无限膨胀。就算每个对象很小日积月累也非常可怕。静态集合不是不能有但必须有容量上限、定期清理或过期策略。4.2 ThreadLocal 使用后未清理ThreadLocal在线程池场景下是一枚「定时炸弹」。ThreadLocal 的 value 是存放在线程自身的ThreadLocalMap里的key 是对 ThreadLocal 实例的弱引用value 是强引用。一旦 ThreadLocal 实例本身被回收、key 变成 nullvalue 却依然被线程的 Map 强引用除非显式remove()否则在复用线程的池中永远无法回收。private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public String format(Date date) { return DATE_FORMAT.get().format(date); // 用完没有 remove()线程归还线程池后 value 仍然存活 }如果线程池是固定大小且会被多个任务反复使用这些残留的 value 对象并非无限增长泄漏规模受线程数限制但如果 value 内部又指向其他大对象例如数据库连接、用户会话、大缓存实际占用的内存可远超预期。4.3 各种连接、流、文件未关闭数据库连接、Socket 连接、文件输入输出流、Zookeeper、Redis 客户端连接等资源如果只打开不关闭会造成两类问题一是系统资源泄漏文件描述符、端口、连接池打满二是这些连接对象本身及内部缓冲区长期驻留堆内存。public void query() throws SQLException { Connection conn dataSource.getConnection(); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(select * from orders); // 忘记 close长期运行连接池被耗尽conn/stmt/rs 对象再也无法回收 }应当使用try-with-resources语法保证资源可靠关闭。连接池本身通常会有超时回收机制但如果业务代码不归还回收也只是兜底且归还期间连接对象依然占用内存。4.4 内部类隐式持有外部类引用非静态内部类会隐式持有外部类实例的引用。这种引用关系容易被忽视一旦内部类实例的生命周期被拉长它所引用的外部类往往包含大字段、上下文对象、数据库连接等也无法回收。public class OrderService { private byte[] bigBuffer new byte[10 * 1024 * 1024]; public Runnable createTask() { return new Runnable() { // 匿名内部类持有 OrderService.this Override public void run() { System.out.println(bigBuffer.length); } }; } }如果这个Runnable被放入一个生命周期很长的线程池队列或调度器中而对应的OrderService本来早该销毁那么bigBuffer这种大对象就会被一直拽住。当内部类只是「借用」外部方法参数、不需要访问外部实例时可以声明为静态内部类或独立顶层类来切断隐式引用。4.5 监听器、回调注册后未注销事件驱动框架中注册监听器、观察者、回调后忘记移除会让发布者长期持有订阅者引用。比如 GUI 组件、MQ 消费者、配置中心监听、业务自定义事件总线都会出现这类问题。注册方生命周期短、被注册方生命周期长时注册方会一直存活这就是典型的「长生命周期持有短生命周期」反向引用。eventBus.register(orderCreatedListener); // 之后没有 unregister // eventBus 是单例长生命周期对象orderCreatedListener 却持有某个请求上下文或状态解决方案是提供对称的注销机制或者在监听器内部只依赖弱引用又或者在注册方生命周期结束的钩子里自动反注册。4.6 缓存过期策略缺失自行实现缓存时如果采用「纯内存、无淘汰、无过期」的策略本质上就是 4.1 静态集合泄漏的变种。即使使用Guava Cache、Caffeine这类成熟缓存也需要配置合理的maximumSize、expireAfterWrite、expireAfterAccess和弱引用策略。CacheString, User cache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .build(); // 没有容量上限和过期时间的内存缓存就是泄漏温床缓存是一把双刃剑提高性能的同时也承担了「对象滞留」的风险必须从生命周期上做闭环设计。4.7 单例模式持有过多状态单例对象本身是长生命周期对象如果单例内部维护了和业务请求相关的状态比如某个订单上下文、某个用户临时数据就会导致每个请求的状态都被单例积累下来。单例应尽量只存放「无状态的服务逻辑」和「真正需要全局共享的配置」绝不应该存放与单次业务处理相关的可变数据。public enum ReportCenter { INSTANCE; private final ListReport pendingReports new ArrayList(); public void add(Report report) { pendingReports.add(report); // 单例集合只增不减典型的泄漏写法 } }4.8 自定义类加载器与动态类生成的泄漏在使用热部署、动态代理、字节码增强、脚本引擎等技术的场景下如果反复创建自定义ClassLoader来加载新的类而这些 ClassLoader 因为类的静态字段或被缓存的对象引用而无法被卸载元空间会持续增长最终造成OutOfMemoryError: Metaspace。排查此类问题需要关注动态类的加载数量和 ClassLoader 的卸载情况。4.9 字符串常量的无节制使用JDK 7 之后字符串常量池移入堆中String.intern()如果被滥用可能让大量唯一字符串进入常量池。常量池中的字符串在类卸载前通常长期存活当被 intern 的字符串数量极大且内容极其多样比如拼接了用户 id、随机值的字符串时也会造成事实上的内存占用失控。应避免对动态生成的、生命周期本就短暂的高基数字符串调用intern()。五、排查工具链全景工欲善其事必先利其器。Java 生态的内存排查工具非常丰富按「生产环境轻量级 → 离线深度分析」的分类如下。5.1 JDK 自带命令行工具这些工具随 JDK 一同安装生产环境一般都能直接使用jps列出本地所有 Java 进程及其进程号类似 Linux 的 ps但只针对 JVM。jstat实时查看 JVM 的类加载、编译、GC、堆分区容量和使用情况是最常用的在线观测工具。jmap生成堆转储heap dump文件、查看堆的概要、直方图等。生产环境要慎用jmap:live可能触发较长停顿。jstack打印线程快照用于排查死锁、线程阻塞、长耗时线程常与内存问题结合使用。jcmd功能更全面的综合命令可以代替 jmap、jstack 完成大部分操作jcmd GC.heap_dump是推荐的堆转储方式。jinfo查看和调整 JVM 运行参数。常用命令示例jps -l jstat -gcutil 28645 1000 20 jstat -gccapacity 28645 jmap -histo:live 28645 | head -50 jcmd 28645 GC.heap_dump /data/dump/payment-gateway.hprof jstack 28645 /data/dump/thread-dump.txt5.2 Arthas生产环境在线诊断利器JDK 自带工具已经能解决大部分问题但在生产环境直接jmap有停顿风险而且不少容器出于安全考虑没有完整 JDK 工具或不允许远程登录。这里推荐Arthas它是阿里巴巴开源的 Java 诊断工具支持 attach 到运行中的 JVM 进程实时查看类加载、方法调用、对象占用等情况非常适合「边排查边观察」。dashboard整体概览线程、内存、GC 信息相当于一个轻量版监控面板。thread查看线程状态找出 CPU 占用高、阻塞或长期存活的线程。heapdump在线生成堆转储效果等价于jcmd GC.heap_dump。sc搜索类加载信息排查动态类过多导致的元空间泄漏。tt记录方法调用的参数与返回值定位业务逻辑中持有大对象的方法。Arthas 的优势是命令丰富、无需重启服务缺点是会带来少量性能开销排查完毕应及时执行stop不要长期挂载在生产进程上。5.3 MAT堆转储离线分析拿到.hprof文件后通常使用 Eclipse Memory AnalyzerMAT进行离线深度分析。以下四个功能几乎覆盖了 90% 的排查场景Leak Suspects Report自动给出内存泄漏嫌疑报告适合快速锁定方向尤其是第一次面对陌生堆文件时。Histogram按类聚合对象数量以及 Shallow Heap、Retained Heap快速找出占用最高的类。Dominator Tree以支配关系展示对象引用层级能直观看到某个根对象实际「拽住」了多少内存。Path to GC Roots从具体对象出发反向寻找它到 GC Roots 的引用链是定位泄漏根因的核心一步。分析大堆时要提前调整MemoryAnalyzer.ini中的-Xmx避免 MAT 自身内存不够。对于服务器上不便导出大文件的场景可以先用jmap -histo缩小范围再决定是否生成完整堆转储。5.4 可视化工具与其他辅助工具除命令行与 MAT 外还有VisualVM、JProfiler、Async Profiler、YourKit等工具。JProfiler 在复杂引用分析和实时堆遍历方面体验良好Async Profiler 适合采样 CPU 热点辅助分析「哪些方法在疯狂创建对象」。实际工作中建议采用「在线工具定位 离线堆分析确认」的组合方式。六、事故排查完整过程从现象到根因下面回到文章开头那场支付网关事故按照真实排查时序逐步还原。整个过程可以拆成五个阶段每一步都有明确的产出力图做到「从大范围监控逐步聚焦到具体代码」。6.1 第一阶段应急止血与现场保留告警出现后值班同学优先做两件事一是扩容降级把流量切到备用节点先保住服务可用性二是保存监控截图、GC 日志和关键配置快照。这里有一个重要原则在根因不清的情况下不要急着改代码或乱调 JVM 参数因为频繁的干预会破坏现场反而让后续排查更难。随后观察监控大盘内存曲线呈典型阶梯式上升每次 Young GC 后波谷一次比一次高Full GC 频率由平时的数小时一次变为每分钟多次单次停顿从几十毫秒拉长到数秒。这些现象已经强烈指向内存泄漏而不是单纯的业务流量高峰。6.2 第二阶段用 jstat 观察 GC 趋势进入容器后先用jps -l找到网关进程号再执行jstat -gcutil 28645 1000 20持续观测。重点关注三个信号OUOld Used每次 Full GC 后几乎不下降反而随时间缓慢上升说明老年代里存在长期滞留对象。FGC 与 FGCTFull GC 次数快速累积单次停顿持续拉长说明系统已经陷入「频繁回收却回收不掉」的状态。S0/S1 新生代区域回收表现正常可以排除「新生代对象分配速度过快」这类非泄漏因素。这一步的意义在于把问题从笼统的「内存高」缩小为「老年代对象无法回收」进一步指向泄漏。6.3 第三阶段jmap 直方图锁定嫌疑对象确认泄漏方向后执行jmap -histo:live 28645 | head -80查看存活对象直方图。排除正常业务对象后三个异常点浮出水面byte[]数量极大总占用明显异常。自定义类com.pay.context.OrderContext的对象数量与已经结束的请求量级严重不符请求结束了对象仍然存活。网关固定线程池的每个工作线程中ThreadLocalMap都残留了大量OrderContext。直方图只是快照能回答「谁多、谁大」却回答不了「为什么不回收」。因此必须进一步生成堆转储做引用链分析。6.4 第四阶段堆转储与 MAT 支配树分析避开业务高峰后用jcmd 28645 GC.heap_dump /data/dump/payment-gateway.hprof生成堆转储下载到本地后用 MAT 打开。先运行 Leak Suspects Report报告直接提示com.pay.context.OrderContext占据约三分之二的堆空间与直方图结论一致。随后打开 Dominator Tree 展开该类看到的引用结构大致如下根节点是java.lang.Thread的某个工作线程。经由ThreadLocalMap的 Entry 关联到一个 value即com.pay.context.OrderContext。OrderContext内部又持有订单明细、报文缓存、加签数据等大对象。支配树显示这些对象被同一批固定线程「钉死」集中分布在网关线程池的工作线程上。6.5 第五阶段Path to GC Roots 定位根因在 MAT 中选中一个OrderContext实例执行 Path to GC Roots得到如下引用链线索java.lang.Thread - threadLocals : ThreadLocal.ThreadLocalMap - table : ThreadLocal.ThreadLocalMap.Entry[] - value : com.pay.context.OrderContext继续结合线程名和业务代码排查最终确认根因网关在渠道报文解析时把OrderContext放进一个ThreadLocal链路处理完成后只执行了set没有执行remove。由于使用的是固定大小复用线程池下一笔请求虽然会覆盖该线程的 value但低峰期大量线程长时间空闲上一笔交易的完整上下文便一直残留高峰期不同渠道还可能产生多个上下文进一步放大内存占用。七、根因确认与修复方案这起事故的根因可以总结为一句话ThreadLocal 在请求结束后未清理配合线程池复用导致 OrderContext 及其关联大对象的生命周期被意外延长为一个线程池线程的运行周期。单条引用链看起来非常直观但在高并发高峰期被成倍放大最终表现为老年代持续上涨、Full GC 频繁。7.1 立即可上线的修复最直接的修复方式是在网关统一过滤器或拦截器的finally块中显式调用remove()确保所有退出路径都能清理public class OrderContextHolder { private static final ThreadLocalOrderContext CONTEXT new ThreadLocal(); public static void set(OrderContext ctx) { CONTEXT.set(ctx); } public static OrderContext get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); // 必须在请求结束的 finally 中调用 } }在网关责任链末尾增加统一清理覆盖正常返回、异常抛出以及可能切换线程的路径try { OrderContextHolder.set(context); chain.doFilter(request, response); } finally { OrderContextHolder.clear(); }修复上线后监控显示老年代内存在每次 Full GC 后明显回落内存曲线恢复为健康的锯齿状Full GC 频率回到正常水平。7.2 修复后的验证曲线对比对比修复前后同一时间段的 OU 曲线波谷回到低位并保持稳定。灰度观察先在 10% 流量灰度集群观察 24 小时确认无异常后再全量发布。压力测试使用接近生产峰值的 QPS 压测 2 小时观察 Full GC 次数与停顿时间是否可控。八、工程化预防规范一次线上事故的教训不应只停留在修复这几行代码上更重要的是沉淀为团队规范避免同类问题反复出现。8.1 代码层面让资源生命周期可见ThreadLocal 必须配套 remove建议封装withContext工具方法并在 Code Review 检查清单中强制要求。集合与缓存必须有上限静态集合、自定义缓存要明确容量上限、过期时间和清理策略。资源统一 try-with-resources连接、流、文件等实现AutoCloseable的对象都走该语法。监听器必须有反注册注册与注销成对出现或在生命周期钩子中自动清理。8.2 监控层面把内存泄漏消灭在早期JVM 指标接入统一监控至少采集堆内存、非堆内存、GC 次数、GC 耗时、线程数并设置分级告警。关注内存波谷趋势相比瞬时峰值波谷是否持续走高是更可靠的泄漏预警信号。保留定时 Heap Dump核心服务可按天或按周留存堆转储样本便于对比对象增长趋势。8.3 架构与配置层面预留排查条件容器保留 JDK 诊断工具至少保留 jps、jstat、jcmd必要时开放 Arthas 的临时使用通道。合理设置堆参数避免堆配置过小导致频繁 Full GC也要避免堆过大导致单次 GC 停顿过长。核心链路采用无状态设计复用对象集中管理请求级状态明确收尾减少隐式引用。九、总结这次 Java 内存泄漏的排查过程可以浓缩成一条主线监控曲线发现异常jstat 确认是泄漏jmap 锁定嫌疑类堆转储与 MAT 定位引用链最终回到代码里的 ThreadLocal 清理缺陷并完成修复。整个流程没有特别玄乎的环节关键在于按步骤收敛证据、保留现场、从大范围逐步聚焦到根因。对 Java 开发者来说内存泄漏并不神秘。只要理解 GC Roots、强引用和对象生命周期再配合一套熟练的排查流程绝大多数线上内存问题都能在可控时间内定位并解决。更重要的是在日常编码中养成资源清理、容量控制以及注册注销成对管理的习惯从源头上减少泄漏的发生。
返回列表