
很多人看 I/O 模型的文章上来就背 BIO、NIO、AIO 的区别背完面试还是说不清楚项目里遇到高并发连接还是不知道怎么调。这篇文章我用另一个思路来讲先搞懂操作系统在数据到达时到底做了什么再看 Java 的 API 各自封装了哪一层。你会发现I/O 模型本质上就是两件事——数据等没等以及等的时候你在干嘛。聊到 Java 后端面试I/O 模型几乎是躲不过去的硬骨头。你可能会被问到BIO、NIO、AIO 有什么区别也可能被追问NIO 和 IO 的区别还有经典的epoll 和 select 的区别。这些问题的答案都指向同一个底层坐标系阻塞与非阻塞、同步与异步。把这四个词吃透所有 I/O 模型都只是这四个词的组合。这篇文章从操作系统内核的视角切入把 BIO、NIO、多路复用、信号驱动、异步 I/O 逐个拆开附一个从 BIO 改造到多路复用的 Java 实战 Demo最后聊聊我在真实项目里踩过的坑。不管你是准备面试的 Java 后端还是想理解 Netty 为什么那样设计的框架使用者这篇都值得你花二十分钟读完。1. 别急着背模型先搞清楚阻塞、非阻塞、同步、异步这四个词到底在说什么1.1 一次网络读操作内核替你干了哪些事我先给你一个生活化的场景。你点了一份外卖接下来有三种等外卖的方式一直站在小区门口盯着外卖员的路线他不到你不动这叫阻塞。回屋干自己的事每隔两分钟下楼看一眼到了没这叫非阻塞轮询。装一个 App 推送外卖员快到的时候系统自动通知你下楼你收到通知再下去这叫事件驱动/多路复用。直接让外卖员把餐放到快递柜柜子给你发一条取件码你有空了去拿就行这叫异步 I/O。这个类比能帮你在认知上建立一个框架。但网络 I/O 比取外卖多一层复杂性——内核态和用户态。当你的 Java 程序调用socket.read()时实际发生的事比从网线读数据复杂得多数据先通过网络到达网卡网卡通过 DMA 把数据写入内核的缓冲区。内核拿到完整数据包后把它放到 socket 对应的接收队列里。这时才轮到你的应用程序把数据从内核缓冲区拷贝到用户态的内存里。也就是说一次读操作分为两个阶段等待数据就绪阶段一把数据从内核态拷贝到用户态阶段二。四个 I/O 模型的本质区别就是这两个阶段分别由谁等待、由谁执行。1.2 阻塞与非阻塞看的是阶段一的姿势阻塞 I/O在阶段一的表现是调用read()后如果内核缓冲区里没数据线程直接挂起CPU 让出去直到数据到达才被唤醒。线程在等待期间什么都干不了。非阻塞 I/O在阶段一的表现是调用read()后立即返回如果内核没数据返回一个暂时没有数据的标记Java 里对应null或0。线程没有挂起可以继续干别的但你需要反复调用read()才知道数据到底来没来。这个反复调用的行为就是轮询polling。有个关键细节容易被忽略Java NIO 里把 channel 设置为非阻塞模式后read()方法本身确实是非阻塞的但如果你直接while(true) { read(); }死循环轮询CPU 会被白白烧掉。所以非阻塞模式通常要配合多路复用器来用让内核帮你盯着所有 socket而不是你自己挨个问。这一点后面细说。1.3 同步与异步看的是阶段二谁来做同步 I/O的含义是阶段二把数据从内核拷贝到用户态由应用程序所在的线程自己完成拷贝期间线程是占用 CPU 的。阻塞 I/O、非阻塞 I/O、多路复用、信号驱动——这四种全都是同步 I/O因为最终把数据搬到你 Java 堆里的内存时都是你的线程在搬。异步 I/O的含义是阶段二由内核完成内核把数据从自己的缓冲区直接拷贝到应用程序指定的用户态缓冲区拷贝完再通知你数据已经放好了。整个过程中你的线程完全没有参与等待和拷贝发完一个read请求就可以干别的去了。所以一个常见的误区是NIO 不是异步的吗答案是否定的。NIO 是非阻塞的、同步的。真正的异步 I/O 在 Java 里叫 AIOAsynchronous I/O在 Linux 上的经典实现是io_uring旧的aio系列接口都算早期尝试。Netty 之所以备受推崇一个重要原因就是它用同步非阻塞模型做出了接近异步的高性能效果同时规避了 AIO 在 Linux 上的种种不成熟。模型阶段一等待数据阶段二拷贝数据典型 Java APIBIO阻塞线程挂起应用程序线程完成Socket/ServerSocketNIO非阻塞轮询检查应用程序线程完成SocketChannel多路复用内核监听事件通知应用程序线程完成SelectorSocketChannel信号驱动内核发信号通知应用程序线程完成原生 Java 少见底层SIGIO异步 I/O内核处理全部内核拷贝完成后通知AsynchronousSocketChannel2. BIO 的真正瓶颈瓶颈不在 CPU在线程的等2.1 从一段最经典的 BIO 代码说起BIOBlocking I/O是绝大多数 Java 开发者最早接触的网络编程模型。一个最简单的 server 看起来长这样ServerSocket serverSocket new ServerSocket(8080); while (true) { Socket socket serverSocket.accept(); // 阻塞点 1等连接 new Thread(() - { try { InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream(); byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { // 阻塞点 2等数据 out.write(buffer, 0, len); out.flush(); } } catch (IOException e) { e.printStackTrace(); } }).start(); }两个阻塞点accept()在没有新连接时会让线程挂起read()在没有数据时也会让线程挂起。这段代码的问题很多人都能一眼看出来一个连接一个线程连接多了线程就爆炸。2.2 为什么线程池解决不了根本问题有些同学会说我优化一下用线程池代替new Thread()把线程数量控制在 200让任务排队不行吗行但问题只是被推迟了没有消失。想象一个场景你的服务器有 200 个线程此时来了 200 个客户端每个客户端都建立了连接但都不说话。这 200 个线程全部阻塞在read()上等于全军覆没。第 201 个客户端连接上了却拿不到任何线程来处理它的数据——即使它是一个紧接着就要发送合法请求的客户端。这里就是 BIO 最反直觉的地方瓶颈不在 CPU 的计算能力而在线程的等。每个线程阻塞时它的栈内存、线程上下文都白白占着资源。一台普通的 8G 内存服务器默认栈大小 1MB-Xss可调开 2000 个线程就能吃掉 2G 内存而其中绝大部分线程可能只是在等一个永远不来的数据包。有人会反驳说那我在read()之前加个超时时间不就行了是的socket.setSoTimeout(2000)可以让read()每 2 秒抛一次超时异常这样线程能定期醒过来处理其他任务。但这就是从阻塞走向非阻塞的第一步——你已经发现一直等不是个好主意了。2.3 一个连接的完整生命周期值得你用等待树去理解我把一个 BIO socket 连接的完整过程拆开你会发现每一步都可能产生空等客户端发起连接请求服务端accept()返回——这个等待通常是毫秒级问题不大。客户端发送请求数据中间可能经过网络延迟、客户端业务处理延迟、甚至用户思考时间人肉客户端这段时间服务端线程完全阻塞在read()。服务端处理业务、返回响应。客户端读取响应。如果客户端读得慢服务端的write()也可能阻塞在写缓冲区满上。在一个纯 BIO 模型下一个请求的完整生命周期里服务端线程真正干活的时间可能只有 10%剩下 90% 都在等。这种效率浪费用一句话概括就是你用昂贵的线程资源去补贴廉价的网络等待。3. NIO 与多路复用的本质不是 Java 的功劳是内核变了3.1 非阻塞模式单独拿出来反而更糟糕Java NIO 的核心是Selector配合SocketChannel这一点要在前面先讲明白。单独设置非阻塞模式channel.configureBlocking(false)然后一个线程死循环轮询所有连接的read()表面上解决了线程被阻塞的问题但引入了更严重的 CPU 空转。比如有 1000 个连接其中 999 个没数据你的线程每次轮询都要read()999 次每次都返回没数据这种毫无意义的系统调用每次 read 都是用户态切内核态再切回来比阻塞更浪费资源。所以 NIO 的真正转折点是引入多路复用器你不再挨个问每个 socket 有数据吗而是把一堆 socket 交给内核然后问内核一句这批里面哪些有数据了内核告诉你一个就绪列表你只处理这些就绪的 socket。这就是Selector做的事。3.2 select、poll、epoll 的演进为什么 epoll 是亲儿子Linux 上多路复用经历了三代Java 的Selector在不同平台、不同版本上底层实现不一样但核心机制你需要搞懂。select是第一代。你把 1024 个 socket 的文件描述符fd塞给内核内核遍历一遍发现有数据就标记然后返回给你。每次调用都要把整个 fd 集合从用户态拷贝到内核态拷贝成本高而且有数量上限通常 1024。用大白话说就是你每次都要把整个花名册交给宿管大爷让他挨个宿舍查谁在效率可想而知。poll改进了数量限制改成链表存储 fd没有 1024 的上限。但本质上还是全量遍历 全量拷贝连接少的时候没问题连接一多性能直线下降。epoll是 Linux 2.6 之后引入的。它做了三件事事件注册epoll_ctl把要监听的 socket 注册进内核不再每次全量拷贝。就绪链表内核维护一个就绪链表哪些 socket 有数据了直接往链表里丢你调用epoll_wait时拿到的就是已经就绪的列表不用遍历全部。回调机制数据到达网卡网卡驱动触发中断内核把数据放进 socket 接收队列的同时把这个 socket 加入就绪链表。一句话总结 epoll 的核心从遍历问变成等通知。这也是为什么 epoll 能支撑十万级连接而 select 在几千连接时就开始吃力。3.3 Java 的 Selector 只是壳真正的秘密在内核我见过很多面试者把 Java NIO 和 epoll 混为一谈。实际上Selector.open()在 Linux 上底层可能是epollJDK 1.7 之后默认在 macOS 上是kqueue在 Windows 上则是select。你在 Linux 上能跑出高性能换到 Windows 上可能就拉胯了这就是底层实现差异导致的。Java NIO 的Selector给你提供的 API 是统一的屏蔽了底层的差异。但如果你在 Linux 生产环境遇到低性能问题排查方向直接就指向底层是不是 fd 用完了是不是注册了太多无用的 OP_WRITE 事件导致一直触发这些放在第 6 章展开。NIO 的编程模型比 BIO 复杂一个数量级你不再有一个连接一个线程的天然隔离BIO 的优点是简单而是要自己管理 Buffer、处理半包粘包、管理感兴趣的事件集合。这也是为什么实际项目中大多数人不直接用 JDK NIO而是选择 Netty——Netty 把 Buffer 合并、零拷贝、ByteBuf 池化、Reactor 线程模型这些都封装好了。4. 信号驱动与 AIO看着很美为什么实际用不起来4.1 信号驱动 I/O内核通知你了然后呢信号驱动 I/OSignal-Driven I/O的逻辑是你告诉内核这个 socket 有数据了给我发个信号比如 SIGIO然后你的线程继续干别的事。内核数据就绪后发信号你的信号处理器再去调用read()把数据拿到用户态。这个模型的关键缺陷是信号只告诉你有数据了没说有多少数据、在哪。你还是得自己调用read()去读而且 Linux 的信号处理有诸多限制比如信号处理函数里不能调用所有函数容易打断主线程逻辑。所以在 Java 的世界里你几乎看不到基于信号驱动的标准 APIJDK 也没有直接暴露这种模型。它更像一个理论上的中间状态在模型对比表里占一个位置实际使用场景非常有限。4.2 AIO内核把活全干了但 Java 的 AIO 成了鸡肋异步 I/O 的完整流程是应用程序发起一个read请求带上自己的缓冲区地址和长度然后线程立即返回。内核等数据到达后自己完成从内核缓冲区到用户态缓冲区的拷贝然后回调你的 CompletionHandler。Java 7 引入了AsynchronousSocketChannel在 Windows 上它基于 IOCP输入输出完成端口实现表现还不错。但在 Linux 上早期的 AIO 实现glibc aio 和 libaio都有各自的硬伤——有的在线程池里模拟异步有的对文件 I/O 支持还行但对网络 socket 支持不完善。这就导致一个尴尬局面Java AIO 在 Linux 上并不异步。这直接影响了一个重要决策Netty 官方明确不建议在 Linux 上使用 AIO因为 NIO epoll 已经能达到很高的性能而 AIO 在 Linux 上的实现未必比 NIO 快反而徒增复杂性。于是你在实际项目中看到的情况是Windows 上的 AIO 还能见到身影Linux 上大家全都在用 NIO Reactor 模型。4.3 压垮 AIO 的最后一根稻草io_uring 登场不过现在情况有了新变化。Linux 5.1 引入的io_uring是真正的异步 I/O 大作它通过共享内存的环形队列SQ 和 CQ在用户态和内核态之间传递请求和完成事件避免了传统 read/write 系统调用的上下文切换开销。Netty 在后续版本中也开始支持基于io_uring的传输层实现NativeTransport依赖netty-incubator-transport-native-io_uring。但我要泼一盆冷水io_uring目前主要在高性能框架、存储引擎如 RocksDB和数据面网关里落地普通 Java 业务系统短期内不太可能依赖它。原因很现实你的系统瓶颈如果在线程模型用 NIO 就已经能解决大部分问题如果你的瓶颈真的在系统调用开销那通常也得先把网络协议栈、序列化方式都优化到位才轮得到 io_uring 出场。普通业务离这一步还很远。5. 一个最小的 Java 实战把 BIO 的 Echo Server 改造为多路复用5.1 需求与准备手写一个极简 Echo Server理论讲了一大堆接下来动手。我们写一个简单的 Echo Server——客户端发送什么服务端原样返回。先写 BIO 版本再改成 NIO Selector 版本两个版本对照着看你就能清晰地感知到线程模型的差别。代码基于 JDK 8不需要任何第三方依赖。我建议你本地跑一下用jstack看线程数变化会对模型的理解更深刻。5.2 BIO 版本每个连接一个线程import java.io.*; import java.net.*; public class BioEchoServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(BIO Echo Server started on port 8080); while (true) { Socket socket serverSocket.accept(); new Thread(() - handle(socket)).start(); } } private static void handle(Socket socket) { try (BufferedReader reader new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line reader.readLine()) ! null) { System.out.println(Receive: line); writer.println(line); } } catch (IOException e) { e.printStackTrace(); } } }注意我用BufferedReader.readLine()简化了半包处理真实网络环境下这有坑后面会讲。这个模型下线程数等于连接数连接多了内存和上下文切换开销直线上升。5.3 NIO Selector 版本一个线程管所有连接import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; import java.util.Set; public class NioEchoServer { public static void main(String[] args) throws IOException { Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.socket().bind(new InetSocketAddress(8080)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(NIO Echo Server started on port 8080); ByteBuffer buffer ByteBuffer.allocate(1024); while (true) { selector.select(); // 阻塞到至少有一个 channel 就绪 SetSelectionKey selectedKeys selector.selectedKeys(); IteratorSelectionKey keyIterator selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key keyIterator.next(); keyIterator.remove(); if (key.isAcceptable()) { // 有新的连接进来 ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); // 把新的连接注册到 selector感兴趣的事件是 READ client.register(selector, SelectionKey.OP_READ); System.out.println(New connection: client.getRemoteAddress()); } else if (key.isReadable()) { // 有数据可读 SocketChannel client (SocketChannel) key.channel(); buffer.clear(); int bytesRead client.read(buffer); if (bytesRead -1) { client.close(); continue; } buffer.flip(); client.write(buffer); // echo 回写 } } } } }这个版本里有三个关键点和 BIO 形成了鲜明对比serverChannel.configureBlocking(false)——非阻塞模式。selector.select()只阻塞一次等待任意一个注册的 channel 就绪而不是每个连接各阻塞一次。这叫用一个线程看管所有连接。就绪事件分两类处理OP_ACCEPT表示有新连接OP_READ表示有数据可读。每次迭代处理完就remove()否则会重复处理同一个事件这是新手最容易踩的坑。5.4 从 BIO 到 NIO为什么这样写很多同学会对client.write(buffer)产生疑问为什么你只注册了OP_READ写的时候却直接调write()因为在大多数 echo 场景下响应数据很小socket 发送缓冲区通常能直接容纳write()不会阻塞。但如果响应数据很大超过了发送缓冲区容量write()会返回部分写入或者返回 0你就需要注册OP_WRITE事件等内核告诉你发送缓冲区有空间了再继续写。这引出了 Reactor 模式的核心每个连接都有它自己关心的感兴趣事件集合线程只处理就绪的事件。在 Netty 里OP_WRITE一般不会在业务线程里直接使用而是通过writeAndFlush异步完成避免频繁注册/注销OP_WRITE带来的系统调用开销。最后说说为什么这个 demo 里用Buffer.flip()。这可能是 NIO 初学阶段最绕的一点ByteBuffer内部有 position、limit、capacity 三个指针。buffer.clear()将 position 归零、limit 设为 capacity准备写入数据channel.read(buffer)把网络数据写入 bufferposition 移动到实际读取的数据末尾随后buffer.flip()把 limit 设置为当前的 position、position 归零准备从 buffer 里读取数据写入 socket。没有flip()write()会把position到capacity之间的垃圾数据也发出去或者发不出去。这个细节几乎每个 NIO 初学者都会踩一遍。6. 把内核态和 Java 态串起来我踩过的坑和面试时的高分回答顺序6.1 一个真实项目里看起来 NIO 很慢的排查过程我在某次优化一个长连接网关时用 NIO 重写了原来的 BIO 模型压测结果居然没有明显提升甚至在某些并发段还变慢了。排查过程让我意识到 NIO 不是银弹。第一轮我检查了Selector的使用方式确认select()之后遍历selectedKeys()处理完事件立刻remove()这一步没有错。第二轮我看jstack发现有个线程 CPU 占用特别高。仔细一看问题出在某个连接一直触发OP_READ而每次读取返回的都是 0 字节。原因是有个客户端每隔几秒发一个 TCP Keep-Alive 探活包内核认为有数据可读但实际读出来是 0 字节触发了无效唤醒。修复方式是记录每个 channel 的空读次数连续超过 N 次就移除对OP_READ的兴趣等真有大数据量时再重新注册。这属于典型的内核通知不等于业务数据就绪问题。第三轮更隐蔽我发现ByteBuffer分配和回收带来的 GC 压力比 BIO 时代还大。BIO 里每个连接一个线程它的局部 buffer 随线程生命周期复用NIO 里一个线程管成千上万个连接如果每处理一个事件就ByteBuffer.allocate(1024)会产生大量的短期存活对象。后来我改用ThreadLocalByteBuffer按线程复用GC 压力立刻降下来。Netty 的ByteBuf池化就是为了解决这个问题。6.2 一张表对照完五类模型面试官很难问倒你如果你面试时被问到 I/O 模型我建议按下面的顺序组织答案这条线比背结论更稳先抛出四个核心概念阻塞/非阻塞/同步/异步说明它们的组合关系。再讲 BIO 的缺点线程被等占用C10K 问题出现。然后讲多路复用如何解决等这个问题引入 select/poll/epoll 对比。最后讲 NIO 和 AIO 的适用边界以及 Netty 为什么选择 NIO 而不是 AIO。对比维度BIONIO多路复用NIOSelector信号驱动AIO内核数据就绪后如何通知无阻塞等待非阻塞轮询事件回调epoll信号通知内核完成全部拷贝后通知线程占用情况一连接一线程一线程轮询所有连接一线程监听所有连接信号打断主线程无需业务线程等待实现复杂度低中高高高实际落地场景连接数少很少单独使用Netty、Tomcat NIO基本不用Windows IOCP、io_uring6.3 关于半包粘包、零拷贝和并行十几个连接说一下真实的 Netty 场景前面已经很清楚地说了用原生 JDK NIO 写业务有多麻烦这里的readLine()连半包都没处理真正的生产代码还需要处理 TCP 粘包/拆包。但在学 NIO 的阶段不要急着用 Netty我强烈建议你用原生 JDK NIO 写一个 demo 再切 Netty否则你对 Netty 的认知基本就是黑盒。等到你真正用 Netty 做服务时你会感谢这一课Netty 里一次channelRead收到的不一定是一个完整业务包可能是半个可能是两三个拼在一起你用ByteToMessageDecoder去按分隔符或者固定长度去拆包底层用CompositeByteBuf做零拷贝合并。6.4 我踩过的坑列个清单给你Selector 的 selectedKeys 必须手动 remove。很多初学者把selector.select()返回的集合当成一次性消费不 remove 的话下次还会重复处理。这是一定会踩的坑。不要在大循环里频繁创建 ByteBuffer。复用一个ThreadLocal或直接复用外层 bufferGC 压力差别巨大。非阻塞模式下调用 write 也可能返回部分写入。别假设一次 write 就写完读返回 -1 才代表连接关闭。TCP 是流协议没有消息边界。你发的 100 字节对端可能分 3 次收到你发的两个包对端可能一次就收了 200 字节。处理协议时必须有拆包逻辑。不要小看线程数。NIO 模型下 IO 线程和业务线程要分离IO 线程要极速地做网络读写不要在里面做数据库查询、远程调用等耗时操作否则照样阻塞。不要把 NIO 当银弹。连接数少于几百时BIO 加线程池可能更简单稳定。NIO 的收益主要体现在大量空闲连接场景比如长连接推送、聊天室、网关等。关于 I/O 模型我还想多说一句文章的标题叫一文彻底搞懂但真正的彻底来自亲手跑一遍 demo、看一眼jstack的线程转储、确认一下自己的应用到底把时间花在哪个阶段。看完这篇文章建议你打开 IEDA 把两个 Echo 版本跑起来用jvisualvm或jstack看看线程数和阻塞状态那种直观的感受比任何博文都来得扎实。