关于Netty ByteBuf引用计数


引言

Netty版本4之后,某些对象的生命周期由它们的引用计数来管理,这样Netty可以在不再使用它们时将它们(或它们的共享资源)返回到对象池(或对象分配器)。因为JVM的垃圾收集机制和引用队列不能提供不可达性的有效实时保证,而Netty的引用计数机制则提供了另一种方式来进行垃圾回收。

ByteBuf就是引用计数的典范,它利用引用计数来提高分配和释放性能,接下来将说明Netty中的引用计数如何使用ByteBuf进行工作。

引用计数的基本定义

ByteBuf这类引用计数初始化时的引用计数为1:

ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;

释放引用计数的对象时,其引用计数将减少1。如果引用计数达到0,则将引用计数的对象释放或返回到其来源的对象池中:

assert buf.refCnt() == 1;
// release() 将在引用计数减为0时返回true
boolean destroyed = buf.release();
assert destroyed;
assert buf.refCnt() == 0;

当尝试去获取引用计数已经减为0的对象时,将会抛出IllegalReferenceCountException

assert buf.refCnt() == 0;
try {
  buf.writeLong(0xdeadbeef);
  throw new Error("should not reach here");
} catch (IllegalReferenceCountExeception e) {
  // Expected
}

增加引用计数

只要还没有销毁引用计数对象,也可以通过retain()操作来增加它:

ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;

buf.retain();
assert buf.refCnt() == 2;

boolean destroyed = buf.release();
assert !destroyed;
assert buf.refCnt() == 1;

谁负责销毁引用计数对象?

一般的经验法则是,最后访问引用计数对象的一方也应负责销毁该引用计数对象。尤其是:

  • 通常发送引用计数的组件不需要负责销毁,而是留给接收此引用计数对象的组件进行决定
  • 如果一个组件最后消费引用计数对象并且知道除它本身外没有组件将会访问引用计数对象,那么它应该要负责销毁

示例:

public ByteBuf a(ByteBuf input) {
    input.writeByte(42);
    return input;
}

public ByteBuf b(ByteBuf input) {
    try {
        output = input.alloc().directBuffer(input.readableBytes() + 1);
        output.writeBytes(input);
        output.writeByte(42);
        return output;
    } finally {
        input.release();  // input 出了b方法调用外没有方法继续访问,在这里销毁掉
    }
}

public void c(ByteBuf input) {
    System.out.println(input);
    input.release();        // 释放b方法产生的output
}

public void main() {
    ...
    ByteBuf buf = ...;
    // 此调用将会打印bytebuf到系统输出流并销毁bytebuf
    c(b(a(buf)));
    assert buf.refCnt() == 0;
}
行为谁应该负责释放谁负责释放
1. main() 创建了 bufbufmain()
2. main() 调用 a() 传入 bufbufa()
3. a() 仅仅是直接返回了 bufbufmain()
4. main()调用 b() 传入 bufbufb()
5. b() 返回了 buf的拷贝bufb(), copymain()b() 释放 buf
6. main() 调用 c() 传入 copycopyc()
7. c()消费 copycopyc()c

派生缓冲区

ByteBuf.duplicate()ByteBuf.slice()ByteBuf.order(ByteOrder)创建一个派生缓冲区,该缓冲区共享父缓冲区的内存区域。派生的缓冲区没有自己的引用计数,只是共享父缓冲区的引用计数。

ByteBuf parent = ctx.alloc().directBuffer();
ByteBuf derived = parent.duplicate();

// 创建一个派生缓冲区并不会增加引用计数
assert parent.refCnt() == 1;
assert derived.refCnt() == 1;

相反 ByteBuf.copy()ByteBuf.readBytes(int) 并不产生派生缓冲区,其创建的 ByteBuf 需要被释放。

注意:由于父缓冲区及其派生缓冲区共享相同的引用计数,并且在创建派生缓冲区时引用计数不会增加。因此,如果要将派生的缓冲区传递给应用程序的其他组件,则必须首先在其上调用retain()

ByteBuf parent = ctx.alloc().directBuffer(512);
parent.writeBytes(...);

try {
    while (parent.isReadable(16)) {
        ByteBuf derived = parent.readSlice(16);
        derived.retain();        //    派生缓冲区需要进行引用计数加一
        process(derived);        
    }
} finally {
    parent.release();        // 2.释放父缓冲区
}
...

