NIO 的引入
在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。
为了解决这个问题,在 Java1.4 版本引入了 NIO
(New I/O or Non-Blocking I/O)java.nio
。提供了一种基于缓冲区、选择器和非阻塞 IO 模型的 IO 处理方式。相比于之前的 BIO
模型,NIO
可以实现更高的并发、更低的延迟以及更少的资源消耗。
I/O 包和 NIO
已经很好地集成了,java.io
也已经以 NIO
为基础重新实现了,所以现在它可以利用 NIO
的一些特性。例如,java.io
包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。
Java NIO 概要介绍:初识 Java NIO
使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO 。
Buffer
在 NIO(New Input/Output)模型中,Buffer 是一个重要的概念,与数据打交道,用于在内存中存储数据。通过 Channel 将数据传输到 Buffer 缓冲区中,并在缓冲区内进行数据的读写操作。
Buffer
本质上是一个数组,可以存储多个相同类型的基本数据类型,如 byte、short、int、long、float、double 等。Buffer 封装了内部的数组,并提供了一些操作该数组的方法。
NIO 提供了多种 Buffer 类型,如
ByteBuffer
(最常用的 Buffer 类)CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
等
每种 Buffer
类型对应着不同的基本数据类型,用于存储不同类型的数据。
核心变量
Buffer
除了存储数据外,还保存着四个关键属性:
-
capacity
:表示 Buffer 的容量,即可以存储的最大数据量,容量在创建 Buffer 时就确定,之后不可修改。 -
position
:表示当前 Buffer 中已经处理的数据位置,初始值为 0,必须手动设置。每当进行读写操作时,Position 会自动由相应的get()
和put()
函数更新,向前移动 -
limit
:表示 Buffer 中可供访问的数据的最大位置(上界),初始值为 capacity,可以手动设置为 position 或其他值。 -
mark
:表示一个备忘记录,用于在某个特定位置设置 mark,然后在之后的某个时间点通过调用reset()
方法恢复到该 position。
在进行数据读写操作时,一般需要调用 put()
方法将数据写入 Buffer 中,或者调用 get()
方法从 Buffer 中读取数据。
对于写入操作,由于 position 属性的存在,保证了写入的数据不会覆盖已有的数据;对于读取操作,由于 position 属性的存在,保证了读取的数据不会超出可访问的数据范围。
使用 Buffer 进行数据读写时,需要注意处理好 position 和 limit 属性的值,以及不同类型的 Buffer 之间的数据转换问题。Buffer 并不是线程安全的,使用时需要注意线程同步的问题。
常用方法
-
put()
:向 Buffer 中写入数据。put() 方法有多个重载形式,它们可以将不同类型的数据写入 Buffer 中。例如 put(byte b)、putInt(int i)、putFloat(float f) 等。-
put(type value)
:将指定类型的数据写入到 Buffer 中,position 会自动向前移动。 -
put(byte[] array)
:将 byte 数组从当前 position 处写入 Buffer,同时会增加 position 的值。 -
put(ByteBuffer src)
:将 src 中的剩余字节写入 Buffer,同时会增加 position 的值。
-
-
get()
:从 Buffer 中读取数据。get() 方法也有多个重载形式,根据不同的数据类型可以选择对应的 get() 方法进行读取。例如,getInt()、getFloat()、getChar() 等。-
get()
:从当前 position 处读取一个字节,并将 position 向前移动。 -
get(byte[] array)
:将从当前 position 处开始的字节序列读入给定的 byte 数组中,并增加 position 的值。 -
get(ByteBuffer dst)
:将从当前 position 处开始的字节序列读入给定的 ByteBuffer 中,并增加 position 和 dst 的 position 值。
-
-
flip()
:设置 Buffer 的 limit 属性 为 当前的 position,然后将 position 属性设置为 0。在写入数据后,调用 flip() 方法可以将 Buffer 切换到读模式。 -
rewind()
:将 position 属性设置为 0,不改变 limit 属性,可以重复读取 Buffer 中的数据。 -
clear()
:将 Buffer 清空,position 和 limit 属性设置为初始值。可以重复写入 Buffer 中的数据。 -
compact()
:将 position 属性设置为 Buffer 中未处理数据的下一个位置,将未读取的数据移到缓冲区头部,以便更多数据写入。limit 属性则表示缓冲区尾部未处理空间的末尾位置。 -
mark()
:用于设置一个备忘位置 -
reset()
:用于恢复到 mark() 所标记的位置。 -
capacity()
:返回 Buffer 的容量,即可以存储的最大数据量。 -
position()
:返回当前 Buffer 中的位置(position)。 -
position(int newPostition)
:设置 Buffer 的 Position -
limit()
:返回 Buffer 的上界(limit),表示 Buffer 中可供访问的数据的最大位置。 -
limit(int newLimit)
:设置 Buffer 的 limit -
remaining()
:返回剩余可读取或可写入的元素数量,即 l i m i t − p o s i t i o n limit - position limit−position。 -
hasRemaining()
:检查是否还有剩余可读取或可写入的元素。 -
isReadOnly()
:检查 Buffer 是否为只读缓冲区。 -
array()
:返回 Buffer 所支持的数组,如果 Buffer 不支持数组,则抛出UnsupportedOperationException
异常。 -
duplicate()
:创建一个与原 Buffer 共享相同数据的新 Buffer。 -
slice()
:创建一个新 Buffer,与原 Buffer 共享相同数据,但通过修改其 position、limit 和 mark 属性来表示一个更小的数据集合。 -
compact()
:将未读取的数据移到 Buffer 的开头,同时将 position 设置为未读取数据的结尾,便于继续写入数据。 -
wrap(byte[] array, int offset, int length)
:将一个字节数组或指定范围的字节数组包装成一个 ByteBuffer 对象。offset
:包装的起始位置。length
:包装的长度。二者可以为空使用
ByteBuffer.wrap()
方法可以方便地将字节数组转换为 ByteBuffer 对象,从而可以进行更方便的读取和写入操作。需要注意的是,通过 wrap() 方法包装的 ByteBuffer 对象和原始的字节数组共享内存空间,对其中一个的修改会影响到另一个。
ByteBuffer
ByteBuffer
是 Java NIO 中的一个缓冲区(Buffer)类,用于在内存中存储字节数据。它是一个抽象类,并且是 Buffer 类最常用的子类,提供了操作字节数据的方法。
获取和设置当前字节顺序
ByteBuffer
类中的 order()
方法用于获取或设置字节顺序(Byte Order),字节顺序指的是在多字节数据存储中,高字节放在哪个位置。在 Java NIO 中,ByteBuffer
中的数据存储都是以大端字节顺序(Big Endian)进行存储的,即高位字节存放在低位地址处,而低位字节存放在高位地址处。
如果需要改变字节顺序,在 ByteBuffer
实例化后,可以通过调用 order()
方法设置字节顺序。如果需要将其切换到小端字节顺序(Little Endian),可以通过传入 ByteOrder.LITTLE_ENDIAN
常量作为参数来实现,否则,默认情况下字节顺序仍为大端字节顺序。
// 获取当前 ByteBuffer 的字节序
ByteOrder order = byteBuffer.order();
// 设置 ByteBuffer 的字节序为小端字节序
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
直接缓冲区与非直接缓冲区
在 Java NIO 中,缓冲区(Buffer)是一块连续的内存区域,用于在 Java 程序和底层 I/O 之间传输数据。Java NIO 提供了两种类型的缓冲区:直接缓冲区(Direct Buffer)和非直接缓冲区(Non-direct Buffer)。
-
非直接缓冲区存储在 JVM 内部,数据需要从应用程序(Java)复制到非直接缓冲区,再复制到内核缓冲区,最后发送到设备(磁盘/网络)。
-
对于直接缓冲区,数据可以直接从应用程序(Java)复制到内核缓冲区,无需经过 JVM 的非直接缓冲区。
直接缓冲区
-
直接缓冲区使用了操作系统的内存,通过
ByteBuffer.allocateDirect(int capatity)
方法创建。 -
直接缓冲区的创建和销毁比较慢,但在 I/O 操作中的性能一般较好,特别适合大量的数据传输。
-
由于直接缓冲区使用了堆外内存,因此对于频繁的 I/O 操作,可以减少数据复制的过程,提高读写效率。
-
直接缓冲区的内存分配和销毁不受 Java 堆大小的影响,但是由于使用了本地内存,可能会导致内存消耗较大。因此,在使用直接缓冲区时,需要谨慎使用和及时释放。
非直接缓冲区
-
非直接缓冲区使用了 JVM 堆 内存,通过
ByteBuffer.allocate(int capatity)
或其他分配方法创建。 -
非直接缓冲区的创建和销毁速度相对较快,因为它是在 Java 堆上分配的内存。
-
非直接缓冲区的 I/O 性能相对较差,因为在进行 I/O 操作时,还需要进行数据复制,增加了数据复制的开销。
-
由于使用了 Java 堆内存,因此受到堆大小的限制,当 Java 堆内存较小或已用内存较大时,可能会导致内存不足或频繁的垃圾回收。
MappedByteBuffer
MappedByteBuffer
是 Java NIO 中的一个特殊类型的缓冲区,用于表示一个内存映射文件,将文件的一部分或全部映射到内存中。它与文件 NIO 通道(FileChannel)相关联,并且只能通过 Channel 的 文件通道(FileChannel)创建。
MappedByteBuffer
可以让文件直接在内存(堆外内存)中进行修改,通过直接操作内存来实现对文件的读写,而不需要将文件从磁盘上复制到一个缓冲区中,使得操作文件的 I/O 性能得到提高。
特点
MappedByteBuffer
有以下几个特点:
-
只能通过 FileChannel 创建。
-
需要将文件映射到内存中才能进行数据的读写。
-
映射区域的大小不能超过
Integer.MAX_VALUE
。 -
修改缓冲区中的数据也会修改文件中的数据。
构造方法
MappedByteBuffer
可以通过 FileChannel 的 map
方法来创建,该方法返回一个新的 MappedByteBuffer
,并映射到指定文件的指定区域:
FileChannel.map(FileChannel.MapMode.mode,
int position,
int size)
-
mode
:表示是只读模式(READ_ONLY)、读写模式(READ_WRITE)或专用模式(PRIVATE)。 -
position
:表示从文件的哪个位置开始映射。 -
size
:表示映射到内存的字节数,即缓冲区的容量。
映射到的缓冲区可以进行修改,并且对文件进行修改,这种修改是直接写入到文件,因此修改后的内容将立即反映到文件中。不过需要注意的是,如果写入的数据超过了映射区域的大小,则会抛出异常。
force()
MappedByteBuffer.force()
方法将缓冲区中的修改刷新到磁盘上,保持文件和内存的一致性。
Scatter 和 Gather
Java IO 中的 Scatter
和 Gather
是一种 I/O 模式,用于在网络或磁盘 I/O 操作时改善性能。它们在 NIO(New IO)中引入,并在 Java 1.4 中加入。Scatter
和 Gather
模式通过将 I/O 操作中的散乱数据块收集到一个连续的缓冲区中(Gather),或者将一个连续的数据块分散到不同的缓冲区中(Scatter),来减少数据挪动和复制的次数,从而提高了性能。
Scatter
在 Scatter
模式下,它将从一个 Channel 读取的数据分散(写入)到多个缓冲区。这种操作可以在读取数据时将其分散到不同的缓冲区,有助于处理结构化数据。
例如,我们可以将消息头、消息体和消息尾分别写入不同的缓冲区。
这种模式常用于将数据分发到多个不同的缓冲区,并且数据也可以从多个通道读入到一个缓冲区中。
// 分散读取数据到多个缓冲区
ByteBuffer headerBuffer = ByteBuffer.allocate(128);
ByteBuffer bodyBuffer = ByteBuffer.allocate(1024);
ByteBuffer[] buffers = {headerBuffer, bodyBuffer};
long bytesRead = socketChannel.read(buffers);
// 输出缓冲区数据
headerBuffer.flip();
while (headerBuffer.hasRemaining()) {
System.out.print((char) headerBuffer.get());
}
System.out.println();
bodyBuffer.flip();
while (bodyBuffer.hasRemaining()) {
System.out.print((char) bodyBuffer.get());
}
Gather
在 Gather
模式下,与 Scatter
相反,它将多个缓冲区中的数据聚集(读取)并写入到一个 Channel
。这种操作允许我们在发送数据时从多个缓冲区中聚集数据。
例如,我们可以将消息头、消息体和消息尾从不同的缓冲区中聚集到一起并写入到同一个 Channel
。
这种模式常用于自动化多路复用和数据重新组合。
// 聚集数据从多个缓冲区写入到 Channel
ByteBuffer headerResponse = ByteBuffer.wrap("Header Response".getBytes());
ByteBuffer bodyResponse = ByteBuffer.wrap("Body Response".getBytes());
ByteBuffer[] responseBuffers = {headerResponse, bodyResponse};
long bytesWritten = socketChannel.write(responseBuffers);