相信有一定经验的开发者多少都会在生产环境上碰到过内存溢出(OOM)的问题吧。对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。
Java 的 OutOfMemoryError
是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置 JVM 参数,方便发生 OOM 时进行堆转储:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/xxx/xxx
关于分析堆存储文件的话,推荐使用Eclipse 的 Memory Analyzer
(也叫做 MAT)做堆转储的分析。下载地址:https://www.eclipse.org/mat/
MAT
分析 OOM
问题通常思路:MAT
定位问题:现在有一个OOM后得到的堆转储文件 java_pid29569.hprof
,现在要使用 MAT 的直方图、支配树、线程栈、OQL
等功能来分析此次 OOM 的原因。
首先,用 MAT 打开后先进入的是概览信息界面,可以看到整个堆是 437.6MB:
那么,这 437.6MB 都是什么对象呢?
如图所示,工具栏的第二个按钮可以打开直方图,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存。可以看到,char[]
字节数组占用内存最多,对象数量也很多,结合第二位的 java.lang.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
中;FooService
的 data
字段引用,这个 ArrayList
整个 RetainedHeap
列的值是 431MB。FooService
有一个 data
属性,类型是 ArrayList
。Retained Heap(深堆)
:代表对象本身和对象关联的对象占用的内存;Shallow Heap(浅堆)
:代表对象本身占用的内存。比如,我们的 FooService
中的 data
这个 ArrayList
对象本身只有 16
字节,但是其所有关联的对象占用了 431MB
内存。这些就可以说明,肯定有哪里在不断向这个 List
中添加 String
数据,导致了 OOM
。
如果我们希望看到字符串完整内容的话,可以右键选择 Copy->Value,把值复制到剪贴板或保存到文件中:
这里,我们复制出的是 10000 个字符 a
(下图红色部分可以看到)。
看到这些,我们已经基本可以还原出真实的代码是怎样的了,定位到了问题代码。
其实,我们之前使用直方图定位 FooService
,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上角的红框所示)进入支配树
界面。这个界面会按照对象的 Retained Heap
倒序直接列出占用内存最大的对象。
可以看到,第一位就是 FooService
,整个路径是 FooSerice->ArrayList->Object[]->String->char[]
(蓝色框部分),一共有 21523
个字符串(绿色方框部分)。通常使用这种方式可以一步到位的定位出问题所在。
MAT
寻到具体问题原因我们就从内存角度定位到 FooService
是根源了。那么,OOM
的时候,FooService
是在执行什么逻辑呢?
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图
,首先看到的就是一个名为 main
的线程(Name
列),展开后果然发现了 FooService
:
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。
因为我们希望了解 FooService.oom()
方法,看看是谁在调用它,它的内部又调用了谁,所以选择以 FooService.oom()
方法(蓝色框)为起点来分析这个调用栈。
往下看整个绿色框部分,oom()
方法被 OOMApplication
的 run
方法调用,而这个 run
方法又被 SpringAppliction.callRunner
方法调用。
看到参数中的 CommandLineRunner
你应该能想到,OOMApplication
其实是实现了 CommandLineRunner
接口,所以是 SpringBoot 应用程序启动后执行的。
以 FooService
为起点往上看,从紫色框中的 Collectors
和 IntPipeline
,大概也可以猜出,这些字符串是由 Stream
操作产生的。
再往上看,可以发现在 StringBuilder
的 append
操作的时候,出现了 OutOfMemoryError
异常(黑色框部分),说明这这个线程抛出了 OOM
异常。
我们看到,整个程序是 Spring Boot
应用程序,那么 FooService
是不是 Spring
的 Bean
呢,又是不是单例
呢?
如果能分析出这点的话,就更能确认是因为反复调用同一个 FooService
的 oom
方法,然后导致其内部的 ArrayList
不断膨胀。
点击工具栏的第四个按钮(如下图红框所示),来到 OQL 界面。在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据(你可以直接在 MAT 帮助菜单搜索 OQL Syntax,来查看 OQL 的详细语法)。
比如,输入如下语句搜索 FooService
的实例:
SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService
可以看到只有一个实例,然后我们通过 List objects
功能搜索引用 FooService
的对象:
OOMApplication
使用了 FooService
。ConcurrentHashMap
。可以看到,这个 HashMap 是 DefaultListableBeanFactory
的 singletonObjects
字段,可以证实 FooService
是 Spring 容器管理的单例的 Bean。到现在为止,虽然没看程序代码,但是已经大概知道程序出现 OOM 的原因和大概的调用栈了。再贴出程序来对比一下,果然和我们看到得一模一样:
@SpringBootApplication
public class OOMApplication implements CommandLineRunner {
@Autowired
FooService fooService;
public static void main(String[] args) {
SpringApplication.run(OOMApplication.class, args);
}
@Override
public 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例》,如有侵权请告知删除。