一、写在开头
我们在上一篇博文中提到了Java IO中常见得三大模型(BIO,NIO,AIO),其中NIO是我们在日常开发中使用比较多的一种IO模型,我们今天就一起来详细的学习一下。
在传统的IO中,多以这种同步阻塞的IO模型为主,程序发起IO请求后,处理线程处于阻塞状态,直到请求的IO数据从内核空间拷贝到用户空间。如下图可以直观的体现整个流程(图源:沉默王二)。
如果发起IO的应用程序并发量不高的情况下,这种模型是没问题的。但很明显,当前的互联网中,很多应用都有高并发IO请求的情况,这时就迫切的需要一款高效的IO模型啦。
NIO中的这个N既可以命名为NEW代表一种新型的IO模型,又可以理解为Non-Blocking,非阻塞之意。Java NIO 是 Java 1.4 版本引入的,基于通道(Channel)和缓冲区(Buffer)进行操作,采用非阻塞式 IO 操作,允许线程在等待 IO 时执行其他任务。常见的 NIO 类有 ByteBuffer、FileChannel、SocketChannel、ServerSocketChannel 等。(图源:深入拆解Tomcat & Jetty)
虽然在应用发起IO请求时,之多多次发起,无须阻塞。但在内核将数据拷贝到用户空间时,还是会阻塞的,为了保证数据的准确性和系统的安全稳定。
二、NIO的三大组件
在计算机与外部通信过程中,并非所有场景下NIO的性能都会好,对于连接少,并发地的应用系统中传统的BIO性能反而更好,因为在NIO中应用程序需要不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
为了更好的熟悉和掌握NIO,我们这里从NIO的三大组件入手,这也是很多大厂面试官在面试时会问到的点,虽然频率不高,但一定得会!
三个核心组件:
- Selector(选择器): 一种基于事件驱动的I/O多路复用模型,允许一个线程处理多个Channel,多个Channel注册到一个Selector上,然后由Selector进行轮询监听每一个Channel的变化。
- Channel(通道): 是一个双向的,可读可写的数据传输管道,通过它来实现数据的输入与输出工作,它只负责运输数据,不负责处理数据,处理数据在Buffer中。一般将管道分为文件通道和套接字通道。
- Buffer(缓冲区): NIO中数据的操作都是在缓冲区中完成的。读操作是将Channel中运输过来的数据填充到Buffer中;写操作是将Buffer中的数据写入到Channel中。
为了更好的理解NIO基于三大核心组件的运行流程,画了一个思维导图,如下:
三、组件详解
下面,我们针对上一章总结的三大组件,进行一个个的详细介绍。
3.1 Buffer(缓冲区)
在传统的BIO中,数据的读写操作是基于流的,写入采用输入字节流或字符流,而写出采用都的是输出字节流或者字符流,本质上都是基于字节的数据操作。而NIO库中,采用的是缓冲区,无论是写入还是写出数据,都不会进入到缓冲区里,由缓冲区进行下一步的操作。
上图是Buffer子类的继承关系结构图,我们可以看到,在Buffer中命名是基于基本数据类型的,而我们在日常使用中,ByteBuffer缓冲类最多,它是基于字节存储的,这一点和流一样。
而进入到这些缓冲类的内部够,我们可以发现,其实它们就相当于一个数组容器。在Buffer的源码中,有这样的几个参数:
public abstract class Buffer {
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
}
这四个成员变量的具体含义如下:
- 容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变;
- 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小;
- 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了;
- 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性。
并且,上述变量满足如下的关系:0 <= mark <= position <= limit <= capacity
。
这里我们需要注意一点,Buffer拥有读和写两种模式。Buffer被创建后,默认是写模式 ,调用flip()可以切换到读模式,再调用clear()或者compact()方法切换为写模式。
1️⃣ Buffer的实例化
Buffer无法通过调用构造方法来创建对象,而是需要通过静态方法进行实例化。我们以ByteBuffer为例:
// 分配堆内存,将缓冲区建立在JVM的内存中
public static ByteBuffer allocate(int capacity);
// 分配直接内存,将缓冲区建立在物理内存中,可以提交效率。但这里的数据不会被垃圾回收,容易导致内存溢出。
public static ByteBuffer allocateDirect(int capacity);
2️⃣ Buffer的核心方法
Buffer中我们常用的方法有:
- get : 读取缓冲区的数据;
- put :向缓冲区写入数据;
- flip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0;
- clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。
3️⃣ Buffer的测试用例
基于以上的理论知识学习后,我们写一个小的测试demo,来感受一下Buffer的使用。
【测试案例】
public class TestBuffer {
public static void main(String[] args) {
// 分配一个容量为8的CharBuffer,默认为写模式
CharBuffer buffer = CharBuffer.allocate(8);
System.out.println("起始状态:");
printState(buffer);
// 向buffer写入3个字符
buffer.put('a').put('b').put('c');
System.out.println("写入3个字符后的状态:");
printState(buffer);
// 调用flip()方法,切换为读模式,
// 准备读取buffer中的数据,将 position 置 0,limit 置 3
buffer.flip();
System.out.println("调用flip()方法后的状态:");
printState(buffer);
// 读取字符
//hasRemaining()方法用于判断当前位置和限制之间是否有任何元素。
//当且仅当此缓冲区中至少剩余一个元素时,此方法才会返回true。
while (buffer.hasRemaining()) {
System.out.println("读取字符:" + buffer.get());
}
// 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值
//调用clear()方法后,由读模式切换为写模式。
buffer.clear();
System.out.println("调用clear()方法后的状态:");
printState(buffer);
}
// 打印buffer的capacity、limit、position、mark的位置
private static void printState(CharBuffer buffer) {
//容量
System.out.print("capacity: " + buffer.capacity());
//界限
System.out.print(", limit: " + buffer.limit());
//下一个读写位置
System.out.print(", position: " + buffer.position());
//标记
System.out.print(", mark 开始读取的字符: " + buffer.mark());
System.out.println("\n");
}
}
【输出:】
起始状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符:
写入3个字符后的状态:
capacity: 8, limit: 8, position: 3, mark 开始读取的字符:
调用flip()方法后的状态:
capacity: 8, limit: 3, position: 0, mark 开始读取的字符: abc
读取字符:a
读取字符:b
读取字符:c
调用clear()方法后的状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符: abc
3.2 Channel(通道)
在上面的总结中,我们已经提过了,Channel作为一种双向的数据通道,给外部属于与程序之间搭建了一个传输的桥梁。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。甚至还可以同时读写!
Channel 的子类如下图所示。
这里虽然有很多通道类,但我们在日常生活中常用的,无非是 FileChannel:文件访问通道;SocketChannel、ServerSocketChannel:TCP 通信通道;DatagramChannel:UDP 通信通道;
FileChannel:用于文件 I/O 的通道,支持文件的读、写和追加操作。FileChannel 允许在文件的任意位置进行数据传输,支持文件锁定以及内存映射文件等高级功能。FileChannel 无法设置为非阻塞模式,因此它只适用于阻塞式文件操作。
SocketChannel:用于 TCP 套接字 I/O 的通道。SocketChannel 支持非阻塞模式,可以与 Selector(下文会讲)一起使用,实现高效的网络通信。SocketChannel 允许连接到远程主机,进行数据传输。
与之匹配的有ServerSocketChannel:用于监听 TCP 套接字连接的通道。与 SocketChannel 类似,ServerSocketChannel 也支持非阻塞模式,并可以与 Selector 一起使用。ServerSocketChannel 负责监听新的连接请求,接收到连接请求后,可以创建一个新的 SocketChannel 以处理数据传输。
DatagramChannel:用于 UDP 套接字 I/O 的通道。DatagramChannel 支持非阻塞模式,可以发送和接收数据报包,适用于无连接的、不可靠的网络通信。
1️⃣ Channel的核心方法
- read :读取数据并写入到 Buffer 中;
- write :将 Buffer 中的数据写入到 Channel 中。
2️⃣ Channel的测试案例
RandomAccessFile reader = new RandomAccessFile("E:\\testChannel.txt", "r");
FileChannel channel = reader.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
System.out.println("读取字符:" + new String(buffer.array()));
3.3 Selector(选择器)
选择器的概念在上面已经介绍过了,我们现在主要介绍它的运作原理:
通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。
主要监视事件类型:
- SelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel;
- SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel;
- SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读;
- SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。
SelectionKey集合:
- 所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回;
- 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel,这个集合可以通过 selectedKeys() 返回;
- 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。
Selector中的select()方法:
- int select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量;
- int select(long timeout):可以设置超时时长的 select() 操作;
- int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程;
- Selector wakeup():使一个还未返回的 select() 方法立刻返回。
【测试案例】
public static void main(String[] args) {
try {
//1、通过open()方法构建一个服务套接字通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
//封装8080端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
//2、通过open方法构建一个选择器对象
Selector selector = Selector.open();
// 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
//监听已注册的通道中是否有连接事件,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
//通过selectedKeys返回所有需要进行 IO 处理的 Channel
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 处理连接事件
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 将客户端通道注册到 Selector 并监听 OP_READ 事件
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 处理读事件
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = client.read(buffer);
if (bytesRead > 0) {
buffer.flip();
System.out.println("收到数据:" +new String(buffer.array(), 0, bytesRead));
// 将客户端通道注册到 Selector 并监听 OP_WRITE 事件
client.register(selector, SelectionKey.OP_WRITE);
} else if (bytesRead < 0) {
// 客户端断开连接
client.close();
}
} else if (key.isWritable()) {
// 处理写事件,立刻返回结果
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.wrap("Hello, Client!".getBytes());
client.write(buffer);
// 将客户端通道注册到 Selector 并监听 OP_READ 事件
client.register(selector, SelectionKey.OP_READ);
}
keyIterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
上面的代码创建了一个基于 Java NIO 的简单 TCP 服务器。它使用 ServerSocketChannel 和 Selector 实现了非阻塞 I/O 和 I/O 多路复用。服务器循环监听事件,当有新的连接请求时,接受连接并将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件。当有数据可读时,从 SocketChannel 中读取数据并写入 ByteBuffer,然后将数据从 ByteBuffer 写回到 SocketChannel。
四、总结
到这里基本上就把NIO的几个重要的组件介绍完啦,肯定不能面面俱到,大家想更多了解的,还是要多翻看不同的书籍。同时,后面我们将基于这部分内容,写一个小型的聊天室。