Java NIO - 操作系统的四种 IO 模型以及 netty 的底层原理
内核态进程和用户态进程
怎么理解内核和用户
操作系统为了自保,防止普通进程直接影响整个系统,建立了一套严格的等级机制。它就像一个等级森严的堡垒,所有的划分都是为了防止 “平民” 误伤 “国王”。为了充分理解内核和用户,我们从以下三个角度来剖析这个内核-用户的机制。
从物理内存上看:
- 内核空间 (Kernel Space): 内存中最高级的区域,存放的是操作系统的核心代码和数据。它是常驻内存的,且拥有操作一切硬件的 “绝对权力”。
- 用户空间 (User Space): 内存中留给普通应用程序(如你的浏览器、编辑器)运行的区域。
每个用户进程都有自己独立的虚拟用户空间,这就像给每个程序发了一个“小黑屋”,它们在屋里怎么折腾都行,但绝对摸不到墙外的内核空间。应用程序想要拿硬件里的数据,不能自己去搬,必须求助于内核,这种求助内核的操作,就叫系统调用(System Call)。
CPU 的运行模式来看:
- 内核态 (Kernel Mode / Ring 0): 又称特权模式。相当于最高行政权限。当 CPU 处于这个状态时,可以执行任何指令,访问任何内存地址,直接控制磁盘、网卡等硬件。
- 用户态 (User Mode / Ring 3):相当于受限平民权限。在这种状态下,CPU 只能执行不具破坏性的简单指令,不允许直接操作硬件。如果用户程序想读写硬盘,CPU 必须从用户态切换到内核态,这种切换的代价往往很高,需要保存寄存器上下文,相较来说很耗时。
从任务执行的主体来看:
- 内核进程(通常指内核线程 Kernel Thread): 它们由内核直接创建和调度,始终运行在内核空间和内核态。它们负责 “后台杂务”,比如定期把内存数据刷到磁盘、管理网络连接。它们不需要 “系统调用”,因为它们本身就是系统的一部分。
- 用户进程: 这就是我们平时写的代码或运行的软件(如 Chrome、IDE)。它们主要在用户空间活动。当它们需要申请内存、读写文件时,会通过系统调用临时 “越境” 进入内核态。
总结来说就是:一个[用户进程]运行在[用户空间]内,此时 CPU 处于[用户态];当它需要读写磁盘时,通过系统调用切入[内核态],此时 CPU 进入[内核空间]去执行[内核代码]来操作硬件。
Read 和 Write 的本质
用户程序进行IO的读写依赖于底层的IO读写,基本上会用到底层操作系统的 read 和 write 两大系统调用。虽然在不同的操作系统中 read 和 write 两大系统调用的名称和形式可能不完全一样,但是它们的基本功能是一样的。操作系统层面的read系统调用并不是直接从物理设备把数据读取 到应用的内存中,write系统调用也不是直接把数据写入物理设备。为了更好理解这一点,我们从以下三个角度来说明:
第一:read/write 并不真的负责读写
在程序员眼中,我们是在 “读写文件”;但在操作系统眼中,我们只是在 “搬运内存”。在我们的思维惯性里,调用 read 就像从井里打水。但实际上,系统调用从来不负责 “数据生产”,只负责 “搬运数据”。
- read:只是把已经打好、放在桶里(内核缓冲区)的水,倒进你的杯子(用户缓冲区)里。
- write:只是把你的水倒进桶里,然后告诉你 “倒完了”,至于桶里的水什么时候泼到地里(硬件),它概不负责。
第二:I/O 操作其实是“缓存拷贝”
真正的物理 I/O(磁盘寻道、网卡收发)极其缓慢,而内存读写极快。为了调和这种矛盾,内核在中间修了一个巨大的 “中转仓”:

