引言
根据冯.诺依曼结构,计算机结构分为5个部分:运算器、控制器、存储器、输入设备、输出设备。
输入设备和输出设备都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
从计算机结构的视角来看,I/O描述了计算机系统与外部设备之间通信的过程。
从应用程序的视角来看,我们的应用程序对操作系统的内核发起了IO调用(系统调用),操作系统负责的内核执行具体的IO操作。也就是说,我们的应用程序实际上只是发起了IO操作的调用而已,具体的IO执行是由操作系统的内核来完成
UNIX系统中,IO模型一共有五种:同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步IO。
Java中3种常见IO模型
BIO(同步阻塞IO模型)
同步阻塞IO模型中,应用程序发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的,但是,当面对十万甚至百万级连接的时候,BIO模型是无能为力的。
NIO
在传统的Java I/O模型中,I/O操作是以阻塞的方式进行的,也就是说,当一个线程执行一个I/O操作时,线程会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。
为了解决这个问题,在Java1.4版本引入了一种新的I/O模型——NIO。NIO弥补了同步阻塞IO的不足,他在标准Java代码中提供了非阻塞、面向缓冲(Buffer)、基于通道的IO(Channel),可以使用少量的线程来处理多个连接(Selector),大大提高了IO效率和并发。
需要注意:
使用NIO并不一定意味着高性能,他的性能优势主要体现在高并发和高延迟的网络环境下,当连接数较少、并发程度较低或者网络传输速度较快时,NIO的性能并不一定优于传统的BIO。
NIO核心组件
NIO主要包括以下三个核心组件
- Buffer(缓冲区):NIO读写数据都是通过缓冲区进行操作的,读操作的时候将Channel中的数据填充到Buffer中,而写操作时将Buffer中的数据写入到Channel中。
- Channel(通道):Channel是一个双向的、可读可写的数据传输通道,NIO通过Channel来实现数据的输入输出。通道是一个抽象的概念,他可以代表文件、套接字或者其他数据源之间的连接。
- Selector(连接器):允许一个线程处理多个Channel,基于事件驱动的IO多路复用模型,所有的Channel都注册到Selector上,由Selector来分配线程处理事件。
三者关系如下所示
Buffer(缓冲区)
在传统的BIO中,数据的读写是面向流的,分为字节流和字符流。
在NIO中,读取数据时是将数据直接读取到缓冲区中的,写入数据时,也是将数据直接写入到缓冲区中。
Buffer的子类如下所示,其中,最常用的是ByteBuffer,他可以用来存储和操作字节数据。
可以将Buffer理解为一个数组,IntBuffer、FloatBuffer、CharBuffer等分别对应int[]、float[]、char[].
Buffer类中定义的四个关键成员变量
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
这四个成员变量的含义如下
- 容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变。
- 界限(limit):Buffer中可以读、写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于capacity。读模式下,limit等于Buffer中实际写入的数据大小。
- 位置(position):下一个可以被读写的数据的位置,从写操作模式到读操作模式切换的时候(flip),position会归零,这样就可以从头开始读写了。
- 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性。
并且,上述变量满足如下关系:0<=mark<=position<=limit<=capacity
另外,Buffer有读模式和写模式这两种模式,分别用于从Buffer中读取数据或者向Buffer中写入数据。Buffer被创建之后默认是写模式,调用flip()可以切换到读模式。如果要再次切换回写模式,可以调用clear()或者compact()方法。
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的值。
Channel(通道)
Channel是一个通道,他建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在Channel中自由流动。
BIO中的流是单向的,分为各种InputStream(输入流)和OutputStream(输出流),数据只是在一个方向上传输,通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。
Channel与前面的Buffer打交道,读操作的时候将Channel中的数据填充到Buffer中,而写操作时将Buffer中的数据写入到Channel中。
另外,因为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);
Selector(选择器)
Selector(选择器)是NIO中的一个关键组件,允许一个线程处理多个Channel。Selector是基于事件驱动的I/O多路复用模型,他的工作原理是:
- 通道注册: 通过Selector注册通道的事件,Selector会不断的轮询注册在其上的Channel。
- selector轮询等待事件发生:当事件发生时,比如:某个Channel上面有新的TCP连接接入、读和写事件,这个Channel就处于就绪状态,会被Selector轮询出来。
- 选取就绪Channel进行IO操作:Selector会将相关的Channel加入到就绪集合中,通过selectionKey可以获取就绪Channel的集合。然后对这些就绪的Channel进行相应的IO操作。
一个多路复用器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的I/O状态,是非阻塞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还提供了一系列和select()相关的方法
-
int select(): 监控所有注册的Channel,当他们中间有需要处理的IO操作时,该方法返回,并将对应的SelectionKey加入被选择的SelectionKey集合,该方法返回这些Channel的数量。
-
int select(long timeout): 可以设置超时时长的select()操作。
-
int selectNow():执行一个立即返回的select()操作,相对于select()方法而言,该方法不会阻塞线程。
-
Selector wakeup(): 使一个还未返回的select()方法立刻返回。
示例程序
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();}}
}
测试效果
NIO零拷贝
零拷贝是提升IO操作性能的一个常用手段,像ActiveMQ、Kafka、RocketMQ等都用到了零拷贝。
零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU拷贝时间。也就是说,零拷贝主要解决操作系统在处理IO操作时频繁复制数据的问题。
零拷贝常用技术有:mmap+write、sendfile和sendfile+DMA gather copy。
零拷贝技术对比图:
从图中可以看出,无论是传统的IO方式,还是引入了零拷贝之后,2次DMA拷贝都是少不了的,因为两次DMA(将数据从输入设备传输到内存)都是依赖硬件完成的。零拷贝主要减少CPU拷贝以及上下文切换。
Java对零拷贝的支持
- MappedByteBuffer:是NIO基于内存映射提供的一种实现,底层实际上是调用了Linux内核的mmap系统调用,它可以将一个文件或者文件的一部分映射到内存,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。
- FileChannel:FileChannel的transferTo()/transferFrom()是NIO基于发送文件(sendfile)这种零拷贝方式提供的一种实现,底层调用了linux内核的sendfile系统调用。
他可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区。
代码示例:
private void loadFileIntoMemory(File xmlFile) throws IOException {FileInputStream fis = new FileInputStream(xmlFile);// 创建 FileChannel 对象FileChannel fc = fis.getChannel();// FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());xmlFileBuffer = new byte[(int)fc.size()];mmb.get(xmlFileBuffer);fis.close();
}
总结
文章主要介绍了NIO的核心组件以及零拷贝。如果需要使用NIO构建网络程序的话,不建议直接使用NIO,编程模型过于复杂,可以使用Netty,Netty在NIO的基础上进行了进一步优化和扩展。