NIO:解开非阻塞I/O高并发编程的秘密 _

📅 2026/6/30 2:27:44
NIO:解开非阻塞I/O高并发编程的秘密 _
流与块Standard IO是对字节流的读写在进行IO之前首先创建一个流对象流对象进行读写操作都是按字节 一个字节一个字节的来读或写。而NIO把IO抽象成块类似磁盘的读写每次IO操作的单位都是一个块块被读入内存之后就是一个byte[]NIO一次可以读或写多个字节。I/O 与 NIO 最重要的区别是数据打包和传输的方式I/O 以流的方式处理数据而 NIO 以块的方式处理数据。面向流的 I/O 一次处理一个字节数据: 一个输入流产生一个字节数据一个输出流消费一个字节数据。为流式数据创建过滤器非常容易链接几个过滤器以便每个过滤器只负责复杂处理机制的一部分。不利的一面是面向流的 I/O 通常相当慢。面向块的 I/O 一次处理一个数据块按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。I/O 包和 NIO 已经很好地集成了java.io.*已经以 NIO 为基础重新实现了所以现在它可以利用 NIO 的一些特性。例如java.io.*包中的一些类包含以块的形式读写数据的方法这使得即使在面向流的系统中处理速度也会更快。Java对IO多路复用的支持NIO 常常被叫做非阻塞 IO主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。但其实应该叫new IO是相较于传统IO来说的。Java NIO 中的Selector类是基于操作系统提供的 I/O 多路复用机制实现的而在 Linux 上这个机制就是epoll。关于触发模式Java NIO 的Selector默认使用的是水平触发模式Level-Triggered, LT。这意味着当一个文件描述符在 Java 中通常是SocketChannel或ServerSocketChannel变得可读或可写时Selector会持续通知直到该文件描述符上的事件被处理。这与epoll的水平触发模式是一致的。虽然epoll也支持边缘触发模式Edge-Triggered, ET但 Java NIO 的Selector并没有直接提供对边缘触发模式的支持。如果需要使用边缘触发模式通常需要直接使用底层的系统调用如通过 JNI 调用epoll的边缘触发模式但这超出了标准 Java NIO 库的范围。关于水平触发和边缘触发的区别可以看这篇文章总结一下Java NIO 在 Linux 上使用epoll作为底层的 I/O 多路复用机制。Java NIO 的Selector默认使用epoll的水平触发模式。Java NIO 不直接支持epoll的边缘触发模式需要通过其他方式实现。因此如果在 Linux 上使用 Java NIO 的Selector它使用的是 epoll 的水平触发模式。三大组件通道被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。一个通道会有一个专属的文件状态描述符。那么既然是和操作系统进行内容的传递那么说明应用程序可以通过通道读取数据也可以通过通道向操作系统写数据。通道 Channel 是对原 I/O 包中的流的模拟可以通过它读取和写入数据。通道与流的不同之处在于流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类)而通道是双向的可以用于读、写或者同时用于读写。JAVA NIO 框架中自有的Channel通道包括:所有被Selector(选择器)注册的通道只能是继承了SelectableChannel类的子类。如上图所示FileChannel: 从文件中读写数据DatagramChannel: 通过 UDP 读写网络中数据SocketChannel: TCP Socket套接字的监听通道一个Socket套接字对应了一个客户端IP: 端口 到 服务器IP: 端口的通信连接。ServerSocketChannel: 应用服务器程序的监听通道。只有通过这个通道应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。FileChannel 是磁盘IO的通道后三个是网络IO的通道。并且FileChannel不能切换为非阻塞模式因此FileChannel不适合Selector。缓冲区数据缓存区: 在JAVA NIO 框架中为了保证每个通道的数据读写速度JAVA NIO 框架为每一种需要支持数据读写的通道集成了Buffer的支持。用于读取或写入数据到通道。这句话怎么理解呢? 例如ServerSocketChannel通道它只支持对OP_ACCEPT事件的监听所以它是不能直接进行网络数据内容的读写的。所以ServerSocketChannel是没有集成Buffer的。Buffer有两种工作模式: 写模式和读模式。在读模式下应用程序只能从Buffer中读取数据不能进行写操作。但是在写模式下应用程序是可以进行读操作的这就表示可能会出现脏读的情况。所以一旦您决定要从Buffer中读取数据一定要将Buffer的状态改为读模式。发送给一个通道的所有数据都必须首先放到缓冲区中同样地从通道中读取的任何数据都要先读到缓冲区中。也就是说不会直接对通道进行读写数据而是要先经过缓冲区。缓冲区实质上是一个数组但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问而且还可以跟踪系统的读/写进程。缓冲区包括以下类型:ByteBufferCharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBufferByteBuffer 正确使用姿势向 buffer 写入数据例如调用 channel.read(buffer)调用 flip() 切换至读模式从 buffer 读取数据例如调用 buffer.get()调用 clear() 或 compact() 切换至写模式重复 1~4 步骤ByteBuffer 大小分配每个 channel 都需要记录可能被切分的消息因为 ByteBuffer 不能被多个 channel 共同使用因此需要为每个 channel 维护一个独立的 ByteBufferByteBuffer 不能太大比如一个 ByteBuffer 1Mb 的话要支持百万连接就要 1Tb 内存因此需要设计大小可变的 ByteBuffer一种思路是首先分配一个较小的 buffer例如 4k如果发现数据不够再分配 8k 的 buffer将 4k buffer 内容拷贝至 8k buffer优点是消息连续容易处理缺点是数据拷贝耗费性能参考实现 http://tutorials.jenkov.com/java-performance/resizable-array.html另一种思路是用多个数组组成 buffer一个数组不够把多出来的内容写入新的数组与前面的区别是消息存储不连续解析复杂优点是避免了拷贝引起的性能损耗缓冲区状态变量capacity: 最大容量position: 当前已经读写的字节数limit: 还可以读写的字节数。状态变量的改变过程举例:① 新建一个大小为 8 个字节的缓冲区此时 position 为 0而 limit capacity 8。capacity 变量不会改变下面的讨论会忽略它。② 从输入通道中读取 5 个字节数据写入缓冲区中此时 position 移动设置为 5limit 保持不变。③ 在将缓冲区的数据写到输出通道之前需要先调用 flip() 方法这个方法将 limit 设置为当前 position并将 position 设置为 0。写到输出通道意味着要从buffer中读出才能写入channeljavapublic Buffer flip() { limit position; position 0; mark -1; return this; }④ 从缓冲区中取 4 个字节到输出缓冲中此时 position 设为 4。⑤ 最后需要调用 clear() 方法来清空缓冲区此时 position 和 limit 都被设置为最初位置。⑥ compact 方法是把未读完的部分向前压缩然后切换至写模式文件 NIO 实例以下展示了使用 NIO 快速复制文件的实例:javapublic static void fastCopy(String src, String dist) throws IOException { // 获得源文件的输入字节流 FileInputStream fin new FileInputStream(src); // 获取输入字节流的文件通道 FileChannel fcin fin.getChannel(); // 获取目标文件的输出字节流 FileOutputStream fout new FileOutputStream(dist); // 获取输出字节流的通道 FileChannel fcout fout.getChannel(); // 为缓冲区分配 1024 个字节 ByteBuffer buffer ByteBuffer.allocateDirect(1024); while (true) { // 从输入通道中读取数据到缓冲区中 int r fcin.read(buffer);//对于buffer来说这是写入的过程 // read() 返回 -1 表示 EOF if (r -1) { break; } // 切换读写 buffer.flip(); // 把缓冲区的内容写入输出文件中 fcout.write(buffer);//对于buffer来说这是读取的过程 // 清空缓冲区 buffer.clear(); } }选择器Selector (选择器多路复用器)是JavaNIO 中能够检测一到多个NIO通道是否为诸如读写事件做好准备的组件。这样一个单独的线程可以管理多个channel从而管理多个网络连接。NIO 实现了 IO 多路复用中的 多Reactor多进程/线程 模型一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件从而让一个线程就可以处理多个事件。通过配置监听的通道 Channel 为非阻塞那么当 Channel 上的 IO 事件还未到达时就不会进入阻塞状态一直等待而是继续轮询其它 Channel找到 IO 事件已经到达的 Channel 执行。因为创建和切换线程的开销很大因此使用一个线程来处理多个事件而不是一个线程处理一个事件具有更好的性能。事件订阅和Channel管理应用程序将向Selector对象注册需要它关注的Channel以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。以下代码来自WindowsSelectorImpl实现类中对已经注册的Channel的管理容器:java// Initial capacity of the poll array private final int INIT_CAP 8; // Maximum number of sockets for select(). // Should be INIT_CAP times a power of 2 private final static int MAX_SELECTABLE_FDS 1024; // The list of SelectableChannels serviced by this Selector. Every mod // MAX_SELECTABLE_FDS entry is bogus, to align this array with the poll // array, where the corresponding entry is occupied by the wakeupSocket private SelectionKeyImpl[] channelArray new SelectionKeyImpl[INIT_CAP];轮询代理应用层不再通过阻塞模式或者非阻塞模式直接询问操作系统“事件有没有发生”而是由Selector代其询问。实现不同操作系统的支持多路复用IO技术 是需要操作系统进行支持的其特点就是操作系统可以同时扫描同一个端口上不同网络连接的事件。所以作为上层的JVM必须要为 不同操作系统的多路复用IO实现 编写不同的代码。同样测试环境是Windows它对应的实现类是sun.nio.ch.WindowsSelectorImpl:selector 的作用就是配合一个线程来管理多个 channel获取这些 channel 上发生的事件这些 channel 工作在非阻塞模式下不会让线程吊死在一个 channel 上。适合连接数特别多但流量低的场景low traffic创建选择器javaSelector selector Selector.open();绑定 Channel 事件也称之为注册事件绑定的事件 selector 才会关心javaServerSocketChannel ssChannel ServerSocketChannel.open(); ssChannel.configureBlocking(false); ssChannel.register(selector, SelectionKey.OP_ACCEPT);Channel必须配置为非阻塞模式否则使用选择器就没有任何意义了因为如果通道在某个