Contents

ByteBuffer简介

1. 概述

Buffer类是构建 Java NIO 的基础。但是,在这些类中,最首选的是ByteBuffer类。那是因为Byte类型是最通用的一种。例如,我们可以使用字节来组成 JVM 中的其他非布尔基元类型。此外,我们可以使用字节在 JVM 和外部 I/O 设备之间传输数据。

在本教程中,我们将检查ByteBuffer类的不同方面。

2. ByteBuffer创建

ByteBuffer是一个抽象类,所以我们不能直接构造一个新的实例。但是,它提供了静态工厂方法来方便创建实例。简而言之,有两种方法可以通过分配或包装来创建ByteBuffer实例:

/uploads/java_bytebuffer/1.png

2.1. 分配

分配将创建一个实例并分配具有特定allocate的私有空间。准确地说,ByteBuffer类有两种分配方法:allocateallocateDirect

使用allocate方法,我们将获得一个非直接缓冲区——即具有底层*byte[]*的缓冲区实例:

ByteBuffer buffer = ByteBuffer.allocate(10);

当我们使用allocateDirect方法时,它会生成一个直接缓冲区:

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

为简单起见,让我们关注非直接缓冲区,将直接缓冲区讨论留待以后讨论。

2.2. 包装

包装允许实例重用现有的byte[]

byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);

而上面的代码等价于:

ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, bytes.length);

对现有*byte[]*中的数据元素所做的任何更改都将反映在缓冲区实例中,反之亦然。

2.3. 洋葱模型

现在,我们知道如何获取ByteBuffer实例了。接下来我们把ByteBuffer类看成一个三层洋葱模型,由内而外一层一层的理解:

  • 数据和索引层
  • 传输数据层
  • 查看图层

/uploads/java_bytebuffer/3.png

在最内层,我们将ByteBuffer类视为具有额外索引的byte[]的容器。在中间层,我们专注于使用ByteBuffer实例将数据从/向其他数据类型传输。我们在最外层使用不同的基于缓冲区的视图检查相同的基础数据。

3. ByteBuffer索引

从概念上讲,ByteBuffer类是包装在对象中的byte[]。它提供了许多方便的方法来促进对底层数据的读取或写入操作。而且,这些方法高度依赖于维护的索引。

现在,让我们故意将ByteBuffer类简化为带有额外索引的*byte[]*容器:

ByteBuffer = byte array + index

考虑到这个概念,我们可以将与索引相关的方法分为四类:

  • 基本的
  • 标记并重置
  • 清除、翻转、倒带和紧凑
  • 保持

/uploads/java_bytebuffer/5.png

3.1. 四大基本指标

Buffer类中定义了四个索引。这些索引记录了底层数据元素的状态:

  • 容量:缓冲区可以容纳的最大数据元素数
  • 限制:停止读取或写入的索引
  • 位置:当前要读取或写入的索引
  • 标记:记住的位置

此外,这些指数之间存在不变的关系:

0 <= mark <= position <= limit <= capacity

而且,我们应该注意到所有与索引相关的方法都围绕这四个索引

当我们创建一个新的ByteBuffer实例时,mark是未定义的,position保持为 0,limit等于capacity。例如,让我们分配一个包含 10 个数据元素的ByteBuffer

ByteBuffer buffer = ByteBuffer.allocate(10);

或者,让我们用 10 个数据元素包装一个现有的字节数组:

byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);

结果,mark为 -1,位置为 0,limitcapacity均为10:

int position = buffer.position(); // 0
int limit = buffer.limit();       // 10
int capacity = buffer.capacity(); // 10

capacity是只读的,不能更改。但是,我们可以使用position(int)limit(int)方法来改变相应的positionlimit

buffer.position(2);
buffer.limit(5);

然后,position为 2,limit为 5。

3.2. 标记并重置

*mark()reset()*方法允许我们记住一个特定的位置并在以后返回它。

