开个新坑 【深入理解 ByteBuf】 至于为什么,本篇就是原因
我大概会花一个较长的时间来剖析 Netty 对于 ByteBuf 的实现,对象池的设计,从分配到释放重用,希望可以借此学习理解对象池的设计思想,以及搞清楚,我们不 release ByteBuf 泄露的究竟是什么,
以文章形式作为记录,希望对之后的开发设计有所启发,如果本系列文章帮助到了你,不胜荣幸。
首先模拟内存泄露的场景,这里我写了几个接口先通过 /buffer 接口进行循环分配,/metric 接口可以查看当前 分配器 的一些状态参数
@Slf4j
@RequestMapping("/api/bytebuf")
@RestController
public class ByteBufTestController {
private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3,5,
10, TimeUnit.SECONDS,new ArrayBlockingQueue<>(10));
@RequestMapping("/bufferNoPool")
public ResultVO bufferNoPool() {
final Runnable runnable = new Runnable() {
@Override
public void run() {
try {
while (true) {
final ByteBuf buffer = PooledByteBufAllocator.DEFAULT.buffer(1024 * 10);
buffer.writeBytes(new byte[1024 * 10]);
buffer.retain();
}
}catch (Exception e){
log.error("run buffer error" ,e);
}
}
};
Thread thread = new Thread(runnable);
thread.start();
return ResultVO.successResult(PooledByteBufAllocator.DEFAULT.toString());
}
@RequestMapping("/buffer")
public ResultVO buffer() {
final Runnable runnable = new Runnable() {
@Override
public void run() {
try {
while (true) {
final ByteBuf buffer = PooledByteBufAllocator.DEFAULT.buffer(1024 * 10);
buffer.writeBytes(new byte[1024 * 10]);
buffer.retain();
}
}catch (Exception e){
log.error("run buffer error" ,e);
}
}
};
threadPoolExecutor.submit(runnable);
threadPoolExecutor.submit(runnable);
return ResultVO.successResult(PooledByteBufAllocator.DEFAULT.toString());
}
@RequestMapping("/bufferAndRelease")
public ResultVO bufferAndRelease() {
final Runnable runnable = new Runnable() {
@Override
public void run() {
try {
while (true) {
final ByteBuf buffer = PooledByteBufAllocator.DEFAULT.buffer(1024 * 10);
buffer.writeBytes(new byte[1024 * 10]);
buffer.release();
}
}catch (Exception e){
log.error("run bufferAndRelease error" ,e);
}
}
};
threadPoolExecutor.submit(runnable);
threadPoolExecutor.submit(runnable);
return ResultVO.successResult(PooledByteBufAllocator.DEFAULT.toString());
}
@RequestMapping("/metric")
public ResultVO metric() {
final PooledByteBufAllocatorMetric metric = PooledByteBufAllocator.DEFAULT.metric();
ResultMap resultMap = new ResultMap();
resultMap.put("numDirectArenas", metric.numDirectArenas());
resultMap.put("usedDirectMemory", metric.usedDirectMemory());
resultMap.put("metric", metric.toString());
return ResultVO.successResult(resultMap);
}
}
配置好你本地的启动项,为了观测明显我分配了 2G 的可用直接内存
-Xms2G
-Xmx2G
-XX:MaxDirectMemorySize=2G
-XX:ThreadStackSize=512
-XX:MaxMetaspaceSize=256M
-XX:MetaspaceSize=256M
-Dio.netty.leakDetection.level=paranoid
--add-opens
java.base/java.lang=ALL-UNNAMED
--add-opens
java.base/java.io=ALL-UNNAMED
--add-opens
java.base/java.math=ALL-UNNAMED
--add-opens
java.base/java.net=ALL-UNNAMED
--add-opens
java.base/java.nio=ALL-UNNAMED
--add-opens
java.base/java.security=ALL-UNNAMED
--add-opens
java.base/java.text=ALL-UNNAMED
--add-opens
java.base/java.time=ALL-UNNAMED
--add-opens
java.base/java.util=ALL-UNNAMED
--add-opens
java.base/JDK.internal.access=ALL-UNNAMED
--add-opens
java.base/JDK.internal.misc=ALL-UNNAMED
--add-opens
java.base/sun.net.util=ALL-UNNAMED
那项目刚启动可以观察到,在活动监视器中的内存占用是 1G 多
运行时可以尝试通过 JProfiler 来监听内存和 GC 情况,下面是正常运行的检测
请求分配并且不释放时,堆内存增长,经过 GC 后呈现尖刺状,最后趋近平稳是线程分配已经报错,无法进行分配了,可以看到整个 Java 程序占用 4G 多而且一直不会释放。
再次 GC 后回归正常
但是整个程序堆外内存已经无法分配了
Exception in thread "Thread-53" java.lang.OutOfMemoryError: Cannot reserve 4194304 bytes of direct buffer memory (allocated: 2146073230, limit: 2147483648)
at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:121)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:332)
at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:701)
at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:676)
at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:215)
at io.netty.buffer.PoolArena.tcacheAllocateSmall(PoolArena.java:180)
at io.netty.buffer.PoolArena.allocate(PoolArena.java:137)
at io.netty.buffer.PoolArena.allocate(PoolArena.java:129)
at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:400)
at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:188)
at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:179)
at io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:116)
可以看到这里报错的限制值与配置的 2G 是一致的
那其实能看到明显的堆内存浮动是因为我代码中分配 ByteBuf 的时候同时 new 了一个 byte 数组,去掉这行代码同样可以观察到堆外内存一直居高不下,堆内存没有影响,只有一次明显的 GC 活动
buffer.writeBytes(new byte[1024 * 10]);
这说明如果你没有正确的 release ByteBuf 会导致堆外内存无法释放,从而导致内存泄露,再次尝试申请会报 OOM 错误。
也就是说即使 JVM 帮你回收了没有引用的 ByteBuf,但是 ByteBuf 占用的堆外内存也不会得到释放
at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
如果调用的是分配并正确释放方法,可以观察到内存的使用是稳定的,GC 来自于堆内引用的申请和释放
至此已经复现了问题,并认识到了其严重性,那么具体到代码里,究竟是什么没有释放呢?Netty 为什么没有相关容错的机制?
这个问题勾起了我的好奇心,而故事可能要从对象池的设计讲起