我们在代码里感受到的 “I/O 延迟”,绝大部分时间其实是消耗在等待内核把 “中转仓” 填满(或排空)的过程中。
第三,所有的 I/O 本质都一样
无论是操作一个本地文件(File I/O),还是操作一个网络连接(Socket I/O),在 Linux 内核看来,它们的操作流程几乎完全重合:
- 输入(Input): 硬件 $\rightarrow$ 内核缓冲区 $\rightarrow$ 用户缓冲区。
- 输出(Output): 用户缓冲区 $\rightarrow$ 内核缓冲区 $\rightarrow$ 硬件(网卡、磁盘等)。
这种高度的一致性,就是 Unix 哲学中 “万物皆文件” 的底层逻辑。
所以,理解了“读写即拷贝”,你就才能瞬间明白高性能编程的优化方向:
- 为什么要用缓冲区? 减少系统调用。一次搬一桶水,比一勺一勺搬效率高得多。
- 为什么要用零拷贝(Zero-Copy)? 既然数据只是在缓冲区跳来跳去,能不能直接让用户程序去读内核的缓冲区?
- 为什么要用非阻塞 I/O? 当 “中转仓” 还没水的时候,不要让搬运工(线程)死等,先去干别的,有水了再来。
典型的系统调用流程
用户程序所使用的系统调用read和write并不是使数据在内核缓冲 区和物理设备之间交换:read调用把数据从内核缓冲区复制到应用的 用户缓冲区,write调用把数据从应用的用户缓冲区复制到内核缓冲区。具体到 Java客户端和服务端之间完成一次socket 请求和响应(包括read和write)的数据交换,其完整的流程如下:
- 客户端发送请求:Java客户端程序通过write系统调用将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过客户端机 器的网卡发送出去。在服务端,这份请求数据会从接收网卡中 读取到服务端机器的内核缓冲区。
- 服务端获取请求:Java服务端程序通过read系统调用从Linux内 核缓冲区读取数据,再送入Java进程缓冲区。
- 服务端业务处理:Java服务器在自己的用户空间中完成客户端 的请求所对应的业务处理。
- 服务端返回数据:Java服务器完成处理后,构建好的响应数据 将从用户缓冲区写入内核缓冲区,这里用到的是write系统调用,操作系统会负责将内核缓冲区的数据发送出去。
- 发送给客户端:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层通信协议将数据发送给目标客户端。
四种主要的IO模型
首先,解释一下阻塞与非阻塞。阻塞IO指的是需要内核IO操作彻底完成后才返回到用户空间执行用户程序的操作指令。阻塞就是用户程序(发起IO请求的进程或者线程)的执行状态。可以说传统的IO模型都是阻塞IO模型,并且在 Java 中默认创建的 socke t都属于阻塞IO模型。在 Java 默认的 Socket 里,read() 方法一旦调用,你的线程就被 “钉” 死在那行代码上了,直到数据拷贝完成。
其次,关于同步和异步,可以将同步与异步看成发起IO请求的两种方式。同步IO就是指用户进程或线程是主动发起IO请求的一方,系统内核是被动接收方。而异步IO则反过来,系 统内核是主动发起IO请求的一方,用户空间是被动接收方。
- 同步意味着你是“讨债人”:无论是阻塞还是非阻塞,只要是你(用户空间)主动调用 read、主动去查状态、主动去搬运数据,这都叫同步。内核只是个被动响应的服务员。
- 异步则反过来,你是 “甩手掌柜”。你给内核留个地址和电话(回调函数),然后去睡觉。内核(主动方)负责把数据从硬件读好,再主动塞进你家冰箱,最后打电话叫醒你。
从阻塞和同步的角度考虑,我们将主流的系统IO模型分成四类:同步阻塞IO、同步非阻塞IO、IO多路复用、以及异步IO。
注意:同步非阻塞IO也可以简称为NIO,但是它不是Java编程中的 NIO。Java编程中的NIO(New IO)类库组件所归属的不是基础IO模 型中的NIO模型,而是IO多路复用模型。
下面为了让你彻底看清这四种模型的 “真身”(以read为例),我们必须盯着两个阶段:
- 数据准备阶段(硬件 $\rightarrow$ 内核缓冲区)
- 数据拷贝阶段(内核缓冲区 $\rightarrow$ 用户缓冲区)
同步阻塞 IO
用户进程调用 read,此时用户进程挂起。内核开始等数据,数据到了之后,内核亲自把数据拷贝到用户空间,拷贝完了,read 才返回,用户进程继续。在整个过程中,用户全程死等,从发起请求到数据进屋,你什么都干不了。
同步非阻塞 IO
以发起一个非阻塞socket的read操作的系统调用为例,具体流程如下:
第一阶段,发起调用:用户进程调用 read 系统调用。此时内核会查看内核缓冲区,如果数据还没准备好,内核不会把进程挂起,而是立即返回一个错误码(在 Linux 中通常是 EAGAIN 或 EWOULDBLOCK)。
第二阶段:轮询检查:用户进程拿到错误码后,并不会阻塞,它可以去干点别的(比如算个数、打个日志),但它必须不断地轮询调用 read,就像个急躁的顾客,每隔几毫秒就问柜台:“我的货到了吗?”
第三阶段:数据到达与搬运。当某一次轮询时,内核发现数据已经从硬件(网卡/磁盘)传到了内核缓冲区,此时内核不再返回错误码,而是开始执行数据拷贝,在拷贝过程中,用户进程是 阻塞 的。拷贝完成后,read 调用返回成功。
总体来说,在高并发应用场景中,同步非阻塞IO是性能很低的, 也是基本不可用的,一般Web服务器都不使用这种IO模型。因为:
- 进程在轮询期间,CPU 是 100% 占用在 “询问” 上的。这就像你每秒钟打一个电话问快递,CPU资源占用很高。
- 如果有 1000 个连接,你就要写一个循环调 1000 次 read。即便 999 个都没数据,你也要挨个问一遍。
总结来说,非阻塞 I/O 的本质就是用 “CPU 的忙碌” 换取 “线程的不挂起”。 正因为轮询起来太累,所以才催生了多路复用——既然轮询 1000 次太费劲,不如我把这 1000 个连接都交给内核(管家),内核发现谁有数据了,一次性告诉我。这就是 Netty 真正起飞的起点。
IO 多路复用
要透彻理解 IO多路复用,尤其是 Linux 下的王牌 epoll,你只需要记住它的核心使命:用一个内核线程,同时监控成千上万个连接的状态。在同步非阻塞 IO 中,进程需要自己不断轮询每一个 Socket;而在IO多路复用中,进程把这个苦差事交给了内核。
目前支持IO多路复用的系统调用有select、epoll等。几乎所有的 操作系统都支持select系统调用,它具有良好的跨平台特性。epoll是 在Linux 2.6内核中提出的,是select系统调用的Linux增强版本,select 的 FD_SETSIZE 限制通常只有1024,而 epoll 则无硬性限制,在云计算和微服务时代,单机维护数千甚至上万连接已是常态。select 的 1024 限制使其完全无法胜任现代需求。epoll 是当下 Linux 高并发网络编程的主流和事实标准,而 select 目前基本只存在于历史教材。
- select:每次调用都要把整个 fd 集合(哪怕没变化)从用户空间拷贝到内核。像老师每次点名,即使只有1个学生要回答问题,老师也必须按名单念完所有1000个学生的名字,复杂度 O(n);
- epoll 通过 epoll_ctl() 建立 fd 到内核的注册表,后续 epoll_wait() 只返回就绪的 fd。像学生主动举手,老师只需要看哪些学生举了手,直接叫他们回答,复杂度 O(1),只返回就绪的fd。
1 | // Netty 的默认选择 |
下面就以 epoll 为例介绍IO多路复用的过程。我们可以把 epoll 的工作过程拆解为三个关键动作:开户、挂号、等通知。
第一步,创建 epoll 句柄(开户 - epoll_create):用户进程告诉内核 “我要开始监控大量连接了,请给我开个特殊的监控管理处”。内核会返回一个文件描述符(fd),这个 fd 就是以后你和内核沟通这个 “监控任务” 的凭证。
第二步,添加监控事件(挂号 - epoll_ctl):你有 10,000 个 Socket 连接。你不需要循环调用 read 去问,而是通过 epoll_ctl 告诉内核 “帮我盯着这 10,000 个 Socket,只要其中任何一个有数据进来了(可读事件),你就记录下来”。这个动作只在连接建立时做一次,不需要像 NIO 那样每次都重复询问。
第三步,等待内核通知(等通知 - epoll_wait):内核线程调用 epoll_wait 后会阻塞。这里的阻塞是有意义的。内核会一直盯着那 10,000 个 Socket。当某几个 Socket 真的有数据到了,内核会把这几个“活跃”的 Socket 放进一个就绪列表中。epoll_wait 立即返回,并把这个 “就绪列表” 传给用户进程。
第四步,将数据从内核缓冲区拷贝到用户缓冲区:用户进程一旦接收到就绪通知,它就可以把数据从内核缓冲区拷贝到用户缓冲区,但这依然是由你的进程亲自、同步完成的,这也就是多路复用依然被归类为同步 IO 的根本原因,只要你需要亲自搬运数据,你就没法彻底 “甩手”,所以它不是异步。
Epoll 就像是一个高效的快递代收点。你不需要每天给 100 个快递员打电话(NIO 轮询),只需要等代收点的一条短信(epoll_wait),然后自己去取件(同步拷贝数据)即可。
Netty 框架使用的就是IO多路复用模型。总结起来,Netty 能够支撑百万并发,本质上就是把多路复用到了极致:
- BossGroup 线程: 专门调 epoll_wait 盯着新的连接请求。
- WorkerGroup 线程: 专门调 epoll_wait 盯着已有连接的数据读写。
- 零拷贝: 拿到通知后,用最快的速度把数据处理掉,不浪费任何一次多余的搬运。
异步 IO (AIO)
用户进程调用 aio_read,然后直接去干别的。内核自己等数据、自己搬数据。等数据稳稳当当地躺在用户进程的缓冲区里了,内核发个信号:“搞定了,你直接用吧。” 看起来是不是很完美?你可能觉得 AIO 才是终极武器,但 Netty 选了 epoll(多路复用),原因是:
- 拷贝成本的控制权: 在 epoll 下,数据准备好了通知你,你决定什么时候、用多大内存去拷贝。在 AIO 下,内核主动往你内存里塞,高并发下你极难控制内存的瞬时激增。
- Linux 的偏心: Linux 对 epoll 做了极尽升华的优化,而对 AIO 的网络支持长期处于 “半残” 状态。在 Linux 上,epoll 配合零拷贝(如 sendfile),性能已经和 AIO 几乎没有代差,但稳定性强出几个数量级。
牛逼的零拷贝技术
零拷贝的原理和实现
上面我们已经说过,传统 IO 很多时候会进行无意义的 CPU 搬运。比如你要把磁盘上的一个文件通过网卡发出去。在标准 IO 模型下,数据会经历以下路径:
- 磁盘 $\rightarrow$ 内核缓冲区(DMA 搬运,即 Direct Memory Access)
- 内核缓冲区 $\rightarrow$ 用户缓冲区(CPU 搬运,第 1 次拷贝)
- 用户缓冲区 $\rightarrow$ Socket 缓冲区(CPU 搬运,第 2 次拷贝)
- Socket 缓冲区 $\rightarrow$ 网卡(DMA 搬运)
数据在内存里被复制了两次,且 CPU 参与了这种机械搬运。在高并发下,CPU 的时间全花在 “拷贝”上了,根本没空处理业务逻辑。零拷贝的思路就是 “消除中间商”,即 消除内核空间与用户空间之间的数据交换。怎么实现的呢?两种方式:
实现方式 A:mmap + write(内存映射)
- 原理:操作系统把内核缓冲区的一段地址,直接映射到用户空间。
- 过程:你的程序和内核“共享”同一块内存。你调用 write 时,内核直接从这块共享区域把数据拷到 Socket 缓冲区。
- 效果: 减少了 1 次 CPU 拷贝。虽然还有 3 次拷贝,但用户空间不再持有数据副本。
实现方式 B:sendfile(真正的直通车)
- 原理:Linux 提供的系统调用。它告诉内核:“直接把 A 文件的内容发到 B 套接字去”。
- 过程:数据根本不经过用户空间。数据从内核缓冲区直接进入 Socket 缓冲区(或通过 SG-DMA 直接发给网卡)。
- 效果:只有 2 次拷贝(磁盘$\rightarrow$内核$\rightarrow$网卡),且 CPU 参与度为 0。
零拷贝在 Netty 中的应用
前面我们讲,Netty 之所以快的原因之一,是因为它在不同层面压榨了零拷贝的价值:
操作系统层(Direct Buffer): Netty 优先使用堆外内存(DirectByteBuf)。数据直接在内核和物理内存间交互,避免了从 JVM 堆内存到内核的二次拷贝。
传输层(FileRegion): 在发送文件时,Netty 直接封装了 Java 通道的 transferTo 方法(底层就是 sendfile),数据直接从文件通道流向网络通道。
应用层(CompositeByteBuf): 这是一种 “逻辑组合”。如果你要把两个数据包合并,传统做法是开个新大数组把它们考进去;Netty 则是把两个包 “逻辑挂载” 在一起,看起来是一个整体,实际上物理内存一行都没动。
零拷贝也不是万能药
零拷贝不是万能药,它有明确的适用场景,这些场景包括:静态资源分发(如 Nginx 发送图片)、大文件传输、消息队列(如 Kafka 消费消息)。它们的特点是:我只负责搬运,我不修改数据。如果你需要对数据进行复杂的加密、压缩或修改,你还是得把数据搬回 “用户空间” 的小黑屋里处理。
高并发操作系统层面的配置
要支持百万级并发连接(C1000K),单靠代码层面的 epoll 或 Netty 是远远不够的。你必须从 操作系统内核、网络协议栈 到 应用程序 进行全方位的 “扩容”。
如果把并发连接比作 “高速公路上的车辆”,那么配置的目标就是:增加车道数(句柄)、缩短收费站排队时间(内核参数)以及扩充服务区容量(内存)。
操作系统层
Linux 默认配置是为了通用服务器设计的,对于百万并发来说,这些默认值就像是给摩天大楼装了单人电梯。
文件描述符
在 Linux 中,每个连接(Socket)都是一个文件。默认的限制通常只有 1024。
- 系统级限制:
cat /proc/sys/fs/file-max(整个内核能打开的最大文件数)。 - 进程级限制:
ulimit -n(单个进程能打开的最大文件数)。
设置系统最大文件描述符数量,建议 file-max ≥ (单个进程最大fd数 × 最大进程数) × 1.2(安全系数),典型配置参考:
- 常规 Web / 微服务:100万,覆盖数百个进程,单进程几千fd的常规场景
- API 网关 / 长连接服务:200万-500万,单机维持大量 Socket 连接,消耗巨大
- 数据库服务器:50万-100万,连接数相对可控,但需考虑表文件打开数
- 容器环境 (Docker/K8s):需显式设置,容器默认继承宿主机超大值,建议按 Pod 实际需求调小以保安全
1 | #### 设置系统最大文件描述符数量 |
file-max只是第一道关卡,进程级限制(ulimit)往往先触顶,必须同步调整。
1 | # 修改进程极限:vim /etc/security/limits.conf |
对于大多数生产服务器,将 fs.file-max 设置为 100万 是一个安全且充裕的起点。重点应放在应用层:确保你的服务(如 Java 应用)的 -XX:-MaxFDLimit 或 Go 应用的 GOMAXPROCS 以及 ulimit 配置与之匹配,避免出现 “系统有空闲,但进程报 too many open files” 的尴尬情况。
端口范围
端口范围是客户端高并发场景的 “隐形杀手”。它决定了你的服务器作为客户端(如微服务调用、连接数据库、调用第三方API)时,可用的临时端口数量上限。一旦耗尽,就会报 Cannot assign requested address 错误。在实际配置的时候应注意:1024 以下(1-1023)是系统保留端口(如 80、22),普通进程无法绑定。从 1024 开始才是安全起点。
在实际分析需要多少端口范围时,比如如果每秒有 1 万次短连接请求,且 TIME_WAIT 持续 60 秒,理论上需要 60 万个端口,远超 6 万上限。不同场景的配置建议:
- 纯服务端(如 Web 服务器只接收请求):不主动向外发起大量连接,端口够用,默认值即可(32768 60999)
- 微服务客户端(如 API 网关、Sidecar):需频繁调用下游服务,应扩大池子,可设置 1024 65000(约 6.3 万端口)
- 代理服务器 / 爬虫节点:极端高并发客户端场景,榨干所有可用端口,可以设置 1024 65535(最大值)
1 | #### 临时查看和修改: |
网络协议栈层
内核在处理三次握手和断开连接时,有很多缓冲区和超时等待,必须压缩。
TCP全连和半连
当大量请求瞬间涌入,如果队列满了,新的连接会被直接丢弃。
全连接队列参数 somaxconn 的配置:当客户端完成三次握手后,连接会放入全连接队列,等待应用调用 accept() 取走。此参数定义了单个监听套接字全连接队列的最大长度。生产环境配置应注意:
- 必须与应用程序配合:somaxconn 只是系统上限。应用程序在调用 listen(fd, backlog) 时,传入的 backlog 参数(如 Nginx 的 listen 80 backlog=65535)才是最终生效值。取两者最小值。
- 容器环境:在 Docker/K8s 中,容器内的 somaxconn可能受 Cgroup 限制或与宿主机隔离,需在容器启动时显式设置(如 –sysctl 或 Pod securityContext)。
- 为什么“直接设 65535”是错的?
- 内存开销:每个连接在队列中都会占用内核内存。在百万级连接下,过大的队列会消耗大量内存而引发 OOM。
- 响应延迟:队列过长意味着连接在内核中等待 accept()的时间变长,对于低延迟服务(如游戏、金融),这比直接拒绝连接更糟糕。
- 掩盖问题:如果队列无限大,客户端不会立即失败,但服务端应用可能因处理不过来而雪崩。有时适当的丢弃(配合重试)比无限制的排队更健康。
最终 somaxconn 的建议值:
- 常规 Web 服务:16384(平衡内存与并发能力)
- API 网关 / 高并发代理:32768(需应对突发流量)
- 长连接/低频服务:4096(连接建立不频繁,无需过大)
- 容器 (Pod):8192(单 Pod 资源受限,不宜过大)
1 | # 查看当前值(通常默认是 128,极低) |
半连接队列的配置:存放已收到 SYN 但未完成三次握手的连接。但在 Linux 2.6 之后,其行为已发生根本变化。
- 现代内核逻辑:半连接队列长度 ≈ min(somaxconn, tcp_max_syn_backlog),且受 syncookies 机制影响极大。
- syncookies的干扰:当 net.ipv4.tcp_syncookies = 1(默认开启,防 SYN Flood 攻击)时,在攻击压力下内核会忽略队列长度,直接使用 Cookie 机制,此时 tcp_max_syn_backlog 的设置无意义。
- 最终的建议是不要单独纠结此参数。实际应优先保证 somaxconn足够大,并保持 syncookies=1 的默认安全配置。
实际配置参考:
1 | # 修改 /etc/sysctl.conf |
应用层(关键,否则系统配置无效):
Nginx:在 nginx.conf 的 listen 指令中显式设置 backlog。
1 | server { |
Java (Tomcat/Netty):在 server.xml 或启动参数中设置 acceptCount(Tomcat)或 SO_BACKLOG(Netty)。
内存缓冲区
每个 Socket 都会占用读写缓冲区。如果 100 万个连接每个占用 100KB,内存就要 100GB!
实际配置:调小初始内存,允许动态扩容。直接将下面配置写入 /etc/sysctl.conf:
1 | #### vim /etc/sysctl.conf |
记住这个逻辑:最小值保证连接存活,初始值由应用层(Nginx/Netty)决定,最大值防止单个连接吃光内存。不要试图通过调大缓冲区来“加速”网络,真正的性能瓶颈通常是带宽和延迟,而不是缓冲区大小。
应用层配置(Netty)
线程模型配置
- BossGroup:负责 accept,通常 1 个线程足以处理百万连接的接入。
- WorkerGroup:负责 I/O 读写。通常设置为
CPU 核心数 * 2。
内存管理
主要考虑使用池化与堆外技术:
- 使用 PooledByteBufAllocator:避免频繁创建和销毁缓冲区导致的 GC 压力。
- 使用 DirectBuffer:配合零拷贝,直接在内核态与物理内存间交换数据,减少 JVM 堆内存压力。