0%

(一)NIO - Buffer

1. Java NIO 简介

Java NIO(New IO) 是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同, NIO支持面向缓冲区(Buffer)的、基于通道(Channel)的IO操作。 NIO能以更加高效的方式进行文件的读写操作,并且是非阻塞IO

使用NIO进行数据的读取时,会将文件/网络/磁盘中的数据放入缓冲区中,然后通过通道进行传输,之后再通过程序代码进行数据的其他操作。当使用 NIO进行数据的写入时,通过程序代码将数据存放到缓冲区中,然后再经过通道传输并将缓冲区的数据存储到文件或者进行其他操作。简而言之, Channel 负责传输, Buffer 负责存储。大致流程如下图所示:
在这里插入图片描述
对于传统的IO,其是面向流的。传统的输入输出流都是通过字节的移动来处理的(即使不直接去处理字节流,但底层的实现还是依赖于字节处理),也就是说,面向流的输入/输出系统一次只能处理一个字节,因此面向流的输入/输出系统通常效率不高。另外,传统的IO是阻塞式IO。其数据传输的大致流程如下图所示:
在这里插入图片描述

2. 使用 Buffer

缓冲区(Buffer) :一个用于特定基本数据类型的容器。由 java.nio 包定义的,所有缓冲区都是 Buffer 抽象类的子类。

Java NIO 中的 Buffer 主要用于与 NIO 通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中的。Buffer 就像一个数组,可以保存多个相同类型的数据。根据数据类型不同(boolean 除外) ,有以下 Buffer 常用子类:

ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer 和 DoubleBuffer。

上述 Buffer 类他们都采用相似的方法进行管理数据,只是各自管理的数据类型不同而已,都是通如下方法获取一个 Buffer对象:

static XxxBuffer allocate(int capacity) : 创建一个容量为 capacity 的 XxxBuffer 对象。

2.1 Buffer 中的重要概念

  • 容量 (capacity) : 表示 Buffer 最大数据容量,缓冲区容量不能为负,并且创建后不能更改。
  • 限制 (limit): 第一个不应该读取或写入的数据的索引,即位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量。
  • 位置 (position): 下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制。
  • 标记 (mark)与重置 (reset): 标记是一个索引,通过 Buffer 中的 mark() 方法指定 Buffer 中一个特定的 position,之后可以通过调用 reset() 方法恢复到这个 position。

标记、 位置、 限制、 容量遵守以下不变式: 0 <= mark <= position <= limit <= capacity,可以用下图表示:
在这里插入图片描述

2.2 Buffer 的常用方法

Buffer 的常用方法及其描述具体如下图所示:
在这里插入图片描述
Buffer 所有子类提供了两个用于数据操作的方法: get()与 put() 方法。

获取 Buffer 中的数据

  • get() :读取单个字节;
  • get(byte[] dst):批量读取多个字节到 dst 中;
  • get(int index):读取指定索引位置的字节(不会移动 position)。

放入数据到 Buffer 中:

  • put(byte b):将给定单个字节写入缓冲区的当前位置;
  • put(byte[] src):将 src 中的字节写入缓冲区的当前位置;
  • put(int index, byte b):将指定字节写入缓冲区的索引位置(不会移动 position)。

    2.2.1 示例

1.比如,我们可以先创建一个容量大小为10的缓冲区,然后输出其容量,界限等信息。代码如下:

1
2
3
4
5
6
7
8
9
10
@Test
public void testBuffer() {
//获取指定大小的buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(10);

System.out.println("------allocate()--------");
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//0
System.out.println(byteBuffer.limit());//10
}

具体图示如下示:
在这里插入图片描述
2.可以使用put方法向该缓冲区中存储数据,缓冲区的当前位置position会向后移动。

1
2
3
4
5
6
//向缓冲区存入5个数据
byteBuffer.put("abcde".getBytes());
System.out.println("------put()--------");
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//5
System.out.println(byteBuffer.limit());//10

