OutOfMemoryError问题定位 - 垃圾回收系列(六)

568 阅读11分钟

OutOfMemoryError问题定位 - 垃圾回收系列(六)

一、概述

1.1 内存划分

OutOfMemoryError顾名思义,就是内存不够用了,那是哪里的内存不够用了呢。我们先看下java内存分布:

jvm运行时区域.png

  • 线程共享内存
    1. 堆,用于存放对象以及数组等。几乎所有的对象,都存放在堆中,之所以说几乎,是因为,某些对象经过“逃逸分析”,不会逃出方法,理论上,可以在栈上分配。
    2. 元数据区,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
    3. 直接内存,JAVA可以通过NIO(New Input/Output),它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了 在Java堆和Native堆中来回复制数据。
  • 线程独享
    1. 程序计数器(Program Counter Register),可以简单理解为,用来记录当前线程执行的字节码的行号。
    2. 虚拟机栈:java方法执行时的内存数据结构,每个方法被执行的时,虚拟机都 会同步创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态连接、方法出口等信 息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
    3. 本地方法栈:与虚拟机栈类似,只不过一个是运行java方法,一个是运行本地方法。

1.2 OOM发生位置

回顾了java内存分布,那么哪些区域可能发生OOM呢?

理论上,除了程序计数器外,理论上,都可能发生OOM。

  1. 堆:java几乎所有的对象和数组,都会在堆中分配,堆内存不够用,触发GC后,依然没有足够的内存,就会OOM。堆中抛出OOM,会有进一步提示“Java heap space”,比如:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  2. 元数据区:存储虚拟机加载的类的相关信息,如果我们使用了动态生成类的相关技术,比如CGLIB等,会不断生成新的类,使用不当,就可能造成元数据区的OOM,这种情况会有“Metaspace”提示,比如:Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
  3. 直接内存:使用NIO等手段,可以申请直接内存,比如系统中使用了Netty等基于NIO的框架,使用不当,内存泄露,无法回收,超过了指定的直接内存(-XX:MaxDirectMemorySize=N,没有指定则最大为-Xmx大小),就会OOM。这种OOM,没有提示,类似:Exception in thread "main" java.lang.OutOfMemoryError
  4. 栈:理论上,栈内存不足也可能OOM,但实践中更可能抛出栈溢出异常,即常见于递归死循环的StackOverflowError。

二、定位

2.1 前置准备

建议在生产上,开启-XX:+HeapDumpOnOutOfMemoryError参数,可以在发生OOM时,自动转出内存dump。默认场景下,dump存储在jar包所在的工作目录中,名字为"java_pid + + .hprof",也可以通过-XX:HeapDumpPath=<your path>,来指定dump的存储目录。需要注意:

  1. 如果使用了-XX:HeapDumpPath=<your path>指定目录,目录必须存在,否则dump不会成功;
  2. dump大小一般与发生OOM时堆大小相当,磁盘需要预留有足够的空间。
  3. dump的命名为"java_pid + + .hprof",也就是说,如果java进程没有重启,这个名字是固定的。如果再次发生OOM,如果已经存在名字相同的dump,是不会覆盖的。因此如果发生了OOM,生成了dump,建议及时下载下来,并及时清理旧的dump。

2.2 定位

2.2.1. 初步定位

我们可以根据一些异常的简单信息,简单的定位问题发生在哪里

  1. OOM类型是什么,堆溢出、元数据区溢出还是直接内存溢出。
  2. OOM的堆栈信息。
  3. 最近上线了什么新版本?有什么新业务?有什么高峰期?

通过以上信息,如果问题比较简单,有可能已经初步定位到,哪里发生了问题,提出相应的解决方案即可。

2.2.2 dump分析

