从阻塞到异步:一文讲透BIO、NIO、AIO区别及线程模型
面试里“说一下BIO、NIO、AIO的区别”基本是 Java 后端绕不开的问题。刚入行那会儿我也跟背书一样BIO 阻塞、NIO 非阻塞、AIO 异步三个词背得滚瓜烂熟。直到后来真在线上用 Socket 写服务、调连接数、抓线程 Dump 解决过几次事故才算把这套东西从纸面落到代码里。这篇文章就换个角度用踩过坑的经验把 BIO、NIO、AIO 讲透把阻塞、非阻塞、同步、异步这四个词背后的线程模型和真实选型考虑都拆开说清楚。不管你是准备面试还是正在调一个连接数上不去的服务这篇文章应该都能给你一些参考。1. 先把阻塞、非阻塞、同步、异步这四个词掰扯清楚很多资料一上来就摆出“BIO 是同步阻塞、NIO 是同步非阻塞、AIO 是异步非阻塞”这样的结论然后就没了。但如果没有把底层那套 IO 过程想清楚背多少遍结论都容易混。一次网络 IO 读数据从 Socket 上拿数据要经历两个阶段第一阶段是等待数据从网卡到达内核缓冲区第二阶段是把数据从内核缓冲区复制到用户进程自己的内存。阻塞与非阻塞描述的是第一阶段的行为同步与异步描述的是第二个阶段完成的方式。打个比方你去办业务拿号排队。阻塞模式就是你站在柜台前服务员不叫你你就不走中间什么也干不了。非阻塞模式是你隔几秒就跑到窗口问一句“排到了没”没排到就回头继续干别的但你需要自己反复确认。异步模式则是拿个号直接走人干自己的事轮到你时服务员打电话通知你回来。这里“自己反复确认”叫轮询也就是非阻塞但同步“服务员通知你”叫回调也就是异步。这样再回看四种组合就清楚很多同步阻塞read 调用发出去一直等到数据读完才返回。BIO 就是这个路径。同步非阻塞read 调用发出去数据没到立即返回一个标志数据到了就照常读取。NIO 本质上是这个路径但通过多路复用器把“反复确认”这件事集中管理了下面会细说。异步非阻塞调用发出去就返回整个读写过程由内核完成后调回调函数。AIO 就是这个路径。真正在 Linux 系统上一套阻塞 / 非阻塞 IO 的落地还要涉及 read、write 系统调用、内核缓冲区和用户缓冲区之间的拷贝开销。这些听起来离业务很远但直接影响你该不该上 NIO、怎么压榨服务端性能。这些细节在后面的章节里会一起串起来讲。2. BIO简单可靠但高并发下撑不住2.1 BIO 的线程模型设计BIOBlocking IO是最传统也最直观的模型。服务端用 ServerSocket 监听端口每来一个客户端连接accept 返回一个 Socket然后为这个 Socket 建一个线程去处理读写。一个连接一条线程这个模型的核心思路就一句话要求不高每个连接都有独立线程守着数据什么时候来我就什么时候处理。代码写起来几乎不需要思考并发协作因为每连接一线程天然隔离。但这种设计在高并发下有一个致命问题线程本身是有成本的。JVM 里一个线程默认栈大小普遍是 512KB 到 1MB1000 个连接就是 1GB 级别的栈内存。而且线程多了以后CPU 时间片在上下文切换上的开销会迅速放大真正花在处理业务上的时间可能还没浪费的多。2.2 BIO 的典型代码与它的问题一个最基础的 BIO 服务端长这样ServerSocket server new ServerSocket(8080); while (true) { Socket socket server.accept(); new Thread(() - { try { BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); String line; while ((line reader.readLine()) ! null) { System.out.println(收到消息: line); } } catch (IOException e) { e.printStackTrace(); } }).start(); }只看这段代码有经验的兄弟已经能意识到问题在哪accept 是阻塞的readLine 更是阻塞的。如果客户端连上来之后一不发数据线程就永远停在 read 上变成一个干占资源的业线程。连接数多了以后线程 Dump 里能看到一大堆 WAITING 状态线程CPU 不高但内存吃紧新连接也接受不进来。这种场景我在小型内网工具服务里遇到过几次现象很典型服务运行一段时间后日志不再输出端口还能通但新请求全部像卡住一样。查下去基本就是连接数涨上来之后线程资源耗尽了。2.3 线程池救不了 BIO 的根本性缺陷很多人会想到用线程池优化 BIO这确实是常见的中间过渡方案也就是伪异步 IO。线程池把“每连接一线程”改成“连接来了丢到线程池执行”线程数量被限制住不会无限扩张。代码类似ExecutorService pool Executors.newFixedThreadPool(10); while (true) { Socket socket server.accept(); pool.execute(() - handle(socket)); }代价是线程池只有 10 个线程最多同时处理 10 个客户端。第 11 个连接来了只能排队。如果这 10 个连接的客户端都不发数据后面所有请求都会被堵死。本质原因是 IO 线程上了锁处理线程被“等待数据”这件事卡住造成线程利用率极低。现在凡是了解 NIO 的人都明白正确方向应该是让线程从等待中解放出来而不是换一种排队方式。BIO 适合的场景很明确连接数量少、客户端行为简单直接、开发周期短。很多小工具、命令行程序、内网管理后台仍然用 BIO完全没问题。选型是权衡不是盲目追新。3. NIO一个线程管一万个连接的那套玩法3.1 NIO 三大件Channel、Buffer、SelectorNIO官方叫 New IO通常默认指非阻塞 IO 模型。JDK 1.4 引入了 Channel、Buffer、Selector 三件套目的是解决 BIO 在并发连接上的瓶颈。Channel 是对连接的抽象SocketChannel、ServerSocketChannel 都是典型实现。Buffff 是缓冲区NIO 的读写都要经过它这和传统 IO 的流读写方式完全不同。Selector 是多路复用器一个 Selector 可以注册很多 Channel然后只用一个线程在 Selector 上等待事件发生。三件套的分工大概是这样Channel 负责把底层连接包装成可注册、可监听的对象Buffer 负责在用户空间存取待处理的数据Selector 负责监控一批 Channel 里哪些已经就绪。数据从网卡到用户空间的路径没变只是等待数据这件事不再需要每个连接占一个线程去死等而是全部交给 Selector 统一观察。3.2 多路复用与 Reactor 模型NIO 的性能核心是多路复用。传统的每个 socket 一个线程等于每条马路配一个交警。Selector 模式下一个线程可以同时盯着成千上万个 socket哪个 socket 有数据来了就去处理哪个这就像中央调度中心只派一组交警巡逻整片区域。Selector 上监听四类事件OP_ACCEPT有新的连接可以被接受、OP_CONNECT连接建立完成、OP_READ数据可读、OP_WRITE可以写入数据。实际编码时服务端关注的是 ACCEPT客户端关注的是 CONNECT之后两边都在 READ 和 WRITE 上做文章。这种“一个线程监听事件、事件到来再分发处理”的思路正好对应经典的 Reactor 模式。Reactor 是一个事件循环充当事件分发器处理具体逻辑的 Handler 才是真正干活的。NIO Reactor 模型把连接管理与业务处理分离开所以很多后来特别能扛并发的框架都采用了这种结构。3.3 NIO 服务端完整代码阅读指南下面这段代码是 NIO 服务端最经典的世界理解它基本就算入门了Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int ready selector.select(1000); if (ready 0) { continue; } SetSelectionKey selectedKeys selector.selectedKeys(); IteratorSelectionKey keyIterator selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key keyIterator.next(); keyIterator.remove(); if (key.isAcceptable()) { SocketChannel clientChannel serverChannel.accept(); clientChannel.configureBlocking(false); clientChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel channel (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); int len channel.read(buffer); if (len 0) { buffer.flip(); byte[] data new byte[buffer.remaining()]; buffer.get(data); System.out.println(收到消息: new String(data)); buffer.clear(); } else if (len 0) { channel.close(); } } } }这段代码有几个很关键的细节。第一个是configureBlocking(false)必须保证通道进入非阻塞模式才能被 Selector 管理。第二个是selectedKeys每次处理完都要 remove否则同一个事件会被重复处理这是初学者最容易踩的坑之一。第三个是buffer.flip()前做读写切换flip 把 buffer 从写模式切到读模式读完后还要 clear 或 compact 重置状态。这里特别提醒一件事NIO 里没有任何“流”概念所有数据都是通过 Buffer 打包传输的不理解 position、limit、capacity 三个位置标记第一版代码基本都会出问题。这个放到第 6 节专门讲。3.4 为什么 Netty 选择的不是 JDK 原生 NIO而是包装它聊到 NIO 就绕不开 Netty。很多人在学习时都有一个疑问JDK 已经有 NIO 了为什么 Netty 还要自己封装一套原因其实很朴素JDK 原生 NIO 的坑很多。Selector 空轮询导致 CPU 100%、ByteBuffer 使用复杂容易写错、半包粘包问题需要自己解决、线程模型需要自己设计。Netty 相当于把这些问题全部封装好转成一套顺手的框架对外提供了更简单的 API内部实现了内存池、零拷贝、灵活的线程模型。很多兄弟觉得“用了 Netty 就不需要管 NIO 了”这是不对的。Netty 再方便底层运行的还是 NIO 的 Channel、Buffer、Selector。理解底层直接决定了排查问题的上限。线上莫名其妙 CPU 高、连接打满、消息对不齐最终排查时还是会回到这些基础概念上。从选型角度看现在做高并发网络服务网关、IM、RPC 框架直接裸写 JDK NIO 的已经非常少了。要么用 Netty要么用基于 Netty 的上层框架。但这不代表 NIO 原理不值得掌握反而越是依赖框架越应该把底层吃透。4. AIO异步回调这条路走得有多难4.1 AIO 的两个维度和 Proactor 模型AIOAsynchronous IO也叫 NIO.2JDK 1.7 才正式推出。它想解决的是 NIO 在编程模型上不顺手的短板NIO 虽然是非阻塞的但读写数据时还是要自己手动检查事件、自己读取拷贝整体仍然是同步的方式。AIO 的思路是把读写的整个过程都交给操作系统操作完成后再回调通知业务代码。这个概念对应的是 Proactor 模型。Reactor 模式里事件来了还需要 Handler 主动去实操读写Proactor 模式里读写操作本身由内核主动执行完成后的结果已经放在用户提供的缓冲区里再触发回调。说白了NIO 是别人告诉你“饭做好了”你去端AIO 是别人直接把饭端到你面前叫你快吃。4.2 AIO 代码怎么写AIO 服务端代码风格和 NIO 完全不同不需要 Selector而是基于 CompletionHandler 回调AsynchronousServerSocketChannel server AsynchronousServerSocketChannel.open(); server.bind(new InetSocketAddress(8080)); server.accept(null, new CompletionHandlerAsynchronousSocketChannel, Void() { Override public void completed(AsynchronousSocketChannel client, Void attachment) { server.accept(null, this); ByteBuffer buffer ByteBuffer.allocate(1024); client.read(buffer, buffer, new CompletionHandlerInteger, ByteBuffer() { Override public void completed(Integer result, ByteBuffer attachment) { if (result 0) { attachment.flip(); byte[] data new byte[attachment.remaining()]; attachment.get(data); System.out.println(收到消息: new String(data)); attachment.clear(); client.read(attachment, attachment, this); } } Override public void failed(Throwable exc, ByteBuffer attachment) { exc.printStackTrace(); } }); } Override public void failed(Throwable exc, Void attachment) { exc.printStackTrace(); } }); Thread.sleep(Long.MAX_VALUE);这段代码能看到 AIO 写法的核心没有循环没有手动轮询accept 和 read 都是发起一个异步操作然后操作系统完成后回调。每次 read 完成之后要再次发起下一次 read继续处理后续数据。初看会觉得很舒服写法也不算复杂。但问题出现在更深处。4.3 平台差异为什么 Linux 上 AIO 是“伪异步”关于 AIO 有一个容易被忽略但极其重要的知识点Java 的 AIO 在不同操作系统上实现差距很大。Windows 上基于 IOCP是真正的异步 IOLinux 上底层是 epollepoll 本身只支持非阻塞同步 IO不支持真正的异步 IO。所以 JDK 在 Linux 上的 AIO 实现本质上是内部搞了一个线程池把读写任务丢到线程池里执行读写完成后通知业务代码。也就是说在 Linux 上 Java AIO 所谓的异步是用户态的线程池模拟出来的。这意味着两件事。第一它在 Linux 上依然受线程上下文切换和任务调度开销的影响并不能真正规避传统阻塞模型的线程成本。第二它引入了额外的线程池调度层在某些高并发场景下性能反而未必比 NIO 多路复用模型好。这也是为什么很多服务端在 Linux 上做高并发首选 NIO 而不是 AIO。Windows 服务端确实可以吃到 AIO 的甜头但生产环境大量使用 Linux 已经很明显Java AIO 在 Linux 上的这种局面直接导致它一直没能真正流行起来。4.4 AIO 为什么没有成为主流回看历史就明白了NIO 搭配 Selector、搭配 Reactor 模型已经能稳定支撑几十万连接而 AIO 在 Linux 上没有系统级优势还需要用回调方式写代码回调一多嵌套地狱随之而来代码可读性比 NIO 还要差。整个 Java 生态的主流网络框架几乎全部选择基于 NIO 而不是 AIO。Netty 也明确表示 AIO 没有利用到的操作系统级优势因此主推的是 NIO 模型。所以问 AIO 到底有没有用答案要看场景在 Windows 环境的服务端上AIO 效果确实好如果跑在 Linux 上追求性能极致的服务还是用 NIO 或基于 NIO 的框架更靠谱。这也解释了为什么现在面试会问 AIO但实际项目里用得很少的原因。5. 三张表看清三种 IO 模型的差异和应用选择5.1 关键特征对比表把三种模型拉在一起最容易理解的是直接看结构性的差异。维度BIONIOAIO阻塞类型阻塞非阻塞非阻塞同步/异步同步同步异步线程模型一连接一线程一线程管理多连接回调通知几乎不占线程并发支持弱强强受平台影响编程复杂度低中等中等偏高JDK 版本1.4 前1.4 起1.7 起核心组件ServerSocket、SocketChannel、Buffer、SelectorAsynchronousChannel底层依赖阻塞系统调用多路复用select/poll/epollIOCP/线程池模拟典型代表简单工具、小型服务Netty、网关、IM极少数特定场景另一个常见的理解方式是把同步/异步、阻塞/非阻塞组合起来组合类型代表同步阻塞BIO同步非阻塞NIO异步非阻塞AIO异步阻塞无典型 Java 例子5.2 不同业务场景下的选型建议选型不是越先进越好而是匹配场景。我把实际接触到的场景大致分成四类连接量小、并发低、内部简单工具这种直接用 BIO 最省事一个线程池加一个 Socket 就能跑维护成本最低。硬上 NIO 反而增加理解和调试成本。连接量大、消息频发、注重吞吐这类场景用 NIO 或者直接选 Netty 最合适。长连接、IM、消息推送、RPC 底层基本都是这个套路。追求极致性能和低延迟并且跑在 Windows 服务端环境下可以考虑 AIO。但这种环境在 Java 服务端并不多见如果你确定是这种场景AIO 值得尝试但需要做好踩坑准备。大多数常规业务不需要自己写网络底层直接用 Spring MVC、HTTP 服务等高层框架框架底层已经把 IO 模型选择封装好了。这时候研究 BIO、NIO、AIO 的意义在于出问题时你能看懂底层日志知道线程为什么全部阻塞、连接为什么被打满。网上有句话很实在“BIO 是生活常态NIO 是主流武器AIO 是理论先锋”。放在实际工程里这句话基本定义了现在 Java 服务端 IO 模型的真实分布。6. 实战中绕不开的坑与排查心得6.1 NIO 的 Buffer 操作禁忌Buffer 操作在初次写 NIO 时是最容易出问题的环节尤其是 flip 和 clear。ByteBuffer 有 position、limit、capacity 三个索引每次读写都要注意状态。写入数据后想读出来必须先 flip把 limit 设为原来的 positionposition 归零。读完后想继续用同一个 buffer 接收新数据要么 clear直接重置整个 buffer要么 compact只把没读完的数据往头部挪再继续写。我见过非常多第一次写 NIO 的同事在读完数据后忘了 flip直接 get 出一堆零字节或取错位置。这种 bug 往往不是稳定复现的而是根据数据量时好时坏。排查起来特别恶心。调试这类问题的土办法很有效在关键位置打印 buffer 的 position、limit、capacity一眼看状态对不对就行。另外ByteBuffer.allocate 是堆内内存allocateDirect 是直接内存。直接内存在 IO 操作时减少了内核缓冲区和 JVM 堆之间的拷贝性能更好但申请和释放成本更高。不要每个消息都新建一个直接缓冲区那会把性能优势反向变成劣势正确姿势是复用 Buffer。6.2 半包与粘包问题使用 NIO 后数据会以字节流形式源源不断到达应用层需要自己定义消息边界这就是半包和粘包问题的来源。粘包是说两个消息被拼在一起发过来了半包是说一个消息被拆成了两次到达。解决方案无非三种定长消息比如固定每包 1024 字节不对的补位这种最简单但浪费空间分隔符控制比如按换行符或自定义分隔符切分适合文本协议长度前缀最常见每包消息之前加 4 字节表示消息长度读够长度再解析Netty 里对应 LengthFieldBasedFrameDecoder。真正做生产级协议基本都选长度前缀一是效率高二是能承载二进制数据。6.3 “假死”与 Selector 空转NIO 服务上线之后偶尔出现性能抖动也可能和 Selector 空轮询有关。JDK 在部分版本的 Linux 上有一个著名的 epoll bug即使没有事件selector.select() 也会偶尔能提前返回导致 CPU 空转甚至跑满。Netty 针对这个问题有专门的应对逻辑统计空轮询次数连续多次空轮询就重建 Selector替换旧的。如果自己手动写 NIO 遇到 CPU 空转可以先用 jstack 看线程栈确认线程是不是全部停在 selector.select() 调用处且不断恢复如果是基本就是空轮询问题。解决思路就是记录空转次数超过阈值后重新 open 一个 Selector再把原来的 Channel 重新注册过去。6.4 AIO 回调的坑AIO 写起来虽然少了一层 Selector 逻辑但回调也有自己的麻烦事。CompletionHandler 的回调线程来自内部线程池如果这些回调里执行了耗时的业务逻辑就会把回调线程全部占住后续读写事件无法及时处理。正确做法是回调里只做轻量处理重活丢业务线程池。另外 AIO 的 read 是一次性的每次 read 只会读一次数据。要持续接收数据必须在 completed 里再接一次 read否则一次 read 之后就再也不来了。这个点跟 NIO 的处理方式很像容易漏。还要注意 channel.read 失败时的处理。len 0 表示连接关闭这时候要主动 close不能直接忽略否则连接资源泄漏。这些都是实际写 AIO 时才能体会到的细节标准教程里很少提。最后分享一个个人体会。接触这套知识很多年后真正帮我解决线上问题的往往不是记住了某个概念而是理解了数据到底从哪来、到哪去、线程是怎么等数据、系统调用又是怎么把数据搬上来的。抓住 IO 模型的底层链路回头看 BIO、NIO、AIO 的区别就会觉得没有想象中的复杂。网上资料再多都不如自己手写一遍 BIO 服务、用 NIO 重写一遍、再对比一下两者在线程模型上的差距来得深刻。如果你正准备学习这块内容我的建议是别只背结论赶紧打开 IDE把第 2 节和第 3 节的两段代码跑一遍再把第 6 节的几个坑亲手坑一遍。踩过这几个坑才算真入门。