当我们第一次创建一个ByteBuffer实例时,mark是未定义的。然后,我们可以调用mark()方法,将mark设置为当前位置。经过一些操作后,调用reset()方法会将position改回mark

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0
buffer.position(2);                          // mark = -1, position = 2
buffer.mark();                               // mark = 2,  position = 2
buffer.position(5);                          // mark = 2,  position = 5
buffer.reset();                              // mark = 2,  position = 2

需要注意的一点:如果mark未定义,则调用reset()方法将导致InvalidMarkException

3.3. 清除、翻转、倒带和紧凑

clear()flip()、rewind *()compact()*方法有一些共同点和细微差别:

/uploads/java_bytebuffer/7.png

为了比较这些方法,让我们准备一个代码片段:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10
buffer.position(2);                          // mark = -1, position = 2, limit = 10
buffer.mark();                               // mark = 2,  position = 2, limit = 10
buffer.position(5);                          // mark = 2,  position = 5, limit = 10
buffer.limit(8);                             // mark = 2,  position = 5, limit = 8

clear()方法会将limit更改为capacityposition更改为 0,mark更改为 -1:

buffer.clear();                              // mark = -1, position = 0, limit = 10

flip()方法会将limit更改为positionposition更改为 0,mark更改为 -1:

buffer.flip();                               // mark = -1, position = 0, limit = 5

rewind()方法保持limit不变并将position更改为 0,并将mark更改为 -1:

buffer.rewind();                             // mark = -1, position = 0, limit = 8

compact()方法会将limit更改为capacity,将position更改为剩余(limit - position),并将mark更改为 -1:

buffer.compact();                            // mark = -1, position = 3, limit = 10

以上四种方法都有各自的用例:

  • 要重用缓冲区,*clear()*方法很方便。它将索引设置为初始状态并为新的写入操作做好准备。
  • 调用flip()方法后,缓冲区实例从写模式切换到读模式。但是,我们应该避免两次调用flip()方法。这是因为第二次调用会将limit设置为 0,并且无法读取任何数据元素。
  • 如果我们想多次读取底层数据,*rewind()*方法就派上用场了。
  • *compact()*方法适用于缓冲区的部分重用。例如,假设我们想要读取一些但不是全部的底层数据,然后我们想要将数据写入缓冲区。*compact()*方法会将未读数据复制到缓冲区的开头并更改缓冲区索引以准备写入操作。

3.4. 保持

hasRemaining()remaining()方法计算limitposition的关系:

/uploads/java_bytebuffer/9.png

limit大于position时,hasRemaining()将返回true。此外,reset()方法返回limitposition之间的差异。

例如,如果缓冲区的位置为 2,限制为 8,那么它的剩余部分将为 6:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10
buffer.position(2);                          // mark = -1, position = 2, limit = 10
buffer.limit(8);                             // mark = -1, position = 2, limit = 8
boolean flag = buffer.hasRemaining();        // true
int remaining = buffer.remaining();          // 6

4. 传输数据

洋葱模型的第二层与传输数据有关。具体来说,ByteBuffer类提供了将数据从/向其他数据类型bytecharshortintlongfloatdouble)传输数据的方法:

/uploads/java_bytebuffer/11.png

4.1. 传输byte数据

为了传输byte数据,ByteBuffer类提供了单个和批量操作。

**我们可以在单个操作中从缓冲区的底层数据读取或写入单个字节。**这些操作包括:

public abstract byte get();
public abstract ByteBuffer put(byte b);
public abstract byte get(int index);
public abstract ByteBuffer put(int index, byte b);

我们可能会注意到上述方法的两个版本的get() / put()方法:一个没有参数,另一个接受index。那么,有什么区别呢?

没有索引的是相对操作,对当前位置的数据元素进行操作,之后位置加1。但是,有index的是整体操作,对index处的数据元素进行操作,不会改变position

**相反,批量操作可以从缓冲区的底层数据读取或写入多个字节。**这些操作包括:

public ByteBuffer get(byte[] dst);
public ByteBuffer get(byte[] dst, int offset, int length);
public ByteBuffer put(byte[] src);
public ByteBuffer put(byte[] src, int offset, int length);

