一 何为IO
I/O(Input/Outpu) 即输入/输出 。
我们先从计算机结构的角度来解读一下 I/O。 根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
输入设备向计算机输入数据,输出设备接收计算机输出的数据。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
我们再先从应用程序的角度来解读一下 I/O。
根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
并且,用户空间的程序不能直接访问内核空间。
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
UNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
二 Java中3 种常见IO模型
2.1 BIO (Blocking IO) - 同步阻塞IO
BIO 属于同步阻塞 IO 模型 。同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
2.2 NIO (Non-blocking IO) - 同步非阻塞IO
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio
包,提供了 Channel
, Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
我们先来看看 同步非阻塞 IO 模型。
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
这个时候,I/O 多路复用模型 就上场了。
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
- select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
Buffer、Channel和Selector三者之间的关系 如下
2.3 AIO (Asynchronous IO) - 异步IO
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
最后,来一张图,简单总结一下 Java 中的 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 来分配线程来处理事件。
三者的关系如下图所示(Buffer、Channel和Selector三者之间的关系):
3.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 { // 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 中读取数据或者向 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 中数据变化的过程:
import java.nio.*; public class CharBufferDemo { 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); // 读取字符 while (buffer.hasRemaining()) { System.out.print(buffer.get()); } // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值 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 写入3个字符后的状态: capacity: 8, limit: 8, position: 3 准备读取buffer中的数据! 调用flip()方法后的状态: capacity: 8, limit: 3, position: 0 读取到的数据:abc 调用clear()方法后的状态: capacity: 8, limit: 8, position: 0
为了帮助理解,绘制了一张图片展示 capacity
、limit
和position
每一阶段的变化。
3.2 Channer(通道)
Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。
BIO 中的流是单向的,分为各种 InputStream
(输入流)和 OutputStream
(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。
Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
Channel 和 Buffer之间的关系
另外,因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。
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.3 Selector(选择器)
Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行响应的 I/O 操作。
Selector 选择器工作示意图
一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll()
代替传统的 select
实现,所以它并没有最大连接句柄 1024/2048
的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
Selector 可以监听以下四种事件类型:
SelectionKey.OP_ACCEPT
:表示通道接受连接的事件,这通常用于ServerSocketChannel
。SelectionKey.OP_CONNECT
:表示通道完成连接的事件,这通常用于SocketChannel
。SelectionKey.OP_READ
:表示通道准备好进行读取的事件,即有数据可读。SelectionKey.OP_WRITE
:表示通道准备好进行写入的事件,即可以写入数据。
Selector
是抽象类,可以通过调用此类的 open()
静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannel
的 IO
状况,是非阻塞 IO
的核心。
一个 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 实现网络读写的简单示例:
import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.Set; public class NioSelectorExample { public static void main(String[] args) { try { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(8080)); Selector selector = Selector.open(); // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int readyChannels = selector.select(); if (readyChannels == 0) { continue; } 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(); } } }
在示例中,我们创建了一个简单的服务器,监听 8080 端口,使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 "Hello, Client!"。
NIO 零拷贝
零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。
零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+write
、sendfile
和 sendfile + DMA gather copy
标签:java,进阶,Buffer,Selector,buffer,IO,Channel,NIO From: https://www.cnblogs.com/balfish/p/18052192