什么是 ByteBuffer
ByteBuffer 是 Buffer 的一个具体实现,专门用于存储和操作字节数据。它提供了高效的、基于内存的 I/O 数据处理方式。
Buffer 类是构建 Java NIO 的基础,其中 ByteBuffer 类是 Buffer 子类中最受欢迎的。这是因为字节类型是最通用的类型。例如,我们可以在 JVM 中使用字节来组成其他非布尔基元类型。另外,我们可以使用字节在 JVM 和外部 I/O 设备之间传输数据。
类关系图
ByteBuffer 是 NIO 里用得最多的 Buffer,它包含两个实现方式:
- 堆缓冲区 HeapByteBuffer 是基于堆的实现,使用 JVM 的堆内存,读写操作效率低,会受到 GC 影响。
- 直接缓冲区 MappedByteBuffer(DirectByteBuffer)使用 OS 的内存,读写操作效率高,不会受到 GC 影响。但不主动析构,会造成内存的泄露。
这里扩展一下什么是内存泄露和内存溢出:
内存溢出:实际的数据量已经超过了当前机器的实际物理内存,比如数据量 2.5 GB,但实际物理内存只有 2 GB,将全部数据量全部读取到内存中去放不下那多出来的 0.5 GB,这就叫做溢出(OutOfMemoryException)。
内存泄露:实际的数据量虽然小于当前机器的实际物理内存,但还没读取完内存竟然不够用了,比如数据量 2 GB,实际物理内存也是 2 GB,但只读取了 1.8 GB 内存就不够了。造成内存泄露的原因大概两点:没有主动析构释放内存、内存碎片。
直接缓冲区与非直接缓冲区的区别?
区别维度 | 非直接缓冲区(Heap Buffer) | 直接缓冲区(Direct Buffer) |
---|---|---|
创建方式 | 使用 ByteBuffer.allocate(size) 创建的缓冲区 | 使用 ByteBuffer.allocateDirect(size) 创建的缓冲区 |
内存分配 | 其数据存储在 Java 堆内存中。这意味着数据的存取需要经过 JVM 的内存管理,可能会涉及额外的内存复制(如从堆到内核空间) | 其数据直接存储在操作系统的非易失性内存中,绕过了 Java 堆。这减少了 Java 堆与操作系统之间的数据拷贝,提高了效率,特别是在处理大块数据或进行系统调用时 |
性能角度 | 在小规模的数据操作中可能更快,因为它们避免了内存映射的开销,但对大块数据操作可能较慢 | 通常在处理大量数据或进行低级别 I/O(如文件读写或网络通信)时性能更好,因为减少了数据在用户空间和内核空间之间复制的开销 |
垃圾回收 | 遵循 Java 的垃圾收集机制,当不再引用时会被自动释放 | 不占用堆内存,因此不受 Java 堆大小的限制。但是,它们的生命周期管理更复杂,因为它们不会被垃圾收集器自动回收,除非没有其他强引用指向它们。 |
创建 ByteBuffer 的方法
包装现有数组
// 使用现有的 byte 数组创建缓冲区
byte[] byteArray = new byte[1024];
ByteBuffer wrappedBuffer = ByteBuffer.wrap(byteArray);
分配缓冲区
JDK 提供的 ByteBuffer 一旦分配空间,不可以动态调整大小,但是 Netty 对它进行了改进,支持了动态调整。
正是由于不能动态的扩充,如果添加的数据超过了容量,则会抛出 BufferOverflowException 异常。
// 分配一个容量为 1024 字节的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
分配直接缓冲区
// 分配一个直接缓冲区,性能较高
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
通过字符串集和字符串创建
// 获取字符集,假设使用 UTF-8 编码
Charset charset = StandardCharsets.UTF_8;
ByteBuffer encode = charset.encode("Hello, World!");
核心结构
ByteBuffer 的核心结构围绕三个重要属性构建,这些属性决定了缓冲区的行为和状态:
Capacity(容量)
缓冲区的最大存储数据量,初始化时即固定,类似于数组的 Size。不可改变,决定了该缓冲区最多能容纳的字节数。
使用场景:确保缓冲区的容量足够大,以存储需要处理的数据。
ByteBuffer buffer = ByteBuffer.allocate(1024); // 容量为 1024 字节
System.out.println(buffer.capacity()); // 输出 1024
Position(当前位置)
当前读/写操作的索引,指向缓冲区中下一个要读或写的位置。Buffer 当前缓存的下标,在读取操作时记录读到了那个位置,在写操作时记录写到了那个位置。从 0 开始,每读取一次,下标 +1。读/写数据时会自动变化,范围为 [0, limit]
。
使用场景:追踪数据操作的进度。
buffer.put((byte) 10); // 写入一个字节数据
System.out.println(buffer.position()); // 输出 1,表示当前位置为索引 1
Limit(限制)
当前读/写操作的限制索引,定义了可读或可写的范围。在读操作时,设置了你能读多少字节的数据,在写操作时,设置了你还能写多少字节的数据。写模式下,limit 等于 capacity;读模式下,limit 表示可读数据的终点。
使用场景:切换读/写模式时更新 limit,确保数据操作的边界清晰。
buffer.flip(); // 切换到读模式
System.out.println(buffer.limit()); // 输出 1,表示可读数据的终点
内部关系
- 写模式:默认 position 从 0 开始,limit 等于 capacity。
- 读模式:通过调用 flip() 方法将缓冲区切换到读模式,此时 limit 被设置为当前 position 值,而 position 被重置为 0。
所谓的读写模式,本质上就是这几个状态的变化。Position 和 Limit 联合决定了 Buffer 的读写数据区域。
这里有个坑,如果连续设置两次读模式,那么就会读不到数据:
ByteBuffer buffer = ByteBuffer.allocate(6);
buffer.put(new byte[]{11, 22, 33});
// 容量 = 6 当前位置 = 3 限制 = 6
System.out.printf(format, buffer.capacity(), buffer.position(), buffer.limit());buffer.flip();
// 容量 = 6 当前位置 = 0 限制 = 3
System.out.printf(format, buffer.capacity(), buffer.position(), buffer.limit());
buffer.flip();
// 容量 = 6 当前位置 = 0 限制 = 0
System.out.printf(format, buffer.capacity(), buffer.position(), buffer.limit());while (buffer.hasRemaining()) {System.out.println(buffer.get());
}
这个可以看到,两次 flip 之后,limit 是有变化的,第二次执行后 limit 直接就是 0 了,所以自然而然就读不到数据了,我们看一下源码:
public final Buffer flip() {limit = position;position = 0;mark = -1;return this;
}
注意第一行,每次调用 flip将都会将当前的 position 设置为 limit,然后将 position 设置为 0,当第二次执行 flip 之后,limit 就赋值了第一次赋值为 0 的 position。
数据操作 API
写数据:写模式,创建后、clear、compact
每次写入 position 自动递增。
put(byte b)
:将单个字节写入缓冲区。put(byte[] src)
:将字节数组写入缓冲区。channel.read(buffer)
:从通道向缓冲区写入。
读数据:读模式,调用 flip
每次读取 position 自动递增。
get()
:读取当前 position 的字节。get(byte[] dst)
:从缓冲区读取多个字节。get(int index)
:方法,获取特定 position 上的数据,但是不会对 position 的位置产生影响。rewind()
:可以将 postion 重置成 0,用于复读数据。mark() & reset()
:通过 mark 方法进行标记(position),通过 reset 方法跳回标记,从新执行。remaining()
: 返回当前缓冲区剩余可读的字节数(即 limit - position)。
字符串操作
字符串存储到 Buffer 中
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put("Hello".getBytes());// 自动把 ByteBuffer 设置成读模式,且不能手工调用 flip 方法。
ByteBuffer buffer = Charset.forName("UTF-8").encode("Hello");
// 自动把 ByteBuffer 设置成读模式,且不能手工调用 flip 方法。
ByteBuffer buffer = StandardCharsets.UTF_8.encode("Hello");
// 自动把 ByteBuffer 设置成读模式,且不能手工调用 flip 方法。
ByteBuffer buffer = ByteBuffer.wrap("Hello".getBytes());
Buffer 中的数据转换成字符串
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put("Hello".getBytes());
CharBuffer result = StandardCharsets.UTF_8.decode(buffer);
System.out.println("result.toString() = " + result.toString());
粘包与半包
半包问题指的是发送方在一次写操作中发送的数据大小超过了接收方的接收缓冲区大小,导致数据被拆分成多个部分,在接收方到达时只接收到其中的一部分,剩下的部分需要在后续的读取操作中继续接收。
粘包问题与半包相反,指的是多个小的数据包被TCP层合并成一个大的数据包传送给接收方,接收方无法分辨出不同数据包的边界,导致接收到的是一条合并的、粘在一起的数据包。
这里可以通过在每条消息的末尾添加一个分隔符(如 \n
或其他字符)来标识消息的边界。接收方通过读取直到分隔符的内容来识别完整的消息。
public class CompactDemo {public static void main(String[] args) {ByteBuffer buffer = ByteBuffer.allocate(50);buffer.put("Hi XUE WEI\nl love y".getBytes());doLineSplit(buffer);buffer.put("ou\nDo you like me?\n".getBytes());doLineSplit(buffer);}/*** ByteBuffer接受的数据 \n 切割为完整的行并打印** @param buffer ByteBuffer*/private static void doLineSplit(ByteBuffer buffer) {buffer.flip();for (int i = 0; i < buffer.limit(); i++) {if (buffer.get(i) == '\n') {int length = i + 1 - buffer.position();ByteBuffer target = ByteBuffer.allocate(length);for (int j = 0; j < length; j++) {// 这里存在问题,target 在创建的时候大小固定了// 如果 buffer 中数据超过了 target 的大小,就会抛出 BufferOverflowExceptiontarget.put(buffer.get());}// 截取工作完成target.flip();System.out.println("StandardCharsets.UTF_8.decode(target).toString() = " + StandardCharsets.UTF_8.decode(target).toString());}}buffer.compact();}
}