以上方法均属于相对操作。也就是说,它们将分别从当前位置读取或写入当前position并更改position值。

还有另一个put()方法,它接受一个ByteBuffer参数:

public ByteBuffer put(ByteBuffer src);

4.2. 传输int数据

除了读取或写入byte数据,ByteBuffer类还支持除boolean类型之外的其他基本类型。我们以int类型为例。相关方法包括:

public abstract int getInt();
public abstract ByteBuffer putInt(int value);
public abstract int getInt(int index);
public abstract ByteBuffer putInt(int index, int value);

类似地,带有index参数的*getInt()putInt()*方法是绝对操作,否则是相对操作。

5. 不同的观点

洋葱模型的第三层是关于从不同的角度读取相同的底层数据

/uploads/java_bytebuffer/13.png

上图中的每个方法都会生成一个与原始缓冲区共享相同底层数据的新视图。要理解一个新的观点,我们应该关注两个问题:

  • 新视图将如何解析底层数据?
  • 新视图将如何记录其索引?

5.1. ByteBuffer视图

要将ByteBuffer实例读取为另一个ByteBuffer视图,它具有三个方法:duplicate()slice()asReadOnlyBuffer()

让我们看一下这些差异的说明:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10, capacity = 10
buffer.position(2);                          // mark = -1, position = 2, limit = 10, capacity = 10
buffer.mark();                               // mark = 2,  position = 2, limit = 10, capacity = 10
buffer.position(5);                          // mark = 2,  position = 5, limit = 10, capacity = 10
buffer.limit(8);                             // mark = 2,  position = 5, limit = 8,  capacity = 10

duplicate()方法创建一个新的ByteBuffer实例,就像原来的一样。但是,两个缓冲区中的每一个都有其独立的limitpositionmark

ByteBuffer view = buffer.duplicate();        // mark = 2,  position = 5, limit = 8,  capacity = 10

slice()方法创建底层数据的共享子视图。视图的position将为 0,其limitcapacity将是原始缓冲区的剩余部分:

ByteBuffer view = buffer.slice();            // mark = -1, position = 0, limit = 3,  capacity = 3

与*duplicate()*方法相比,*asReadOnlyBuffer()*方法的工作方式类似,但会产生一个只读缓冲区。这意味着我们不能使用这个只读视图来更改底层数据:

ByteBuffer view = buffer.asReadOnlyBuffer(); // mark = 2,  position = 5, limit = 8,  capacity = 10

5.2. 其他视图

ByteBuffer还提供其他视图:asCharBuffer()asShortBuffer()asIntBuffer()asLongBuffer()asFloatBuffer()asDoubleBuffer()。这些方法类似于slice()方法,即它们提供与底层数据的当前positionlimit相对应的切片视图。它们之间的主要区别是将基础数据解释为其他原始类型值。 我们应该关心的问题是:

  • 如何解释基础数据
  • 从哪里开始解释
  • 新生成的视图中将显示多少元素

新视图会将多个字节组合成目标原始类型,并从原始缓冲区的当前位置开始解释。新视图的容量等于原始缓冲区中剩余元素的数量除以构成视图原始类型的字节数。最后的任何剩余字节在视图中都将不可见。

现在,让我们以*asIntBuffer()*为例:

