Netty 精粹之 JAVA NIO 开发需要知道的
学习 Netty 框架以及相关源码也有一小段时间了,恰逢今天除夕,写篇文章总结一下。Netty 是个高效的 JAVA NIO 框架,总体框架基于异步非阻塞的设计,基于网络 IO 事件驱动,主要贡献在于可以让用户基于 Netty 提供的 API 快速开发高性能、高可靠性的网络应用。这篇文章主要是介绍 Netty 框架的基础技术 ——JAVA NIO。这时候可能会有同学会有点小疑问,是异步 IO(AIO)么?然而并不是,虽然 JDK7 也提供了异步 IO(AIO)的接口,但是 Netty 曾经尝试过某个小版本,但是效果和 NIO 相比并没有什么优势,因此后面的版本 Netty 也把对 AIO 的支持废弃了,今天我们就来扒一下 JAVA NIO。
四种 IO 模型简述
我们先从四种 IO 模型开始扒起,常见的 IO 模型有四种(这四种模型在网络上也有很多很多的资料,为较少篇幅本片将这部分内容压缩一下):
-
同步阻塞(Blocking IO):最简单的一种 IO 模型,用户线程在进行 IO 操作的时候通常是个系统调用,用户线程会由用户空间进入内核空间,内核空间数据包准备好后会将数据拷贝到用户空间,这个时候线程在用户态继续执行。
-
同步非阻塞(Non-blocking IO):同步非阻塞 IO 即在同步阻塞的基础之上将 socket 设置为 NONBLOCK。这样用户线程在发起 IO 操作之后可以立即返回,但是用户线程需要不断轮询来请求数据。
-
IO 多路复用(IO Multiplexing):即 Reactor 设计模式,多路复用模型从流程上和同步阻塞的区别不大,主要区别在于操作系统为用户提供了同时轮询多个 IO 句柄来查看是否有 IO 事件的接口(如 select),这从根本上允许用户可以使用单个线程来管理多个 IO 句柄的问题。
-
异步 IO(Asynchronous IO):即 Proactor 设计模式。在异步 IO 模型中,用户不需要去轮询 IO 事件,然后才进行数据的读取,处理;在异步 IO 模型中,IO 事件就绪的时候,内核会开启一个独立的内核线程去执行执行 IO 操作,实现真正的异步 IO。这个时候用户线程可以直接读取内核线程准备好的数据。
多路复用 IO 模型和异步 IO 模型的区别主要是用户线程得知 IO 事件的时候在多路复用 IO 模型中,用户线程需要自己去处理 IO,而在异步 IO 模型中数据已经由内核线程为用户线程准备好了。在实际应用中,在高效的 IO 应用中,最常见的是第三种 IO 模型,异步 IO 目前操作系统方面的支持并不是很好而且在性能数据上并不是很好看。
上面对四种 IO 模型进行了极其简单的概括,如多读者意犹未尽可以在网上查阅相关资料或者和作者联系。
select、poll 和 epoll
JAVA 对 NIO 的支持是从 1.4 版本开始的,是基于多路复用技术,而在 linux 操作系统方面多路复用技术有三种常用的机制:select、poll 和 epoll,epoll 的支持也只是 linux2.6 版本之后才提供,java 在 jdk5.0 的 update 9 之后才对 epoll 进行支持。这三种机制本质上都是同步 IO,主要是由于他们都需要在读写事件就绪的时候需要自己进行读写,也就是这个这个读写过程是阻塞的。下面对着三种机制进行简单总结:
-
select 函数:改函数允许进程指示内核等待多个事件中的任何一个发生的时候或者在一定时间之后被唤醒,select 有个致命的缺点即在多路复用中文件描述符的数量有限制,如果需要突破限制需要重新编译操作系统内核。
-
poll 函数:poll 机制与 select 机制类似,区别是 poll 没有最大描述符限制。
-
epoll 函数:epoll 在 linux2.6 内核中被提出来,是之前的 select 和 poll 的增强版本。epoll 也没有文件描述符数量限制,而且是用一个文件描述符来管理多个描述符。在性能上相比上面两种有了很大的优化。
关于 select、poll 和 epoll 的详细介绍可以参考这里。
JAVA NIO
JAVA 的 NIO 是基于 IO 多路复用模型,在不同平台上有不同的实现方式。Linux 下面用的是 poll 和 epoll,在 BSD 上用 kqueue,在 Windows 上是重叠 I/O。
在 JAVA NIO 中有三个核心的组件:Channels、Buffers 和 Selectors。
JAVA NIO 核心组件
在 JAVA NIO 中,基本上所有的 IO 都是从 Channel 开始的,读取操作即从 Channel 读到 Buffer,写操作即从 Buffer 写入 Channel。
NIO 读写示意图
Channel
在网络 IO 方面,Channel 的主要实现是 ServerSocketChannel 和 SocketChannel。他们都代表一个面向流的可监听读写事件的 socket。ServerSocketChannel 是用于服务器端的 socket,他提供了一个静态工具方法 open 来为用户提供获取 Channel 的工具:
public static ServerSocketChannel open() throws IOException { return SelectorProvider.provider().openServerSocketChannel(); }
其中涉及到的 SelectorProvider 用于创建具体的 Channel,SelectorProvider 的获取有三种途径,首先从系统属性中获取 key 为 java.nio.channels.spi.SelectorProvider 的值,如果没有则基于 SPI 机制来获取,如果再没有则最后提供默认的,这个默认值跟操作系统平台相关,比如我的 mac 系统,JDK 提供的默认 Provider 是 KQueueSelectorProvider。
ServerSocketChannel 提供的接口
ServerSocketChannel 的使用方式是面向服务器端的,一般的开发流程是:
-
获取一个 ServerSocketChannel。
-
设置网络操作,这些参数主要是和 TCP 协议有关。
-
将 ServerSocketChannel 注册到 Selector(多路复用器)。
-
将 ServerSocketChannel 和某个具体的地址绑定。
-
用户像多路复用器设置感兴趣的 IO 事件。
-
用户线程以阻塞或非阻塞方式轮询 Selector 来查看是否有就绪的 IO 事件。
-
用户针对不同的 IO 事件对 Channel 进行具体的 IO 操作。
SocketChannel 主要是面向客户端的开发的,也是以 open 方式获取 channel,客户端的开发流程大致如下:
-
获取一个 SocketChannel。
-
设置 Channel 为非阻塞方式。
-
获取 Selector。
-
将 channel 注册到 Selector,并监听 CONNECT 事件。
-
调用 channel 的 connect 方法连接指定的服务器和端口。
-
如果连接成功则进行 IO 操作,如果没成功则轮询 Selector 处理 CONNECT 事件。
Selector
Selector 是 JAVA NIO 中的多路复用器,配合 SelectionKey 使用,SelectionKey 代表着一个 Channel 和 Selector 的关系的抽象,Channel 向 Selector 注册的时候产生,由 Selector 维护。Selector 维护着三个 SelectionKey 的集合:
key set:这个集合包含所有向 Selector 注册的 Channel 产生的 SelectionKey,这个集合中的 SelectionKey 是不能直接被修改的,除非 SelectionKey 被 channel,并且发生 select 的时候 SelectionKey 才被移出。
selected key set:这个集合是 key set 集合的子集,当有 SelectionKey 关联的 Channel 有 Channel 向 Selector 注册的 IO 事件就绪的时候并且有 select 操作,对应的 SelectionKey 会被放到 selected key set 中。因为这个集合中的 SelectionKey 可以通过直接调用 Set 的 remove 将 SelectionKey 移除。
cancelled-key: 这个集合是也是 key set 的子集。当有已经向 Selector 注册的 Channel 发生 degistered 的时候,SelectionKey 将被放到这个集合,并且在下一次 select 的时候被从所有的集合中移出。
三种集合的流转我画个图表示一下:
Selector 的 Selection Key 集合流转图
在开发过程中,我们可以将多个 Channel 注册到一个 Selector 实例中,用一个线程来处理所有的 IO 事件,我们也可以将多个 Channel 注册到多个 Selector 实例中,结合高效的线程模型可以达到很好的效果。
ByteBuffer
JAVA NIO 直接和 Channel 打交道的 Buffer 是 ByteBuffer,ByteBuffer 接口提供主要的内存分配、IO 读写等相关接口。值得注意的是 JAVA NIO 提供了两种 Buffer 内存分配机制,一种是堆内存,另一种是直接内存,主要区别:
-
堆内存分配和回收比较快,但是网络数据需要从内核 copy 到堆中。
-
直接内存分配和回收比较慢,但是免去了从内核 copy 到堆中的一次 copy。
这两种内存各有千秋,使用的时候要根据实际情况去选择。
总结:
这篇文章主要介绍一下 JAVA NIO 涉及到的一些基础概念以及 JAVA 提供的 NIO 接口进行简单介绍,JAVA NIO 提供的接口使用起来,略复杂,实际项目中不建议直接使用 JDK 提供的 API 进行开发。Netty 是一个基于 JAVA NIO 开发的可靠的 JAVA NIO 工具,Netty 的精粹我认为除了 IO 模型之外还有下面的几个部分:
-
高效的线程模型
-
内存池技术
-
零 copy 技术
Netty 是一个优秀的开源 NIO 框架,我们可以使用它来快速构建高性能的 IO 服务器,后面我会通过继续深入学习和大家一起分享 Netty 的实现和原理。
#计算机##java##后端#