首次排查 OOM 实录原创
前言
本篇文章的落成更像是一篇笔记,而不是博客。因为在一年的工作后,首次碰上了 OOM 问题,虽然导致的原因比较简单,但也算是值得纪念的,哈哈。
问题复现
问题原因和 Disruptor 相关,如果不了解的同学,就把它理解成一个首尾相连的环形 Queue 就 OK 了。
代码实现
首先创建 Disruptor 存放的实体类 Entity,它有个对象叫 dataList,存放的是 EntityData 的引用:
@Data
public class Entity {
private long id;
private List<EntityData> dataList;
}
@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class EntityData {
private long id;
private String message;
}
创建 Disruptor 消费者,从队列中消费数据,打印了当前消费的 sequence,以及 dataList 中引用的对象数量:
class EntityEventHandler implements EventHandler<Entity> {
@Override
public void onEvent(Entity event, long sequence, boolean endOfBatch) throws Exception {
// 从 ringBuffer 中消费数据
System.out.println("EntityEventHandler Sequence: " + sequence + ", subList size: " + event.getDataList().size());
}
}
创建 Disruptor 生产者,通过调用 publish() 方法将数据放入队列中:
class EntityEventTranslator {
private final RingBuffer<Entity> ringBuffer;
public EntityEventTranslator(RingBuffer<Entity> ringBuffer) {
this.ringBuffer = ringBuffer;
}
private static final EventTranslatorTwoArg<Entity, Long, List<EntityData>> TRANSLATOR = (event, sequence, id, dataList) -> {
event.setId(id);
event.setDataList(dataList);
};
public void publish(Long id, List<EntityData> dataList) {
ringBuffer.publishEvent(TRANSLATOR, id, dataList);
}
}
创建运行的主类,主要的业务操作就是一个死循环去生产数据。
public class OOMTest {
private static int BUFFER_SIZE = 65536;
public static void main(String[] args) throws InterruptedException {
Disruptor<Entity> disruptor = new Disruptor<>(Entity::new, BUFFER_SIZE, DaemonThreadFactory.INSTANCE, ProducerType.SINGLE, new BlockingWaitStrategy());
// 2. 添加消费者
disruptor.handleEventsWith(new EntityEventHandler());
// 3. 启动 Disruptor
RingBuffer<Entity> ringBuffer = disruptor.start();
// 4. 创建生产者
EntityEventTranslator producer = new EntityEventTranslator(ringBuffer);
// 5. 死循环发送事件
while (true) {
long id = RandomUtils.nextLong(1, 100000);
List<EntityData> dataList = mockData(RandomUtils.nextInt(10, 1000));
producer.publish(id, dataList);
TimeUnit.MILLISECONDS.sleep(10);
}
}
private static List<EntityData> mockData(int size) {
List<EntityData> result = Lists.newArrayListWithCapacity(size);
for(int i = 0; i < size; i++) {
result.add(new EntityData(RandomUtils.nextLong(100000, 1000000), RandomStringUtils.randomAlphabetic(1, 100)));
}
return result;
}
}
Java VisualVM
Java VisualVM 是自 JDK 1.6 起,安装时自带的 JVM 监控工具,本次我们使用它来监控程序运行后的堆内存情况。运行程序,启动 Java VisualVM 并连接上程序。可以观察到,随着程序的不断运行,堆的大小不断扩大。
复现 OOM
下面通过控制 JVM 的堆大小,来达到 OOM 的效果。在 IDEA 中配置运行主类的启动参数如下:
-Xms256m -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\disruptor_oom.hprof
第一个指定了堆的初始大小,第二个指定了堆的最大大小,第三个指定了当 JVM 发生 OOM 时,自动生成 dump 文件,第四个指定了 dump 文件的位置。
重新运行程序,由于我们配置了 Xmx,因此当内存增长到阈值时,触发 OOM,dump 文件生成在指定位置。
Heap Dump
Dump
Eclipse 的 MemoryAnalyzer
是目前最为常用的 dump 文件分析工具,有 Ecplise 插件版和独立版两种。由于我们使用的 IDE 是 IDEA,因此安装独立版即可。
安装完毕后,点击 File -> Open Heap Dump
打开生成的 dump 文件,等待右下角加载完毕后,选择 Leak Suspects Report
打开分析报告。
由于使用的这个 Case没有干扰项,所以报告刚打开,Override
页其实就已经展示出了问题:
com.lmax.disruptor.RingBuffer @ 0xf8153f78
Shallow Size: 144 B Retained Size: 232.6 MB
我们假装看不见,点击 Leak Suspects,查看内存泄露分析报告。如图中 ① 所示,报告只出现了一个问题。实际项目这里可能会展示多个,你需要去找到真正导致 OOM 的那一个问题。
在 ② 处和 ③ 处,重点关注 Shallow Heap
和 Retained Heap
两个字段。专业的解释比较晦涩难懂,用大白话来说就是: Shallow Heap
表示对象自身的占用大小;Retained Heap
表示对象自身及其 GC 后可被释放的所有引用对象的大小。
以下面代码为例,Shallow Heap 表示 A 自身的大小,Retained Heap 表示 A + B 的大小(严谨的说,只有 B 不被其他对象所引用,即 GC 在释放掉 A 时也能释放掉 B 的话,Retained Heap 才为二者和)。
class A { B b; }
class B { String sss; }
回到报告,在 ② 处说明了 RingBuffer 队列底层的 entries 数组,其 Shallow Heap 只有 144 byte,而 Retained Heap 有 243879304 byte,说明其引用了大量可以被 GC 的对象。在 ③ 处展示 entries 数组中每一项元素,也可以看出来。
问题原因
问题的原因就是这个 entries 数组太大了,大的原因是 Entity 对象有个 EntityData 的 dataList,每个 Entity 都持有了许多个 EntityData,导致 entries 数组较大。
虽然这些 Entity 对象及其持有的 EntityData 都是可以被 GC 的,但是不幸的是还没等到 GC 就已经 OOM 了。因此想要解决这个问题,就得让程序能够撑到 GC。
将程序主类中的 BUFFER_SIZE 从 65536 下调到 128,重新运行再试试看。
可以看到程序内存保持稳定。
BUFFER_SIZE 设置的就是这个 entries 数组的长度,前面说过可以把它理解成一个首尾相连的环形 Queue,那么当 entries 数组满了,又从头开始覆盖。当被覆盖后,原对象就失去了引用,就可以被 GC。
结语
生成 dump 文件的方式一般有两种,要么是在程序启动时,通过 JVM 参数,在出现 OOM 时自动 dump,就如同 2.3 节那样;另一种方式是通过命令,把当前的程序进行 dump:
jmap -dump:[live,]format=b,file=fileName [pid]
jmap 也是在安装 JDK 时自动安装的小工具,可以帮助我们对堆进行 dump。live 参数是可选的,选中后只输出活跃的对象到 dunmp文件,最后的 pid 指定 java 程序运行的进程号。例如:
jmap -dump:format=b,file=/home/admin/disruptor_oom.hprof 1235
本文作者:Jitwxs