先用一个真实的场景开头吧。前几年维护过一个以传统阻塞IO为核心的老服务连接数爬到两千多的时候线程池直接被打穿监控面板上线程曲线像心电图一样疯狂抖动频繁的上下文切换把CPU耗掉大半吞吐量却几乎不再增长。后来把核心链路改成基于Java NIO和Reactor模型设计的多线程架构两百个连接跑压测QPS反而比之前两千线程的状态高一截。从那时候起我就坚定一个看法Java工程师如果没把NIO和Reactor这套东西彻底吃透遇到高并发架构相关的设计和面试题迟早是会露怯的。这篇算是一份从原理到代码的完整复盘。我会先讲清楚BIO为什么在高并发下必然出局然后把NIO的Channel、Buffer、Selector三个零件逐个拆开沿着Reactor模型从单线程到主从多线程的演进路线手写一个可运行的主从Reactor骨架最后把我在实际调优和压测中踩过的坑、以及面试官最常追问的那一串问题一起聊透。适合正在啃NIO源码、准备高并发方向面试、或者想自己搭一套网络框架底座的Java开发者阅读参考。1. 先搞清楚BIO到底死在哪NIO到底强在哪1.1 一个连接一个线程BIO模型的致命结构传统BIO的代码写起来非常直观一个ServerSocket配合accept来一个连接就开一个线程处理读写。最典型的教科书写法长这样while (true) { Socket socket serverSocket.accept(); // 阻塞等待连接 new Thread(() - { try (InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream()) { byte[] buf new byte[1024]; int len; while ((len in.read(buf)) ! -1) { // 阻塞等待数据 out.write(buf, 0, len); } } catch (IOException e) { // 处理连接异常 } }).start(); }这段代码藏着两个层面的问题。第一层是accept阻塞服务端同一时刻只能处理一个连接请求的接收连接到达速度一旦超过处理速度客户端就只能排队表现为连接建立的延迟越来越高。第二层更致命的是read阻塞线程在等待数据期间不会释放CPU和内存资源但又不产生任何有效计算这就是典型的占着资源不干活。1.2 线程数量膨胀以后CPU到底浪费在哪里很多人以为线程多就是并行度高性能一定好但BIO模型恰恰是反例。每个线程在JVM里默认要占用独立的栈内存Linux上默认栈大小通常是512KB到1MB。我们按1MB估算1000个连接就是1000个线程光栈内存就吃掉约1GB这在很多服务器上已经占了相当高的内存比例GC和大对象分配的空间都跟着紧张。更隐蔽的是线程切换成本。CPU在多个线程之间切换时要保存和恢复寄存器、程序计数器、栈指针等上下文一次上下文切换大概要消耗几个微秒到几十微秒。当系统里活跃线程超过CPU核心数许多倍时大量CPU时间片会消耗在调度和切换上而不是真正的业务计算。我用一个简单公式来量化假设单线程处理一个请求需要5msBIO模型接到1000个连接其中100个连接同时活跃至少要准备100个线程。线程调度、锁竞争、内存占用叠加起来这台机器的实际吞吐量可能连1000 QPS都撑不住。而NIO配合Reactor模型用一两个线程去管理这一千个连接的IO事件同样的机器可以轻松跑到数千甚至上万QPS。差异的核心其实不在于非阻塞这个称呼而在于IO等待不再独占线程。线程从每连接一个变成了按就绪事件按需调度资源利用率完全是两个量级。1.3 非阻塞的真正含义数据有没有准备好由系统通知你NIO的全称是New I/O大家习惯叫它非阻塞IO但这个词很容易被误解。所谓非阻塞指的是读写操作本身大多数情况下会立即返回返回值有三种常见情况读取到数据的字节数、返回0表示暂时没有数据、返回-1表示连接已关闭。这确实解决了线程干等read()的问题但代价是代码复杂程度立刻上来了。你没法像BIO那样线性地读完这一笔再读下一笔必须不断主动查询这个连接有没有数据给我没有就去忙别的。在没有Selector的时代这种查询只能靠线程轮询所有连接连接数量一大CPU空转的浪费依然触目惊心。Selector解决的核心问题就是谁来通知。它把有哪些连接可读、可写、可接受连接这件事情下沉到了操作系统内核。你把自己关心的socket注册进去调用一次select等待内核发现就绪事件后把对应channel标记为ready你再从就绪集合里挨个取出处理。这样一来线程的忙碌程度与连接总数彻底解耦只跟真正发生事件的连接数量相关。这是Reactor模型能成立的前提——事件驱动取代阻塞等待是整个多线程架构简化的起点。2. NIO的三个零件Channel、Buffer、Selector是怎么配合的2.1 Channel双向IO通道也是事件注册的载体BIO里拿到的是InputStream和OutputStream一个负责读一个负责写职责分明。NIO里的Channel是双向的一个SocketChannel既可以读也可以写这跟操作系统的socket本身完全对齐。理解Channel的重点不是它是Stream的升级版而是一条管道口。管道本身不存数据数据只在Buffer和Channel之间搬运。所有read和write方法接收的参数都是Buffer对象也就是说NIO的IO操作本质上是缓冲区之间的数据搬运。更关键的一点是Channel可以被注册到Selector上这给了它等待就绪的能力。Stream没有这种能力所以基于BIO的服务无法平滑改造为事件驱动模型必须换一套设计。2.2 Buffer三个游标管住读写状态机Buffer是NIO里最容易写错的地方核心数学模型就是三个位置position、limit、capacity。写模式下position从0往capacity方向移动limit等于capacity写完以后要切换成读模式调用flip()此时limit被设为positionposition归零意思是只能读刚才写入的那一段。读完准备重新写入时调用clear()position归零、limit恢复capacity如果还有未读完的数据要保留则用compact()把未读数据搬到缓冲区头部position指向未读数据的末尾方便继续write。我提供一个记住流程的口诀写完切换读用flip读完重新写用clear或compact。每次从Channel读数据之前通常先clear一下每次把数据写给Channel之前一定先flip否则你写出去很可能是从position到limit边界内不应该被发送的残留数据。Buffer还有个不可忽视的工程问题HeapByteBuffer是JVM堆内数组读写时涉及一次用户态到内核态的拷贝DirectByteBuffer是堆外内存能减少一次拷贝但分配和回收成本高。这两个特性直接引出Buffer池化管理的话题后面调优章节会详细展开。2.3 Selector把一万个连接的等待压缩成一个线程的等待Selector在不同操作系统上有不同实现Linux上是epollmacOS上是kqueue老一些的平台可能退化为select或者poll。epoll的底层核心是内核维护的一棵红黑树加一个就绪链表注册和修改fd事件是O(log n)复杂度事件到达后通过回调机制挂进链表用户调用epoll_wait就可以拿到就绪事件列表。Java层的Selector对外暴露四个事件常量我用表格整理一下事件含义典型触发时机OP_ACCEPT有新的连接可以被acceptServerSocketChannel注册后新客户端连接到达OP_READ通道有数据可读SocketChannel注册后对端发来数据或半包到达OP_WRITE通道可以写数据注意SocketChannel几乎一直可写通常只在发送缓冲区满时关注OP_CONNECT客户端连接建立完成SocketChannel发起connect后连接建立成功这里有个非常经典的误用注册了OP_WRITE就以为可以放心大块写数据。实际上大多数时候socket的发送缓冲区都是空的OP_WRITE永远处于就绪状态你一旦注册它select循环会瞬间被可写事件占满CPU忙到飞起却没产出实际工作。正确做法是先尝试一次非阻塞write只有当写入字节数小于请求字节数时才注册OP_WRITE等写事件到达后再继续写剩余部分。Netty内部很多writeAndFlush的逻辑就是基于这个思路只是它把细节封装得更精致。3. Reactor模型的演进图谱从单线程到主从多线程3.1 单Reactor单线程架构极简但容不下任何耗时操作Reactor模型把网络层抽象成两个角色Reactor负责监听和分发事件Handler负责处理IO和业务。第一种形态是单Reactor单线程也就是说一个线程里同时干完accept、read、业务逻辑、write全部事情。单线程模型的优点是很明显的没有多线程竞争资源的问题代码逻辑连贯定位Bug极其轻松。直接写一个端口转发或者简单协议转换的服务它甚至已经够用。但它有一个硬约束任何Handler里如果出现一个稍微耗时的操作——比如查一次数据库、读一次磁盘、做一次复杂的JSON序列化——整个Selector循环就会被拖住其他所有连接的事件全部排队等待延迟瞬间恶化。所以单Reactor单线程模型的关键使用前提是事件处理绝不能包含长时间阻塞操作。一旦业务环节存在IO等待就必须考虑下一个演进版本。3.2 单Reactor多线程把业务丢给线程池IO线程保持清爽改进方式非常直接把Handler里的业务处理拆出去丢给一个独立的业务线程池来执行Reactor线程只负责IO事件的分发。伪代码大概是这样的public void handleRead(SelectionKey key) { SocketChannel channel (SocketChannel) key.channel(); ByteBuffer buffer (ByteBuffer) key.attachment(); channel.read(buffer); businessExecutor.submit(() - processAndWrite(buffer, channel)); }拆开之后Reactor线程不再被数据库、RPC等慢操作拖累单线程的selector可以专注处理网络事件整体的吞吐量明显提升。但这里还有一个隐患没有解决Selector线程仍然只有一个不管来了多少连接accept和读写事件都由这一个线程分发。如果某个瞬间大量socket同时到达就绪状态比如秒杀开场、热点事件触发的流量洪峰单个Selector线程遍历就绪集合并触发回调的耗时就会明显变长成为新的瓶颈。另外业务线程处理完以后要回写响应如果写操作的数据量很大也会反过来挤压Selector线程的可用时间。3.3 主从Reactor多线程Netty里BossGroup和WorkerGroup的底层原型主从Reactor是Java网络框架里最经典的形态它的核心思路是把接客和服务两者彻底分开用两组Reactor各管一摊BossGroup主Reactor通常只用一个线程负责监听ServerSocketChannel的OP_ACCEPT事件接受新连接以后把SocketChannel注册到某个WorkerGroup的Selector上。WorkerGroup从Reactor多个线程每个线程持有自己的Selector负责处理注册到它那里的连接的OP_READ和OP_WRITE事件。这样做的好处有两个第一accept操作被独立出来即使WorkerGroup线程全部在暴力处理读写新连接也能被快速接受不会出现连接能建立但业务长时间不被分发的问题第二每个Worker线程管理的连接数量被分散就绪事件集合的遍历成本被摊薄。Netty的EventLoopGroup就是主从Reactor最典型的实现。BossEventLoopGroup和WorkerEventLoopGroup的分工本质上就是我说的这两组Reactor只是Netty把底层的线程模型、缓冲池、编解码链路都封装成了一套高度可复用的框架。三种模型对比如下模型线程构成业务处理位置适用场景主要风险单Reactor单线程1个线程事件线程内纯转发、极短业务链路一旦阻塞整体瘫痪单Reactor多线程1个Reactor N个业务线程业务线程池业务耗时较长但连接并行度中等单Reactor线程成为瓶颈主从Reactor多线程Boss线程 Worker线程组 业务线程池业务线程池高并发高连接数的通用服务线程与池参数调优复杂度上升从单线程到主从演进的本质其实只有一个逻辑把不同性质的耗时操作从同一个线程里逐步剥离。先剥离业务处理再剥离accept每一次剥离都是在为下一个可能出现的瓶颈腾出位置。4. 主从Reactor骨架实现从accept到读写分离的代码细节4.1 核心类设计与BossReactor的accept分发我不打算直接贴一个完整框架而是把一个最小可运行的主从Reactor骨架拆成几个核心类来讲。项目结构大概是这样的NioServer启动入口创建Boss线程和Worker线程池BossReactor持有ServerSocketChannel和Selector只处理OP_ACCEPTWorkerReactor持有自己的Selector处理读写事件业务丢给业务线程池ChannelSession封装SocketChannel、读缓冲区、半包上下文。BossReactor的select循环是理解主从分发的关键public void run() { while (running) { try { selector.select(1000); IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); if (key.isAcceptable()) { ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel socket server.accept(); socket.configureBlocking(false); workerGroup.register(socket); // 关键把连接分发给Worker } } } catch (IOException e) { // 记录异常避免Reactor线程死亡 } } }注意这里select带了1000毫秒的超时参数没有用无参的select()阻塞到底。这样即使完全没有网络事件线程也能定期醒来检查running标记方便后续实现优雅关闭。register方法是把连接交给某个Worker线程这里涉及负载均衡策略最简单的实现是round-robin轮询稍讲究一点可以加个计数器按当前Worker管理的连接数动态分配。4.2 WorkerReactor的跨线程注册与事件循环WorkerReactor有一个跨线程难题Selector的注册操作不应该在别的线程里直接执行因为Selector内部的key集合并非线程安全。稳妥做法是维护一个并发队列把新连接先投递进队列由Worker线程自己在select循环开头统一处理。private void processRegisterQueue() { SocketChannel ch; while ((ch pendingRegister.poll()) ! null) { try { ch.register(selector, SelectionKey.OP_READ, new ChannelSession(ch)); } catch (ClosedChannelException e) { // 通道已关闭则跳过 } } }每个SelectionKey的attachment是一个ChannelSession对象里面除了保存SocketChannel还要挂一个ByteBuffer作为读缓冲区以及半包处理需要的状态字段。这里特别强调一点attachment不要只放一个裸ByteBuffer因为你在处理粘包半包时需要记录分帧状态、已经读入的数据长度、解出来的完整消息等用一个独立对象去承载比堆一堆静态变量好维护得多。注册完成后数据读写就在Worker的select循环里进行。每次读到数据先append到Session的pendingBuffer里然后尝试提取完整的消息帧提取成功就把消息交给业务线程池处理。这是Reactor和业务层的交接点也是整个模型中最容易设计出错的位置下一小节详细说。4.3 从select到业务线程池半包粘包分帧与回写设计网络传输把大消息拆成多个TCP段发送或者把多个小消息粘连在一个TCP段里到达这是常态而非意外。Reactor模型里每次read到的数据量完全不可控所以必须在IO线程侧完成分帧操作。以最常见的定长包头协议为例4字节的消息长度头后面跟N字节正文。处理逻辑大概是void handleRead(ChannelSession session, ByteBuffer readBuffer) { readBuffer.flip(); while (true) { if (session.frameLength -1) { if (readBuffer.remaining() 4) break; // 长度头还没凑齐 session.frameLength readBuffer.getInt(); if (session.frameLength MAX_BODY_SIZE) { session.close(); // 大包攻击防御超过上限直接断开 return; } } if (readBuffer.remaining() session.frameLength) break; // 正文还没凑齐 byte[] body new byte[session.frameLength]; readBuffer.get(body); session.frameLength -1; businessExecutor.submit(() - dispatch(session, body)); } readBuffer.compact(); // 剩余数据挪到头部为下一次read腾出写空间 }思路非常朴素每次只剥离一帧剥不完整就退出循环等下一个OP_READ事件到来后继续剥离。注意这里用的是compact而不是clear目的是保留那些读了半截的消息数据避免丢数据。很多初次写NIO的开发者在这里直接buffer清空最后消息对不齐、数据莫名其妙丢失排查半天才发现是分帧状态被自己清掉了。关于业务线程池回写数据我再强调一个容易踩的坑不要直接在业务线程里调用channel.writeSocketChannel不是线程安全的并发写会导致数据乱序严重的会抛异常。比较稳妥的写法是业务线程把响应消息放入Session的发送队列然后调用workerSelector.wakeup()让Worker线程在select循环里统一执行真正的write操作。这样所有写操作都收敛到IO线程天然串行化完全不需要显式加锁。5. 高性能调优线程数、Buffer管理、零拷贝和那些压测才能看见的坑5.1 线程数到底怎么定两个瓶颈两套思路很多人在项目里一上来就把Worker线程数设成500理由是要充分利用CPU核心结果压测一跑性能反而不升反降。这里要区分两种线程的性质。第一种是IO线程也就是WorkerGroup里的Selector线程它的职责是轮询就绪事件和做轻量编解码本身不阻塞。对这种线程数量不是越多越好8核机器开8到16个就足够它的上限由CPU核心数和事件处理的轻量程度共同决定。开得太多反而产生大量Selector空转CPU花在线程调度上的比例上升。第二种是业务线程池它是给真正耗时的业务逻辑用的参数要看业务性质分如果业务里有大量IO等待比如查数据库、调下游RPC、读文件线程数可以大胆一些经验值在任务耗时占CPU计算耗时的倍数乘以核心数这个量级如果业务是纯CPU计算比如加密、压缩、复杂序列化线程数逼近核心数即可再多也没有意义。压测时建议准备两组配置对比Worker等于核心数、业务线程等于核心数乘4与Worker等于核心数乘2、业务线程等于核心数乘8。观察CPU利用率和P99延迟有没有毛刺用数据选配置不要拍脑袋。5.2 Buffer管理直接缓冲区的分配与回收不能乱来前面提到DirectByteBuffer可以减少一次拷贝但它的分配和回收成本都很高。在高QPS下每次请求都new一个DirectByteBuffer会产生巨大的系统调用和GC压力。正确的做法是池化。省事的方案有两种第一种是用ThreadLocal缓存固定大小的ByteBuffer做完整读/写流程后复位重用前提是确认同一线程内不会同时有两个地方使用同一个Buffer。第二种是参照Netty的引用计数式池化自己实现的话用无锁队列或者带加锁的对象池都可以。池化之后要警惕Buffer泄漏问题也就是从池里借出来但没还回去。规范做法是在写完成或者连接超时关闭时统一触发归还归还失败就打印告警并定期清理异常会话。如果业务量级没有大到需要精打细算HeapByteBuffer加ThreadLocal复用就能拿到大部分收益没必要一步到位上DirectByteBuffer。这个取舍完全取决于量级和性能目标。5.3 零拷贝的适用边界不是所有场景都能用普通read/write一次转发的路径大致是网卡DMA拷贝到内核缓冲区CPU拷贝到JVM堆或者DirectBuffer应用处理再拷贝到内核socket发送缓冲区最后网卡DMA拷贝到网络。一次读写至少四五次内存拷贝。Java NIO提供的零拷贝API主要是FileChannel.transferTo()和transferFrom()底层对应Linux的sendfile能减少用户态与内核态之间的重叠拷贝尤其适合文件内容原样发给客户端例如HTTP静态文件下载、日志文件导出。但必须注意零拷贝在Java NIO里只对FileChannel到SocketChannel有效。如果业务流程涉及协议转换、加密、数据清洗数据终究要进JVM加工零拷贝就不再适用。它是一个解决特定场景问题的利器不是放之四海而皆准的银弹。5.4 空轮询与CPU 100%一个让我凌晨三点头疼的经典坑这个坑我印象极其深刻。某天服务无端CPU飙到100%线程dump之后看到某个Selector线程在疯狂循环select每次都立即返回但就绪集合里一个事件都没有整个线程陷入空转状态。原因出在部分Linux内核版本上某些连接断开会触发一次level-triggered的虚假就绪事件select返回后集合里其实空无一物如果不做处理线程就在select-遍历空集合-再select之间死循环。防御策略分两层。第一层是统计连续空转次数超过阈值比如1024次后主动sleep 100ms暂停一段时间让事件有机会真正积压同时将空转计数清零。第二层是参考Netty的RebuildableSelector思路检测到问题后把旧的Selector关闭用新建的Selector替换重新注册所有通道。新内核版本已经修复底层的bug但线上生产环境不保证所有机器都升级了系统防御性代码保留着没有任何坏处。5.5 优雅关闭与fd泄漏比内存泄漏更隐蔽的坑优雅关闭分五步走第一把running标志置为false第二调用selector.wakeup()让阻塞在select上的线程立即返回不依赖超时等待第三在select循环尾部检查标志位如果为false则关闭所有注册的channel再关闭selector第四等待业务线程池shutdown设置合理的awaitTermination超时第五在finally里把Selector和ServerSocketChannel都close掉避免文件描述符泄漏。线上很多服务的连接数按月上涨排查到最后都发现是某条分支忘记关闭Channel导致进程的fd耗尽。这个问题的隐蔽性比内存泄漏还高因为不崩溃、不报错就是连接数缓缓上升直到某天突然无法接受新连接。建议在压测时持续观察进程打开的fd数量如果稳步增长基本可以断定有资源泄漏尽早排查。6. 压测验证与面试追问拿数据说话也拿原理说话6.1 三套模型同场景压测的数据对比理论讲了一堆最终还是要用压测来验证。我用BIO、单Reactor多线程、主从Reactor多线程分别实现了同一个echo服务客户端发一行数据服务端原样返回。压测环境是同一台4核8G机器固定连接数1000每个连接持续发送观察3分钟。测出的典型数据大概是这样模型最大QPSP99延迟(ms)线程数CPU峰值利用率BIO每连接一线程4800左右320100070~90%大量耗在线程切换单Reactor多线程12000左右451 Selector 8业务线程60%主从Reactor多线程20000181 Boss 8 Worker 8业务线程75%最刺痛的数据是P99延迟和线程数。BIO模型上千线程P99的尾巴长得吓人主从Reactor把P99稳定在20ms以内线程总数不到20。这组数字很好概括了Reactor的价值用很少的线程去管理海量连接把线程资源从每连接独占变成按事件按需调度。6.2 面试官沿Reactor延伸出来的六连问Reactor模型是Java后端面试的高频考点我整理了一下常见的追问链条本质上是层层递进考验你对模型的理解深度。第一层是问Netty中BossGroup和WorkerGroup的区别对应主从Reactor的分工设计。第二层是问Worker线程数为什么常设为CPU核心数的两倍对应IO线程不阻塞的特性与线程调度开销的权衡。第三层是问select、poll、epoll的区别能答出epoll的事件驱动、避免fd重复拷贝、支撑海量fd这几个点就基本过关。第四层是问NIO里为什么写数据时要关注OP_WRITE这时可以把5.1节里写事件常备陷阱的原理讲一遍面试官通常都会眼前一亮。第五层是问粘包半包怎么处理答案就是我们前面写的定长包头和循环分帧逻辑。第六层是问业务回写频繁时如何做背压这时要说出发送队列的容量上限、超限降级断连、防止无界队列导致OOM的方案。这一串问题几乎都能从具体实现细节展开单纯背理论很容易被追问到卡壳。我准备面试时会习惯性把自己写的主从Reactor框架关键代码在脑子里过一遍某个位置用了什么数据结构为什么用compact而不是clear为什么跨线程要调用wakeup每个细节都能说出设计理由。这样比死记硬背八股文扎实得多。6.3 后续还能往哪些方向扩展如果想把这套骨架打磨成真正能落地的网络框架至少还有这几个扩展方向协议层抽象把分帧逻辑与业务解码器彻底解耦连接级超时管理类似Netty的IdleStateHandler思路支持心跳检测和空闲连接回收SSL/TSL支持在握手阶段不能阻塞IO线程需要单独设计握手流程更细致的流量控制按连接统计读写速率超过阈值就降级或者断连。建议先在局域网搭一个简易聊天服务或者RPC转发Demo把连接数压到10000量级你会遇到比这篇文章里讲的更具体也更能折磨人的问题。每解决一个对Reactor模型的理解就深一层。我个人实际使用中的体会是选型Netty还是手写Reactor都不重要把模型为什么长成这个样子想清楚才是真正有价值的部分。读再多的源码剖析都不如自己在压测环境里被几个坑虐过以后在无数个深夜盯着CPU曲线、骂骂咧咧地修改那些看起来不起眼的细节——所谓的高性能不过是一次次修正错误之后积累下来的设计取舍。