EZLippi-浮生志

记一次Netty堆外内存排查

背景

线上有2个服务堆外内存占用比较多,通过TOP命令看到RSS大小远大于-Xmx参数指定的大小, 堆外内存占用比较多,需要排查是否有内存泄漏的情况。

资源占用情况

找了exchange服务作为分析的对象, 通过top命令查到exchange服务RSS内存大小为9.1g


该机器与内存有关的jvm参数为:

-Xms6500m
-Xmx6500m
-Xmn2500m
-Xss256k
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M

通过jcmd命令导出该机器的native memory使用情况,需要添加jvm参数-XX:NativeMemoryTracking=summary,这里省略了几个无关紧要的区域:

3190903:
Native Memory Tracking:

  • Java Heap (reserved=6656000KB, committed=6656000KB)
    (mmap: reserved=6656000KB, committed=6656000KB)
    
  • Class (reserved=1184072KB, committed=150728KB)
    (classes #20947)
    (malloc=4424KB #83269)
    (mmap: reserved=1179648KB, committed=146304KB)
    
  • Thread (reserved=2037767KB, committed=2037767KB)
    (thread #7653)
    (stack: reserved=2018704KB, committed=2018704KB)
    (malloc=10095KB #38283)
    (arena=8968KB #15305)
    
  • Code (reserved=275422KB, committed=149714KB)
    (malloc=25822KB #31249)
    
  • Compiler (reserved=10783KB, committed=10783KB)
    (malloc=10652KB #11548)
    (arena=131KB #3)
    
  • Internal (reserved=1674310KB, committed=1674310KB)
    (malloc=1674278KB #133970)
    (mmap: reserved=32KB, committed=32KB)
    

和预期的一致,主要是线程栈(2037767KB)和Netty堆外内存(Internal区)(1674310KB)占用的比较多,线程特别多,会导致频繁的上下文切换, 而且线程池里面的线程也会竞争的从队列中取任务,这里需要提一下Netty的NioEventLoop,其实现为包含单个线程的线程池,每个线程有单独的队列,既减少了竞争很多场景也可以避免使用锁。

而Netty堆外内存占用大主要是这2个服务的报文比较大, NettyServer在编解码请求参数和返回值时会使用堆外内存,这部分内存只有真正写入到底层Socket后才会释放, 如果返回的报文很大,就会出现堆外内存使用比较多的情况。

这是某个服务接收到的报文大小监控, 有2个方法返回的报文都超接近1.5MB了

JVM内存模型

结合Jvm内存模型来分析下上面jcmd的结果,根据JVM规范,JVM运行时数据区共分为堆(Heap)、方法区(永久代/Metaspace)、程序计数器(PC)、虚拟机栈、本地方法栈五个部分,如下所示:

堆外内存主要包括虚拟机栈,本地方法栈,方法区,CodeCache和NIO框架使用的堆外内存

  • 虚拟机栈
    通过-Xss参数可以指定每个线程的栈大小,java 8默认是1M,我们线上系统都是设置的256K,所有线程使用的内存大小可以大致通过256k * 线程数计算出来,
    线程数可以使用top -Hp java进程Id,输出的tasks数目就是线程数,如下入所示:

    因此ad-exchange线程占用的资源为256k * 7636 = 200173KB, 和jcmd的结果基本一致。
  • 方法区
    方法区的主要内存消耗就是Metaspace,由于指定了-XX:MaxMetaspaceSize=256M参数,实际使用了150728KB
  • CodeCache
    CodeCache主要是JIT编译生成的二进制代码,可以通过jvm参数-XX:ReservedCodeCacheSize=240m指定,实际使用了149714KB
  • Netty堆外内存
    调用ByteBuffer.allocateDirect()申请的内存算在jcmd的Internal区域,当然我们的使用场景下netty并不是使用ByteBuffer.allocateDirect()来申请堆外内存,主要原因是
    返回的DirectByteBuffer由Cleaner来负责释放,Cleaner对象是虚引用, 如果DirectByteBuffer对象进入到了老年代,只有CMS GC和Full GC才能出发引用处理,这会导致堆外内存释放不及时,可能出现内存泄漏的情况。

堆外内存大小控制参数

配置堆外内存大小的参数有-XX:MaxDirectMemorySize和-Dio.netty.maxDirectMemory,这2个参数有什么区别?

-XX:MaxDirectMemorySize

用于限制Netty中hasCleaner策略的DirectByteBuffer堆外内存的大小,默认值是JVM能从操作系统申请的最大内存,由Runtime.getRuntime().maxMemory()返回,其实就是-Xmx参数指定的大小,代码位于java.nio.Bits#reserveMemory()方法中

也就是说通过ByteBuffer.allocateDirect(size)方法申请的内存,最大不能超过MaxDirectMemorySize指定的大小

-Dio.netty.maxDirectMemory

用于限制noCleaner策略下Netty的DirectByteBuffer分配的最大堆外内存的大小,如果该值为0,则使用hasCleaner策略,代码位于PlatformDependent#incrementMemoryCounter()方法中,noCleaner策略下Netty使用unsafe.allocateMemory(size)来申请堆外内存,这个大小是不受限制的, 因此netty提供了io.netty.maxDirectMemory来控制最大申请的堆外内存大小。

hasCleaner和noCleaner

jdk默认使用Cleaner来释放DirectByteBuffer申请的堆外内存, Cleaner继承自PhantomReference, 由于引用处理依赖GC,回收时间不可控,因此Netty会根据io.netty.maxDirectMemory参数和jdk里是否有Cleaner类来决定是使用ByteBuffer.allocateDirect(size)还是unsafe.allocateMemory(size)

测试环境复现(服务端)

测试服务端返回给客户端的报文比较大的场景下堆外内存的占用情况,服务端返回一个长度为512K的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HelloServiceImpl implements HelloService.Iface {
private static final String result;
static {
char[] chars = new char[512 * 1024];
Arrays.fill(chars, '0');
result = String.valueOf(chars);
}

@Override
public String helloString(String para) throws TException {
return result;
}
}

客户端起10个线程去调用服务端,观察服务端的堆外内存占用情况:

1
2
3
4
# 先使用jcmd建立内存基线
jcmd 5949 VM.native_memory baseline
# 观察内存占用情况
jcmd 5949 VM.native_memory summary.diff

不到一分钟堆外内存占用就超过400M了:

通过jmap命令可以看到jvm堆内有50个PoolChunk对象,每个PoolChunk占8M(netty默认每个PoolChunk是申请16M内存,由于我们在代码里配置了io.netty.allocator.pageSize=4096,实际PoolChunk就是占用8M),一共400M,和NMT的Internal区域大小相符合:

那就对上号了,每次创建PoolChunk都会根据chunksize申请堆外内存,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//PoolArena.java
//这里的Chunksize=pageSize << 11 = 8M
protected PoolChunk<ByteBuffer> newChunk(int pageSize, int maxOrder,
int pageShifts, int chunkSize) {
if (directMemoryCacheAlignment == 0) {
return new PoolChunk<ByteBuffer>(this,
allocateDirect(chunkSize), pageSize, maxOrder,
pageShifts, chunkSize, 0);
}
}

private static ByteBuffer allocateDirect(int capacity) {
return PlatformDependent.useDirectBufferNoCleaner() ?
PlatformDependent.allocateDirectNoCleaner(capacity) : ByteBuffer.allocateDirect(capacity);
}

为了进一步验证这些堆外内存是被写缓冲区(ChannelOutboundBuffer)的对象引用了,通过jmap把堆镜像导出来用MAT分析,结果如下:

可以看到堆内有286个io.netty.buffer.PooledUnsafeDirectByteBuf对象,每个对象的引用计数都是2,其中一个是Recycle对象,这个是netty内部的对象池用来复用对象的,可以忽略,另一个引用就是io.netty.channel.ChannelOutboundBuffer$Entry,代码里调用Channel.writeAndFlush()后会写到ChannelOutBoundBuffer缓冲区,并转换成Entry对象,真正写入到Socket后才会通过RefrenceCountUtil.release(object)释放内存。

客户端断开连接后, 通过jcmd发现Internal区域的内存释放了

而且是在客户端断开连接后立马就释放了

debug的时候在PlatformDependent.freeDirectNoCleaner处打断点,看到底是什么原因导致客户端断开后堆外内存立马就释放了,调试时发现Channel出现IOException时会调用outboundBuffer.failFlushed(cause, notify),立马会把ChannelOutBoundBuffer里未写入到Socker的数据清空,然后逐步把从PoolChunk中申请的堆外内存归还, 当PoolChunk中内存使用率为0%并且属于q000这个PoolChunkList时destroyChunk(),并把最开始申请的8M堆外内存释放掉,这个q000又是个什么东西呢?

Netty为了提高从PoolChunk申请内存的效率,使用了6个PoolChunkList来管理PookChunk,每个PoolChunkList存储内存使用率超过一定比例的PoolChunk,这样分配内存的时候优先从剩余内存多的PoolChunk中申请, PoolChunk每次申请或释放内存后,都要根据其内存使用率移动到前一个或者下一个PoolChunkList,前面的q000就是使用率为0%的PoolChunkList:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PoolArena {
protected PoolArena(PooledByteBufAllocator parent, int pageSize,
int maxOrder, int pageShifts, int chunkSize, int cacheAlignment) {
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);

q100.prevList(q075);
q075.prevList(q050);
q050.prevList(q025);
q025.prevList(q000);
q000.prevList(null);
qInit.prevList(qInit);
}


}

分配内存的代码,可以看到Netty分配内存的优先级是q050 -> q025 -> q000 -> qinit -> q075:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
if (q050.allocate(buf, reqCapacity, normCapacity) || q025.allocate(buf, reqCapacity, normCapacity) ||
q000.allocate(buf, reqCapacity, normCapacity) || qInit.allocate(buf, reqCapacity, normCapacity) ||
q075.allocate(buf, reqCapacity, normCapacity)) {
return;
}

// Add a new chunk.
PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
boolean success = c.allocate(buf, reqCapacity, normCapacity);
assert success;
qInit.add(c);
}

freeChunk的代码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
void freeChunk(PoolChunk<T> chunk, long handle, SizeClass sizeClass, ByteBuffer nioBuffer, boolean finalizer) {
final boolean destroyChunk;
synchronized (this) {
// We only call this if freeChunk is not called because of the PoolThreadCache finalizer as otherwise this
// may fail due lazy class-loading in for example tomcat.
destroyChunk = !chunk.parent.free(chunk, handle, nioBuffer);
}
if (destroyChunk) {
// destroyChunk not need to be called while holding the synchronized lock.
destroyChunk(chunk);
}
}

回到我们前面的问题, 既然是ChannelOutBoundBuffer里占用的堆外内存过多,如果客户端的请求速度降下来那是不是堆外内存也能逐渐释放呢,写代码测试下:

1
2
3
4
5
6
7
final long start = System.currentTimeMillis();   
scheduledExecutorService.scheduleWithFixedDelay(() -> {
if (System.currentTimeMillis() - start > 60000) {
return;
}
HelloService.sayHello();
}, 10, 10, TimeUnit.MILLISECONDS);

测试结果和预期一致, 客户端停止发请求后服务端的堆外内存使用在1分钟内降低到之前的大小:

堆外内存的申请和释放并非在同一个线程完成的, 堆外内存只有真正写入到Socket后才会释放,这是一个异步的过程,当服务端qps很高并且返回的报文都很大时就会出现堆外内存占用过多的情况, 但只要qps的峰值是确定的,堆外内存占用的峰值也是确定的,
因此最佳的解决办法是优化模型, 只返回客户端必须的字段,减少报文大小。

🐶 您的支持将鼓励我继续创作 🐶