byte[] bytes = new byte[]{
  (byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, // CAFEBABE ---> cafebabe
  (byte) 0xF0, (byte) 0x07, (byte) 0xBA, (byte) 0x11, // F007BA11 ---> football
  (byte) 0x0F, (byte) 0xF1, (byte) 0xCE               // 0FF1CE   ---> office
};
ByteBuffer buffer = ByteBuffer.wrap(bytes);
IntBuffer intBuffer = buffer.asIntBuffer();
int capacity = intBuffer.capacity();                         // 2

在上面的代码片段中,buffer有 11 个数据元素,int类型占用 4 个字节。因此,intBuffer将有 2 个数据元素 (11 / 4 = 2) 并省去额外的 3 个字节 (11 % 4 = 3)。

6. 直接缓冲

什么是直接缓冲区?直接缓冲区是指分配在 OS 函数可以直接访问的内存区域上的缓冲区的底层数据。非直接缓冲区是指其底层数据是分配在 Java 堆区域中的*byte[]*的缓冲区。

那么,我们怎样才能创建一个直接缓冲区呢?通过调用具有所需容量的allocateDirect()方法创建直接ByteBuffer

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

**为什么我们需要一个直接缓冲区?**答案很简单:非直接缓冲区总是会引发不必要的复制操作。当将非直接缓冲区的数据发送到 I/O 设备时,本机代码必须“锁定”底层byte[],将其复制到 Java 堆外,然后调用 OS 函数来刷新数据。但是,本机代码可以直接访问底层数据并调用操作系统函数来刷新数据,而无需使用直接缓冲区产生任何额外开销。

鉴于上述情况,直接缓冲区是否完美?不,主要问题是分配和取消分配直接缓冲区的成本很高。那么,实际上,直接缓冲区总是比非直接缓冲区运行得快吗?不必要。那是因为许多因素在起作用。而且,性能权衡可能因 JVM、操作系统和代码设计而异。

最后,要遵循一条实用的软件格言:首先,让它工作,然后,让它快速。这意味着,让我们首先关注代码的正确性。如果代码运行的不够快,那就做相应的优化吧。

7. 杂项

ByteBuffer类还提供了一些辅助方法:

/uploads/java_bytebuffer/15.png

7.1. Is相关方法

*isDirect()方法可以告诉我们缓冲区是直接缓冲区还是非直接缓冲区。请注意,包装缓冲区(使用wrap()*方法创建的缓冲区)始终是非直接的。

所有缓冲区都是可读的,但并非所有缓冲区都是可写的。*isReadOnly()*方法指示我们是否可以写入底层数据。

为了比较这两种方法,*isDirect()*方法关心底层数据存在的位置,在 Java 堆或内存区域中。但是,**isReadOnly()方法关心底层数据元素是否可以更改

如果原始缓冲区是直接的或只读的,则新生成的视图将继承这些属性。

7.2. 数组相关的方法

如果一个ByteBuffer实例是直接的或只读的,我们就无法得到它的底层字节数组。但是,如果缓冲区是非直接的并且不是只读的,那并不一定意味着它的底层数据是可访问的。

准确地说,hasArray()方法可以告诉我们缓冲区是否具有可访问的后备数组。如果hasArray()方法返回true,那么我们可以使用*array()arrayOffset()*方法来获取更多相关信息。

7.3. 字节顺序

默认情况下,ByteBuffer类的字节顺序总是ByteOrder.BIG_ENDIAN。并且,我们可以使用*order()order(ByteOrder)*方法分别获取和设置当前字节顺序。

字节顺序影响如何解释基础数据。例如,假设我们有一个buffer实例:

byte[] bytes = new byte[]{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE};
ByteBuffer buffer = ByteBuffer.wrap(bytes);

使用ByteOrder.BIG_ENDIANval将为 -889275714 (0xCAFEBABE):

buffer.order(ByteOrder.BIG_ENDIAN);
int val = buffer.getInt();

但是,使用ByteOrder.LITTLE_ENDIAN时,val将为 -1095041334 (0xBEBAFECA):

buffer.order(ByteOrder.LITTLE_ENDIAN);
int val = buffer.getInt();

7.4. 比较

ByteBuffer类提供了*equals()compareTo()方法来比较两个缓冲区实例。这两种方法都基于[position, limit)*范围内的剩余数据元素执行比较。

例如,具有不同基础数据和索引的两个缓冲区实例可以相等:

byte[] bytes1 = "World".getBytes(StandardCharsets.UTF_8);
byte[] bytes2 = "HelloWorld".getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer1 = ByteBuffer.wrap(bytes1);
ByteBuffer buffer2 = ByteBuffer.wrap(bytes2);
buffer2.position(5);
boolean equal = buffer1.equals(buffer2); // true
int result = buffer1.compareTo(buffer2); // 0