Java 的 IO 模型、Netty 原理详解
1.什么是 IO
虽然作为 Java 开发程序员,很多都听过 IO、NIO 这些,但是很多人都没深入去了解这些内容。
Java 的 I/O 是以流的方式进行数据输入输出的,Java 的类库涉及很多领域的 IO 内容:标准的输入输出,文件的操作、网络上的数据传输流、字符串流、对象流等
2.同步与异步、阻塞与非阻塞
同步:一个任务完成之前不能做其他操作,必须等待。
异步:一个任务完成之前,可以进行其他操作
阻塞:相对于 CPU 来说,挂起当前线程,不能做其他操作只能等待
非阻塞:CPU 无需挂起当前线程,可以执行其他操作
3.三种 IO 模型
BIO(Blocking I/O)
同步并阻塞模式,调用方在发起 IO 操作时会被阻塞,直到操作完成才能继续执行,适用于连接数较少的场景。
例如:服务端通过 ServerSocket 监听端口,accept()阻塞等待客户端连接。
优缺点:
优点:实现简单
缺点:线程资源开销大,连接数多时,每个线程都要占用 CPU 资源,容易出现性能瓶颈
适用于低并发、短连接的场景,如传统的 HTTP 服务

NIO(Non-blocking I/O)
同步非阻塞模型,客户端发送的连接请求都会注册到 Selector 多路复用器上,服务器端通过 Selector 管理多个通道 Channel,Selector 会轮询这些连接,当轮询到连接上有 IO 活动就进行处理。
NIO 基于 Channel 和 Buffer 进行操作,数据总是从通道读取到缓冲区或者从缓冲区写入到通道。Selector 用于监听多个通道上的事件(比如收到连接请求、数据达到等等),因此使用单个线程就可以监听多个客户端通道。
IO 多路复用:一个线程可对应多个连接,不用为每个连接都创建一个线程

核心组件:
Channel:双向通信通道(如 SocketChannel),数据可流入流出
Buffer:数据缓冲区,是双向的,可读可写
Selector:一个 Selector 对应一个线程,一个 Selector 上可注册多个 Channel,并轮询多个 Channel 的就绪事件
优缺点:
可以减少线程数量,降低线程切换的开销,适用于需要处理大量并发连接的场景
缺点:实现复杂度高
使用于高并发、长连接的场景,如即时通讯场景
AIO(Asynchronous I/O)
异步非阻塞模型,基于事件回调或 Future 机制
调用方发起 IO 请求后,无需等待操作完成,可继续执行其他任务。操作系统在 IO 操作完成后,通过回调或事件通知的方式告知调用方
Java 中
AsynchronousSocketChannel
是 AIO 的代表类,通过回调函数处理读写操作完成后的结果
优缺点:
IO 密集型的应用,AIO 提供更高的并发和低延迟,因为调用方在等待 IO 时不会被阻塞
缺点:实现复杂
适用于高吞吐、低延迟的场景,如日志批量写入
4.什么是 Netty
说起 Java 的 IO 模型,绕不开的就是 Netty 框架了,那什么是 Netty,为什么 Netty 的性能这么高呢?
Netty 是由 JBOSS 提供的一个 Java 开源框架。提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器
Netty 的原理就是 NIO,是基于 NIO 的完美封装
很多中间件的底层通信框架用的都是它,比如:RocketMQ、Dubbo、Elasticsearch
4.1 Netty 的核心要点
核心特点:
高并发:通过多路复用 Selector 实现单线程管理大量连接,减少线程开销
传输快:零拷贝技术,减少内存拷贝次数
封装性:简化 NIO 的复杂 API,提供链式处理(ChannelPipeline)和可扩展的编解码能力(如 Protobuf 支持)
高性能的核心原因:
主从 Reactor 线程模型,无锁化设计,减少线程竞争
零拷贝技术,堆外内存直接操作
高效内存管理,对象池技术,预分配内存块并复用,对象复用机制
基于 Selector 的 I/O 多路复用,异步事件驱动机制
Selector 空轮询问题修复
4.2 零拷贝技术
Netty 的零拷贝体现在操作数据时, 不需要将数据 buffer 从 一个内存区域拷贝到另一个内存区域。少了一次内存的拷贝,CPU 效率就得到的提升。
4.2.1 Linux 系统的文件从本地磁盘发送到网络中的零拷贝技术

