Java NIO 核心知识介绍
Java NIO 核心知识介绍
概要
在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。
为了解决这个问题,在 Java1.4 版本引入了一种新的 I/O 模型 — NIO (New IO,也称为 Non-blocking IO) 。NIO 弥补了同步阻塞 I/O 的不足,它在标准 Java 代码中提供了非阻塞、面向缓冲、基于通道的 I/O,可以使用少量的线程来处理多个连接,大大提高了 I/O 效率和并发。
BIO、NIO 和 AIO 处理客户端请求的简单对比,如下图:
需要注意:使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO 。
一、NIO 核心组件
NIO 主要包括以下三个核心组件:
- Buffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
- Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。
- Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。
三者的关系如下图所示:
1. Buffer(缓冲区)
在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。
在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。
Buffer 的子类,如下图:
其中,最常用的是 ByteBuffer,它可以用来存储和操作字节数据。
Buffer 可以理解为一个数组,IntBuffer、FloatBuffer、CharBuffer 等分别对应 int[]、float[]、char[] 等。为了更清晰地认识缓冲区,我们来简单看看Buffer 类:
public abstract class Buffer { // Buffer允许将位置直接定位到该标记处,这是一个可选属性 private int mark = -1; // 下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。 private int position = 0; // Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置); // 读模式下,limit 等于 Buffer 中实际写入的数据大小。 private int limit; // Buffer可以存储的最大数据量,Buffer创建时设置且不可改变 private int capacity; // 用于存储直接缓冲区的内存地址(仅在直接缓冲区中使用) long address; //... }
上述变量满足如下的关系:0 <= mark <= position <= limit <= capacity 。
另外,Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。如下图:
position、limit和capacity之前的关系,如下图:
Buffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer。
这里以 ByteBuffer为例进行介绍:
// 分配堆内存 public static ByteBuffer allocate(int capacity); // 分配直接内存 public static ByteBuffer allocateDirect(int capacity);
Buffer 最核心的两个方法:
- get : 读取缓冲区的数据
- put :向缓冲区写入数据
除上述两个方法之外,其他的重要方法:
- flip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0。
- clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。
- ……
Buffer中数据变化的过程:
1 public class CharBufferDemo { 2 public static void main(String[] args) { 3 // 分配一个容量为8的CharBuffer 4 CharBuffer buffer = CharBuffer.allocate(8); 5 System.out.println("初始状态:"); 6 printState(buffer); 7 8 // 向buffer写入3个字符 9 buffer.put('a').put('b').put('c'); 10 System.out.println("写入3个字符后的状态:"); 11 printState(buffer); 12 13 // 调用flip()方法,准备读取buffer中的数据,将 position 置 0,limit 的置 3 14 buffer.flip(); 15 System.out.println("调用flip()方法后的状态:"); 16 printState(buffer); 17 18 // 读取字符 19 while (buffer.hasRemaining()) { 20 System.out.print(buffer.get()); 21 } 22 23 // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值 24 buffer.clear(); 25 System.out.println("调用clear()方法后的状态:"); 26 printState(buffer); 27 28 } 29 30 // 打印buffer的capacity、limit、position、mark的位置 31 private static void printState(CharBuffer buffer) { 32 System.out.print("capacity: " + buffer.capacity()); 33 System.out.print(", limit: " + buffer.limit()); 34 System.out.print(", position: " + buffer.position()); 35 System.out.print(", mark 开始读取的字符: " + buffer.mark()); 36 System.out.println("\n"); 37 } 38 }
2. Channel(通道)
Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。
BIO 中的流是单向的,分为各种 InputStream(输入流)和 OutputStream(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。
Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。Channel和Buffer之间的关系如下图:
另外,因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。Channel 的子类如下图所示。
其中,最常用的是以下几种类型的通道:
- FileChannel:文件访问通道
- SocketChannel、ServerSocketChannel:TCP 通信通道
- DatagramChannel:UDP 通信通道
如下图:
Channel 最核心的两个方法:
- read :读取数据并写入到 Buffer 中
- write :将 Buffer 中的数据写入到 Channel 中。
这里我们以 FileChannel 为例演示一下是读取文件数据的。
RandomAccessFile reader = new RandomAccessFile("/Users/guide/Documents/test_read.in", "r")) FileChannel channel = reader.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(1024); channel.read(buffer);
3. Selector(选择器)
Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。
1)工作原理
Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。如下图:
一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll() 代替传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
2)监听的四种事件类型
- SelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel。
- SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel。
- SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读。
- SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。
Selector是抽象类,可以通过调用此类的 open() 静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannel 的 IO 状况,是非阻塞 IO 的核心。
3)Selector实例的SelectionKey集合
一个 Selector 实例有三个 SelectionKey 集合:
- 所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回。
- 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel,这个集合可以通过 selectedKeys() 返回。
- 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。
简单演示一下如何遍历被选择的 SelectionKey 集合并进行处理:
Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key != null) { if (key.isAcceptable()) { // ServerSocketChannel 接收了一个新连接 } else if (key.isConnectable()) { // 表示一个新连接建立 } else if (key.isReadable()) { // Channel 有准备好的数据,可以读取 } else if (key.isWritable()) { // Channel 有空闲的 Buffer,可以写入数据 } } keyIterator.remove(); }
Selector 还提供了一系列和 select() 相关的方法:
- int select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。
- int select(long timeout):可以设置超时时长的 select() 操作。
- int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。
- Selector wakeup():使一个还未返回的 select() 方法立刻返回。
- ……
使用 Selector 实现网络读写的简单示例:
服务端代码:
1 package org.example.chapter; 2 3 import java.io.IOException; 4 import java.net.InetSocketAddress; 5 import java.nio.ByteBuffer; 6 import java.nio.channels.SelectionKey; 7 import java.nio.channels.Selector; 8 import java.nio.channels.ServerSocketChannel; 9 import java.nio.channels.SocketChannel; 10 import java.util.Iterator; 11 import java.util.Set; 12 public class NioSelectorExample { 13 14 public static void main(String[] args) { 15 try { 16 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); 17 serverSocketChannel.configureBlocking(false); 18 serverSocketChannel.socket().bind(new InetSocketAddress(8080)); 19 20 Selector selector = Selector.open(); 21 // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件 22 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); 23 24 while (true) { 25 int readyChannels = selector.select(); 26 27 if (readyChannels == 0) { 28 continue; 29 } 30 31 Set<SelectionKey> selectedKeys = selector.selectedKeys(); 32 Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); 33 34 while (keyIterator.hasNext()) { 35 SelectionKey key = keyIterator.next(); 36 37 if (key.isAcceptable()) { 38 // 处理连接事件 39 ServerSocketChannel server = (ServerSocketChannel) key.channel(); 40 SocketChannel client = server.accept(); 41 client.configureBlocking(false); 42 43 // 将客户端通道注册到 Selector 并监听 OP_WRITE事件 44 client.register(selector, SelectionKey.OP_WRITE); 45 System.out.println("客户端连接成功,准备发送数据..."); 46 } else if (key.isReadable()) { 47 // 处理读事件 48 SocketChannel client = (SocketChannel) key.channel(); 49 ByteBuffer buffer = ByteBuffer.allocate(1024); 50 int bytesRead = client.read(buffer); 51 52 if (bytesRead > 0) { 53 buffer.flip(); 54 System.out.println("收到数据:" +new String(buffer.array(), 0, bytesRead)); 55 // 将客户端通道注册到 Selector 并监听 OP_WRITE 事件 56 client.register(selector, SelectionKey.OP_WRITE); 57 } else if (bytesRead < 0) { 58 // 客户端断开连接 59 client.close(); 60 } 61 } else if (key.isWritable()) { 62 // 处理写事件 63 SocketChannel client = (SocketChannel) key.channel(); 64 ByteBuffer buffer = ByteBuffer.wrap("Hello, Client!".getBytes()); 65 //写数据到客户端 66 client.write(buffer); 67 68 System.out.println("服务端发送数据:Hello, Client!"); 69 // 将客户端通道注册到 Selector 并监听 OP_READ 事件 70 client.register(selector, SelectionKey.OP_READ); 71 } 72 73 keyIterator.remove(); 74 } 75 } 76 } catch (IOException e) { 77 e.printStackTrace(); 78 } 79 } 80 }
客户端代码:
1 package org.example.chapter; 2 3 import java.io.IOException; 4 import java.net.InetSocketAddress; 5 import java.nio.ByteBuffer; 6 import java.nio.channels.SocketChannel; 7 8 public class TestWritableClient { 9 public static void main(String[] args) { 10 try { 11 // 创建客户端通道 12 SocketChannel clientChannel = SocketChannel.open(); 13 clientChannel.configureBlocking(false); 14 15 // 连接服务端 16 if (!clientChannel.connect(new InetSocketAddress("localhost", 8080))) { 17 while (!clientChannel.finishConnect()) { 18 System.out.println("正在连接服务端..."); 19 } 20 } 21 System.out.println("连接服务端成功!"); 22 23 // 监听服务端返回的数据 24 ByteBuffer buffer = ByteBuffer.allocate(1024); 25 while (true) { 26 // 读取服务端发送的数据 27 int bytesRead = clientChannel.read(buffer); 28 if (bytesRead > 0) { 29 buffer.flip(); 30 System.out.println("收到服务端数据:" + new String(buffer.array(), 0, bytesRead)); 31 buffer.clear(); 32 } 33 if (bytesRead < 0) { 34 System.out.println("服务端关闭连接!"); 35 break; 36 } 37 } 38 39 clientChannel.close(); 40 } catch (IOException e) { 41 e.printStackTrace(); 42 } 43 } 44 }
在示例中,我们创建了一个简单的服务器,监听 8080 端口,使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 "Hello, Client!"。
测试流程:
1)启动服务端程序,在8080端口处监听
2)启动客户端程序
此时,服务端显示:
客户端连接成功,准备发送数据...
服务端发送数据:Hello, Client!
客户端这边显示:
连接服务端成功!
收到服务端数据:Hello, Client!
服务端程序中止,客户端显示:
服务端关闭连接!
二、NIO 零拷贝
零拷⻉是⽹络编程的关键,很多性能优化都离不开。它是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。
零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主要解决操作系统在处理 I/O 操作时频繁复制数据的问题,实现 CPU 的零参与,彻底消除 CPU 在这⽅⾯的负载。零拷贝的常见实现技术有: mmap+write、sendfile和 sendfile + DMA gather copy 。
各种零拷贝技术的对比,如下图:
无论是传统的 I/O 方式,还是引入了零拷贝之后,2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换。
三、总结
这篇文章我们主要介绍了 NIO 的核心知识点,包括 NIO 的核心组件和零拷贝。如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty。Netty 在 NIO 的基础上进行了一些优化和扩展比如支持多种协议、支持 SSL/TLS 等等。
参考链接:
https://javaguide.cn/java/io/nio-basis.html
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 我与微信审核的“相爱相杀”看个人小程序副业
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~