⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 码猿技术专栏 「不才陈某」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

今天介绍如何使用 JVM 堆转储的工具 MAT 来分析 OOM 问题。

使用 MAT 分析 OOM 问题

对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。

堆转储,包含了堆现场全貌和线程栈信息(Java 6 Update 14 开始包含)。

使用 jstat 等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储,就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。

Java 的 OutOfMemoryError 是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置 JVM 参数,方便发生 OOM 时进行堆转储:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.

我更推荐使用 Eclipse 的 Memory Analyzer(也叫做 MAT)做堆转储的分析。你可以点击这个链接:https://www.eclipse.org/mat/,下载 MAT。

使用 MAT 分析 OOM 问题,一般可以按照以下思路进行:

  1. 通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
  2. 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
  3. 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
  4. 辅助使用查看线程栈来看 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,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上角的红框所示)进入支配树界面(有关支配树的具体概念参考这里)。这个界面会按照对象保留的 Retained Heap 倒序直接列出占用内存最大的对象。

可以看到,第一位就是 FooService,整个路径是 FooSerice->ArrayList->Object[]->String->char[] (蓝色框部分),一共有 21523 个字符串(绿色方框部分):

这样,我们就从内存角度定位到 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。

你甚至可以在这个 HashMap 上点击右键,选择 Java Collections->Hash Entries 功能,来查看其内容:

这样就列出了所有的 Bean,可以在 Value 上的 Regex 进一步过滤。输入 FooService 后可以看到,类型为 FooService 的 Bean 只有一个,其名字是 fooService:

到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现 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 的问题无从遁形。

文章目录
  1. 1. 使用 MAT 分析 OOM 问题