内核缓冲区是 Linux 系统的 Page Cahe。为了加快磁盘的 IO,Linux 系统会把磁盘上的数据以 Page 为单位缓存在操作系统的内存里
内核缓冲区到 Socket 缓冲区之间并没有做数据的拷贝,只是一个地址的映射,底层的网卡驱动程序要读取数据并发送到网络上的时候,看似读取的是 Socket 的缓冲区中的数据,其实直接读的是内核缓冲区中的数据。
零拷贝中所谓的“零”指的是内存中数据拷贝的次数为 0
4.2.2 Netty 零拷贝技术
使用了堆外内存进行 Socket 读写,避免 JVM 堆内存到堆外内存的数据拷贝
提供了 CompositeByteBuf 合并对象,可以组合多个 Buffer 对象合并成一个逻辑上的对象,用户可以像操作一个 Buffer 那样对组合 Buffer 进行操作,避免传统内存拷贝合并
文件传输使用 FileRegion,封装 FileChannel#transferTo()方法,将文件缓冲区的内容直接传输到目标 Channel,避免内核缓冲区和用户态缓冲区间的数据拷贝
4.2.3 Netty 和操作系统的零拷贝的区别?
Netty 的 Zero-copy 完全是在用户态(Java 应用层)的, 更多的偏向于优化数据操作。而在 OS 层面上的 Zero-copy 通常指避免在用户态(User-space)与内核态(Kernel-space)之间来回拷贝数据
4.3 Reactor 模式

基于 IO 多路复用技术,多个连接共用一个多路复用器,程序只需要阻塞等待多路复用器即可
基于线程池技术复用线程资源,程序将连接上的任务分配给线程池中线程处理,不用为每个连接单独创建线程
Reactor 是图中的 ServiceHandler,在一个单独线程中运行,负责监听和分发事件
Reactor 可以分为单 Reactor 单线程模式、单 Reactor 多线程模型,主从 Reactor 多线程模型
4.3.1 单 Reactor 单线程模式

Reactor 通过 select 监听客户端请求事件,收到事件后通过 dispatch 分发
该模式简单,所有操作都由 1 个 IO 线程处理,缺点是存在性能瓶颈,只有 1 个线程工作,无法发挥多核 CPU 的性能。
4.3.2 单 Reactor 多线程模式

Reactor 主线程负责接收建立连接事件和后续的 IO 处理,Worker 线程池处理具体业务逻辑
充分发挥了多核 CPU 的处理能力,缺点是用一个线程接收事件和响应,高并发时仍然会有性能瓶颈
4.3.3 主从 Reactor 多线程模式

Reactor 主线程负责通过 select 监听连接事件,通过 acceptor 处理连接事件
Reactor 从线程负责处理建立连接后的 IO 处理事件
worker 线程池负责业务逻辑处理,并将结果返回给 Handler
该模式优点是主从线程分工明确,能应对更高的并发。缺点是编程复杂度较高。
应用该模式的中间件有:Dubbo、RocketMQ、Zookeeper 等
小结
Reactor 模式的核心在于用一个或少量线程来监听多个连接上的事件,根据事件类型分发调用相应处理逻辑,从而避免为每个连接都分配一个线程
4.4 Netty 的线程模型

