JVM- 技术专题 -MAT 解析 OOM 问题
对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。
堆转储,包含了堆现场全貌和线程栈信息
使用 jstat 等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储,就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。
Java 的 OutOfMemoryError 是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置 JVM 参数,方便发生 OOM 时进行堆转储:
使用 MAT 分析 OOM 问题,一般可以按照以下思路进行:
通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
查看那些消耗内存最大类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
辅助使用查看线程栈来看 OOM 问题是否和过多线程有关,甚至可以在线程栈看到 OOM 最后一刻出现异常的线程。
比如,有一个 OOM 后得到的转储文件 java_pid29569.hprof,现在要使用 MAT 的直方图、支配树、线程栈、OQL 等功能来分析此次 OOM 的原因。
首先,用 MAT 打开后先进入的是概览信息界面,可以看到整个堆是 437.6MB:
前提概要
那么,这 437.6MB 都是什么对象呢?
如图所示,工具栏的第二个按钮可以打开直方图,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存。可以看到,char[]字节数组占用内存最多,对象数量也很多,结合第二位的 String 类型对象数量也很多,大概可以猜出(String 使用 char[]作为实际数据存储)程序可能是被字符串占满了内存,导致 OOM。
我们继续分析下,到底是不是这样呢。
我们继续分析下,到底是不是这样呢。
在 char[]上点击右键,选择 List objects->with incoming references,就可以列出所有的 char[]实例,以及每个 char[]的整个引用关系链:
随机展开一个 char[],如下图所示:
接下来,我们按照红色框中的引用链来查看,尝试找到这些大 char[]的来源:
在①处看到,char[]是 10000 个字符、占用 20000 字节(char 是 UTF-16,每一个字符占用 2 字节);
在②处看到,char[]被 String 的 value 字段引用,说明 char[]来自字符串;
在③处看到,String 被 ArrayList 的 elementData 字段引用,说明这些字符串加入了一个 ArrayList 中;
在④处看到,ArrayList 又被 FooService 的 data 字段引用,这个 ArrayList 整个 RetainedHeap 列的值是 431MB。
Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存,Shallow Heap(浅堆)代表对象本身占用的内存。
比如,我们的 FooService 中的 data 这个 ArrayList 对象本身只有 16 字节,但是其所有关联的对象占用了 431MB 内存。这些就可以说明,肯定有哪里在不断向这个 List 中添加 String 数据,导致了 OOM。
左侧的蓝色框可以查看每一个实例的内部属性,图中显示 FooService 有一个 data 属性,类型是 ArrayList。
如果我们希望看到字符串完整内容的话,可以右键选择 Copy->Value,把值复制到剪贴板或保存到文件中:
这里,我们复制出的是 10000 个字符 a(下图红色部分可以看到)。对于真实案例,查看大字符串、大数据的实际内容对于识别数据来源,有很大意义:
看到这些,我们已经基本可以还原出真实的代码是怎样的了。
其实,我们之前使用直方图定位 FooService,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上⻆的红框所示)进入支配树界面。这个界面会按照对象保留的 RetainedHeap 倒序直接列出占用内存最大的对象。
可以看到,第一位就是 FooService,整个路径是 FooSerice->ArrayList->Object[]->String->char[](蓝色框部分),一共有 21523 个字符串(绿色方框部分):
这样,我们就从内存⻆度定位到 FooService 是根源了。那么,OOM 的时候,FooService 是在执行什么逻辑呢?
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图,首先看到的就是一个名为 main 的线程(Name 列),展开后果然发现了 FooService:
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。因为我们希望了解 FooService.oom()方法,看看是谁在调用它,它的内部又调用了谁,所以选择以 FooService.oom()方法(蓝色框)为起点来分析这个调用栈。
往下看整个绿色框部分,oom()方法被 OOMApplication 的 run 方法调用,而这个 run 方法又被 SpringApplication.callRunner 方法调用。看到参数中的 CommandLineRunner 你应该能想到,OOMApplication 其实是实现了 CommandLineRunner 接口,所以是 SpringBoot 应用程序启动后执行的。
以 FooService 为起点往上看,从紫色框中的 Collectors 和 IntPipeline,你大概也可以猜出,这些字符串是由 Stream 操作产生的。再往上看,可以发现在 StringBuilder 的 append 操作的时候,出现了 OutOfMemoryError 异常(黑色框部分),说明这这个线程抛出了 OOM 异常。
我们看到,整个程序是 SpringBoot 应用程序,那么 FooService 是不是 Spring 的 Bean 呢,又是不是单例呢?如果能分析出这点的话,就更能确认是因为反复调用同一个 FooService 的 oom 方法,然后导致其内部的 ArrayList 不断增加数据的。
点击工具栏的第四个按钮(如下图红框所示),来到 OQL 界面。在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据(你可以直接在 MAT 帮助菜单搜索 OQLSyntax,来查看 OQL 的详细语法)。
比如,输入如下语句搜索 FooService 的实例:
可以看到只有一个实例,然后我们通过 List objects 功能搜索引用 FooService 的对象:
得到以下结果:
可以看到,一共两处引用:
第一处是,OOMApplication 使用了 FooService,这个我们已经知道了。
第二处是一个 ConcurrentHashMap。可以看到,这个 HashMap 是 DefaultListableBeanFactory 的 singletonObjects 字段,可以证实 FooService 是 Spring 容器管理的单例的 Bean。
你甚至可以在这个 HashMap 上点击右键,选择 Java Collections->Hash Entries 功能,来查看其内容:
这样就列出了所有的 Bean,可以在 Value 上的 Regex 进一步过滤。输入 FooService 后可以看到,类型为 FooService 的 Bean 只有一个,其名字是 fooService:
到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现 OOM 的原因和大概的调用栈了。我们再贴出程序来对比一下,果然和我们看到得一模一样:
到这里,我们使用 MAT 工具从对象清单、大对象、线程栈等视⻆,分析了一个 OOM 程序的堆转储。可以发现,有了堆转储,几乎相当于拿到了应用程序的源码+当时那一刻的快照,OOM 的问题无从遁形。
参数配置
承接以上追踪相关的 JVM 的 OOM 的排查工作任务之后,我们需要深入分析和了解相关的 JVM 参数,为了以后解决 OOM 以及性能调优点奠定基础。
JVM 参数分类
根据 jvm 参数开头可以区分参数类型,共三类:“-”、“-X”、“-XX”
标准参数(-):所有的 JVM 实现都必须实现这些参数的功能,而且向后兼容;
例子:-verbose:class,-verbose:gc,-verbose:jni……
非标准参数(-X):默认 jvm 实现这些参数功能,但是并不保证所有 jvm 实现都满足,且不保证向后兼容;
例子:Xms20m,-Xmx20m,-Xmn20m,-Xss128k……
非 Stable 参数(-XX):此类参数各个 jvm 实现会有所不同,将来可能会随时取消,需要慎重使用;
例子:-XX:+PrintGCDetails,-XX:-UseParallelGC,-XX:+PrintGCTimeStamps……
关键参数详解
最重要和常见的几个参数如下:
-Xms20m :设置 jvm 初始化堆大小为 20m,与-Xmx 相同避免垃圾回收完成 jvm 重新分。(默认为 1/64)
-Xmx20m:设置 jvm 最大可用内存大小为 20m。(默认为整个系统的 1/4)
-Xmn10m:设置新生代大小为 20m。(sun 官方推荐为整个堆的 3/8)
-Xss128k:设置每个线程的栈大小为 128k。
上面这几个参数我以前经常容易被混淆,不过后来根据字母拆分就简单了很多。
如下图:
还有几个 GC 的参数见名知意就不详解了,后面测试会一一说明,主要的如下:
■ -verbose:gc:可以输出每次 GC 的一些信息;
■ -XX:-UseConcMarkSweepGC:使用 CMS 收集器;
■ -XX:-UseParallelGC ;采用新生代 parallel scavenge 回收器
■ -XX:-UseSerialGC;采用新生代串行回收器
■ -XX:CMSInitiatingOccupancyFraction=80 CMS gc,表示在老年代达到 80%使用率时马上进行回收;默认 92% 之前版本为 68 过大会造成 concurrent promotion failure 降成为串行回收器
■ -XX:+PrintGC;
■ -XX:+PrintGCDetails:打印 GC 详情;
■ -XX:+PrintGCTimeStamps:打印时间戳;
jvm 参数设置和测试
在 idea 设置 jvm 参数之前文章有详细讲过,这里就不再赘述了,具体看下图:
配置的最后两个参数介绍:
■ -XX:+PrintGCDetails:打印 GC 详细信息;
■ -XX:SurvivorRatio=8:eden/survivor=8;
运行结果如下图:
可以看到输出了一些主要内容,对主要的内容解释如下:
PSYoungGen:PS 是 Parallel Scavenge 的简写,整个就表示新生代采用了 Parallel Scavenge 收集器。
后面紧跟 total 参数:表示新生代使用内存 9216k,只有 9M 是因为只计算了 eden 和 from survivor,我们知道 to survivor 在 jvm 运行时是预留的
eden space 8192K, 33% used:eden 区域总共 8192k,使用了 33%。2731/8192 约等于 0.33。
from space 1024K, 0% used;
to space 1024K, 0%used:因为还没有进行过回收所以两个 survivor 区域都是空的;
ParOldGen total 10240K, used 0K:Par 是 Parallel Old 的简写,所以老年代采用的是 Parallel Old 收集器进行垃圾回收。
Metaspace used 3312K:元空间,因为用的是本地内存,所以没有 total 只有 used。
在代码中加入一个字节数组如下图:
可以看到新生代的内存使用比上一个测试增加了 512k(3243-2731=513),字节数组长度是 512*1024,1024 个字节等于 1k。这说明数组确实存放到了堆的新生代!
栈内存和堆内存的区别
栈(stack)的操作方式类似于数据结构中的栈(仅在表尾进行插入或删除操作的线性表)。栈的优势在于,它的存取速度比较快,仅次于寄存器,栈中的数据还可以共享。其缺点表现在,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。
堆(heap)是一个程序运行动态分配的内存区域,在 Java 中,构建对象时所需要的内存从堆中分配。这些对象通过 new 指令“显式”建立,这种分配方式类似于数据结构中的链表。堆内存在使用完毕后,是由垃圾回收(Garbage Collection,GC)器“隐式”回收的。
堆的优势是在于动态地分配内存大小,可以“按需分配”,其生存期也不必事先告诉编译器,在使用完毕后,Java 的垃圾收集器会自动收走这些不再使用的内存块。其缺点为,由于要在运动时才动态分配内存,相比于栈内存,它的存取速度较慢。
再次分析内存结构
内存溢出的排查流程
jvisualvm:可以用来查看分析内存转储文件,也可以用其做 Java 虚拟机当前状况查看
内存溢出时的虚拟机状况
从上图可以知道,是在com.zas.jvm.om.DataObject
这个类的对象出了问题。
在 Linux 环境下,可以借助 jdk 自带的 jmap 来转储堆内存文件来分析。
定位方法:用 JDK 自带的命令 jmap -histo pid 去查看堆内存的使用情况
会看到实例被调用的次数,从大到小排序,越排前面说明占内存空间越大
发现排名第一的就是自己应用程序里所调用的方法,此时只需要在代码里找到该方法,发现该方法使用之后没有及时被释放导致的内存溢出
评论