在前文中介绍了ByteBuf
的概念和使用,本文进一步讲解背后的引用计数特性。
9.3 引用计数
服务端的网络通讯应用在处理一个客户端的请求时,基本都需要创建一个缓冲区ByteBuf
,直到将字节数据解码为POJO对象,该缓冲区才不再有用。由此可知,当面对大量客户端的并发请求时,如果能有效利用这些缓冲区而不是每次都创建,将大大提高服务端应用的性能。
或许你会有疑问:既然已经有了JAVA的GC自动回收不再使用的对象,为什么还需要其他的回收技术?因为:1.GC回收或者引用队列回收效率不高,难以满足高性能的需求;2.缓冲区对象还需要尽可能的重用。有鉴于此,Netty4开始引入了引用计数的特性,缓冲区的生命周期可由引用计数管理,当缓冲区不再有用时,可快速返回给对象池或者分配器用于再次分配,从而大大提高性能,进而保证请求的实时处理。
需要注意的是:引用计数并不专门服务于缓冲区ByteBuf
。用户可根据实际需求,在其他对象之上实现引用计数接口ReferenceCounted
。下面将详细介绍引用计数特性。
9.3.1 基本概念
引用计数有如下两个基本规则:
- 对象的初始引用计数为1
- 引用计数为0的对象不能再被使用,只能被释放
在代码中,可以使用retain()
使引用计数增加1,使用release()
使引用计数减少1,这两个方法都可以指定参数表示引用计数的增加值和减少值。当我们使用引用计数为0的对象时,将抛出异常IllegalReferenceCountException
。
9.3.2 谁负责释放对象
通用的原则是:谁最后使用含有引用计数的对象,谁负责释放或销毁该对象。一般来说,有以下两种情况:
- 一个发送组件将引用计数对象传递给另一个接收组件时,发送组件无需负责释放对象,由接收组件决定是否释放。
- 一个消费组件消费了引用计数对象并且明确知道不再有其他组件使用该对象,那么该消费组件应该释放引用计数对象。
一个示例如下:
public ByteBuf a(ByteBuf input) {
input.writeByte(42);
return input;
}
public ByteBuf b(ByteBuf input) {
try {
output = input.alloc().buffer(input.readableBytes() + 1);
output.writeBytes(input);
output.writeByte(42);
return output;
} finally {
input.release();
}
}
public void c(ByteBuf input) {
System.out.println(input);
input.release();
}
public void main() {
ByteBuf buf = Unpooled.buffer(1);
c(b(a(buf)));
}
其中main()
作为发送组件传递buf给a()
,a()
也仅仅写入数据然后发送给b()
,b()
同时作为消费者和发送者,消费input同时生成output发送给c()
,c()
仅仅作为消费者,不再产生新的引用计数对象。所以,a()
不负责释放对象;b()
完全消费了input,所以需要释放input,生成的output发送给c()
,所以不负责释放output;c()
完全消费了b()
的output,故需要释放。
9.3.3 派生缓冲区
我们已经知道通过duplicate()
,slice()
等等生成的派生缓冲区ByteBuf
会共享原生缓冲区的内部存储区域。此外,派生缓冲区并没有自己独立的引用计数而需要共享原生缓冲区的引用计数。也就是说,当我们需要将派生缓冲区传入下一个组件时,一定要注意先调用retain()
方法。Netty的编解码处理器中,正是使用了这样的方法,可认为是下面代码的变形:
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(); // 原生缓冲区释放
}
...
public void process(ByteBuf buf) {
...
buf.release(); // 派生缓冲区释放
}
另外,实现ByteBufHolder
接口的对象与派生缓冲区有类似的地方:共享所Hold缓冲区的引用计数,所以要注意对象的释放。在Netty,这样的对象包括DatagramPacket
,HttpContent
和WebSocketframe
。
9.3.4 缓冲区泄露检测
没有什么东西是十全十美的,引用计数也不例外,虽然它大大提高了ByteBuf
的使用效率,但也引入了一个新的问题:引用计数对象的内存泄露。由于JVM并没有意识到Netty实现的引用计数对象,它仍会将这些引用计数对象当做常规对象处理,也就意味着,当不为0的引用计数对象变得不可达时仍然会被GC自动回收。一旦被GC回收,引用计数对象将不再返回给创建它的对象池,这样便会造成内存泄露。
为了便于用户发现内存泄露,Netty提供了相应的检测机制并定义了四个检测级别:
-
DISABLED
完全关闭内存泄露检测,并不建议 -
SIMPLE
以1%的抽样率检测是否泄露,默认级别 -
ADVANCED
抽样率同SIMPLE
,但显示详细的泄露报告 -
PARANOID
抽样率为100%,显示报告信息同ADVANCED
有以下两种方法,可以更改泄露检测的级别:
-
使用JVM参数
-Dio.netty.leakDetectionLevel
:java -Dio.netty.leakDetectionLevel=advanced ...
-
直接使用代码
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.ADVANCED);
最佳实践
- 单元测试和集成测试使用
PARANOID
级别 - 使用
SIMPLE
级别运行足够长的时间以确定没有内存泄露,然后再将应用部署到集群 - 如果发现有内存泄露,调到
ADVANCED
级别以提供寻找泄露的线索信息 - 不要将有内存泄露的应用部署到整个集群
此外,如果在单元测试中创建一个缓冲区,很容易忘了释放。这会产生一个
内存泄露的警告,但并不意味着应用中有内存泄露。为了减少在单元测试代码中充斥大量的try-finally
代码块用于释放缓冲区,Netty提供了一个通用方法ReferenceCountUtil.releaseLater()
,当测试线程结束时,将会自动释放缓冲区,使用示例如下:
import static io.netty.util.ReferenceCountUtil.*;
@Test
public void testSomething() throws Exception {
ByteBuf buf = releaseLater(Unpooled.directBuffer(512));
...
}