public void process(ByteBuf buf) {
    ...
    buf.release();        // 1.释放派生缓冲区
}

ByteBufHolder接口

有时,缓冲区持有者(例如DatagramPacketHttpContentWebSocketframe)包含ByteBuf。这些类型扩展了一个称为ByteBufHolder的通用接口。 缓冲区持有者共享它包含的缓冲区的引用计数,就像派生缓冲区一样。

ChannelHandler中的引用计数

入站消息

当事件循环(EventLoop)将数据读入ByteBuf并触发该channelRead()时,相应管道中的ChannelHandler负责释放缓冲区。因此,使用接收到的数据的处理程序应在其channelRead()处理程序方法中对该数据调用release():

public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    try {
        ...
    } finally {
        buf.release();
    }
}

在谁负责销毁引用计数对象章节写到,如果handler仅仅是将改引用计数对象传递到下一个handler,它就不需要负责销毁:

public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    ...
    ctx.fireChannelRead(buf);
}

注意:Netty中并不是仅只有ByteBuf是引用计数对象,比如,解码器产生的对象,很有可能是引用计数对象:

// 假设handler在 `HttpRequestDecoder` 解码器之后
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    if (msg instanceof HttpRequest) {
        HttpRequest req = (HttpRequest) msg;
        ...
    }
    if (msg instanceof HttpContent) {
        HttpContent content = (HttpContent) msg;
        try {
            ...
        } finally {
            content.release();
        }
    }
}

如果不确定传入对象是否是引用计数对象,可以使用ReferenceCountUtil.release()进行释放:

public void channelRead(ChannelHandlerContext ctx, Object msg) {
    try {
        ...
    } finally {
        ReferenceCountUtil.release(msg);
    }
}

除此之外SimpleChannelHandler也是一个不错的选择,它会在每次接收到数据之后调用ReferenceCountUtil.release(msg);

出站消息

与入站消息不同,出站消息是由应用程序创建的,Netty的责任是在将它们写到网络上后释放它们。但是,拦截写请求的处理程序(ChannelHandler)应确保正确释放任何中间对象。

// 只是简单的传递引用计数对象
public void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
    System.err.println("Writing: " + message);
    ctx.write(message, promise);
}

// 拦截引用计数对象并进行转化
public void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
    if (message instanceof HttpContent) {
        // 将 HttpContent 转化为 ByteBuf
        HttpContent content = (HttpContent) message;
        try {
            ByteBuf transformed = ctx.alloc().buffer();
            ....
            ctx.write(transformed, promise);
        } finally {
            content.release();  // 释放 HttpContent
        }
    } else {
        // 传递非HttpContent 不需要进行释放
        ctx.write(message, promise);
    }
}

解决内存泄露

引用计数的缺点是容易泄漏引用计数的对象。由于JVM不会处理Netty实现的引用计数,因此一旦它们无法继续访问,即使它们的引用计数不为零,JVM也会自动对它们进行垃圾回收。垃圾回收后就无法恢复对象,因此无法将其返回到它来自的池中或者再释放掉,从而将导致内存泄漏。

尽管很难发现泄漏,但是Netty默认情况下会抽样大约1%的缓冲区分配内存,以检查应用程序中是否存在泄漏。如果发生泄漏,将发现以下日志消息:

LEAK: ByteBuf.release() was not called before it’s garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option ‘-Dio.netty.leakDetectionLevel=advanced‘ or call ResourceLeakDetector.setLevel()

  • -Dio.netty.leakDetectionLevel=advanced
  • ResourceLeakDetector.setLevel()

内存泄漏检测级别

  • DISABLED - 关闭内存泄漏检测,不推荐
  • SIMPLE - 默认级别。
  • ADVANCED - 高级检测模式
  • PARANOID - 和 ADVANCED 模式类似为每一个ByteBuf进行检测,在自动测试阶段很有帮助,如果输出了LEAK:构建就会失败能够提早发现内存泄漏点

通过JVM启动参数进行指定 JVM option -Dio.netty.leakDetection.level

java -Dio.netty.leakDetection.level=advanced ...

此属性对应 io.netty.leakDetectionLevel

防止内存泄漏最佳实践

  • PARANOID级别运行单元测试检测内存泄漏
  • 如果发现内存泄漏使用ADVANCED级别进行泄漏点定位

文章作者: Ubi-potato
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ubi-potato !
评论
  目录