在dump分析前,需要先确定下OOM位置,不同位置,排查的侧重点不同。

  • 堆溢出:分析哪些对象占用了内存。
  • 元数据溢出:主要关注class相关信息,是否有重复类,是否构造了很多反射类。
  • 直接内存溢出:直接内存溢出,在dump比较难看出端倪,直接内存的信息,在dump中也没有。

如何获取dump

建议线上开启-XX:+HeapDumpOnOutOfMemoryError功能,一般情况下,OOM时,我们可以得到一个dump。可以使用MAT等工具,进行分析。

也可以使用jmap命令,生成dump,不过注意,这个过程会STW,如果在生产上,生成dump前,需要先摘除流量,否则会影响线上用户。

jmap -dump:format=b,file=heapdump.hprof <pid>

MAT的安装这里就不介绍了,注意两点:

  1. 最新版本的MAT要求JDK最低为17,可以下载较新版的JDK,解压到某个位置(不必修改环境变量),然后在配置文件(MemoryAnalyzer.ini)中,增加启动MAT的JDK(-vm)。
  2. dump一般比较大,建议把-Xmx设置的大一些。
-vm
your path to jdk bin
-vmargs
--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED
-Xmx4096m

PS:MAT下载地址

关于MAT的使用,这里强烈推荐大佬的两片文章:

我这里根据经验,简单介绍下MAT的使用技巧。

1. Leak suspects(泄露猜想)

使用MAT打开dump后,可以先打开Leak suspects报表,这里包含内存泄露的一些猜想和系统信息。入口:

enter_leak_supects.png

MAT会列出可能的内存泄露的几种可能,一些简单的问题,通过这个泄露猜测报表,就能直观的找到,比如下面这个:

leak_suspects.png

很明显,主线程持有一个数组(实际上是一个ArrayList),持有了540217个对象,占用约12M内存(堆大小只有20M),占比94.69%。还可以进一步查看stacktrace等细节。

通常,如果一个线程持有了过多的内存,可以在这个“Leak suspects”发现。比如我们一个请求,不合理的查询了特别大的范围的数据,一下子有几百甚至是几千万的数据,载入到内存。

2. 寻找占用内存的大对象

如果“Leak suspects”没有什么有价值的信息,我们可以通过MAT的其他功能,查找哪些对象,占用了大内存。

2.1 Dominator tree(支配树)

先解释下一些术语:

  • Dominator(支配):如果B仅被A引用,那么A支配B,假设A支配B,则如果A可以被回收,那么B也可以被回收。
  • shallow heap(浅堆):一个对象自身结构的大小,包括基本类型+对象引用占用空间的大小(注意是引用,而不是对象本身)。如果对象有个数组属性,也仅计算指向数组的引用,而不计算数组本身。
    • 一个对象有一个long属性+一个数组,则shallow heap为8字节的long + 8(或4)字节的数组引用。
    • 一个对象数组,引用长度8(或4)* 数组长度 + 数组对象头(16)+ int类型数组长度(4)。
  • Retained Heap(持有堆):一个对象回收后,可以回收的堆大小,换句话说,一个对象支配的所有对象的大小的总和。

术语解释完成,我们看下支配树视图,入口在这里:

enter_dominator_tree.png

点击上面按钮或者下面的链接,都可以进入支配树。

oom_dominator_tree.png

可以看到,这里分析与上一章Leak suspects分析相同,都是一个数组占用了超大空间(不是一个dump,数组大小不一样),我们以此为例,计算下shallow heap/Retained Heap

  • shallow heap:对象引用大小(4byte)* 数组大小(810325)+ 数组对象头(16)+ int类型数组长度(4) = 3241320
  • Retained Heap:shallow heap + 对象大小(16byte)* 数组大小(810325) = 16206520 (这个例子中,对象大小都相同)。

注意

一定要注意一点,支配树 != 引用树,支配树只显示支配的对象,如果A对象引用了B对象,但B对象也被其他对象引用了,那么B不会出现在A的支配树中。

