`

Netty 引用计数对象

    博客分类:
  • Java
阅读更多

相关链接:

一些基本信息与疑问

为什么 Netty 要采用人工操作的引用计数机制?

从 版本4 开始,Netty 中某些对象的生命周期管理是通过它们的引用计数实现的。
既然 JVM 有自己的垃圾回收机制,为什么 Netty 还要再额外采用这种机制来回收对象?

简单地说,为了提高堆外内存的垃圾回收效率
因为 GC 和 引用队列(ReferenceQueue) 无法保证高效实时的“不可达对象”处理机制。
而通过引用计数机制,一旦这些对象(或它们所占用的共享资源)不再被使用,就可以立刻将其返还给对象池。

 

堆外内存 DirectBuffer 是如何被回收的,为什么效率低?

DirectBuffer 用到了 JVM 堆外内存,这些空间并不是直接由GC回收的。

当它内部字段 Cleaner 被回收(即将进入引用队列)时,会被特殊处理 —— 调用 Cleaner.clean()。

该方法最终调用 Unsafe.freeMemory() 回收这些堆外内存。

这是一个非常迂回的过程,效率当然受影响。
 

Reference.tryHandlePending():

// Fast path for cleaners
if (c != null) {
  c.clean();
  return true;
}

 

DirectByteBuffer#Deallocator.run():

unsafe.freeMemory(address);
Bits.unreserveMemory(size, capacity);

 

为什么要使用堆外内存?

内核无法直接读写堆内存。所以常规 IO 操作中 数据需要在堆内存和堆外内存之间拷贝(用户态与内核态的切换)。
Netty 直接使用堆外内存(Direct Memory)的最大好处就是避免此类拷贝操作。

 

为什么要使用“对象池”?

因为分配堆外内存的开销比堆内存高,所以 Netty 引入了“池”的概念。(堆内存也是个池。)
类似于线程池:因为创建销毁线程的成本太高,所以搞了个“池”。

 

“对象池”是有代价的

其实“对象池”在Java中一直是个有争议的话题。尤其是对于那些“只占用内存,不占用外部资源”的对象。
根据实际情况来看,Netty 采用池化机制是值得的,尤其是在分配较大的缓冲区时。
但是 Netty 的“池化+引用计数”机制带来高性能的同时,也带来一些不便。
如果未正确设置引用计数,还会引发内存泄漏。

如,某个对象占用了一块堆外内存,且代码中未正确设置其引用计数;
那么当该对象不可达时,GC会将其回收,但不回收对应的堆外内存,
因为 GC 感知不到 Netty 的这套引用计数机制;
而相应的引用计数直接大于0,无法触发 Netty 回收此堆外内存,这就引发内存泄漏。
 

因此我们需遵守一些最佳实践来规避错误,并结合 Netty 的缓冲区泄漏检查机制进行排障。

 

“引用计数”基础

Netty 中的 引用计数对象 都实现了接口 ReferenceCounted。为了方便,下文用“对象”指代它们。
 

新创建的对象,其引用计数值为 1。
调用对象的 retain() 方法一次,计数值会 增加 1。
调用对象的 release() 方法一次,计数值会 减 1。
当该数值降到 0 时,相应的资源会被回收到 Netty 的对象池。
 

悬空引用(Dangling Reference)

如果一个对象的引用计数降到了 0,那么访问该对象会引发异常 IllegalReferenceCountExeception。

 

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

一般的经验法则是:最后访问对象的一方负责销毁对象
具体来说就是:

  • 假设一个组件(发送方)将一个 引用计数对象 传给另一个组件(接收方),
    那么发送方不需要销毁对象,可以将此操作推迟,交由接收方负责。
  • 如果一个组件消费了一个 引用计数对象,且知道后续不会有任何其它组件访问该对象(当然也不会将对象传给另一个组件),
    那么该组件需要负责销毁它。即,调用对象的 release() 方法。

示例:

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

ByteBuf b(ByteBuf input) {
  try {
    output = input.alloc().directBuffer(input.readableBytes() + 1);
    output.writeBytes(input);
    output.writeByte(42);
    return output();
  } finally {
    input.release();
  }
}

void c(ByteBuf input) {
  System.out.println(input);
  input.release();
}

void main() {
  ...
  ByteBuf buf = ...;
  c(b(a(buf)));
  assert buf.refCnt() == 0;
}
 操作 谁应该调用 release 谁调用了 release
1. main() 创建了 buf main() 负责 release buf   
2. main() 调用 a(),参数为 buf a() 负责 release buf   
3. a() 只是返回了 buf main() 负责 release buf   
4. main() 调用了 b(),参数为 buf b() 负责 release buf  
5. b() 返回了 buf 的副本

b() 负责 release buf,

main() 负责 release 副本 

b() 调用了 buf.release()
6. main() 调用了 c(),参数为副本 c() 负责 release 副本   
7. c() 吞掉了副本 c() 负责 release 副本  c() 调用了副本的 release() 方法

 

派生缓存区(Derived Buffers)

ByteBuf.duplicate()、ByteBuf.slice()、ByteBuf.order(ByteOrder) 等方法会创建一个 “派生”的缓冲区对象,该对象与原对象共享同一块内存区域。“派生缓冲区”并没有自己的引用计数,它与原对象共享同一个引用计数。
 

相反,ByteBuf.copy() 和 ByteBuf.readBytes(int) 不会“派生”缓冲区。它们返回的 buffer 对象需要另行release。
 

注意:父Buffer 与 其派生出的Buffer 共享同一个 引用计数,且派生Buffer被创建时引用计数值不会增加。因此,如果你需要将一个 派生Buffer 传给另一个组件,你需要先增加其引用计数 —— 调用 retain() 方法。
因为接收方会认为它是Buffer的最终消费者,调用对象 release() 方法,这样就导致对象过早被回收。
 

示例:

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();
}
...

void process(ByteBuf buf) {
  ...
  buf.release();
}

 

ByteBufHolder

有时候,ByteBuf 是包含在一个 ByteBufHolder 对象中的。
如,DatagramPacket、HttpContent、WebSocketFrame 都实现了该接口。
这些 holder 对象与它内部的 ByteBuf 共享引用计数,所以应像 派生Buffer 那样处理它们。

 

ChannelHandler 中的 引用计数

入站消息(Inbound Messages)

当一个 EventLoop 将数据读取到 ByteBuf,触发 channelRead() 事件时,相应管道(Pipeline)中的 ChannelHandler 负责释放该 Buffer。也就是说,消费这些入站数据的 handler 应该在其 channelRead() 方法中调用 release() 方法:

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

 

但是正如前文“谁负责销毁”中所说,如果你的 handler 将 buffer 之类的引用计数对象 传给了下一个 handler,那么当前 handler 就不需要调用 release()。
 

注意:ByteBuf 并不是 Netty 中唯一的 引用计数对象 类型。
如果你无法确定一个对象是否为 引用计数对象,或简化相关判断代码,可以直接调用帮助类方法:
ReferenceCountUtil.release():

public static boolean release(Object msg) {
  if (msg instanceof ReferenceCounted) {
    return ((ReferenceCounted) msg).release();
  }
  return false;
}

 

当然,你也可以让你的 handler 继承自 SimpleChannelHandler,这个类会针对所有收到的 message 对象调用上述帮助类方法。

 

出站消息(Outbound Messages)

与 入站消息 不同,出站消息 是你的应用程序创建的,它们由 Netty 负责在完成消息发送后进行释放。
但是中间进行拦截处理的 自定义Handler 需要自行释放中间对象。

// 直接写消息
void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
  System.err.println("Writing: " + message);
  ctx.write(message, promise);
}

// 把原始数据转换后再写
void write(ChannelHandlerContext ctx, Object message, ChannelPromise promise) {
  if (message instanceof HttpContent) {
    // 转换后再写
    HttpContent content = (HttpContent) message;
    try {
      ByteBuf transformed = ctx.alloc().buffer();
      ...
      ctx.write(transformed, promise);
    } finally {
      // 释放原缓存区
      content.release();
    }
  } else {
    // 对于其它类型的内容,直接写
    ctx.write(message, promise);
  }
}

 

缓存区泄漏 故障排查

手动维护引用计数的实践难度比较高。
对初学者而言会比 C/C++ 的主动释放内存更麻烦。CompositeByteBuf 中的对 分片Component 的释放处理就比较复杂。
如果处理不好就可能引发内存泄漏。
 

Netty 的设计也考虑到了这些问题,它提供了一套应对方案。
默认情况下,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()

 

如果以下指定 JVM 启动参数,则可以得到更详细的检测日志输出,告诉你
最近访问目标对象的代码行 和 最后访问目标对象的Handler。(可能就是在该 Handler 中忘了调用 release() 方法)

-Dio.netty.leakDetectionLevel=advanced

 

“泄漏检测”的等级

Netty 一共有 4个 泄漏检测等级,可参照上述示例中的 JVM 启动参数进行设置。
  • Disabled:禁用泄漏检测。不推荐使用。
  • Simple:对 1% 的缓存区对象进行检测。默认设置。
  • Advanced:对 1% 的缓存区对象进行检测,并输出访问代码行。
  • Paranoid:对 所有 缓存区对象进行检测,并输出访问代码行。一般在自动化测试阶段使用。

 

防止泄漏的最佳实践

  • 【测试】单元测试 和 集成测试 时 Paranoid 和 Simple 检测等级都要执行
  • 【发布】用 Simple 检测等级进行 金丝雀发布,并持续运行合理的较长时间
  • 【排障】如果检测到泄漏,则在 Advanced 等级下重新金丝雀发布,以获得更多异常提示
  • 【不要一把梭】不要将存在泄漏问题的程序完全发布。即,使用金丝雀发布,不要 all in。

 

单元测试中的泄漏

在编写单元测试时很容易忘记释放缓冲区。这会产生泄漏警告,但并不意味着被测程序存在泄漏。虽然可以用 try-finally 包揽释放所有缓存区对象,但不够优雅。
你可以使用帮助类方法 ReferenceCountUtil.releaseLater() 来处理这些测试专用对象。
 

releaseLater() 的原理

  • releaseLater() 方法会将 释放目标对象的操作 包装成一个 Task;
  • 当前线程 和 此Task 会被注册到 Netty 的一个后台线程中;
  • 该后台线程会轮询这些注册项;如果检测到 注册的线程已终止,则执行相应的Task,释放相应的对象。
  • 两次轮询操作 间隔1秒;当然是按需执行;当不存在注册项或所有注册项被处理完后,后台线程会退出。

详见 ThreadDeathWatcher

 

使用示例:

@Test
public void testSomething() {
  ByteBuf dummyBuf = Unpooled.directBuffer(512);
  ByteBuf buf = ReferenceCountUtil.releaseLater(dummyBuf);
  ...
}

 

分享到:
评论

相关推荐

    netty源码分析buffer

    每当`ByteBuf`被一个新的对象引用时,其引用计数增加;当不再需要该`ByteBuf`时,通过调用`release()`方法减少引用计数。当引用计数降为0时,`ByteBuf`会被自动释放,其占用的内存资源也会被回收。 **2. ByteBuf ...

    learning-netty

    `ReferenceCount`是Netty中用于管理缓冲区引用计数的机制,确保资源在不再使用时能够被及时释放。 #### 接口ReferenceCounted 定义了对象引用计数的行为。 #### 类AbstractReferenceCountedByteBuf 这是一个实现了...

    netty教程并发编程

    - **使用ReferenceCountUtil**:利用工具类辅助管理对象引用计数。 ##### 4.2 性能瓶颈分析 - **网络延迟**:通过调整TCP参数如TCP_NODELAY等来减少网络延迟。 - **CPU利用率**:监控并优化线程池配置,避免过度...

    netty-in-action中文版

    - **引用计数器**:解释`ByteBuf`的引用计数机制,这是内存管理的关键部分。 ### 第三部分:ChannelHandler和ChannelPipeline - **ChannelHandler家族**:详细介绍Netty中的各种`ChannelHandler`,包括它们的分类...

    Netty基础应用实战.docx

    ByteBuf 提供了各种方法进行数据读写、复制和转换,同时支持引用计数机制,以确保资源的有效管理。 Netty 的Codec 框架用于编解码网络数据,Decoder 和 Encoder 分别用于解码接收到的数据和编码要发送的数据。抽象...

    NettyChat-master.zip

    4. **使用ReferenceCounted对象**:在Netty中,包括ByteBuf在内的许多对象都是ReferenceCounted,这意味着你需要管理它们的引用计数,使用完后调用`release()`方法释放资源。 在NettyChat项目中,解决这个问题可能...

    JVM常用知识(面试可用)

    - **判断对象是否已死**:引用计数法和可达性分析算法(GCRoots)。 - **垃圾收集算法**:标记-清除、复制、标记-整理和分代收集算法。 - **垃圾收集器**:新生代有Serial、ParNew和Parallel Scavenge,老年代有...

    基于Java的网页浏览器.zip

    Java的垃圾回收机制可以帮助管理内存,但开发者仍需注意对象的生命周期和引用计数。 10. **国际化与本地化**: 对于一个面向全球用户的浏览器,支持多语言和不同区域设置是必要的。Java提供了ResourceBundle和Locale...

    JAVA核心知识点整理.pdf

    垃圾回收机制是JVM的核心功能之一,涉及到多种算法,包括引用计数法、可达性分析、标记-清除、复制、标记-整理算法,以及分代收集算法等。Java中定义了四种引用类型:强引用、软引用、弱引用和虚引用。这些引用类型...

    java成神之路

    - **对象存活的判定**: 引用计数、可达性分析算法等。 **5. JVM参数及调优** - **Xms/Xmx**: 设置堆内存的初始值/最大值。 - **XX:MaxPermSize**: 方法区的最大值(已废弃)。 - **XX:NewRatio**: 新生代和老年代的...

    跳槽涨薪涨薪必备精选面试题.pdf

    1. `String`、`StringBuffer`和`StringBuilder`的区别:`String`是不可变对象,每次修改都会创建新对象;而`StringBuffer`和`StringBuilder`在多线程环境下,`StringBuffer`是线程安全的,`StringBuilder`在单线程下...

Global site tag (gtag.js) - Google Analytics