写点什么

Java 的 IO 模型、Netty 原理详解

作者:卷福同学
  • 2025-03-30
    湖北
  • 本文字数:4872 字

    阅读完需:约 16 分钟

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


核心代码如下:



long time = System.nanoTime();
//调用select方法,阻塞时间为上面算出的最近一个将要超时的定时任务时间int selectedKeys = selector.select(timeoutMillis);
//计数器加1++selectCnt;
if (selectedKeys != 0 || oldWakenUp || this.wakenUp.get() || this.hasTasks() || this.hasScheduledTasks()) { //进入这个分支,表示正常场景
//selectedKeys != 0: selectedKeys个数不为0, 有io事件发生 //oldWakenUp:表示进来时,已经有其他地方对selector进行了唤醒操作 //wakenUp.get():也表示selector被唤醒 //hasTasks() || hasScheduledTasks():表示有任务或定时任务要执行 //发生以上几种情况任一种则直接返回
break;}
//此处的逻辑就是: 当前时间 - 循环开始时间 >= 定时select的时间timeoutMillis,说明已经执行过一次阻塞select(), 有效的selectif (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) { //进入这个分支,表示超时,属于正常的场景 //说明发生过一次阻塞式轮询, 并且超时 selectCnt = 1;} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) { //进入这个分支,表示没有超时,同时 selectedKeys==0 //属于异常场景 //表示启用了select bug修复机制, //即配置的io.netty.selectorAutoRebuildThreshold //参数大于3,且上面select方法提前返回次数已经大于 //配置的阈值,则会触发selector重建
//进行selector重建 //重建完之后,尝试调用非阻塞版本select一次,并直接返回 selector = this.selectRebuildSelector(selectCnt); selectCnt = 1; break;}currentTimeNanos = time;
复制代码


发布于: 刚刚阅读数: 2
用户头像

卷福同学

关注

一个在福报厂修福报的程序员 2020-04-30 加入

阿里巴巴Java资深开发,终身学习者,持续文章撰写者,福报厂卷着。目前主要从事地图领域相关业务,负责100万QPS的系统,有丰富的高并发高可用经验

评论

发布
暂无评论
Java的IO模型、Netty原理详解_Java_卷福同学_InfoQ写作社区