如果需要查看引用树,可以选中,右键 -> List objects -> with incoming/outgoing references。

  • with incoming references :查看谁引用了选中对象。
  • with outgoing references :查看选中对象引用了谁。

更多的时候,内存溢出并没有那么明显,比如MAT官方的这个dump的样例,一个对象最多支配6.46%(当然,其实也不少)。

sample_objects.png

这时,我们可以选择按照类型聚合,查看哪类的支配数量比较多:

how_group_by.png

group by类型:

  1. No Grouping(objects),不聚合,也就是列出对象的支配树。
  2. Group by class,按照类聚合。
  3. Group by class loader,按照class loader聚合。
  4. Group by package,按照包聚合。

有时根据那一类的对象比较多,初步定位到问题出现在哪里,这里还可以搜索,以寻找自己关注的类/包下的对象:

search_dominator.png

为什么对象没有被回收

有时候,你可能会疑惑,某个对象,为什么无法被回收,这时可以查看对象的关联的GC ROOTS,就可以找到无法被回收的原因。建议排除软、弱、平台等非强引用。

path_to_gc_roots.png

2.2 直方图

除支配树外,还可以使用直方图定位问题所在。直方图就是这个:

enter_histogram.png

相较与支配树,直方图就很好理解,它就是按照某种类型聚合,显示每种类型的数量以及shallow heap(浅堆)、Retained Heap(持有堆)。

  • 默认按照shallow heap(浅堆)降序排序,可以更改排序规则;
  • 支持按照类、超类、classloader、包聚合;
  • 支持右键 -> List objects -> with incoming/outgoing references,查看所有对象。
  • 支持查看GCRoots。

直方图默认按照类聚合、shallow heap(浅堆)降序排序,这种情况下,一般是一些基础类型排在前面,比如char[]和byte[]等,我们可以手动修改下排序顺序,比如按照Retained Heap(持有堆)排序。

histogram_order.png

总的来说,与支配树的使用方式,是类似的,只是视角不同而已。

2.3 Duplicate classes

以上都是在堆的角度上,排查问题,看哪个、或者哪些类型的对象占用内存比较高。但如果OOM是因为元数据溢出而产生的,建议关注类相关的信息。比如我们可以在支配树、直方图上,观察哪些class对象、反射对象数量较多、占用内存较大。

除此外,还可以直接查看MAT为我们提供的Duplicate classes视图,查看重复的类对象。

什么是重复的类对象呢?

正常情况下,一个类,只会被同一个类加载器,加载一次。但如果某些代码有bug,创建了很多类加载器,那就可以对同一个类,加载多次,占用大量的元数据空间,造成内存溢出。Duplicate classes视图的入口,在这里: enter_duplicate_class.png

这个例子中,ClassLoaderOOMExample类,被不同的类加载器,加载了70185次,造成了元数据区的OOM。

duplicate_class.png

三、总结

总结一下

  1. 建议开启-XX:+HeapDumpOnOutOfMemoryError参数,这样一般OOM时,可以得到一个dump;
  2. 发生OOM后,先关注OOM的类型,并结合日志,一般可以对哪里出现问题,有初步判断;
  3. 结合dump分析OOM原因
    1. MAT的Leak suspects,会帮助们提供内存泄露的思路,简单的问题,可以很快定位到;
    2. 堆溢出,可以通过支配树、直方图等功能,重点关注哪些或者哪类对象,占用了大量空间。再进一步分析
      • 这些大对象引用了哪些对象,造成了内存浪费。
      • 哪些对象引用了这些大对象,造成无法回收。
    3. 元数据区溢出,重点关注类对象数量,还可以通过Duplicate classes视图,查看是否有类对象重复加载难题。
    4. 直接内存溢出,这种场景,一般dump也分析不出来什么东西,因为它就不占JVM的堆空间,可以关注下程序中哪里应用了NIO等使用了直接内存。具体怎么排查呢,emmm,以后在整理篇文章吧。