
我前阵子接手一个老项目的线上问题排查症状特别典型白天高峰期连接数一上来CPU没跑满线程却堆到几千个内存一路飙升最后直接 OOM。看了下调用链好家伙清一色的 BIO——每个 socket 一个线程线程池无脑开满连接一多就直接被线程数量拖死。这种问题网上查一圈铺天盖地都是“阻塞、非阻塞、同步、异步、BIO、NIO、AIO”这些词但说实话绝大多数文章都是各讲各的概念之间拎不清看完还是一脑子浆糊。今天这篇就当是我自己的一个学习复盘把这些 IO 模型从头到尾一锅端把概念、原理、代码和踩坑一次性讲明白适合刚接触网络编程的读者也适合用了好几年 NIO 却始终没把底层逻辑理顺的同学。先说结论阻塞/非阻塞说的是调用线程是否等待同步/异步说的是数据就绪后谁来搬运BIO/NIO/AIO 是这三种模型的 Java 落地实现多路复用是 NIO 底层的核心机制。这四个维度看起来绕其实用几个生活化的场景拆开一下就通了。1. 先搞懂最基础的“阻塞”和“非阻塞”1.1 阻塞和非阻塞到底卡在哪一步很多人把“阻塞”理解成“程序卡住了”其实不对。阻塞是调用线程在等待系统调用返回时自己进入了休眠状态CPU 被让出去了但线程本身卡在调用点什么活都干不了。以网络读取为例当程序执行read()系统调用时如果内核缓冲区里还没数据传统的阻塞模式下线程会一直等在read()这条语句上直到内核收到数据并拷贝到用户空间read()才返回。注意这个等待是“线程级别”的等待期间线程不占 CPU但占用线程资源——每个线程默认有 1MB 左右的栈空间上千个线程就是上 GB 的内存开销。非阻塞模式则不一样。read()系统调用被设置成非阻塞后如果内核缓冲区没数据它会立刻返回一个错误码比如EAGAIN线程不会卡住可以继续做别的事情。但代价是你不知道数据什么时候来只能一遍一遍地问内核“有数据了吗”这个反复询问的动作叫轮询。1.2 用“外卖配送”类比阻塞与非阻塞我特别喜欢拿外卖来打比方。阻塞模式就是你点完外卖搬个小板凳坐在门口死等电梯响一下你心就悬一下不做任何别的事直到外卖到手。非阻塞模式就是你先回屋写代码每隔几分钟跑门口看一眼外卖到了没——没到就继续回来写到了就拿进来。一眼就看出来非阻塞模式能让你的时间利用率更高但代价是你得“主动”去查。如果这期间你忘了出门看外卖放门口凉了你也不知道。也就是说非阻塞需要你自己保证后续的检查动作这在编程里就体现为一个循环去不断轮询状态。这里有个极其常见的误解很多人以为 NIO 就是“非阻塞”的代名词这句话只对了一半。NIO 的核心不仅在于非阻塞的 Channel更在于多路复用器Selector后面会细讲。如果没有 Selector让你对上千个连接做非阻塞轮询每次都要遍历所有连接挨个问一遍那并发一高光是用户态到内核态的切换就够你喝一壶的。2. 同步与异步本质是“数据搬运谁来做”2.1 同步/异步关注的是数据拷贝完成后的动作分工阻塞和非阻塞是两兄弟同步和异步则是另一组容易混的概念。它俩讨论的不是“线程等不等”而是数据从内核缓冲区搬到用户缓冲区这个“搬运”过程是应用程序自己做还是内核做完之后直接通知你。同步模式下不管是阻塞还是非阻塞数据拷贝都得应用程序自己去读。比如你在非阻塞模式下轮询到内核有数据了还得自己调read()把数据从内核态搬出来——这段搬运过程由你亲自完成所以叫“同步”。异步模式不一样。调用aio_read()这类接口时你传一个缓冲区地址和回调函数进去然后你的代码马上返回该干嘛干嘛。内核收到数据后会主动把数据从内核缓冲区搬到你这个缓冲区里数据妥妥当当了才触发回调告诉你“数据已经备好”。整个过程应用程序全程不碰数据拷贝真正的“收到即用”。再回到外卖的例子。同步就是你听到敲门声自己去门口把外卖拿进来异步就是你提前告诉物业“外卖到了直接帮我送进屋放桌上”等你工作告一段落发现桌上已经摆好了外卖。中间那个“开门去取”的动作前者是你自己干的后者是别人代劳的。2.2 两个维度交叉组合出的四种模型阻塞/非阻塞是一根轴同步/异步是另一根轴两根轴交叉就能得到四种经典组合组合等数据时线程状态数据搬运者典型实现同步阻塞休眠等待应用程序BIO同步非阻塞忙轮询应用程序NIO配合多路复用异步阻塞休眠等待内核少见一般没人用异步非阻塞完全不等待内核AIOJava 里叫 NIO.2这里面“异步阻塞”几乎是概念上的废品等数据时把人挂起、数据搬运又交给内核两头好处都没沾到实际工程中也基本见不到就直接略过。剩下三种正好对应 BIO、NIO、AIO 三条技术路线搞懂了这三个名词整个 IO 模型的地图就画了一半。3. BIO、NIO、AIO 三种模型逐个拆解3.1 BIO一连接一线程的“老实人”BIO 是阻塞式 IO 的缩写也是 Java 早期网络编程的标准姿势。它的模型可以概括成服务端每 accept 到一个连接就启动一个新线程去处理这个连接的读写。这安排极其简单粗暴一个 socket 对应一个专职线程线程内部是同步阻塞的点对点读写代码写起来尤其顺手accept()、read()、write()一个个按顺序写下来即可出错也容易控制。但顺着我开头说的那起事故就能看到连接数一旦上来这个模型就撑不住了。我们说几个硬指标一个线程默认栈空间 1MB两千个连接就是约 2GB 虚拟内存线程数一动起来CPU 光在线程上下文切换上的开销就能占到两三成。更致命的是大部分连接在绝大多数时间其实是空闲的那个线程守着一条半天不吭声的连接纯粹是在浪费国家电力。这就好比你开了一家店每来一个顾客就专门雇一个店员全程陪着哪怕对方只是进来吹个空调不买东西这个店员也得站在旁边候着。顾客一多你雇人的开销比卖货的利润还大这生意铁定干不长。3.2 NIO一人管理万条连接的“超级前台”NIO 全称 New IO它的核心思想是用一个线程配合一个 Selector选择器统一管理成千上万个连接的事件。这个从前台模式升级成了万能前台模式前台不再给每个顾客配一个专属接待员而是只有一个接待员坐在总台他说“今天来了三波人一波要喝水一波要结账一波要问路我挨个处理完就行”。NIO 解决的核心痛点是连接变多不再等于线程变多。它把连接注册到 Selector 上Selector 帮你盯着这些连接上有没有发生感兴趣的事件可读、可写、有连接进来。线程只需要“阻塞地”问一句 Selector“有事件发生吗有的话把事件列表给我。”筛选出来后逐个处理即可。这里注意线程阻塞在 Selector 的select()上是高效的阻塞——它是在没有事件时让出 CPU一旦有事件立刻被唤醒不会像 BIO 那样傻傻等在某个连接的read()上。这种“一个线程 事件驱动”的模式就是多路复用。多说一句NIO 并不是说完全不再使用阻塞调用。恰恰相反在实际工程中Selector.select()往往是阻塞的这样操作系统可以把它放到一个更高效的等待队列中有事件才唤醒比忙轮询省电又省 CPU。3.3 AIO内核把饭菜端上桌才叫你AIO 是异步 IO 的简称在 Java 里也叫 NIO.2JDK7 才引入。它在代码形态上最直观的特点是发起 IO 操作后立即返回不用轮询不用阻塞等待数据准备到位后由内核主动调用回调。我们对比一下读写两个阶段BIO读 - 线程卡死直到数据完整 - 自己把数据从内核拷出来NIO读 - Selector 通知有数据了 - 自己调用read()把数据拷出来AIO发起read()并传入缓冲区 - 立即返回 - 内核自己读数据、自己拷入缓冲区 - 回调你的方法AIO 把“等待数据可读”和“数据拷贝完成”两步全都外包给了内核理论上它是三兄弟里性能上限最高、线程利用率最彻底的一个。但实际项目中 AIO 的普及程度远不如 NIO原因后面我会单独聊。在 Linux 上AIO 的底层实现早期用的是io_uring之前的 libaio虽然epoll已经满足不了纯异步需求时它会很香但它的编程复杂度、调试难度和坑的数量都不小。底层网络通信领域目前 NIO 依旧是最主流的答案AIO 在高性能磁盘 IO 和特定框架里有一席之地但到不了“取代 NIO”的程度。3.4 三种模型的适用场景对照模型连接数线程模型典型应用场景代码复杂度BIO少几十到几百一连接一线程传统即时通讯、小规模后台服务低NIO多几千到几万一线程多连接Netty、Tomcat NIO 模式、网关中高AIO多回调/异步高性能文件 IO、某些 RPC 框架高项目选型时最忌讳“无脑上 NIO”如果你的系统并发连接量就几十个BIO 反而省心得多代码好维护也不会有那么多线程安全和缓冲区翻转问题。NIO/AIO 是为亲吻高并发的场景而生的这是选型的大前提。4. 多路复用NIO 所以能“一夫当关”的技术核心4.1 Selector 的工作机制多路复用这个名词听起来很高深但核心就一句话一个线程能同时监测多个文件描述符连接只处理有事件的那些。这件事在操作系统层面依赖三兄弟——select()、poll()、epoll。在 Java NIO 里Selector是建立在操作系统多路复用机制之上的封装。大致的流程是创建Selector把若干Channel注册到它身上并声明你关心的事件类型比如OP_READ表示可读事件、OP_ACCEPT表示有新连接。调用selector.select()线程进入阻塞等待内核返回“哪些 channel 有事件发生”。内核返回一组SelectionKey每个 key 绑定着一个 channel 和对应就绪的事件。遍历选中的 key 集合逐个处理事件处理完一轮再回到第 2 步。这套机制最关键的点在于一次系统调用就能知道所有连接中谁有消息要处理不需要挨个连接去轮询。比如有 10000 个连接但这一瞬间只有 3 个连接可读多路复用会精确地把这 3 个捞出来给你处理剩下 9997 个完全不打扰。这比 NIO 早期非阻塞模型里“挨个询问所有连接”的方式不知道高到哪里去了。4.2 select、poll、epoll 的差异是时候彻底分清了这个知识点是面试常客也是很多人容易背混的部分。一句话总结三者的区别select管理文件描述符用的是固定大小的数组比如 1024每次调用都要把全部 fd 从用户态拷贝到内核态内核逐个检查状态返回后还要遍历所有 fd 看哪些就绪了。复杂度 O(n)fd 数量受限。poll改用链表存储 fd解决了数量上限问题但每次调用仍要全量拷贝和全量遍历复杂度还是 O(n)。epoll这是 Linux 专属的进化版。它通过三个操作来工作epoll_create建一个 epoll 实例epoll_ctl注册/修改/删除 fdepoll_wait等待事件。关键改进有三点事件就绪后只返回有事件的 fd 列表不用再遍历全部fd 和回调机制绑定在红黑树上内核中直接高效增删利用 mmap 共享用户态与内核态的事件表减少拷贝开销。所以在实际选型中看 Linux 服务器的并发支撑默认能上 epoll 就上 epollJava NIO 的 Selector 在 Linux 上的实现也正是 epoll。这也解释了为什么 Netty 在 Linux 上默认就跑在 epoll 模式上性能窗体确实比 select/poll 宽敞太多。4.3 从代码层面感受一次多路复用我拿一个极简版的 NIO 服务端来演示核心代码就是这个 Selector 循环Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 这一步线程会阻塞直到至少有一个事件就绪 selector.select(); IteratorSelectionKey keys selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey key keys.next(); keys.remove(); // 处理完一定要手动移除否则下次还会拿到 if (key.isAcceptable()) { SocketChannel client serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); System.out.println(新连接接入: client.getRemoteAddress()); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); int bytesRead client.read(buffer); if (bytesRead -1) { client.close(); } else { buffer.flip(); System.out.println(收到消息: new String(buffer.array(), 0, buffer.limit())); // 回写数据 buffer.rewind(); client.write(buffer); } } } }有几个到这里必须动手记下的坑。第一个selectedKeys()返回的集合不会自动移除你得自己调用iterator.remove()清掉本次事件否则下次循环又处理一遍造成重复逻辑。第二个如果对accept()得到的 SocketChannel 没有设置configureBlocking(false)恭喜你你的“非阻塞”服务端会在这个 accept 上卡死。第三个单线程处理事件时如果某个操作很慢比如写一个大文件会阻塞整个事件循环这就是“多路复用需要配异步线程池干活”的原因所在Netty 的 EventLoop 之所以能保持轻快就是绝不在 IO 线程里干重活。5. 实操把一个 BIO 的服务端改造成 NIO 全流程5.1 改造前的问题分析我接过一个小的老系统功能就是一个设备数据上报服务单机最多能撑两三百台设备再往上就不稳定。看代码典型的 BIOServerSocket serverSocket new ServerSocket(8080); while (true) { Socket socket serverSocket.accept(); new Thread(() - handleSocket(socket)).start(); }这段代码的问题一眼看清楚每来一个客户端就丢一个线程线程数随连接数线性增长。设备量少的时候还行但每台设备每 3 秒上报一次数据一次连接保持 30 秒300 台设备的并发连接换算过来可能就有 1000 多个线程这显然不现实。改造思路很简单用 NIO 的 Selector 接收所有连接但业务上不需要也不应该一个连接一个线程处理所有事把“连接管理”和“业务处理”拆开Selector 只负责监听事件、收发数据收到一条完整业务报文后丢给线程池去处理。5.2 改造后的服务端核心逻辑我的做法是这样ExecutorService bizPool Executors.newFixedThreadPool(8); // 业务线程池 Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); IteratorSelectionKey iter selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key iter.next(); iter.remove(); if (key.isAcceptable()) { SocketChannel client serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(4096); int len client.read(buffer); if (len 0) { buffer.flip(); // 这里不直接干活丢给业务线程池防止阻塞事件循环 bizPool.submit(() - handleBiz(client, buffer)); } else if (len 0) { client.close(); } } } }实际操作中我做了几个微调。第一不是每收到一个包就提交一次线程池因为高频小包连接会带来巨量的任务调度开销我设置了一个阈值把小报文攒到一个环形缓冲里到阈值或超时再打包处理。第二client.read时如果缓冲区没有足够空间要处理半包情况——这条在 TCP 长连接的粘包/半包处理里尤其关键后面单独说。5.3 改造后的整体收益与后续优化方向改造完成后效果立竿见影连接数从 300 的临界值直接撑到 5000 以上线程数从原先峰值 1200 降到固定 81 个GC 压力明显下降因为线程上下文切换基本消失了。但改造完不等于万事大吉。这种手写 NIO 的水平在实际生产里还远远不够再往下走就得考虑几个更上层的问题如何处理半包和粘包如何设计心跳机制剔除死连接如何优雅处理空闲连接高并发下的背压控制怎么做。这些细活手写起来工作量极大所以后来我把这部分直接迁移到了 Netty——Netty 把 Selector 循环、半包解码、重连、心跳这些烂活全都封装好了业务代码专注自己的逻辑就行。但底层的这些概念一定要自己亲手写一遍因为只有你亲自踩过全手写 NIO 的坑才能看出 Netty 帮你省了多少事也才能真正理解它的设计精髓。6. 常见问题与排查技巧实录6.1 手写 NIO 时最想躲却躲不过的坑第一号坑是 Buffer 的翻转问题。ByteBuffer用完后没有重置position和limit就继续使用导致读取的数据错乱。一般处理是在每次读完数据后调用buffer.clear()或者在做完flip()后读完再compact()。我见过一个线上 bug就是因为服务端收到一条消息后没有把 buffer 位置复位下一次收到较短消息时读到的内容里还带着上一条消息的残留尾巴整整排查了两天。第二号坑是上面提到过的粘包/半包问题。TCP 是流式传输没有消息边界你调用read()读到的可能是一条消息的一半也可能是两条消息连着一起。这时候就需要自定义协议比如在报文头部加一个长度字段读到不完整的数据先在缓冲区存着等凑够长度了再交给业务逻辑。这个叫拆包器Netty 里内置了一堆现成的手写 NIO 的话对不起得自己造轮子。第三号坑是空轮询。某些 JDK 版本在 Linux 上有已知的 bugSelector.select()在没有事件发生时也可能提前返回导致while循环空转CPU 飙升到 100%。网上流传的解决方案是在循环里记录空转次数超过一定阈值就重建 Selector。实际遇到这种问题我建议直接换新版本 JDK 同时升级内核补丁比在业务代码里打补丁要根治得多。6.2 排查线上问题时的判断技巧如果你接手一个已经线上跑着的服务怀疑 IO 模型不合理第一步不是看代码而是看两个指标线程数jstack或者jconsole看线程栈如果大量线程名字相似且阻塞在SocketInputStream.read或ServerSocket.accept大概率是 BIO。CPU 使用率如果 CPU 老早就排满了但系统吞吐量惨淡一会儿高一会儿低多半是线程频繁切换典型 BIO 线程爆炸的特征。还有一个非常实用的排查手法用jstack抓现场时看java.lang.Thread.State。BIO 模式下的线程大量处于RUNNABLE但实际阻塞在 native 的read调用上NIO 模式下则是少量线程阻塞在Selector.loop或EPollArrayWrapper.epollWait上。你一眼就能看到线程数量的分布差异BIO 往往是几百上千个线程挤在一起NIO 则是屈指可数的几个线程在忙活。另外补充一个架构级建议BIO/NIO/AIO 的选型不是看“哪个高级用哪个”而是看你的连接生命周期和速率。长连接、低频率大报文场景NIO 的优势远不如高并发短连接明显如果只是几十个内部服务之间的粗粒度调用BIO 的简单直观反而是巨大的开发效率优势。别再被“让代码用上 NIO 性能提升”这种口号带偏先看清楚业务画像再动手。我平时还用一张速查表给自己快速定位问题时候的对照放在这里供参考症状可能模型排查动作修复方向线程数随连接线性暴涨BIOjstack 看线程数和栈引入 NIO/Netty高并发下 CPU 100% 但吞吐极低BIO 线程切换 / NIO 空轮询抓火焰图看切换开销降低线程数升级 JDK偶发消息串包粘包/半包处理缺失打印收到的十六进制数据自定义拆包器某个连接阻塞拖垮全局事件循环里混入耗时逻辑查看 IO 线程栈拆出业务线程池6.3 关于 AIO 的真相最后把 AIO 单独拎出来说几句因为不管标题里怎么“一锅端”实际项目里用 AIO 的人确实不多这里不是因为它不先进而是它的收益和成本在多数场景下不匹配。AIO 在 Java 里实现垂直于网络的AsynchronousServerSocketChannel它在 Windows 上的实现确实不错IOCP 模型但在 Linux 上早期底层是靠 epoll 模拟出来的并不能真正利用io_uring的全部能力。结果就是在 Linux 上AIO 的编程复杂度更高而且多数场景下性能并不比 NIO 有明显优势。相反你还要处理回调里的线程安全问题、资源释放问题、异常的传播问题调试难度直接翻倍。真正能发挥 AIO 优势的更多是高性能本地文件 IO 场景或者需要极致异步化处理的特定框架。Netty 在 Linux 上默认使用 NIO 而不是 AIO那个默认值的背后就有这么一段苦涩的历史。所以现在不少新手听说了“AIO 是最先进的”就贪新鲜往生产环境里扔我一般都要劝一句先进与否不等于适用与否NIO 这套已经磨合了十几年的成熟路线在绝大多数业务里依然是更稳的选择。7. 最后分享一个小技巧写到这里我在键盘前又想起当年踩过的一个泥坑手写 NIO 的时候碰到连接刚建立就发数据的情况注册OP_READ之后立刻就去select()结果发现那个连接的事件迟迟不触发。折腾半天才意识到configureBlocking(false)只保证了非阻塞但刚注册完的 channel 还没完成连接必须等到OP_CONNECT事件就绪之后才能真正读写。现在很多框架把这层细节屏蔽掉了但如果你自己写底层一定要把OP_CONNECT和OP_READ分开处理。这一整套 IO 模型的知识说实话单纯靠背概念是记不住的。我的体会是先接受“阻塞/非阻塞”和“同步/异步”这两根正交轴的设定再把 BIO、NIO、AIO 代入到具体代码里去体会接着亲手写一个几百行的小型 NIO 服务端让它在你的机器上跑起来压一下连接数很多模糊感就会自然消失。这些东西看起来散实际是一条完整的知识链条连接模型决定线程模型线程模型决定并发上限并发上限决定架构选型。搞懂了你再回头看那些“高并发 Java 后端”的岗位要求会发现其实就那么回事。