存入数据后,缓冲区中的具体图示如下示:
在这里插入图片描述
3.使用flip方法将缓冲区切换成读模式,具体代码和缓存区变化如下所示:

1
2
3
4
5
6
//切换成读模式
System.out.println("------flip()--------");
byteBuffer.flip();
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//0
System.out.println(byteBuffer.limit());//5

在这里插入图片描述
4.接下来我们才可以进行缓冲区中数据的读取操作,这里使用到了get方法。代码如下:

1
2
3
4
5
6
7
8
9
10
//读取数据
System.out.println("------get()--------");
//记得是限界以内的数据大小
byte[] buf = new byte[byteBuffer.limit()];
//读取数据到目标数组中
byteBuffer.get(buf);
System.out.println(new String(buf, 0, buf.length));//abcde
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//5
System.out.println(byteBuffer.limit());//5

5.当然我们也可以使用rewind方法将position指针重新指向缓冲区0号位处,然后就可以再次进行数据的读取:

1
2
3
4
5
6
//重复读数据
System.out.println("------rewind()--------");
byteBuffer.rewind();
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//0
System.out.println(byteBuffer.limit());//5

6.另外,我们可以使用clear方法清空缓冲区,恢复到缓冲区创建时的初始状态。但是缓冲区中的数据依然存在,但是处于被遗忘状态。所谓被遗忘就是我们无法在确定缓冲区的界限是多少,因此我们便无法自由准确的读取缓冲区中的数据。代码示例如下:

1
2
3
4
5
6
7
8
//清空缓冲区,但是缓冲区中的数据依然存在,但是是处于被遗忘状态
System.out.println("------clear()--------");
byteBuffer.clear();
System.out.println(byteBuffer.capacity());//10
System.out.println(byteBuffer.position());//0
System.out.println(byteBuffer.limit());//10
//仍然可以获取缓冲区的数据,但是我们无法确定其界限。
System.out.println((char)byteBuffer.get(0));//a

7.最后我们在演示一下mark和reset方法的使用,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void testRemark() {
String data = "abcde";
//获取指定大小的buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byteBuffer.put(data.getBytes());
//标记当前位置
System.out.println("mark position = " + byteBuffer.position());//5
byteBuffer.mark();
//再次向缓冲区存取数据
byteBuffer.put("fg".getBytes());
System.out.println("before reset position = " + byteBuffer.position());//7
//reset回到mark标记的位置
byteBuffer.reset();
System.out.println("after reset position = " + byteBuffer.position());//5
}

3. 直接缓冲区与非直接缓冲区

前面,我们都是使用allocate方法来创建缓冲区的,其所创建的缓冲区为非直接缓冲区。该缓冲区是建立在 JVM 的内存中的。

若使用非直接缓冲区,那么当应用程序向操作系统的磁盘发出一次读写请求时,其过程涉及到内核地址空间和用户地址空间之间数据的来回拷贝操作,这无疑会增加数据的读写的操作时间。具体过程如下图所示:
在这里插入图片描述
当然,我们也可以使用allocateDirect方法(注:只有ByteBuffer有提供该方法)来创建直接缓冲区,直接缓冲区的读写过程如下图所示:
在这里插入图片描述
它避免了内核地址空间和用户地址空间之间的数据的来回拷贝,而是直接在物理磁盘和应用程序之间建立一个操作系统中的物理内存映射文件。当然,对直接缓冲区进行分配和取消分配所需成本通常高于非直接缓冲区。

直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对应用程序的内存需求量造成的影响可能并不明显。所以,建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区,毕竟直接缓冲区的创建成本比较高。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。

字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其 isDirect() 方法来确定。提供此方法是为了能够在性能关键型代码中执行显式缓冲区管理。例如:

1
2
3
4
5
6
7
8
@Test
public void testBufferType() {
ByteBuffer allocateDirect = ByteBuffer.allocateDirect(100);
System.out.println(allocateDirect.isDirect());//true

ByteBuffer byteBuffer = ByteBuffer.allocate(10);
System.out.println(byteBuffer.isDirect());//false
}
------ 本文结束------