「Java 进阶之路」系列 Day36
写在前面
前面五篇讲了内存模型、类加载、垃圾回收算法、分代收集、可达性分析这些原理,这一篇是模块五的收官篇,把这些原理落地到一个真实场景:线上服务突然抛出OutOfMemoryError,或者数据库连接池耗尽导致请求大量超时,该怎么一步步定位到根因。这类问题最怕的不是"不会背GC原理",而是"背得出原理,但现场手忙脚乱不知道从哪下手"。
一、是什么:OOM 不是只有一种
OutOfMemoryError报错本身会带一行具体信息,不同的信息对应完全不同的病因,排查方向也完全不一样:
一句话总结区分方式:看OutOfMemoryError后面跟的那句话,它已经告诉你是堆、方法区、还是线程数出了问题,不要一律当成"内存不够"就去调大-Xmx了事------盲目调大堆反而可能让本该早点暴露的问题拖得更久,最后在更大的堆上再炸一次,排查更麻烦。
二、为什么必须靠堆转储分析,而不是靠猜
前几篇讲过,垃圾回收器只负责清理"不可达"的对象,如果代码里有一条强引用链一直续着某些本该被丢弃的对象(比如集合类越塞越多却从不清理、监听器注册了从不注销、缓存没有过期策略),这些对象在可达性分析里永远"活着",GC拿它们完全没办法------这正是"内存泄漏"在Java里的真实含义:不是内存真的丢了,而是一批本该死掉的对象,因为一条不该存在的强引用,被迫一直占着位置。
光看监控曲线只能看到"堆内存在持续上涨、Full GC越来越频繁但回收效果越来越差"这个现象,但看不出具体是哪些对象在堆积 。真正定位问题必须靠堆转储文件(Heap Dump)------把某一时刻堆里所有对象的快照导出来,一个个类地统计数量和占用大小:
bash
# 手动导出一份堆快照(生产环境更推荐提前配置好自动导出)
jmap -dump:live,format=b,file=heap.hprof <pid>
# 更推荐提前配置:一旦发生OOM,JVM自动导出堆快照再退出,不用等人工手动介入
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
拿到.hprof文件之后,用Eclipse MAT或者IDEA自带的Profiler打开,重点看两个视图:
- Histogram(直方图):按类分组,看哪个类的实例数量、占用内存明显不正常(比如某个自定义DTO类实例数量是十万级别,远超合理业务量)
- Dominator Tree(支配树):找到"保留大小"最大的对象------如果一个对象被回收,能连带释放多少内存,这个指标直接指向真正占大头的那条引用链的根
顺着支配树往下追,通常能直接看到是哪个集合、哪个缓存Map在不断膨胀,再回头去代码里找这个集合到底谁在写入、有没有对应的清理逻辑。
三、怎么用:一次连接池耗尽的排查思路
比内存泄漏更高频的线上问题是数据库连接池耗尽------表现通常是接口大面积超时,日志里能看到类似Timeout waiting for connection from pool这样的报错。排查思路和内存问题类似,都是"先看现象、再找根因、最后验证":
第一步:确认连接到底是"不够用"还是"用不回来"
先看连接池的监控指标(HikariCP、Druid都有暴露):活跃连接数是不是长期顶着最大值不下降。如果活跃连接数一直居高不下,说明连接被借出去之后没有归还,而不是业务量真的需要这么多连接。
第二步:用线程栈定位"卡在哪里不还连接"
bash
jstack <pid> > threads.txt
在线程栈里搜索业务线程的堆栈,常见能定位到的几种情况:
- 某段代码忘记在
finally里关闭连接 (或者用了try-with-resources但外层又手动catch吞掉了异常导致连接对象没走到关闭逻辑)------这是最经典的"忘记还连接" - 连接被拿到之后卡在一次慢查询上迟迟不返回(比如一条没走索引的SQL全表扫描),连接本身没泄漏,只是每个连接占用时间过长,导致池子里能周转的连接数不够用
- 事务边界过大:一个事务里夹杂了远程调用(比如调第三方接口、发MQ),远程调用耗时不可控,却一直攥着数据库连接不放,等于把连接的生命周期和一个不可控的外部调用绑在了一起
第三步:区分"连接池太小"还是"代码有泄漏"
如果通过jstack确认线程都在正常处理业务、只是连接周转确实不够快,那是容量规划问题,适当调大连接池最大连接数、拆分大事务、给慢查询加索引就能解决;但如果连接池比正常业务量宽裕很多、活跃连接数却依然长期占满,同时找不到几个线程真的在使用这些连接,那基本可以断定是代码层面的连接泄漏------某处拿到连接后走了一条异常分支,没有执行到归还逻辑,这时候调大连接池只是把问题爆发的时间往后拖,治标不治本。
四、面试追问
Q1:线上出现OutOfMemoryError,第一步应该做什么?
先看OutOfMemoryError后面具体的提示信息(比如Java heap space、Metaspace、unable to create new native thread),不同提示对应完全不同的病因和排查方向,不能一律当成"堆不够大"就直接调大-Xmx。同时应该提前配置好-XX:+HeapDumpOnOutOfMemoryError,让JVM在发生OOM时自动导出堆快照,方便事后分析,而不是等人工手动介入时现场已经重启、证据已经丢失。
Q2:为什么会出现"对象明明该被回收却没被回收"的内存泄漏?
Java的垃圾回收基于可达性分析,只要有一条从GC Roots出发的强引用链能摸到某个对象,它就永远不会被判定为垃圾。内存泄漏的本质是代码里存在一条不该存在的强引用链,让本该被丢弃的对象持续可达------常见于集合类只增不减、监听器注册了不注销、缓存没有过期淘汰策略。GC本身没有任何问题,问题出在业务代码维护引用关系的方式上。
Q3:拿到堆转储文件之后,具体怎么定位是哪里在泄漏?
用MAT或者Profiler工具打开堆快照,先看Histogram直方图,找到实例数量或占用内存明显异常的类;再用Dominator Tree支配树,找到"保留大小"最大的对象------这个指标反映了如果这个对象被回收能连带释放多少内存,直接指向占用最大的那条引用链的根。顺着这条链往回追到具体的集合或者缓存对象,再回代码里定位是谁在写入、有没有对应的清理逻辑。
Q4:连接池耗尽和内存泄漏在排查思路上有什么共同点?
两者的核心思路是一致的:先通过监控指标确认现象(内存持续增长、还是活跃连接数长期占满),再通过工具还原当时的具体状态(堆转储快照、还是线程栈dump),顺着这份快照找到具体是哪个对象或者哪段代码在制造问题,最后回到源码定位根因。都不能靠猜测直接下结论,必须要有具体的快照数据支撑。
Q5:怎么判断连接池耗尽是容量规划问题还是代码泄漏?
用jstack导出线程栈,观察业务线程实际在做什么。如果大部分线程确实都在正常处理业务、只是连接周转速度跟不上业务量,说明是容量不够,可以调大连接池、优化慢查询、拆分大事务解决;但如果连接池配置已经比正常业务量宽裕很多,活跃连接数却依然长期占满,同时找不到足够多的线程真的在使用这些连接,就说明存在代码层面的连接泄漏------某处拿到连接后走了异常分支没有执行归还逻辑,这种情况调大连接池只是拖延问题爆发的时间。
下一篇预告
模块五(JVM与GC)到这里全部写完。Day37 开始进入模块六------Java 8+ 函数式编程,从Lambda表达式的语法糖本质讲起。