NIO

本文针对不了解NIO的同学,主要对IO模型、NIO的概念以及基本原理做简述,没有深入源码解析。

 

1. NIO基本概念

NIO官方叫法为 New I/O,原因在于它相对之前的I/O类库是新增的。但是,由于之前老的I/O类库是阻塞I/O,New I/O的目标就是让Java支持非阻塞I/O,所以,更多的人喜欢称之为非阻塞I/O(Non-block I/O)。

Java NIO 主要由以下三个核心部分组成:

  • 通道 Channel
  • 缓存区 Buffer
  • 多路复用器 Selector

因为Java NIO 的核心类库Selector就是基于epoll的多路复用技术实现,因此需先了解I/O多路复用技术。

 

2. I/O多路复用技术

Linux网络I/O模型

Linux内核将所有外部设备都看作是一个文件来操作,对于一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。

UNIX 网络编程,对I/O分为5种模型:

(1)阻塞I/O模型:在进程空间中调用recvfrom,会一直等待数据返回,该过程一直被阻塞。

(2)非阻塞I/O模型:recvfrom从应用层到内核,若缓冲区没有数据,直接返回一个错误,然后会轮询重复请求,知道内核有数据到来。

(3)I/O复用模型:Linux 提供select/poll 和 epoll

select/poll:进程将一个或多个fd传递给select或poll,阻塞在select操作上,select/poll会轮询顺序扫描fd是否就绪。

epoll:基于事件驱动的方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。

(4)信号驱动I/O模型:非阻塞的,通过调用sigaction执行信号处理函数,当数据准备就绪,生成一个信号,通过信号回调通知应用程序调用recvfrom。

(5)异步I/O:告知内核启动某个操作,并让内核在整个操作完成后通知我们。

 

I/O多路复用技术

把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下,同时处理多个客户端请求。

优势:系统不需要创建额外的线程和进程,也不需要维护这些线程的运行,减少线程之间切换上下文的系统开销。

epoll对比select的改进:

(1) 支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数,可用cat /proc/sys/fs/file-max查看)。

select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。虽然可修改,但修改后需要重新编译内核。

(2) I/O效率不会随着FD数目的增加而线性下降。

传统的select/poll另一个致命弱点,就是当socket集合很大时,由于网络延时或者链路空闲,任一时刻只有少部分socket是“活跃”的,但select/poll每次调用都会线性扫描全部集合,导致效率呈线性下降。

(3) 使用mmap加速内核与用户空间的消息传递。

(4) epoll的API更简单。

 

3. Buffer

Buffer是一个对象,它包含一些要写入或读出数据。缓存区实质上是一个数组,通常它是一个字节数组(ByteBuffer)。

Buffer类型:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer
  • MappedByteBuffer (用于表示内存映射文件,rocketmq消息刷盘时用到)

MappedByteBuffer请参考:https://blog.****.net/qq_41969879/article/details/81629469

Buffer主要属性值:

capacity:容量,表示buffer的最大数据容量,缓冲区容量不能为负,创建后不能更改。

position:位置,下一个要读取或写入的索引位置,该位置不能大于limit的限制。

limit:限制,按照索引来,limit之后的数据不可读写,即只有在limit范围内的数据我们才可以读写操作。

mark:标记,标记后的索引位置,我们可以通过reset方法恢复position到该位置,该mark的位置要小于或等于position

以上的4个属性满足条件:mark<=position<=limit<=capacity

Buffer 类源码如下图:

NIO

Buffer主要方法:

allocate(int) :Buffer的内存分配。

flip():反转缓冲区,将limit的值设为position的值, 然后position的值设为0。为从缓冲区读取字节做准备。

rewind():从头再读或再写,limit不变,position设置为0。

mark():标记当前的position值,和reset()配合使用。

reset():将当前position设为mark标记的值。

hasRemaining():position和limit之前是否还有元素。

clear():清空整个缓冲区(没有擦除)。 position的值设为0, limit的值设为capacity,mark的值被丢弃。为把字节写到缓冲区做准备。

compact():只清空已经读过的数据(没有擦除)。未读数据复制到缓冲区的起始处,position设到最后一个未读数据后。

 

创建缓冲区

缓冲区有两种,非直接缓冲区 和 直接缓冲区;

非直接缓存区:在虚拟机内存中创建,易回收,但占用虚拟机内存开销,处理中有复制过程。

直接缓存区:在虚拟机内存外开辟的内存,IO操作直接进行,不需对其进行复制,但创建和销毁开销大(可用内存池技术优化)。

下面是它们的创建方式:

非直接缓存区:ByteBuffer buffer=ByteBuffer.allocate(1024);    //大小为1024个字节  此时它的position=0 limit=1024 capacity=1024

直接缓存区:ByteBuffer buf =ByteBuffer.allocateDirect(1024);    //大小为1024个字节

 

写入数据

buffer.put("abcde".getBytes());     //将一个字节数组写入缓冲区,此时它的position=5 limit=1024 capacity=1024

再写一个数组

buffer.put("abcde".getBytes()); //该数组会在之前position开始写,写完后 position=10 limit=1024 capacity=1024

 

读取数据

在读取数据的时候,重要的一步是将缓冲区翻转,即:

buffer.flip();  

这一步的作用是使position=0,limit为可以操作的最大字节数,这里limit=10, capacity不变 还是1024。

可以这样理解,此时你只能操作缓冲区中有数据的那部分 。翻转之后就可以读取了,如下:

System.out.println(new String(buffer.array(),0,buffer.limit()));

 

4. Channel

网络数据通过Channel读取和写入。通道和流不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream 或者 OutputStream的子类),通道读写可以同时进行。

Channel类型:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。如下图:

NIO

 

5. Selector

Selector会不断的轮询注册在其上的Channel,如果某个Channel上面发送读或写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey获取就绪的Channel集合,进行后续的I/O操作。(ps:这里运用了 I/O多路复用技术)

这是在一个单线程中使用一个Selector处理3个Channel的图示:

NIO

使用Selector,需向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件。

 

AIO(扩展)

AIO,即NIO 2.0,引入了异步通道概念,并提供了异步文件通道和异步套接字通道的实现。NIO 是同步非阻塞I/O,AIO 是异步非阻塞i/O。

NIO 2.0不需要通过多路复用器(Selector)对注册的通道进行轮询操作即可实现异步读写,从而简化了NIO的编程模型。

异步通道提供以下两种方式获取操作结果:

  • 通过java.util.concurrent.Futurn类来表示异步操作的结果
  • 在执行异步操作时传入一个java.nio.channels,CompletionHandler接口的实现类作为操作完成的回调。