通过分析堆转储文件来排查OOM问题,分析程序堆内存使用情况。
堆转储,包含了堆现场全貌和线程栈信息,
jstat
等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。
设置 JVM 参数,当发生 OOM 时进行堆转储。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
下面我们利用 Eclipse 的 Memory Analyzer(MAT)
做堆转储的分析。
使用MAT
分析OOM问题,可以参考以下步骤:
- 通过 支配树功能 或 直方图功能 查看消耗内存最大的类型,来分析内存泄露的大概原因;
- 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
- 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
- 辅助使用查看线程栈来看 OOM 问题是否和过多线程有关,甚至可以在线程栈看到 OOM 最后一刻出现异常的线程。
现在有一个OOM
后的堆转储文件java_pid232.hprof
,利用 MAT 来分析 OOM 的原因。
1)MAT 导入java_pid232.hprof
后,首先是概览信息页面,可以看到整个堆是1.6G。
2)打开直方图(工具栏的第二个按钮),可以看到堆中都是什么对象,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存。
可以看到,char[]
字节数组占用内存最多,对象数量也很多,结合第二位的 String
类型对象数量也很多,大概可以猜出(String 使用 char[]
作为实际数据存储)程序可能是被字符串占满了内存,导致 OOM。
在char[]
上点击右键,选择 List objects -> with incoming references
,就可以列出所有的 char[]
实例,以及每个 char[]
的整个引用关系链。
选择一个char[]
打开。
按照红色框中的引用链来查看,尝试找到这些大 char[]
的来源:
- 在(1)处看到,这些
char[]
几乎都是 10000 个字符、占用 20000 字节左右(char 是 UTF-16,每一个字符占用 2 字节); - 在(2)处看到,
char[]
被 String 的 value 字段引用,说明char[]
来自字符串; - 在(3)处看到,String 被 ArrayList 的
elementData
字段引用,说明这些字符串加入了一个 ArrayList 中; - 在(4)处看到, ArrayList 又被
FooService
的data
字段引用,这个 ArrayList 整个Retained Heap
列的值是 1600MB。
Retained Heap
(深堆)代表对象本身和对象关联的对象占用的内存,Shallow Heap
(浅堆)代表对象本身占用的内存。
比如, FooService 中的 data 这个 ArrayList 对象本身只有 16 字节,但是其所有关联的对象占用了 1600MB内存。这些就可以说明,肯定有哪里在不断向这个 List 中添加 String 数据,导致了 OOM。
左边可以查看每一个实例的内部属性,图中显示 FooService 有一个 data 属性,类型是 ArrayList。
如果想看到字符串完整内容的话,可以右键选择 Copy -> Value
,把值复制到剪贴板或保存到文件中。、
可以看到,复制出来的是 10000 个字符 a,对于真实案例,查看大字符串、大数据的实际内容对于识别数据来源,有很大意义。
3)也可以点击支配树页面(工具栏的第3个按钮),这个界面会按照对象保留的 Retained Heap
倒序直接列出占用内存最大的对象。
可以看到,第一位就是 FooService
,整个路径是 FooSerice->ArrayList->Object[]->String->char[]
,一共有 83717个字符串。
这样,我们就从内存角度定位到 FooService 是根源了。
那么,OOM 的时候,FooService 是在执行什么逻辑呢?
4)点击工具栏的第5个按钮,打开线程视图,首先看到的就是一个名为 main 的线程(Name 列),展开后果然发现了 FooService。
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。
以 FooService.oom()
方法为起点来分析这个调用栈。
往下看整个蓝色框部分,oom()
方法被SpringDemoApplication
的 run
方法调用,而这个 run
方法又被 SpringAppliction.callRunner
方法调用。
看到参数中的 CommandLineRunner
应该能想到,SpringDemoApplication
其实是实现了 CommandLineRunner
接口,所以是 SpringBoot 应用程序启动后执行的。
以 FooService
为起点往上看,从黄色框中的 Collectors
和 IntPipeline
,可以猜出,这些字符串是由 Stream
操作产生的。再往上看,可以发现在 StringBuilder 的 append 操作的时候,出现了 OutOfMemoryError
异常(绿色框部分),说明这这个线程抛出了 OOM 异常。
我们看到,整个程序是 Spring Boot 应用程序,那么 FooService 是不是 Spring 的 Bean 呢,是不是单例呢?
如果能分析出这点的话,就更能确认是因为反复调用同一个 FooService 的 oom 方法,然后导致其内部的 ArrayList 不断增加数据的。
5) 点击工具栏的第四个按钮,来到 OQL
界面。
在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据。
比如,输入如下语句搜索 FooService 的实例。
select * from com.samples.springdemo.demo.FooService
可以看到只有一个实例,然后我们通过 List objects 功能搜索引用 FooService 的对象。
、
得到以下结果:
可以看到,一共两处引用:
- 第一处是,SpringDemoApplication使用了 FooService;
- 第二处是一个 ConcurrentHashMap,可以看到,这个 HashMap 是
DefaultListableBeanFactory
的singletonObjects
字段,可以证实 FooService 是 Spring 容器管理的单例的 Bean。
也可以在这个 HashMap 上点击右键,选择 Java Collections -> Hash Entries
功能,来查看其内容。
这样就列出了所有的 Bean,可以在 Value 上的 Regex 进一步过滤,输入 FooService 后可以看到,类型为 FooService 的 Bean 只有一个,其名字是 fooService。
到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现 OOM 的原因和大概的调用栈了。
最后再贴出代码来对比一下,果然和我们看到得一模一样。
@SpringBootApplication
public class SpringDemoApplication implements CommandLineRunner{@AutowiredFooService fooService;public static void main(String[] args) {SpringApplication.run(SpringDemoApplication.class, args);}@Overridepublic void run(String... args) throws Exception {//程序启动后,不断调用Fooservice.oom()方法while (true) {fooService.oom();}}
}@Component
public class FooService {List<String> data = new ArrayList<>();public void oom() {//往同一个ArrayList中不断加入大小为10KB的字符串data.add(IntStream.rangeClosed(1, 10_000).mapToObj(__ -> "a").collect(Collectors.joining("")));}
}
到这里,我们使用 MAT 工具从对象清单、大对象、线程栈等视角,分析了一个 OOM 程序的堆转储。
可以发现,有了堆转储,几乎相当于拿到了应用程序的源码 + 当时那一刻的快照,OOM 的问题无从遁形。
参考资料:《Java业务开发错误100例》