JAVA NIO

新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。

Java IO与NIO的区别

标准IO是对字节流的读写,在进行IO之前,首先创建一个流对象,流对象进行读写操作都是按字节 ,一个字节一个字节的来读或写。
而NIO把IO抽象成块,类似磁盘的读写,每次IO操作的单位都是一个块,块被读入内存之后就是一个byte[],NIO一次可以读或写多个字节。

I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

Java NIO 由以下几个核心部分组成:

  1. 缓冲区 Buffer
  2. 通道 Channel
  3. 选择器 Selector

传统的IO操作面向数据流,意味着每次从流中读一个或多个字节,直至完成,数据没有被缓存在任何地方。
NIO操作面向缓冲区,数据从Channel读取到Buffer缓冲区,随后在Buffer中处理数据。

NIO的主要用途是网络IO,在NIO之前java要使用网络编程就只有用Socket。而Socket是阻塞的,显然对于高并发的场景是不适用的。所以NIO的出现就是解决了这个痛点。

主要思想是把Channel通道注册到Selector中,通过Selector去监听Channel中的事件状态,这样就不需要阻塞等待客户端的连接,从主动等待客户端的连接,变成了通过事件驱动。没有监听的事件,服务器可以做自己的事情。

Buffer

Buffer是一个内存块。
在NIO中,所有的数据都是用Buffer处理,有读写两种模式。在读模式下,应用程序只能从Buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦决定要从Buffer中读取数据,一定要将Buffer的状态改为读模式。

Channel

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。
通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。

常用的四种channel

  • FileChannel,读写文件中的数据。
  • SocketChannel,TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP: 端口 到 服务器IP: 端口的通信连接。
  • ServerSockectChannel,应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持多路复用IO的端口监听。同时支持UDP协议和TCP协议。
  • DatagramChannel,UDP 数据报文的监听通道。

Channel本身并不存储数据,只是负责数据的运输。必须要和Buffer一起使用。

Selector

Selector,选择器/多路复用器/轮询代理器/事件订阅器/channel容器管理机。
只有网络IO才会使用选择器,文件IO是不需要使用的。
选择器可以说是NIO的核心组件,它可以监听通道的状态,来实现异步非阻塞的IO。换句话说,也就是事件驱动。以此实现单线程管理多个Channel的目的。

  • 事件订阅和Channel管理
    应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。
  • 轮询代理
    应用程序不再通过阻塞模式或者非阻塞模式直接询问操作系统“事件有没有发生”,而是由Selector代其询问。
  • 实现不同操作系统的支持
    多路复用IO技术是需要操作系统进行支持的,其特点就是操作系统可以同时扫描同一个端口上不同网络连接的事件。
    所以作为上层的JVM,必须要为不同操作系统的多路复用IO实现编写不同的代码。

NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。

监听配置为非阻塞的通道 Channel,那么当该 Channel 上的 IO 事件还未到达时,Selector 就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件具有更好的性能。
应该注意的是,只有 SocketChannel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1、创建选择器
Selector selector = Selector.open();

// 2、将通道注册到选择器上
ServerSocketChannel ssChannel = ServerSocketChannel.open();
// 通道配置为非阻塞
ssChannel.configureBlocking(false);
// 注册的具体事件
// SelectionKey.OP_CONNECT
// SelectionKey.OP_ACCEPT
// SelectionKey.OP_READ
// SelectionKey.OP_WRITE
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

// 3、监听事件 使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。
int num = selector.select();
// 4、获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
// 5、事件循环
// 因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。
while (true) {
int num = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
}

零拷贝在Java中的实现

如果涉及到文件传输,transferTo是首选,但是如果涉及到对内存数据的修改选用MappedByteBuffer。

mmap - MappedByteBuffer

MappedByteBuffer 是 Java 中的 mmap 操作类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
说明
1. MappedByteBuffer 可让文件直接在内存(堆外内存)修改
*/
public class MappedByteBufferTest {
public static void main(String[] args) throws Exception {
RandomAccessFile randomAccessFile = new RandomAccessFile("test.txt", "rw");
//获取对应的通道
FileChannel channel = randomAccessFile.getChannel();
/**
* 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
* 参数2: 0 : 可以直接修改的起始位置
* 参数3: 5: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
* 可以直接修改的范围就是 0-5
* 实际类型 DirectByteBuffer
*/
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(3, (byte) '9');
mappedByteBuffer.put(5, (byte) 'Y');//IndexOutOfBoundsException
randomAccessFile.close();
System.out.println("修改成功~~");
}
}

