ByteBuf提供的读写操作API主要操作底层字节容器以及读和写索引这两个指针,可以查阅相关的API说明
Netty 中主要分为以下三大类 ByteBuf:
- Heap Buffer(堆缓冲区) 。这是最常用的一种 ByteBuf,它将数据存储在 JVM 的堆空间,其底层实现是在 JVM 堆内分配一个数组,实现数据的存储。堆缓冲区可以快速分配,当不使用时也可以由 GC 轻松释放。它还提供了直接访问底层数组的方法,通过 ByteBuf.array() 来获取底层存储数据的 byte[] 。
- Direct Buffer(直接缓冲区) 。直接缓冲区会使用堆外内存存储数据,不会占用 JVM 堆的空间,使用时应该考虑应用程序要使用的最大内存容量以及如何及时释放。直接缓冲区在使用 Socket 传递数据时性能很好,当然,它也是有缺点的,因为没有了 JVM GC 的管理,在分配内存空间和释放内存时,比堆缓冲区更复杂,Netty 主要使用内存池来解决这样的问题,这也是 Netty 使用内存池的原因之一。
- Composite Buffer(复合缓冲区) 。我们可以创建多个不同的 ByteBuf,然后提供一个这些 ByteBuf 组合的视图,也就是 CompositeByteBuf。它就像一个列表,可以动态添加和删除其中的ByteBuf
内存管理
Netty 使用 ByteBuf 对象作为数据容器,进行 I/O 读写操作,其实 Netty 的内存管理也是围绕着ByteBuf 对象高效地分配和释放。从内存管理角度来看,ByteBuf 可分为 Unpooled 和 Pooled 两类。
- Unpooled,是指非池化的内存管理方式。每次分配时直接调用系统 API 向操作系统申请 ByteBuf,在使用完成之后,通过系统调用进行释放。Unpooled 将内存管理完全交给系统,不做任何特殊处理,使用起来比较方便,对于申请和释放操作不频繁、操作成本比较低的 ByteBuf 来说,是比较好的选择。
- Pooled,是指池化的内存管理方式。该方式会预先申请一块大内存形成内存池,在需要申请 ByteBuf 空间的时候,会将内存池中一部分合理的空间封装成 ByteBuf 给服务使用,使用完成后回收到内存池中。前面提到 DirectByteBuf 底层使用的堆外内存管理比较复杂,池化技术很好地解决了这一问题。
下面我们从如何高效分配和释放内存、如何减少内存碎片以及在多线程环境下如何减少锁竞争这三个方面介绍一下 Netty 提供的 ByteBuf 池化技术。
Netty 首先会向系统申请一整块连续内存,称为 Chunk(默认大小为 16 MB),这一块连续的内存通过 PoolChunk 对象进行封装。之后,Netty 将 Chunk 空间进一步拆分为 Page,每个 Chunk 默认包含 2048 个 Page,每个 Page 的大小为 8 KB。
在同一个 Chunk 中,Netty 将 Page 按照不同粒度进行分层管理。如下图所示,从下数第 1 层中每个分组的大小为 1 * PageSize,一共有 2048 个分组;第 2 层中每个分组大小为 2 * PageSize,一共有 1024 个组;第 3 层中每个分组大小为 4 * PageSize,一共有 512 个组;依次类推,直至最顶层。
Netty 使用完全平衡树的结构实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示
按需分配:ByteBufAllocator接口
Netty通过interface ByteBufAllocator实现了ByteBuf池化,它可以用来分配我们所描述的任意类型的ByteBuf实例
可以通过 Channel(每个都可以有一个不同的 ByteBufAllocator 实例)或者绑定到 ChannelHandler 的 ChannelHandlerContext 获取一个到 ByteBufAllocator 的引用
public static void obtainingByteBufAllocatorReference() {
Channel channel = CHANNEL_FROM_SOMEWHERE; //get reference form somewhere
//从 Channel 获取一个到ByteBufAllocator 的引用
ByteBufAllocator allocator = channel.alloc();
//...
ChannelHandlerContext ctx = CHANNEL_HANDLER_CONTEXT_FROM_SOMEWHERE; //get reference form somewhere
//从 ChannelHandlerContext 获取一个到 ByteBufAllocator 的引用
ByteBufAllocator allocator2 = ctx.alloc();
//...
}
Netty提供了两种ByteBufAllocator的实现:PooledByteBufAllocator和UnpooledByteBufAllocator。前者池化了ByteBuf的实例以提高性能并最大限度地减少内存碎片。后者的实现不池化ByteBuf实例,并且每次它被调用都会返回一个新的实例。 虽然Netty默认使用了PooledByteBufAllocator,但这可以很容易通过Channel-Config API或者在引导你的应用程序时指定一个不同的分配器来更改。
引用计数
引用计数是一种通过在某个对象所持有的资源不再被其他对象引用时释放该对象所持有的资源来优化内存使用和性能的技术。引用计数背后的想法并不是特别的复杂;它主要涉及跟踪到某个特定对象的活动引用的数 量。一个 ReferenceCounted 实现的实例将通常以活动的引用计数为 1 作为开始。只要引用计 数大于 0,就能保证对象不会被释放。当活动引用的数量减少到 0 时,该实例就会被释放。注意, 虽然释放的确切语义可能是特定于实现的,但是至少已经释放的对象应该不可再用了。 引用计数对于池化实现(如 PooledByteBufAllocator)来说是至关重要的,它降低了 内存分配的开销。
public static void referenceCounting() {
Channel channel = CHANNEL_FROM_SOMEWHERE; //get reference form somewhere
//从 Channel 获取ByteBufAllocator
ByteBufAllocator allocator = channel.alloc();
//...
//从 ByteBufAllocator分配一个 ByteBuf
ByteBuf buffer = allocator.directBuffer();
//检查引用计数是否为预期的 1
assert buffer.refCnt() == 1;
//...
}