JDK 1.4 增加了重新设计的非阻塞 IO 工具包。

“同步”与“异步”

同步:调用者比较“死心眼”,不完成调用不给返回结果。 异步:被调用者收到请求立即给出响应,响应只意味着被调用者收到了请求,至于最终的处理结果,需要依赖事件、回调等机制。

“阻塞”与“非阻塞”

阻塞:不等到结果誓不罢休。 非阻塞:调用者发起请求后,先去干别的事情,待会儿再来检查结果好了没。

I/O 模型

说明:例中如对应从 socket 读取数据,那么硬件设备就是网卡,内核通过 DMA 从网卡读取数据,相应的系统调用应为 recvfrom(),这里统一用 read() 表示。

同步阻塞

screenshot-1563180728

实际上,数据拷贝过程应该算进阻塞时间内,拷贝完 read() 才会返回,图中省略。

可以看到,用户程序会一直阻塞直到内核返回。

同步非阻塞

screenshot-1563181933

实际上同步非阻塞的方式能使用的场景很少,因为一般应用都需要等待数据到来才能走下面的逻辑,这种轮询(polling)还可能导致数据 ready 后需要多经过一定的延时才被拷贝(最坏的情况会延迟一个轮询的 polling duration,即图中的红色部分)。

异步阻塞

image

这种方式看起来和“同步阻塞”差不多,而且好像多了一次系统调用,那岂不是更慢呢? 其实不然,这里的 select() 其实作用于多个 registrations,select 会轮询这些 registrations,阻塞直到至少一个的状态变成 ready,然后返回,所以实际上不会更慢。

以单线程轮询五个通道为例:

image_4

image_5

本篇的 Java NIO 选择的就是这种方式,但是经过更新已经不用 select() 而改用更高效的 epoll() 了,后面会介绍。

异步非阻塞

image_1

Unix I/O 模型中有两种异步 I/O 模型,分别是 Signal-Driven I/OAsynchronous I/O,区别就是前者告诉程序数据已准备好,程序会再发起一次系统调用进行数据拷贝,后者则是再数据拷贝完成后通知程序。

NIO 与 BIO

BIO 是面向流的,数据被一个字节一个字节的读写,而 NIO 是面向 Buffer 的,数据会存到缓冲区中,需要时可在缓冲区中前后移动指针以灵活的操作数据。

在网络 I/O 中:

  • BIO 的服务器与每个客户端建立 TCP/IP 连接,打开 Socket,都需要维护一个线程。这样一来,服务端能保持的连接数有限,系统开销比较大。
  • NIO 使用 多路复用器(Multiplexer)在一个线程中管理多个 Socket 连接(因为网络 IO 不是每时每刻都有数据的读写),多路复用器只要处理此时此刻有 IO 操作的 Socket 即可,而某个 Socket 没有读写时,不必浪费一个线程去等待,这样就提高了 短连接居多的服务器 的并发度(长连接居多的服务器适合用异步 I/O 去提升并发度)。

NIO 到底快在哪儿

BIO 的工作方式简单明了,容易理解,但是等待的时间不确定(虽可以通过 timeout 控制)。

NIO 不用阻塞到 IO 的“到来”,而是使用 Selector 不停地遍历绑定的 Channel 以确定 Socket 是否准备好读或者写,并且做个收集统计,然后处理这些准备好的 Channel,这样就不用在**“建立 Socket 后发生读写前”** 的这段时间干等着了。

NIO 本身并不会比 BIO 快,只是因为在高并发下能够一定程度上减少服务器 瞬时 并发线程数(或者说同样的线程池能支持更多的并发),从而减少上下文切换,提高 CPU 执行效率

Java NIO

Java NIO(New I/O or Non-Blocking I/O)一般指 JDK1.4 发行的 java.nio 包提供的非阻塞 I/O 能力。

Buffer

Java NIO 支持以 java.nio.Buffer 为操作对象,让我们不用再直接面对流进行操作。数据将从 Buffer 中读取或者写入 Buffer 中。Buffer 是一个内聚了 byte 数组的对象,Java 封装了四个“指针”:mark、position、limit 和 capacity,并提供 API 方便对 Buffer 进行操作。

