
说出来你可能不信,我上个月又双叒叕被OOM折腾了一回。凌晨三点,应用告警群里疯狂弹消息,Java进程直接挂掉重启,看得我血压都上来了。
说实话,OOM这个东西吧,十个Java应用九个都遇到过。新手遇到它就是一脸懵,老手遇到它也得骂骂咧咧。这篇文章不整那些花里胡哨的理论,就实打实地聊聊当你的应用频繁OOM时,怎么用工具一步步揪出真凶。
很多人一遇到OOM第一反应就是"赶紧jmap dump堆啊",但其实这是错的。第一步永远是先看日志。
Java应用在OOM的时候,默认会在控制台或者日志文件里抛出异常信息。这个异常信息很关键,它会告诉你到底是什么类型的OOM。常见的OOM有这么几种[1]:
java.lang.OutOfMemoryError: Java heap space:堆内存不够用了,最常见的一种java.lang.OutOfMemoryError: GC overhead limit exceeded:GC回收了98%内存却还是腾不出空间,基本等于内存泄漏java.lang.OutOfMemoryError: Metaspace:元空间满了,类加载太多java.lang.OutOfMemoryError: Direct buffer memory:直接内存溢出,Netty玩多了容易遇到java.lang.OutOfMemoryError: unable to create new native thread:线程数爆了,常见于线程池配置不当我那次遇到的是 Java heap space,堆内存4G全被吃光了。但光看这个还不够,我得知道这内存是被谁吃掉的。
光知道OOM的类型还不够,你得知道GC到底在干什么。jstat这个工具就是干这个的。
jstat -gc <pid> 1000
这个命令的意思是每隔1000毫秒输出一次GC信息。重点关注这几个列:
我当时看到的现象特别明显:FGC次数每小时涨几百次,每次耗时几百毫秒。这就说明老年代一直被填满,GC根本回收不动,典型的内存泄漏特征[2]。
还有个命令更直观,能看堆内存使用率:
jstat -gcutil <pid> 1000
输出里有个O列表示老年代使用率,如果这玩意儿长期在95%以上晃悠,那你基本可以确定是内存泄漏了。
确认了是内存泄漏的问题,下一步就是dump堆。jmap是JDK自带的工具,用起来很简单:
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
不过这里有个坑要说一下。dump堆的时候JVM会暂停所有线程,也就是STW(Stop-The-World)。如果你的应用是核心业务,最好在低峰期操作,不然可能引发雪崩。
还有一种更好的方式是配置JVM参数让OOM时自动dump:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
这样应用每次OOM都会自动生成一份堆快照,省得你半夜爬起来手动操作(我那次就是这么干的,设了自动dump后安心睡觉,第二天再分析)。
dump出来的文件一般会很大,我那次dump出来有6个多G。如果你要把文件拉到本地分析,建议先压缩一下:
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
加个live参数只保留存活对象,文件能小不少,传输也快。
拿到堆快照后,就该主角登场了——Eclipse MAT(Memory Analyzer Tool)。这玩意儿绝对是分析内存问题的神器,没有之一[2]。
下载安装就不多说了,官网下下来解压就能用。注意要改一下MemoryAnalyzer.ini里的内存配置,这个内存要大于你的dump文件大小,不然MAT会直接OOM(没错,分析工具自己OOM,听着是不是很魔幻)。
-vmargs
-Xmx8g
打开dump文件后,重点看这几个东西:
MAT会自动帮你分析可能的内存泄漏点,生成一份报告。点开就能看到哪些对象占的内存最多,哪些对象本该被回收却还在内存里"赖着不走"。
我那次MAT直接给我标红了一个HashMap,里面装着几十万条UserSession对象。这就是元凶了。
这个视图能看到"谁支配着谁",也就是哪些大对象是其他对象的"主子"。在支配树里,最上面的一般就是占用内存最多的家伙。
按类的维度展示对象数量和占用内存。如果某个类的实例数明显异常,比如本应该只有几百个结果出现了几十万,那就是有问题的。
这个最关键,能看到对象为什么没被GC回收。比如你发现有个UserSession对象一直没被回收,就可以用"Path to GC Roots"功能看它被谁引用着。结果一查,发现是被一个静态的HashMap长年累月地引用着,永远不会被释放。
如果说MAT是事后分析的神器,那VisualVM就是实时监控的利器[1]。它能让你看到内存的实时变化曲线。
VisualVM也是JDK自带的工具,JDK8之前直接在bin目录下有jvisualvm,JDK9之后需要单独下载。
用它主要干两件事:
VisualVM还有个插件叫VisualGC,装上之后能看到各个内存分区的实时使用情况,包括Eden、Survivor、Old、Metaspace的实时数据。配合jstat用,绝配。
有些内存问题不是单纯的堆内存泄漏,而是频繁创建大对象导致GC压力剧增。这种情况光看堆dump看不出来,得用profiler工具。
async-profiler是近两年比较火的一个工具,不像传统的JProfiler那么重,对生产环境的影响很小。可以用它来采样:
./profiler.sh -e alloc -d 30 -f /tmp/alloc.html <pid>
这个命令的意思是采样30秒内的内存分配情况,生成一个火焰图。从火焰图里能清晰地看到哪些代码路径在疯狂分配对象。
我之前遇到过一个案例,应用每隔几分钟就Full GC一次,但用MAT看堆里又没多少对象。最后用async-profiler一查,发现是某个日志框架在疯狂创建StringBuilder对象,每次请求都创建几百个。这就不是内存泄漏了,是代码写法的问题。
说到OOM,还有一种特别容易被忽略的情况——元空间(Metaspace)溢出。
JDK8之后,永久代(PermGen)被元空间取代了。元空间主要存放类的元数据,比如类信息、方法信息、字段信息等。如果应用加载了大量动态类(比如用了CGLIB、反射、或者JSP),元空间就可能被撑爆。
我之前有个项目用了大量的Groovy脚本动态编译,结果运行两天后元空间就满了。这种情况光看堆内存是看不出来的,得用:
jstat -gcmetacapacity <pid>
或者直接看JVM参数有没有限制元空间大小:
-XX:MaxMetaspaceSize=512m
没限制的话默认会一直涨,直到把机器内存吃光。
还有一种OOM是unable to create new native thread。这种情况不是堆内存的问题,而是线程数太多,把系统资源耗尽了。
Linux下线程数是有限制的,可以通过ulimit -u查看。如果你的应用创建了大量线程(比如每个请求都new一个线程),很快就会达到上限。
排查这种问题比较简单,用jstack <pid>看看线程栈就行:
jstack <pid> > /tmp/thread.txt
然后统计一下线程数:
grep -c '^"' /tmp/thread.txt
如果线程数明显异常(比如上万),那基本可以确定是线程池没配好,或者代码里在某个地方死循环创建线程。
说回我那次凌晨三点被叫醒的故障吧。
应用是个电商系统,每到晚上高峰期就频繁OOM。dump堆之后用MAT分析,直方图里UserSession类有40多万个实例,每个大概1KB左右,占了将近400MB内存。
点开Path to GC Roots一看,路径是这样的:
UserSession
→ HashMap$Node
→ HashMap
→ XXXService (静态字段)
问题很明确,有个Service类里有个静态的HashMap,每次用户登录都往里塞一个Session,但从来不清除。改完之后直接用Caffeine缓存替代原来的HashMap,设置过期时间30分钟。线上效果立竿见影,内存使用率从95%降到了40%以下,OOM彻底消失。
最后总结一下我的排查套路,基本上任何OOM问题都能按这个流程来:
工具是死的,思路是活的。OOM这玩意儿看似可怕,但只要掌握了工具的使用方法,配合一点点耐心,基本上都能找到根因。
别忘了线上一定要配HeapDumpOnOutOfMemoryError这个参数,不然真的出事了,你连现场都没得查。
如果觉得文章对你有帮助,欢迎转发给你的同事朋友,运维路上一起升级打怪。