为什么Netty宁可自己实现ByteBuf也不愿意使用ByteBuffer

面试遇到个题目,“为什么Netty宁可自己实现ByteBuf也不愿意使用ByteBuffer”
正常说来就是引用计数好回收和大小可变,可是面试官提出不同的看法,热烈讨论一番之后我们谁也不能说服谁。
虽然面试黄了但总结不能少,不能及时拿出成体系的论据证明自己的观点,还是平时还是过于怠惰。

1. 接口差异

先看二者的接口差异,找到netty到底比jdk自带的ByteBuffer多做了哪些事情

1.1. 实例化

ByteBuffer主要通过静态方法进行内存分配,有堆内和对外两种模式。

1
2
3
4
5
6
import java.nio.ByteBuffer;

// 分配堆内缓冲区
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// 分配堆外缓冲区
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

ByteBuf主要通过Unpooled工具类创建,也可以使用PooledByteBufAllocator在池化的内存空间中创建ByteBuf,避开系统反复开辟和回收的开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.PooledByteBufAllocator;

// 创建非池化的堆内存 ByteBuf
ByteBuf heapByteBuf = Unpooled.buffer(1024);
// 创建非池化的直接内存 ByteBuf
ByteBuf directByteBuf = Unpooled.directBuffer(1024);

// 获取池化的 ByteBuf 分配器
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
// 分配一个池化的 ByteBuf 实例
ByteBuf byteBuf = allocator.buffer(1024);
try {
// 使用 ByteBuf
byteBuf.writeByte(1);
System.out.println("ByteBuf 写入成功");
} finally {
// 释放 ByteBuf
byteBuf.release();
}

从对象创建角度来看,两者区别不大。Netty额外提供了池化的内存分配工具。

1.2. 读写操作

ByteBuffer需要手动调用filp()方法从写模式切换到读模式,调用clear()compact()方法从读模式切换到写模式

1
2
3
4
5
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put((byte) 1); // 写操作
buffer.flip(); // 切换到读模式
byte value = buffer.get(); // 读操作
buffer.clear(); // 切换回写模式

ByteBuf读写模式分离,有独立的读索引和写索引,不需要切换模式,操作更简单。

1
2
3
ByteBuf byteBuf = Unpooled.buffer(10);
byteBuf.writeByte(1); // 写操作
byte readValue = byteBuf.readByte(); // 读操作

从读写方面来看,ByteBuffer操作繁琐,由于只有一个索引,读和写需要调用filp()或者clear()切换,编写代码容易出错。

1.3. 容量管理

ByteBuffer容量固定,一旦分配不能动态扩展,需要创建新的ByteBuffer实例然后手动复制数据至新的实例。

1
2
3
4
5
ByteBuffer original = ByteBuffer.allocate(10);
// 如果需要更大容量,需要重新分配
ByteBuffer newBuffer = ByteBuffer.allocate(20);
original.flip();
newBuffer.put(original);

ByteBuf容量可以通过ensureWritable(int minWritableBytes)方法保证有足够空间写入数据,如果不够,会自动扩展不需要手动复制。

1
2
ByteBuf byteBuf = Unpooled.buffer(10);
byteBuf.ensureWritable(20); // 确保有 20 字节的可写空间

这里比较有意思的是ByteBuf根据实现会提供不同的行为,主要看capacity()的具体实现。比如UnpooledDirectByteBuf在实际扩容时依然进行了复制,但是PooledByteBuf由于使用池化资源,仅移动了计数器。可以看到,ByteBuf通过暴露的接口可以方便的调整大小而不用手动复制,比起ByteBuffer使用更为便捷。

1.4. 垃圾回收

ByteBuffer依赖垃圾回收,堆外的直接内存需要手动调用java.lang.ref.Cleaner进行释放(cleaner并不堆外暴露清除直接内存的方法),或者等待ByteBuffer不再被引用后在某个时段被GC回收。

ByteBuf使用引用计数来管理内存,通过retain()方法来增加引用计数,release()方法减少引用计数。当计数为0时,会自动释放。

1
2
3
ByteBuf byteBuf = Unpooled.buffer(10);
byteBuf.retain(); // 增加引用计数
byteBuf.release(); // 减少引用计数

1.5. 切片与复制

ByteBuffer通过slice()方法创建的切片与原缓冲区共享底层数据,修改切片会影响原缓冲区

1
2
3
ByteBuffer buffer = ByteBuffer.allocate(10);
ByteBuffer slice = buffer.slice();
slice.put((byte) 1); // 会影响原缓冲区

ByteBuf的切片和复制更灵活,slice()方法创建的切片同样共享底层数据,但duplicate()方法创建的副本也共享底层数据,而copy()方法创建的是独立的副本,修改副本不会影响原ByteBuf