读模式

image-20190817093926378

写模式

image-20190817094320037

另外,JDK 还提供了 scattering read 和 gathering Write 的能力,即将一个 Channel 的数据读到多个 Buffer 中,和将多个 Buffer 的数据写到一个 Channel 中。

Buffer 缓冲区包括 “直接缓冲区”和“非直接缓冲区”

非直接缓冲区

非直接缓冲对应的类就是 HeapByteBuffer,HeapByteBuffer 操作 JVM 堆内存,通过初始化字节数组,在虚拟机堆上申请内存空间。

直接缓冲区

直接缓冲对应的是内存映射缓存 MappedByteBuffer,MappedByteBuffer 直接将数据映射虚拟内存(mmap()),提供大于实际可用物理内存的内存,这样就可以应对数据量较大的场景。对虚拟内存的直接操作,性能比 read()write() 等需要通过空间切换拷贝数据的系统调用更高。

例如 DirectByteBuffer(MappedByteBuffer 子类)通过 unsafe.allocateMemory() 的 JNI 调用在 非 JVM 堆 的内存中申请地址空间,并在 address 变量中维护指向该内存的地址。

Java 的文件锁是直接映射操作系统的锁机制的,其它进程 也能看到文件锁,被 MappedByteBuffer 打开的文件只有在垃圾收集时才会被关闭,而这个点是不确定的,所以文件资源存在一直被占用的可能。

Channel

Channel 是与支持非阻塞读取的文件、套接字等的连接,类似于传统的流,但与流的区别在于它 不能直接用作操作数据,而只能(如上所述)通过操作 Buffer 向 Channel 中去读写数据。Channel 是 双向 的,既可读也可写。

Channel 有阻塞(blocking)和非阻塞(nonblocking)两种模式。

非阻塞模式的通道永远不会让调用的线程休眠。请求的操作要么立即完成,要么返回一个结果表明未进行任何操作。只有面向流的(stream-oriented)的通道,如 sockets 和 pipes 才能使用非阻塞模式。

有人说 BufferedInputStream 不也是面向 buffer 吗?对,BufferedInputStream 确实内聚了缓冲,但它本质是对流的装饰,并不能实现 全双工 的操作。

Channel 的主要实现类有:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

分别对应 FileSystem、UDP Datagram、TCP Socket(Client & Server)。

其中的 ServerSocketChannel 是一个基于 Channel 的 Socket 监听器,本身并不参与某个实际的 Socket 的数据传输,可以理解为专为 Selector 服务的组件。

Selector

SelectableChannel 通过 Selector 实现多路复用,且线程安全。

线程可以通过 Selector 管理多个 SelectableChannel,每个 SelectableChannel 都在 Selector 上 注册,并且拿到一个代表这个 Channel 的 SelectionKey(包含 I/O 事件状态)。

Channel 可以在多个 Selector 上注册,那样会拥有多个 Key。

Selector 持有已注册的 Key、被选中的 Key 和已取消的 key。

  • 被选中的 Key(selected-key)包含一些 在前一次 select 后,至少准备好执行一种操作 的 SelectionKey 集合
  • 取消选择的 key(cancelled-key)包括 已被取消但其 Channel 尚未注销 的 SelectionKey 的集合。

这里的“操作”包含四种,分别对应四个事件:

  • OP_READ:如果 Selector 检测到相应的 Channel 已准备好“读”、已到达流末尾、远程服务已关闭再也读不了了或者有 error 时,则它会将 OP_READ 添加到该 Key 的就绪操作集中。
  • OP_WRITE:同理,如果 Selector 检测到相应的 Channel 已准备好“写”、远程服务已关闭再也写不了了或者有 error,则它会将 OP_WRITE 添加到该键的就绪操作集中。
  • OP_CONNECT:如果 Selector 检测到相应的 Socket Channel 已就绪(TCP 连接完成)或者有 error,则它会将 OP_CONNECT 添加到该键的就绪操作集中。
  • OP_ACCEPT:如果 Selector 检测到相应的 Server Socket 已准备好建立下一个连接或有 error,则它会将 OP_ACCEPT 添加到该键的就绪操作集中。

