记一次堆外内存泄露的排查过程

内存泄露

Posted by MistRay on February 24, 2020

记一次堆外内存泄露的排查过程

背景

生产上运行的某个服务使用了Netty提供的组件作为HTTP服务器,里面运行着我们的核心业务。由于该服务在最开始的时候新增功能的频率较高,所以大约每两周就要上线一次,一直相安无事。 但随着服务运行逐渐稳定,功能逐步完善,上线的频率变得很低。终于在两个多月后的一天,该服务所在的服务器发均出了报警,报警内容是服务器内存使用量超过了80%。

分析问题

首先我们查看了该服务在CAT上的各项指标,均显示正常。fullGC次数也在可接受范围之内,服务内部的业务指标也都是健康值。

幽灵内存

监控指标 和运维同学再次确认,服务器内存确确实实是被该服务占住的。

然后我们找运维同学要了一份生产环境的堆dump,着手开始分析。堆内存回收之后也仅有100多M,远达不到2G的Xmx上限值,GC日志也没有什么异样,堆内存是十分健康的。 到这里我们基本已经可以判断出是该服务的堆外内存泄露了。

堆外内存

java 8下是指除了Xmx设置的java堆(java 8以下版本还包括MaxPermSize设定的持久代大小)外,java进程使用的其他内存。 主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存, jvm本身运行过程分配的内存,codeCache,java 8里还包括metaspace元数据空间。

但监控项中的非堆内存指标却显示为正常值,稳定且没有一点波动。 监控指标

有同事质疑监控可能出了问题,所以我们选择了JConsole,来进一步确定非堆内存的使用量,不出意外的也很正常。这就让我们绕进了死胡同。 这部分确定不了的内存难道是幽灵,哪里也不在,但又无处不在?(笑)

Netty使用的堆外内存

由于服务使用的RPC框架底层采用了Netty等NIO框架,会使用到DirectByteBuffer这种“冰山对象”。 关于DirectByteBuffer先介绍一下:JDK 1.5之后ByteBuffer类提供allocateDirect(int capacity)进行堆外内存的申请, 底层通过unsafe.allocateMemory(size)实现,会调用malloc方法进行内存分配。实际上,在java堆里是维护了一个记录堆外地址和大小的DirectByteBuffer的对象, 所以GC是能通过操作DirectByteBuffer对象来间接操作对应的堆外内存,从而达到释放堆外内存的目的。但如果一旦这个DirectByteBuffer对象熬过了young GC到达了Old区, 同时Old区一直又没做CMS GC或者Full GC的话,这些“冰山对象”会将系统物理内存慢慢消耗掉。

对于这种情况JVM留了后手,Bits给DirectByteBuffer前首先需要向Bits类申请额度, Bits类维护了一个全局的totalCapacity变量,记录着全部DirectByteBuffer的总大小,每次申请,都先看看是否超限(堆外内存的限额默认与堆内内存Xmx设定相仿), 如果已经超限,会主动执行Sytem.gc(),System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存。 但如果启动时通过-DisableExplicitGC禁止了System.gc(),那么这里就会出现比较严重的问题,导致回收不了DirectByteBuffer底下的堆外内存了。 所以在类似Netty的框架里对DirectByteBuffer是框架自己主动回收来避免这个问题。

Netty真实的堆外内存使用

抱着不信邪的态度,我google了一个工具类,通过反射每秒打印Netty堆外内存的使用量。

netty4.1x默认使用池化的bytebuf,每个池子初始16mb,使用直接内存的最大池子数默认16, 由jvm启动参数决定-Dio.netty.allocator.numDirectArenas。 默认最大堆外内存默认3817865216 bytes,由-Dio.netty.maxDirectMemory决定

package com.mistray.util;

import io.netty.util.internal.PlatformDependent;

import java.lang.reflect.Field;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class DirectMemoryReporter implements Runnable {

    private final static int _1k = 1024;

    private final static String business_key = "netty_direct_memory";

    private static AtomicLong directMemory;

    public static void init() {
        try {
            Field field = PlatformDependent.class.getDeclaredField("DIRECT_MEMORY_COUNTER");
            field.setAccessible(true);
            directMemory = (AtomicLong)field.get(PlatformDependent.class);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void startReport() {
        ExecutorService pool = Executors.newScheduledThreadPool(1);
        ((ScheduledExecutorService) pool).scheduleAtFixedRate(new DirectMemoryReporter(), 0, 1, TimeUnit.SECONDS);
    }

    public void run() {
        try {
            int memoryInKb = (int)(directMemory.get());
            System.out.println(business_key + " : " + memoryInKb);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

打印后的结果让人大吃一惊,堆外内存的使用量在某个接口被访问时会以一个极其缓慢的速度提升,这和监控给出的结果截然不同。

监控遗漏

查了资料后发现,不管是JConsole还是CAT,都是调用JMX来获取监控信息。 JMX(Java Management Extensions,即Java管理扩展)是一个为应用程序、设备、系统等植入管理功能的框架。 JMX可以跨越一系列异构操作系统平台、系统体系结构和网络传输协议,灵活的开发无缝集成的系统、网络和服务管理应用。

JMX提供了监控direct bufferMXBean,但启动服务时需要开启。 估计是运维同学在启动时并未开启该项,导致JConsoleCAT获取到了错误的数据,所以也解释了为什么监控获取到的堆外内存如此平稳,没有一丝波动。

定位问题

猜测:创建堆外内存的ByteBuf后,未释放

既然确定了问题是堆外内存泄露,这时我想到了该服务使用了Netty,Netty中可以创建堆外内存的ByteBuf。 我在代码中全局搜索了该服务中使用到ByteBuf的地方,挨个排查,发现使用的ByteBuf要么就释放了,要么就是没有使用到直接缓冲区(即堆外内存)。

老办法解决新问题:删除法

既然已经确定是服务内的某处代码导致堆外内存溢出,而且知道了触发条件,那么就可以通过注释代码的方式,一点一点排除掉无关代码,从而找到真正的bug。 该过程比较枯燥,我这里一笔带过,不过多赘述。但最终我们通过该方法确定了问题点。

真相大白

String message = Base64.encode(payload, false).toString(CharsetUtil.UTF_8);

就是这样一行简单的代码,看似人畜无害,竟然会导致内存泄露。
我点进Base64.java看了下,果然Base64.encode(payload, false)函数的返回值是一个ByteBuf。 获取到该ByteBuf后,直接调用了toString方法,写出这段代码的人可能认为toString方法是被ByteBuf的实现类重写过的,所以使用后会自动释放吧。

总结

ByteBuf这种使用直接内存的东西不要定义成隐式的啊!!!
如果这里定义成显式的,估计早就被路过review代码的大兄弟解决了!!!

Reference

Java堆外内存增长问题排查
Netty实战
Netty堆外内存监控
Netty堆外内存泄漏排查,这一篇全讲清楚了

转载

本文遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。