1
2
3
4
5
ByteBuf byteBuf = Unpooled.buffer(10);
ByteBuf slice = byteBuf.slice();
ByteBuf copy = byteBuf.copy();
slice.setByte(0, 1); // 会影响原 ByteBuf
copy.setByte(0, 2); // 不会影响原 ByteBuf

2. 网络编程场景对比

2.1. 频繁数据读写

2.1.1. 场景描述

在网络编程中,服务器需要不断接收客户端发送的数据并进行处理,同时也需要向客户端发送响应数据,数据的读写操作非常频繁。

2.1.2. ByteBuffer的潜在风险

使用ByteBuffer需要确认当前读写模式,如果处于读模式,忘记调用filp()就会抛出异常。在业务复杂的情况下,容易出现难以察觉的编码失误,出错风险增加。

2.1.3. ByteBuf的好处

采用读写分离的索引,不需要手动切换读写模式,代码更加简洁,减少了出错的可能性。

2.2. 数据量不确定的情况

2.2.1. 场景描述

网络传输中,待接收的数据量往往不确定。例如在文件上传场景中,客户端上传的文件大小就不确定。再比如接收一段分成多个chunk发送的长报文,比如GB28181的设备目录,协议仅提供条目总数信息,并不能给出每个条目占用多大空间。此时需要动态处理不同大小的数据。

2.2.2. ByteBuffer的问题

ByteBuffer的容量固定,一旦分配就无法动态扩展,如果数据量超过一开始开辟的大小,需要手动创建新的ByteBuffer对象并复制数据,这会增加内存开销和代码复杂度。

2.2.3. ByteBuf的好处

ByteBuf可以通过ensureWritable(int minWritableBytes)方法动态扩展容量,当数据量超过当前容量时,会自动进行扩容,无需手动处理。尤其池化的ByteBuf没有创建新对象和数据复制的开销。

2.3. 高并发场景下的内存管理

2.3.1. 场景描述

在高并发的网络编程中,会有大量的连接同时进行数据传输,每个连接都需要分配一定的内存来处理数据。如果内存管理不当,会导致内存泄漏或内存溢出。

2.3.2. ByteBuffer的潜在风险

ByteBuffer的内存管理依赖于Java的垃圾回收机制,对于堆外内存如果不等待GC,还需要手动调用Cleaner进行释放。在高并发场景下,频繁的创建和销毁ByteBuffer会给垃圾回收带来很大压力,影响系统性能。

2.3.3. ByteBuf的优势

ByteBuf采用引用计数机制,通过retain()release()方法来管理内存。当一个ByteBuf不再使用时,只要其引用计数为0,就会立即释放内存,避免了垃圾回收的压力。在Netty中,还提供了池化的 ByteBuf分配器,可以复用ByteBuf实例,减少内存分配和释放的开销。

3. Netty 何时使用池化的ByteBuf

3.1. 高并发场景

在高并发的网络编程环境中,大量的连接会频繁地进行数据读写操作。每次创建和销毁ByteBuf会带来显著的内存分配和回收开销,这可能会成为系统性能的瓶颈。池化的ByteBuf可以通过复用已有的ByteBuf实例,减少内存分配和垃圾回收的压力,从而提高系统的性能和响应速度。
例如,在一个基于Netty构建的高并发服务器中,每秒可能会处理数千甚至数万个请求,每个请求都需要分配一定的内存来处理数据。使用池化的ByteBuf可以避免频繁的内存分配和回收操作,使得系统能够更高效地处理大量请求。

3.2. 内存资源紧张的场景

当系统的内存资源有限时,池化的ByteBuf可以更好地管理内存使用。通过复用ByteBuf实例,减少了内存碎片的产生,提高了内存的利用率。这对于一些资源受限的设备(如嵌入式系统)或者在云计算环境中需要严格控制内存使用的场景尤为重要。

3.3. 长连接场景

在长连接的网络应用中,客户端和服务器之间会保持长时间的连接,不断地进行数据传输。在这种情况下,使用池化的ByteBuf可以减少每次数据传输时的内存分配开销,提高连接的稳定性和性能。

总结

为什么Netty宁可自己实现ByteBuf也不愿意使用ByteBuffer呢?虽然没有机会直接询问作者,但是从源码入手可以明显感受到,jdk自带的ByteBuffer在使用上存在诸多不便,比如没有池化,回收条件苛刻等。netty作为网络框架需要更高效和便捷的工具,为此不得不额外增加大量设计。

对了,还有ByteBuf的容量是否可变的争论。从接口定义来看,是可变的。细纠实现,非池化的ByteBuf会创建新的数组并且复制数据,池化的ByteBuf仅会改变内部的计数器,没有其他副作用。


为什么Netty宁可自己实现ByteBuf也不愿意使用ByteBuffer
http://firestige.xyz/2025/why-netty-prefer-bytebuf-to-bytebuffer-c335750444a7/
作者
firestige
发布于
2025年2月28日
许可协议