就绪操作集(readyOps,int 类型,通过位实现区分操作)。

不同环境下,JDK 会调用不同的 Selector 实现(sun.nio.ch 包),在 Linux 中,JDK 会调用 EPollSelectorProvider 的 openSelector 方法构建 EPollSelectorImpl 作为 Selector 的实现。

select 操作包含三个步骤:

  1. 将 cancelled-key set 中的键从已注册的 Key 集合中移除,并注销其 channel。
  2. 内核调用查询并更新剩余 channel 的 ready 信息:
  3. 如果该 channel 的 Key 不在 selected-key set 中,则以 ready 信息 重新初始化 ready-operation set(丢弃之前的记录)。
  4. 如果该 channel 的 Key 已在 selected-key set 中,则 追加 ready 信息到 ready-operation set 中。
  5. 如果在步骤 2 的执行过程中要将 Key 添加到已取消键集中,则处理过程如步骤 1。

一句话:多路复用器 Selector 提供了汇总 ready-operation 的能力,可以感知 Channel 是否已经准备好执行 I/O 操作。

多路复用实现

Linux 内核对多路复用(I/O Multiplexing)有多种实现。

select/poll

select 和 poll 都是 Linux 系统 function,前者会阻塞到有文件描述符(filedescriptor or fd)就绪才返回,内核在调用返回后就遍历 fdset 找到就绪的描述符。

后者基于链表来存储 fd,没有最大连接数的限制。

Linux 下所有外部设备都映射到文件,对文件的读写就通过对描述符的读写完成,socket 的读写也一样,也会有相应的描述符,称为 socketfd。64位 Linux 单进程最多打开2048个 fd。

epoll

epoll 是 Linux 内核实现的 IO 多路复用器,它用一个数据结构存包含所有 socketfd,可以非常高效地处理数以百万计的 socket 句柄。epoll 使用了 mmap() 减少了复制开销(通过用户与内核共享内存段),被称之为“零拷贝”。

JDK1.5 NIO 在受支持的 OS 上启用了 epoll 以替代传统的 select/poll,极大的提升了 NIO 的性能。

epoll 使用的是一个 select() 系统调用,这个系统调用会阻塞直到有 Socket 发生读写,只不过对于每一个 Channel 或者说 Socket 来说,一般会设置成 Non-Blocking,而 Socket 的 Non-Blocking 使用 recvfrom() 系统调用轮询,如果暂时没有可用的数据,则返回 Resource temporarily unavailable,下次轮询再看。综上,I/O Multiplexing 相对于 Socket 的 Non-Blocking 而言的不同仅仅是多路复用可以管理多个 Socket,仅此而已。

Java NIO 有个 select 导致 CPU 占用 100% 的 bug,Netty 解决了这个 bug。

三个 epoll 相关的系统调用:

  • epollcreate():在内核的一个存储 被监控的 Socket 句柄文件 的文件系统中增加一个节点(数据结构为红黑树,并且有缓存);
  • epollctl():往节点上增加一个 Socket 句柄,给内核一个回调函数,让其在 Socket 有数据读写(或中断)时,将数据从网卡 copy 到内核后,回调这个函数,然后把 Socket 句柄插入到就绪链表里。
  • epollwait():观察就绪链表里有没有数据,有就立即返回没有就 sleep 阻塞等待。

epoll 的两种工作模式:

  • LT:level-trigger,水平触发模式,只要某个 Socket 处于 readable/writable 状态,无论什么时候进行 epoll_wait 都会返回该 socket。

  • ET:edge-trigger,边缘触发模式,只有某个 socket 从 unreadable 变为 readable 或从 unwritable 变为 writable 时,epoll_wait 才会返回该 socket。

JDK NIO 使用的是 LT ,而 Netty epoll 使用的是 ET。