sendfile - FileChannel.transferTo()

FileChannel的transferTo()/transferFrom(),底层就是sendfile() 系统调用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
//得到一个文件channel
FileChannel fileChannel = new FileInputStream("test.zip").getChannel();
//准备发送
long startTime = System.currentTimeMillis();
//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));
//关闭
fileChannel.close();
}
}

Java对IO多路复用的支持

多路复用IO技术是操作系统的内核实现。
在不同的操作系统,甚至同一系列操作系统的版本中所实现的多路复用IO技术都是不一样的。
那么作为跨平台的JAVA JVM来说如何适应多种多样的多路复用IO技术实现呢?

JAVA NIO中对各种多路复用IO的支持,主要的基础是java.nio.channels.spi.SelectorProvider抽象类。
其中的几个主要抽象方法包括:

  • public abstract DatagramChannel openDatagramChannel(): 创建和这个操作系统匹配的UDP 通道实现。
  • public abstract AbstractSelector openSelector(): 创建和这个操作系统匹配的NIO选择器,就像上文所述,不同的操作系统,不同的版本所默认支持的NIO模型是不一样的。
  • public abstract ServerSocketChannel openServerSocketChannel(): 创建和这个NIO模型匹配的服务器端通道。
  • public abstract SocketChannel openSocketChannel(): 创建和这个NIO模型匹配的TCP Socket套接字通道(用来反映客户端的TCP连接)

IO多路复用

什么是IO多路复用,简单讲,就是一个进程可以同时处理多个网络连接的IO请求。

IO多路复用技术最适用的是高并发场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下IO多路复用技术发挥不出来它的优势。

select、poll、epoll(mac的kqueue、windows的IOCP)是操作系统层面的IO多路复用的实现方法。
Reactor模型、Proactor模型是应用程序层面处理并发I/O的模型。

Linux中典型的IO多路复用实现

select/poll/epoll 就是操作系统内核提供给用户态的多路复用系统调用函数,线程可以通过一个系统调用函数从内核中获取多个事件。

select

  1. 用户态已连接的 Socket (先 accept 好的)都放到一个文件描述符集合
  2. 然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写;
  3. 接着再把整个文件描述符集合拷贝回用户态里,用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里;
而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的FD_SETSIZE限制,默认最大值为1024,只能监听 0~1023 的文件描述符

poll

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长

epoll

epoll是在Linux2.6版本推出的一种IO多路实现手段。
mac平台是kqueue、windows平台是IOCP。

  • epoll_create 负责创建一个池子,一个监控和管理句柄 fd 的池子;
  • epoll_ctl 负责管理这个池子里的 fd 增、删、改;
  • epoll_wait 就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒;

epoll 通过两个方面,很好解决了 select/poll 的问题。

  1. epoll 在内核里使用红黑树跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过epoll_ctl()函数加入内核中的红黑树里。
    红黑树是个高效的数据结构,增、删、改一般时间复杂度是 O(logn)。
  2. epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件
    当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中;
    当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符。
    不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

Reactor模型

Reactor模型是对事件处理流程的一种模式抽象,是对IO多路复用模式的一种封装。
Reactor模式特别适合应用于处理多个客户端并发向服务器端发送请求的场景。

在Reactor模型中,主要有3个角色:

  • Reactor
    派发器,负责监听和分配事件,并将事件分派给对应的 Handler。
    新的事件包含连接建立就绪、读就绪、写就绪等。
  • Acceptor
    请求连接器,处理客户端新连接。
    Reactor 接收到 client 端的连接事件后,会将其转发给 Acceptor,由 Acceptor 接收 Client 的连接,创建对应的 Handler,并向 Reactor 注册此 Handler。
  • Handler
    请求处理器,负责事件的处理,将自身与事件绑定,执行非阻塞读/写任务,完成 channel 的读入,完成处理业务逻辑后,负责将结果写出 channel。
    可用资源池/线程池来管理。

Reactor模型 - 单reactor单线程

单reactor单线程

  1. Reactor 线程通过 select 监听事件,收到事件后通过 Dispatch 进行分发
  2. 如果是连接建立事件,则将事件分发给 Acceptor,Acceptor 会通过 accept() 方法获取连接,并创建一个Handler 对象来处理后续的响应事件
  3. 如果是IO读写事件,则 Reactor 会将该事件交由当前连接的 Handler 来处理
  4. Handler 会完成 read -> 业务处理 -> send 的完整业务流程

