这个系列的上一篇,我把BIO的线程模型和它为什么撑不住高并发讲透了。这篇接着往下聊:NIO到底改了什么,AIO又是怎么回事,以及一个真实的、可以用在项目里的NIO多路复用服务器应该怎么手写。如果你面试被问到BIO/NIO/AIO的区别,或者你正处于“会写Socket但看不懂Netty源码”的阶段,这篇应该能帮你把中间那块拼图补上。
1. 从BIO的痛点说起:为什么会有NIO
很多人在写第一版网络程序的时候,都干过同一件事:来一个连接,就new一个线程去处理。代码里就是ServerSocket.accept()等连接,拿到Socket之后丢给Thread去跑InputStream.read()。听起来很合理,连接多了自然要多线程应付。
但问题恰恰出在这个“合理”上。BIO是同步阻塞模型,accept()阻塞是等连接,read()阻塞是等数据。等数据这个过程是纯浪费,尤其是像长连接场景,客户端连上来之后十分钟不发一条消息,这十分钟里线程就怼在read上干瞪眼。如果有一万个连接,传统BIO就得开一万个线程,线程栈默认就是1MB内存起步,这还没算上下文切换的代价。线程不是不要钱的,创造、销毁、切换,全部都要操作系统和CPU买单。
1.1 BIO的线程模型到底卡在哪
很多人会说,那用线程池不就行了?确实,线程池能限制总线程数,缓解创建线程的开销,但并解决不了根本问题。为什么?因为底层系统调用还是阻塞的。池子里100个线程,如果100个连接同时发来请求,OK,能处理;但如果100个连接都是“僵尸连接”——建立连接之后长时间不发数据,那100个线程就全部阻塞在read上。这时候哪怕来了第101个真正要发数据的连接,也只能在任务队列里排队干等。
说白了,BIO的短板不是线程创建的开销,而是阻塞等待导致的资源无效占用。线程在等待IO期间无法做任何事,而这个等待时间在绝大多数场景里远比数据读写的实际时间更长。想想看,一个Web服务,连接的平均空闲率如果超过70%,BIO模型下等于70%的线程都在发呆,CPU利用率却不高,内存倒是快爆了。
1.2 NIO要解决的本质问题
NIO的英文全称是Non-blocking I/O(有些地方也叫New I/O),它要解决的本质问题就是:用少量的线程,去管理大量的连接。
核心思路是“同步非阻塞 + IO多路复用”。听起来很高大上,其实逻辑很朴素。原来是一个连接分配一个线程,现在改成——把所有连接交给一个监听者,这个监听者同时盯着几千个连接,哪个连接有数据准备好了,它就去哪个连接上读写,读不读得到取决于事件是否就绪,不会傻等着。这个监听者在Java里叫Selector,底层在Linux上封装的是epoll,在macOS上封装的是kqueue,在Windows上对应的是IOCP。你把系统级的多路复用机制理解成一个闹钟就行:你觉得哪个连接有动静了,闹钟就响一声提醒你。
这个模式也叫Reactor模式,事件驱动。后面聊Netty的时候你会反复看到这个概念,现在先有个印象就好。
2. NIO的三大件:Buffer、Channel、Selector
NIO和BIO最大的区别,除了阻塞与非阻塞,还有一套完全不同的API设计。BIO是面向流(Stream)的,InputStream读、OutputStream写,两个流互相独立。NIO不一样,它引入了三个核心组件:Buffer、Channel、Selector。可以这么记:Channel是管道,Buffer是管道里流动的数据容器,Selector是管道上的监控器。三者配合,才构成NIO的多路复用能力。
2.1 Buffer:先搞懂position、limit、capacity
Buffer就是一块连续内存区域,所有数据读写都是围绕这块区域进行的。它有一个固定容量capacity,还有两个关键游标:position(当前读写位置)、limit(当前模式下可读写的边界)。
新手最容易栽的坑就是flip()。很多人不理解为什么写完数据之后要flip才能读出来。看一下过程就明白了:你用put()往里写数据时,position不断往前移,比如写入了10个字节,position就到10。如果此时直接去读,会从position=10的位置开始读,读到capacity为止,等于读了一堆你没写过的空数据。flip()的作用是把position重置为0,把limit设置为旧的position(也就是10),这样读的时候就只能读到那10个有效字节了。
读完之后如果要继续写,得clear(),把position归零、limit恢复到capacity。如果Buffer里还有没读完的数据,你又想保留它再继续写,就得用compact(),先把未读数据挪到头部,再在尾部追加。这三个操作是Buffer的日常工作流,写代码前先在脑子里过一遍。
真正做高并发服务时,我更推荐用allocateDirect()分配直接内存。直接内存在Java堆之外,省掉了内核态到用户态拷贝的那一步,性能会好一些。但有得必有失,直接内存的分配和回收成本都比堆内Buffer高得多,所以不适合频繁创建销毁,更好的做法是用池化技术复用。
2.2 Channel:既能读又能写的管道
BIO里的流是单向的,InputStream只能读,OutputStream只能写。Channel不一样,它是双向的,既能读又能写,而且支持非阻塞模式。常用的有SocketChannel(TCP连接通道)、ServerSocketChannel(服务端监听通道)、FileChannel(文件通道)。
Channel本身不存数据,数据都装在Buffer里。读写操作都是以Channel为起点,把数据倒进Buffer或者从Buffer倒出去。比如channel.read(buffer)是把通道里的数据读入Buffer,channel.write(buffer)是把Buffer里的数据写到通道上。
要让Channel支持NIO的非阻塞机制,必须调用configureBlocking(false)。这里有个细节:ServerSocketChannel自己只能注册OP_ACCEPT事件,不能注册OP_READ。要等它accept出真正的SocketChannel之后,再把那个SocketChannel注册到Selector上,才能监听读写事件。如果忘了调configureBlocking(false)就注册,会直接抛IllegalBlockingModeException。
2.3 Selector:事件分发的中枢
Selector是整套NIO机制的“大脑”。你先把关心的Channel注册进来,附带告诉它你关心什么事件,然后调用select()阻塞等待,有事件发生了它才会返回。这里支持四类事件:
OP_ACCEPT:有新的连接可以被acceptOP_READ:通道里有数据可以读OP_WRITE:通道可以往外写数据OP_CONNECT:客户端连接完成
select()返回的是一个int值,表示有多少个事件就绪。然后通过selectedKeys()拿到所有就绪事件对应的SelectionKey集合。这里有一个特别经典的坑:处理完的事件Key必须手动remove掉,不然这个Key会一直留在已选集合里,下次遍历还会处理一遍,轻则重复读,重则报错。很多刚入手的同学在循环里处理完事件不remove,一上来就把CPU干到100%,排查半天不知道怎么回事。
3. 手写一个NIO多路复用服务器
光说理论没用,直接来一段能跑的代码。下面是一个最简单的NIO Echo服务器,客户端发什么,它就回什么。代码量不大,但把NIO的核心链路完整串起来了。
3.1 核心代码拆解
public class NioEchoServer { private static final int PORT = 8080; public static void main(String[] args) throws IOException { // 1. 打开Selector和ServerSocketChannel Selector selector = Selector.open(); ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(PORT), 128); serverChannel.configureBlocking(false); // 2. 注册accept事件 serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("server started on port " + PORT); while (true) { selector.select(); // 阻塞等待事件就绪 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); // 注意:不移除会被重复处理 if (key.isAcceptable()) { // 新连接来了 ServerSocketChannel server = (ServerSocketChannel) key.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); // 给每个连接单独分配一个Buffer作为附件 client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024)); System.out.println("accept: " + client.getRemoteAddress()); } else if (key.isReadable()) { // 数据可读 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); int len = client.read(buffer); if (len == -1) { System.out.println("client closed: " + client.getRemoteAddress()); client.close(); continue; } if (len > 0) { buffer.flip(); client.write(buffer); // 简单回显 buffer.clear(); } } } } } }这段代码虽然精简,但已经把NIO服务器的骨架写出来了。流程是:先注册监听端口,然后进入无限循环调用select(),当有连接事件就绪时accept,把新连接注册为读事件;当有读事件就绪时从Channel里读数据到Buffer,再写回去。整个循环里只有一个main线程,却可以同时管理成千上万个连接。
3.2 事件处理里的几个经典问题
代码看着简单,真跑起来会遇到不少问题。先提醒几个最常见的。
第一,不remove SelectionKey的后果。前面提过,selectedKeys返回的集合不会自动清理,你不删,下一次select返回后它还在,事件会被反复处理。所以每次处理完一个Key,iterator.remove()是标配。
第二,read返回0不代表连接关闭。非阻塞模式下,连接上没有数据可读时,read可能返回0,这是正常的,不代表连接断了。只有返回-1才对应对端关闭,这时候需要关闭Channel并取消这个Key。
第三,Buffer不能多个连接共享。上面代码里每个连接注册时都new了一个Buffer挂在attachment上,这是有意的。如果所有连接共享同一个Buffer,多个连接的读写操作会互相覆盖数据,那数据就是一团浆糊。建议每个连接用独立的Buffer,或者用连接级别的对象池。
第四,不要在select循环线程里做耗时业务。事件循环线程的核心职责是收发数据,如果在这个线程里去做数据库查询、远程调用、耗时的业务计算,会把这个线程堵住,导致其他连接的事件没法及时处理。合理的架构是:事件循环线程只负责IO读写,拿到完整数据后丢给业务线程池去处理。
3.3 提升吞吐的几个细节
手写NIO服务器,能把上面骨架跑通只是第一步,距离“能上生产”还有不少路要走。这里分享几个在实践里对性能影响较大的细节。
一是堆外内存的活用。前面说ByteBuffer.allocateDirect()适合复用场景。你可以为每个连接初始化一个DirectByteBuffer,配合一个自定义的池化管理器,避免频繁分配和GC。内存大一点,比如64KB,减少多次读写的系统调用次数。
二是OP_WRITE事件按需注册。很多人会把OP_WRITE和OP_READ一起注册,结果发现select()疯狂返回写事件,CPU直接拉满。原因是只要发送缓冲区有空位,写事件就会一直处于就绪状态。所以正确做法是:平时只注册OP_READ,只有当一次write()没有写完、Buffer里还有残留数据时,才把OP_WRITE注册上,等写完了再取消。
三是半包与粘包问题。上面示例代码是简单地echo,一个消息一进一出刚好。但真实业务里,比如客户端发来一个1000字节的JSON,一次read可能只读到了400字节,剩下的数据要等下一次事件。底层是TCP字节流,根本没有消息边界,你必须自己在应用层做拆包。最简单的方案是约定一个固定长度的包头,里面写消息长度,再根据长度去拼完整的消息体。对这块有需求的,建议直接了解半包拆包器相关的设计,比如长度字段解码器、分隔符解码器,明白原理后去参考成熟框架的实现会容易得多。
4. AIO全解析:异步不是银弹
聊完BIO和NIO,最后看AIO(Asynchronous I/O)。它在Java 7里正式出现,核心卖点是“异步非阻塞”,也就是说:你发起一个IO操作之后,不需要自己去关注事件是否就绪,系统会在整个操作完成之后主动通知你结果。一个典型调用是AsynchronousServerSocketChannel.accept(),方法会立刻返回,等真正有新连接接入并完成accept后,CompletionHandler回调才被触发。
4.1 AIO的工作机制:操作系统把活干完再叫你
AIO的模型通常叫Proactor模式,它和NIO的Reactor模式有本质区别。
Reactor模式里,你是事件响应者:“连接有数据了,你赶紧去读”。但你去读的时候,数据可能还没完全到达,你得自己循环读。Proactor模式则是:“你告诉系统你要读多少数据,系统帮你读好,读完了再告诉你‘数据已经放在你给的Buffer里了,去处理吧’”。在这个模型里,真正的读写动作由系统完成,你的代码是收尾的人。
看一段最简单的AIO服务器代码:
public class AioEchoServer { private static final int PORT = 8080; public static void main(String[] args) throws IOException, InterruptedException { AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(PORT)); serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() { @Override public void completed(AsynchronousSocketChannel client, Void attachment) { // 继续accept下一个连接 serverChannel.accept(null, this); // 发起一次异步读,注意读的是固定字节数 ByteBuffer buffer = ByteBuffer.allocate(1024); client.read(buffer, null, new CompletionHandler<Integer, Void>() { @Override public void completed(Integer result, Void att) { if (result > 0) { buffer.flip(); client.write(buffer); } } @Override public void failed(Throwable exc, Void att) { exc.printStackTrace(); } }); } @Override public void failed(Throwable exc, Void attachment) { exc.printStackTrace(); } }); // 主线程不能直接退出,这里用一个假的阻塞 Thread.currentThread().join(); } }看这段代码的时候注意一点:回调里为了继续接受连接,需要再调用一次serverChannel.accept(null, this)。这个“循环接受”是AIO编程里很容易忽略的细节——accept只接受一个连接,你得在自己回调里重新触发下一次accept,连接才会源源不断地被处理下去。
这种编程方式的好处是代码量少,不用像NIO那样每个事件都判断一下再手动读写。但坏处是,一旦回调链变长,业务逻辑分散在多个CompletionHandler里,调试起来就有种“断点打不下去”的痛苦。而且异步回调天然难以做事务边界控制。
4.2 AIO在真实项目里的处境
很多人以为AIO是NIO的“下一代”,理应更先进,怎么到现在都没普及?这事得分平台看。
在Windows平台上,AIO是建立在IOCP上的,IOCP是Windows原生的高效异步IO模型,所以Java AIO在Windows上表现确实不错。但在Linux平台上就是另一回事了。Linux下经典的AIO实现,在通用文件/网络IO上一直存在认可度不高的问题,所以Java在Linux上实现AIO时,实际上是基于epoll加自己实现的线程池去模拟异步回调。这个“模拟异步”的方案,跟你直接用NIO + 线程池,本质上没有多大区别,甚至因为多了一层封装而牺牲了一部分可控性。
另外一个更现实的原因:主流的网络框架并没有把AIO作为核心选择。很多人在面试时会讲Netty基于NIO,但不直接采用AIO,原因就在上面的Linux支持问题上。同时,Netty本身已经基于NIO把Reactor模型打磨得非常成熟了,包括零拷贝、池化、拆包粘包、基于更加精细的IO处理模型,这些能力比直接使用AIO更全面。AIO并不是不好,只是在通用网络编程领域,它目前还没有形成对NIO的压倒性优势。
那AIO适合什么场景?我个人觉得两类场景值得考虑:一是在Windows上的高性能服务端,利用IOCP原生的异步能力;二是文件IO场景,比如大文件异步读写,这类场景里异步处理不会频繁受到网络包边界和连接状态变化的干扰,写起来会比网络编程舒服一些。
5. 三种IO模型横向对比与选型思路
前面说了这么多,用一张表把三种模型的核心差异收拢在一起,方便你面试前快速回忆起要点。
| 维度 | BIO | NIO | AIO |
|---|---|---|---|
| 阻塞性 | 同步阻塞 | 同步非阻塞 | 异步非阻塞 |
| 并发模型 | 一连接一线程 | 单线程/少量线程管理大量连接 | 操作系统完成IO后回调通知 |
| 多路复用 | 本身不支持 | Selector(epoll/kqueue) | 内核完成通知 |
| 编程复杂度 | 低 | 中 | 中高(回调调试较难) |
| 底层支持 | 所有JDK版本 | Linux/macOS表现良好 | Windows上IOCP表现良好,Linux上偏弱 |
| 典型场景 | 连接少、内网小工具 | 中高并发网络服务器 | 特定环境下的文件IO或服务端 |
表格只能帮你搭框架,实战选型还是要看具体场景。
如果你做的是一两个连接的小工具,比如本机端口转发、内部调试服务,BIO完全够用,代码结构也最清晰,没必要为了“高级”硬上NIO。如果连接数多、空闲连接多、消息频繁,比如网关、IM服务、推送系统、游戏服务器,NIO模型是可靠的选择。当然,不要求你非要用原生NIO,直接用封装好了的Netty也行,但前提是你得能读懂NIO的这些底层细节,不然出了问题都不知道该去哪条日志里定位。
至于AIO,在你手头的场景明确、平台明确、有性能实测依据之前,不建议作为首选。多数项目里,NIO加合理的线程分工就已经能扛住很大的压力了,你真不一定有机会遇到必须用AIO才能解决的瓶颈。
6. 常见问题与排查技巧实录
最后这部分,算是我自己拿原生NIO和AIO写Demo时踩过坑的速查记录。有些问题是第一次写必踩的,留着排查时对照着看会更省时间。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| CPU飙到100%,select反复立即返回 | 早期JDK在Linux上的epoll空轮询问题 | 统计空轮询次数,超过阈值重建Selector;升级JDK版本 |
| 已就绪事件被重复处理,数据读了多遍 | 没有调用iterator.remove()移除已处理Key | 在处理事件代码块开头调用iterator.remove() |
抛IllegalBlockingModeException | Channel未设置非阻塞就注册到Selector | 注册前调用channel.configureBlocking(false) |
| 写入的数据出现乱码或半截 | 一次write()没有写完Buffer,未处理剩余数据 | 循环直到Buffer写完,或按需注册OP_WRITE事件继续写 |
| 客户端消息读不完整、多条消息粘在一起 | TCP字节流没有消息边界,发生半包粘包 | 应用层设计拆包器:长度字段/分隔符/定长协议 |
| BufferOverflowException | 分配容量小于单次需要读取的数据量 | 动态扩容,或按协议最大包长分配Buffer |
| 事件循环线程被卡死,其他连接无响应 | 在select循环里做了耗时IO或阻塞业务 | 将耗时处理放入独立业务线程池 |
| read返回0,误以为是连接关闭 | 非阻塞模式下无数据可读,返回0是正常情况 | 只有返回-1时才执行close操作 |
再补充一个比较隐蔽的排查心得:NIO服务器如果内存持续上涨,往往是DirectByteBuffer没有及时回收。直接内存不归JVM堆管,GC对它的处理相对滞后,如果你的Buffer是每次事件处理时new出来的,高并发下很容易堆积。最好的习惯是从一开始就用池化方案,或者用Netty这类框架已经封装好的池化内存,别自己裸写分配销毁。这个经验我是实实在在吃过亏换来的,当时一个测试服务上线不到半天内存占用超过2GB,排查半天才锁到堆外内存泄漏上。
另外,如果你发现自己写的NIO服务器性能比BIO还差,别急着怀疑模型。先检查是不是在select循环里做了太多重活,再检查Buffer是不是频繁分配销毁导致GC压力大,最后看看你是不是把读到的数据做了不必要的内存拷贝。很多时候性能瓶颈都不是模型层面的问题,而是细节没做到位。
对于正在从BIO迈向NIO的朋友,我的建议是:别直接跳到Netty源码里去硬啃,先把原生NIO这个echo服务器写出来,让它稳定跑上一周,你能独立解决掉它遇到的问题,再去看主流网络框架的源码,会发现很多设计都变得好理解了。IO模型是网络编程的地基,地基没打牢,上面盖什么都容易出问题。