BossGroup:boss 线程组,负责接收客户端的连接请求,连接来了之后,将其注册到 Worker 线程组的 NioEventLoop 中
WorkerGroup:Worker 线程组,每个线程都是一个 NioEventLoop,负责和处理一个或多个 Channel 的 I/O 读写操作。处理逻辑通常是通过 ChannelPipeline 中的各个 ChannelHandler 来完成
业务线程组(可选):还可以引入一个业务线程组来处理业务逻辑,避免阻塞 Worker 线程
简单理解:Boss 线程是老板,Worker 线程是员工,老板负责接收处理的事件请求,Worker 负责工作,处理请求的 I/O 事件,并交给对应的 Handler 处理
本质是将线程连接和具体的业务处理分开
5.多路复用 I/O 的 3 种机制
5.1 select
这三种都是操作系统中的多路复用 I/O 机制
轮询机制:select 使用一个固定大小的位图来表示文件描述符集,将文件描述符的状态(如可读、可写)存储在一个数组中,调用 select 时,每次需将完整的位图从用户空间拷贝到内核空间,内核遍历所有描述符,检查就绪状态
局限:
文件描述符限制通常为 1024,限制了并发处理数
性能低:搞并发场景,每次都要遍历整个位图,性能开销大,时间负责度为 O(N)
5.2 poll
poll 使用了动态数组来替代位图,使用 pollfd 结构数组存储文件描述符和事件,无数量限制
工作机制:每次调用时仍然需要遍历所有描述符,即使只有少量描述符修改了,仍然要检查整个数组,时间复杂度为 O(N)
5.3 epoll
1)事件驱动模型:epoll 使用红黑树来存储和管理注册的文件描述符,使用就绪事件链表来存储触发的事件。当某个文件描述符上的事件就绪时,epoll 会将该文件描述符添加到就绪链表中。
2)触发模式:支持水平触发(LT)和边缘触发(ET),ET 模式下事件仅通知一次
水平触发(Level Triggered),默认模式,只要文件描述符上有未处理的数据,每次调用 epoll_wait 都会返回该文件描述符
边缘触发(Edge Triggered),仅在状态发生变化时通知一次,减少重复事件的通知次数
3)工作流程:
epoll_create
创建实例:分配相应数据结构,并返回一个 epoll 文件描述符。内核分配一棵红黑树管理文件描述符,以及一个就绪事件的链表epoll_ctl
注册、修改、删除事件:epoll_ctl 是用于管理文件描述符与事件关系的接口epoll_wait
等待事件:epoll 会检查就绪事件链表,将链表中所有就绪的文件描述符返回给用户空间。epoll_wait 高效体现在它返回的是已经发生事件的文件描述符,而不是遍历所有注册的文件描述符
优点是时间复杂度 O(1),仅处理活跃连接,性能和连接数无关
4)零拷贝机制:
通过内存映射 mmap 减少了在内核和用户空间之间的数据复制,进一步提高了性能
总结:epoll 每次只传递发生的事件,不需要传递所有文件描述符,所以提高了效率
6. Netty 如何解决 JDK NIO 空轮询 bug 的?
Java NIO 在 Linux 系统下默认是 epoll 机制,理论上无客户端连接时 Selector.select()方法是会阻塞的。
发生空轮询 bug 表现时,即时 select 轮询事件返回数量是 0,Select.select()方法也不会被阻塞,NIO 就会一直处于 while 死循环中,不断向 CPU 申请资源导致 CPU 100%
底层原因:
Linux 内核在某些情况下会错误地将 Selector 的 EPOLLUP(连接挂起)和 EPOLLERR(错误)事件标记为就绪状态,JDK 中的 NIO 实现未正确处理这些事件,导致 select()方法误判事件存在而提前返回
6.1 Netty 的解决方式
Netty 并没有解决这个 bug,而是绕开了这个错误,具体如下:
1)统计空轮询次数:通过 selectCnt 计数器来统计连续空轮询的次数,每次执行 Selector.select()方法后,如果发现没有 IO 事件,selectCnt 就会递增
2)设置阈值:定义了一个阈值,默认为 512,当空轮询达到这个阈值时,Netty 就会触发重建 Selector 的操作
3)重建 Selector:Netty 新建一个 Selector,并将所有注册的 Channel 从旧的 Selector 转移到新的 Selector 上,过程涉及取消旧 Selector 上的注册,以及新 Selector 上重新注册
4)关闭旧的 Selector:重建 Selector 并将 Channel 重新注册后,Netty 关闭旧的 Selector
总结:通过 SelectCnt 统计没有 IO 事件的次数,来判断当前是否发生了空轮询,如果发生了,就重建一个 Selector 来替换之前出问题的 Selector
核心代码如下:
版权声明: 本文为 InfoQ 作者【卷福同学】的原创文章。
原文链接:【http://xie.infoq.cn/article/5945f5b4eb8d93fedc295e219】。文章转载请联系作者。
评论