改进后的Reactor模型相对于传统的IO模型主要有如下优点:

  • 从模型上来讲,如果仅仅还是只使用一个线程池来处理客户端连接的网络读写,以及业务计算,那么Reactor模型与传统IO模型在效率上并没有什么提升。但是Reactor模型是以事件进行驱动的,其能够将接收客户端连接,网络读和网络写,以及业务计算进行拆分,从而极大的提升处理效率;
  • Reactor模型是同步非阻塞模型,工作线程在没有网络事件时可以处理其他的任务,而不用像传统IO那样必须阻塞等待。

Reactor模型 - 单Reactor多线程 - 业务处理与IO分离

在上面的Reactor模型中,由于网络读写和业务操作都在同一个线程中,在高并发情况下,这里的系统瓶颈主要在两方面:

  • 高频率的网络读写事件处理
  • 大量的业务操作处理

基于上述两个问题,这里在单线程Reactor模型的基础上提出了使用线程池的方式处理业务操作的模型。
如下是该模型的示意图:
单reactor多线程

  1. Reactor 线程通过 select 监听事件,收到事件后通过 Dispatch 进行分发
  2. 如果是连接建立事件,则将事件分发给 Acceptor,Acceptor 会通过 accept() 方法获取连接,并创建一个Handler 对象来处理后续的响应事件
  3. 如果是IO读写事件,则 Reactor 会将该事件交由当前连接对应的 Handler 来处理
  4. 与单Reactor单线程不同的是,Handler 不再做具体业务处理,只负责接收和响应事件,通过 read 接收数据后,将数据发送给后面的 Worker 线程池进行业务处理。
  5. Worker 线程池再分配线程进行业务处理,完成后将响应结果发给 Handler 进行处理。
  6. Handler 收到响应结果后通过 send 将响应结果返回给 Client。

这种模式相较于单reactor单线程模式性能有了很大的提升,主要在于在进行网络读写的同时,也进行了业务计算,从而大大提升了系统的吞吐量。
但是这种模式也有其不足,主要在于:

  • 网络读写是一个比较消耗CPU的操作,在高并发的情况下,将会有大量的客户端数据需要进行网络读写,此时一个线程将不足以处理这么多请求。
  • Handler 使用多线程模式,自然带来了多线程竞争资源的开销,同时涉及共享数据的互斥和保护机制,实现比较复杂

Reactor模型 - 主从Reactor多线程 - 并发读写

主从 Reactor 多线程模型将 Reactor 分成两部分:

  1. MainReactor
    只负责处理连接建立事件,通过 select 监听 server socket,将建立的 socketChannel 指定注册给 subReactor,通常一个线程就可以了。
  2. SubReactor
    负责读写事件,维护自己的 selector,基于 MainReactor 注册的 SocketChannel 进行多路分离 IO 读写事件,读写网络数据,并将业务处理交由 worker 线程池来完成。
    SubReactor 的个数一般和 CPU 个数相同。

主从reactor多线程

Proactor模型

Proactor基于异步IO模式。
所有的 I/O 操作都交由系统提供的异步 I/O 接口去执行。工作线程仅仅负责业务逻辑。

在 Proactor 中,用户函数启动一个异步的文件操作。同时将这个操作注册到多路复用器上。多路复用器并不关心文件是否可读或可写而是关心这个异步读操作是否完成。异步操作是操作系统完成,用户程序不需要关心。多路复用器等待直到有完成通知到来。当操作系统完成了读文件操作——将读到的数据复制到了用户先前提供的缓冲区之后,通知多路复用器相关操作已完成。多路复用器再调用相应的处理程序,处理数据。

Java NIO的bug

epoll空轮询

epoll机制是Linux下一种高效的IO复用方式,相较于select和poll机制来说,其高效的原因是将基于事件的fd放到内核中来完成,在内核中基于红黑树+链表数据结构来实现,链表存放有事件发生的fd集合,然后在调用epoll_wait时返回给应用程序,由应用程序来处理这些fd事件。

使用IO复用,Linux下一般默认就是epoll,Java NIO在Linux下默认也是epoll机制。
但是JDK中epoll的实现却是有漏洞的,其中最有名的java nio epoll bug就是即使是关注的select轮询事件返回数量为0,NIO照样不断的从select本应该阻塞的**Selector.select()/Selector.select(timeout)**中wake up出来,导致CPU 100%问题。