使用 MAT 排查分析 OOM 问题案例

MAT

相信有一定经验的开发者多少都会在生产环境上碰到过内存溢出(OOM)的问题吧。对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。

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

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/xxx/xxx

关于分析堆存储文件的话,推荐使用Eclipse 的 Memory Analyzer(也叫做 MAT)做堆转储的分析。下载地址:https://www.eclipse.org/mat/


MAT 分析 OOM 问题通常思路:

  • 1、通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
  • 2、查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
  • 3、配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
  • 4、辅助使用查看线程栈来看 OOM 问题是否和过多线程有关,甚至可以在线程栈看到 OOM 最后一刻出现异常的线程。

使用MAT定位问题:

定位问题方式一:

现在有一个OOM后得到的堆转储文件 java_pid29569.hprof,现在要使用 MAT 的直方图、支配树、线程栈、OQL 等功能来分析此次 OOM 的原因。

首先,用 MAT 打开后先进入的是概览信息界面,可以看到整个堆是 437.6MB:
使用 MAT 排查分析 OOM 问题案例_第1张图片
那么,这 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[]Stringvalue 字段引用,说明 char[]来自字符串;
  • 在③处看到,StringArrayListelementData 字段引用,说明这些字符串加入了一个 ArrayList 中;
  • 在④处看到,ArrayList 又被 FooServicedata 字段引用,这个 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() 方法被 OOMApplicationrun 方法调用,而这个 run 方法又被 SpringAppliction.callRunner 方法调用。

  • 看到参数中的 CommandLineRunner 你应该能想到,OOMApplication 其实是实现了 CommandLineRunner 接口,所以是 SpringBoot 应用程序启动后执行的。

  • FooService 为起点往上看,从紫色框中的 CollectorsIntPipeline,大概也可以猜出,这些字符串是由 Stream 操作产生的。

  • 再往上看,可以发现在 StringBuilderappend 操作的时候,出现了 OutOfMemoryError 异常(黑色框部分),说明这这个线程抛出了 OOM 异常。

我们看到,整个程序是 Spring Boot 应用程序,那么 FooService 是不是 SpringBean 呢,又是不是单例呢?

如果能分析出这点的话,就更能确认是因为反复调用同一个 FooServiceoom 方法,然后导致其内部的 ArrayList 不断膨胀。

点击工具栏的第四个按钮(如下图红框所示),来到 OQL 界面。在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据(你可以直接在 MAT 帮助菜单搜索 OQL Syntax,来查看 OQL 的详细语法)。


比如,输入如下语句搜索 FooService 的实例:


SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService


可以看到只有一个实例,然后我们通过 List objects 功能搜索引用 FooService 的对象:

使用 MAT 排查分析 OOM 问题案例_第2张图片
可以看到,一共两处引用:

  • 第一处是,OOMApplication 使用了 FooService
  • 第二处是一个 ConcurrentHashMap。可以看到,这个 HashMap 是 DefaultListableBeanFactorysingletonObjects 字段,可以证实 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例》,如有侵权请告知删除。

你可能感兴趣的:(架构之路,JVM,&,JDK,问题&优化归集)