ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BIO、NIO、AIO深度解析:从IO模型原理到高并发实战

BIO、NIO、AIO深度解析:从IO模型原理到高并发实战 1. 从一次网络请求看IO的本质1.1 一次read()调用背后发生了什么很多同学学了几年Java写过的网络程序也不少但你有没有停下来想过这样一个问题当你的程序调用一次read()去读Socket上的数据时CPU到底经历了什么我简单把过程拆开。你的应用跑在用户态操作系统跑在内核态两者之间隔着一堵不能随便翻的墙。当你的线程调用read()时首先会触发一次系统调用从用户态切换到内核态。内核拿到这个请求后会去看你指定的Socket上有没有数据到达。如果数据还没到内核有两种选择让你这个线程在这儿睡着等或者让你先回去忙别的数据到了再叫你。如果数据到了内核会把数据从自己维护的内核缓冲区拷贝到你传入的用户态缓冲区然后返回。注意这里藏着两个完全不同的维度。一个维度是“数据没到的时候你这个线程是等还是不等”这叫阻塞与非阻塞。另一个维度是“拷贝数据的过程是你自己盯着做还是让内核做完之后通知你”这叫同步与异步。BIO、NIO、AIO这三个东西本质上就是这两个维度组合出来的三套处理方案。我见过不少人把这几张概念图背得滚瓜烂熟但问他“为什么Netty用NIO不用AIO”就答不上来一问他“AIO是不是一定比NIO快”也含糊其辞。原因很简单他学的只是结论不是机制。这篇文章我就把这三兄弟的底裤扒干净你真正理解了它们各自解决什么问题面试的时候那些八股题自然就有根了。1.2 同步异步、阻塞非阻塞到底在说什么先把这四个词掰扯清楚它们是理解BIO/NIO/AIO的地基也是面试里最容易绕晕的考点。阻塞和非阻塞说的是线程在等待结果时的状态。还是拿read()说话如果Socket上没数据阻塞模式下你的线程会挂起CPU把时间片让给别人直到内核说“有数据了”线程才被唤醒继续往下走。非阻塞模式下你的线程不会被挂起read()立刻返回一个“没数据”的结果你可以继续做别的事过一会儿再来问一次。同步和异步说的是“数据搬运”这件事由谁完成。同步IO是指数据从内核缓冲区拷到用户缓冲区这个动作由你的线程亲自参与你得等这个拷贝完成才能继续。异步IO是指你发起调用后直接走人内核自己把数据拷贝到你指定的缓冲区拷完了再发一个信号或回调通知你“活儿干完了”。这里有个很多人会踩的误区认为非阻塞就等于异步。不是一回事。非阻塞说的是“读不到数据时我不等”但一旦数据到了后续的拷贝仍然是你自己的线程在干所以非阻塞IO仍然属于同步IO。只有发起后全程不参与、由内核干完活再通知你的那种才叫异步IO。把这两个维度一画你就能看到一个坐标阻塞同步 最原始的BIO线程傻等等到了自己拷数据非阻塞同步 NIO线程轮询拷数据还是自己来非阻塞异步 AIO线程甩手内核全包这个坐标理解了后面看代码就轻松了。2. BIO最传统的阻塞IO2.1 传统BIO服务端长什么样BIO全称Blocking IO就是传统的阻塞IO模型。你学Java网络编程时写的第一个TCP服务端十有八九就是它。我贴一段最典型的代码你肯定见过ServerSocket serverSocket new ServerSocket(8080); while (true) { // accept()阻塞没有客户端连接时线程在这儿等着 Socket socket serverSocket.accept(); // 来一个客户端就开一个线程去处理 new Thread(() - handleRequest(socket)).start(); }这段代码的逻辑很简单主线程死循环里调accept()有连接进来就新开一个线程去处理读写。注意accept()本身是阻塞的如果没有客户端连进来主线程就卡在那儿。同样socket.getInputStream().read()也是阻塞的如果客户端连上了但迟迟不发数据处理它的那个线程就白白挂在那儿。你算一笔账就明白了。假设一台机器可以开500个线程每个连接从建立到断开会持续几秒而这几秒里真正在读写数据的时间可能不到10%。也就是说绝大部分时间里那500个线程在等待中白白消耗内存和上下文切换的开销。如果第501个客户端来连接要么拒之门外要么排队等待吞吐量上限就卡死了。这种设计还有个麻烦事就是线程数量的不可控性。每个线程默认栈大小在虚拟机上一般是512KB到1MB1000个线程就是1GB左右的内存还没算线程切换的CPU开销。你把-Xss调小能缓解一点但治标不治本。2.2 BIO为什么撑不住高并发我在之前项目里接手过一个老系统用的就是纯BIO的服务端单机最高只能扛住两三百个并发连接再往上就开始出现大量连接超时。问题出在哪里我总结下来有三个核心痛点。第一个痛点是每连接一线程的资源浪费。前面说了一个连接大部分时间在等待线程却要一直占着。连接数和线程数是1:1的关系高并发就意味着高线程数而线程数是有天花板的。第二个痛点是线程上下文切换的代价。单CPU核心同一时刻只能执行一个线程线程超过CPU核心数之后操作系统就要不停地在它们之间切换。每次切换都要保存当前线程的寄存器状态、加载下一个线程的状态这个开销在高并发下会被无限放大。你开1000个线程但CPU只有8核那剩下的992个线程都在排队等着被调度调度本身的成本高得吓人。第三个痛点是没有IO事件通知机制。BIO根本不知道Socket上有没有数据只能靠线程阻塞去“撞运气”。这就像一个外卖员站在客户门口干等着开门而不是手机收到通知再过来效率自然低。不仅是服务端BIO的客户端也一样。一个客户端如果要同时跟多个服务端通信就得开多个线程每个线程管一个连接线程管理成本又上来了。2.3 BIO真的没用了说说我的看法聊到这里你可能会想BIO这么拉胯是不是应该彻底把它扔进垃圾桶我的答案是分场景。BIO在自己的适用范围内仍然是简单可靠的选择。比如连接数少、且每个连接都是“发请求-等响应-断开”这种短连接模式的场景比如一个内部管理系统的接口调用并发量也就几十那BIO完全够用。代码简单、逻辑直白、调试方便出问题也好排查没必要为了用NIO而用NIO。再比如你写一个同步的、要求强一致性的客户端BIO的编程模型天然契合。因为每个连接一个线程你可以在线程里按顺序执行“读请求-处理-写响应”不需要考虑事件回调里的并发问题。我自己给团队定过一个简单的选型标准日均请求量在百万以下、峰值并发在几百以内的内部系统优先用BIO别折腾。超过这个量级再考虑NIO。为了性能引入的复杂度是要拿维护成本来换的这个账要算清楚。3. NIO非阻塞IO与Selector多路复用3.1 NIO三个核心组件Channel、Buffer、SelectorNIO全称Non-blocking IO也有人叫New IO是从JDK 1.4开始引入的一套IO API。它在概念上跟BIO的InputStream/OutputStream那一套完全不同核心就三个组件Channel、Buffer、Selector。先看Channel你可以把它理解成一条双向通道既能读也能写。跟BIO里InputStream只能读、OutputStream只能写不一样Channel是二合一的。常用的有FileChannel文件、SocketChannelTCP、ServerSocketChannel服务端监听。不过要注意FileChannel不能设置为非阻塞模式只有网络相关的Channel才支持。再看Buffer。NIO里所有读写操作都不直接面向字节流而是面向Buffer。你要往Channel写数据得先把数据填进Buffer再写出去你要从Channel读数据得先读到Buffer里再从Buffer里取。Buffer的本质是一块内存区域里面有position当前位置、limit可读写的上限、capacity容量这三个关键游标读写模式的切换通过flip()、clear()这些方法完成。新手最容易栽的坑就是忘了在写模式切成读模式前调flip()结果读出来的全是脏数据。最后是Selector这是NIO的灵魂。它做的事情可以这样理解原来服务端有100个连接就要开100个线程去各管各的现在只需要一个线程把100个连接的Channel都注册到Selector上然后调一次select()这个方法会阻塞但阻塞期间内核会帮你盯着这100个通道只要其中有任何一个通道有数据可读或有空间可写select()就会从那100个通道里挑出就绪的返回。线程这会儿再去处理就绪的通道处理完继续回到select()上等下一批。这样一来一个线程就能管理成千上万个连接线程数和连接数彻底解耦。这就是NIO能支撑高并发的最根本原因。3.2 select、poll、epoll到底解决了什么问题这里有一个计算机底层知识你没绕过去就是多路复用的系统调用。Selector在底层依赖操作系统提供的多路复用机制常见的三个是select、poll和epoll。你不需要把它们的源码背下来但你得知道它们之间的差异因为面试官极爱问。select和poll是一类东西它们的做法是把你关注的Socket文件描述符放进一个集合里然后每次调用都把这个集合整个传给内核内核遍历一遍看看哪些就绪了再把集合带回来。问题在于第一集合的大小有限制第二每次调用都要在用户态和内核态之间来回拷贝全量集合第三内核是遍历检查连接数越多越慢。所以它们能应付几百上千个连接但到几万个就力不从心了。epoll是Linux下的增强版它的核心改进是事件驱动。第一次调用epoll_create在内核里建一个事件表之后你要关注某个Socket就通过epoll_ctl动态注册进去内核会为每个注册的Socket挂一个回调。当Socket就绪时回调会把就绪事件塞进一个就绪队列你的线程只需要调用epoll_wait去取就绪队列里的事件就行不需要每次都把所有Socket集合拷来拷去也不需要遍历全部连接。这样一来连接再多每次就绪队列里的数量都是有限的性能基本只跟活跃连接数挂钩。有个面试的坑你得注意epoll是Linux特有的Windows上没有。你在Windows上写NIO程序底层用的是select实现所以同一段代码在不同平台上的表现差异很大。这就是为什么很多高性能网络框架在生产环境都推荐部署在Linux上的原因之一。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) { // 阻塞等待就绪事件有事件了才继续往下走 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); } 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 if (bytesRead 0) { buffer.flip(); // 业务逻辑处理这里简单回声 client.write(buffer); } } } }你看核心就四步注册通道、调select等待、遍历就绪事件、处理事件。整个服务端只有一个线程在跑这个循环但它可以管住成千上万个连接。写这段代码时有几个细节要注意。第一configureBlocking(false)必须在注册前调否则默认是阻塞模式根本注册不到Selector上。第二selectedKeys()返回的集合不会自动清理你处理完一个SelectionKey必须手动从迭代器里移除否则下次select的时候这个已经处理过的事件还会再出现轻则重复处理重则死循环。第三Selector.select()是阻塞的但它阻塞在“等待内核通知有就绪事件”上跟BIO阻塞在“等待某个连接的数据”是完全不同的两码事。一个线程等1000个连接的就绪通知和1000个线程各自等自己连接的数据效率不在一个量级。第四读写要注意ByteBuffer的边界。每次读取前要clear()重置游标每次读完之后要flip()才能开始取数据不处理好这个你会被各种莫名其妙的数据错位折磨到怀疑人生。3.4 实战中的NIONetty为什么选NIO说到NIO的实战不得不提Netty。你去看Netty的架构它的核心就是一个封装好了的NIO多路复用模型但在原始NIO上做了大量增强让开发者不用再跟Selector打那些底层的交道。Netty为什么选NIO而不是AIO这是面试里一个几乎必问的题。我的看法是NIO加上多路复用在Linux的epoll加持下已经能支撑十万级甚至百万级的连接而AIO虽然概念上更先进但在Java的实现里底层依赖的异步通知机制在Linux上的表现并不稳定而且它在某些版本的JDK上内部仍然是用线程池模拟的并没有真正把系统异步能力发挥出来。也就是说NIO在工程上已经够用而且成熟稳定Netty没必要去赌AIO。我还想提一个概念叫IO线程模型。NIO写得好的人不仅仅会用Selector还会区分Boss线程和Worker线程。Boss线程只负责accept()接受新连接然后把它注册到Worker线程组的Selector上Worker线程负责具体的读写和业务处理。Netty就是这样做的它把一个Selector循环拆成两组避免了一个线程既管接受连接又管数据读写时被某个慢业务卡住导致新连接也进不来。这个设计思想直接来自NIO的实践你理解了NIO再看Netty的线程模型就会有种豁然开朗的感觉。4. AIO异步非阻塞IO4.1 AIO的模型和代码体验AIO全称Asynchronous IO是在JDK 1.7时引入的也叫NIO.2。它的设计思路就是前面坐标里的“异步非阻塞”你发起一个读操作read()立刻返回线程不需要轮询也不需要等着拷贝数据等内核把数据准备妥当并且拷贝进你指定的缓冲区之后会主动来通知你。在Java里AIO的代码长这样AsynchronousServerSocketChannel serverChannel AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(8080)); serverChannel.accept(null, new CompletionHandlerAsynchronousSocketChannel, Object() { Override public void completed(AsynchronousSocketChannel client, Object attachment) { // 递归调用accept继续接收下一个连接 serverChannel.accept(null, this); ByteBuffer buffer ByteBuffer.allocate(1024); client.read(buffer, buffer, new CompletionHandlerInteger, ByteBuffer() { Override public void completed(Integer result, ByteBuffer attachment) { attachment.flip(); client.write(attachment); } Override public void failed(Throwable exc, ByteBuffer attachment) { exc.printStackTrace(); } }); } Override public void failed(Throwable exc, Object attachment) { exc.printStackTrace(); } });你看这里没有Selector没有轮询取而代之的是CompletionHandler回调。你发出一个异步读请求传一个回调对象进去然后代码继续往下走不用等任何东西。哪天内核那边读完了回调自然会被触发。这种编程方式确实优雅代码里没有阻塞点全程回调驱动模型上就是完美的异步非阻塞IO。4.2 为什么AIO在实际项目里用得少既然AIO模型这么完美为什么实际项目里很少看到用AIO的我直接说结论Java的AIO实现并没有兑现它的设计承诺。在Windows上AIO底层可以借助IOCPIO Completion Port那是Windows原生的一套异步IO机制算是正经的异步。但在Linux上Java的AIO早期实现并没有直接使用内核原生的异步事件通知机制而是用epoll加上线程池方式做了一层模拟。也就是说你的异步回调本质上还是由一个匿名的线程池在背后调用的线程池里的线程数有限一旦大量连接同时触发事件回调线程反而会成为瓶颈。既然是这样跟直接写NIO的Selector循环又有什么区别性能上甚至可能更差。还有一个很现实的问题就是编程模型。AIO是回调驱动的你的业务逻辑会被拆散到各个回调方法里一个完整的业务流程被切成好几段代码的阅读和维护难度成倍增加。回调里稍微有点业务逻辑就很容易陷入回调地狱排查问题的时候链路都理不清。这也是为什么后来响应式编程、协程这类思路会更受欢迎——它们想要的是写同步代码的体验同时拿到异步的性能。我在实际中基本不推荐用AIO。如果有面试官问你“Java的AIO为什么没有被大规模使用”你可以从上面这两个角度回答底层在Linux上模拟、回调编程模型复杂。把这两点讲清楚面试官会觉得你是真的懂而不是在背答案。5. 三种模型的选型对比与常见问题5.1 一张表看清BIO、NIO、AIO我习惯把三种模型的关键差异汇总成一张表方便快速决策和复习。这张表也适合直接作为面试前的提纲。维度BIONIOAIO阻塞性阻塞非阻塞非阻塞同步异步同步同步异步线程模型每连接一线程单线程管理多连接回调通知底层依赖无特殊依赖select/poll/epollIOCP/epoll线程池模拟适用场景连接少、并发低连接多、长连接连接极多且底层支持好编程复杂度低中高JDK版本1.0起1.4起1.7起这里要提醒一句NIO那一行我写的是“单线程管理多连接”但不是说你只能用单线程。实际生产中一个Selector循环挂在单线程上跑还是多个线程各跑一个Selector又或者像Netty那样Boss线程和Worker线程分工都是可以设计的。NIO解决的最核心问题是把线程和连接的比例从1:1变成了1:NN具体有多大取决于你后续的架构设计。5.2 高频面试题和标准思路我把跟BIO/NIO/AIO相关的面试题整理了一下基本就那么几类你把题目背后的机制想通了答案都是水到渠成的事。第一个问题BIO、NIO、AIO的区别是什么这题别傻傻只答“阻塞、非阻塞、异步”六个字。我的回答思路是先亮身份BIO是阻塞同步NIO是非阻塞同步AIO是非阻塞异步然后各举一个生活化例子比如BIO是你打电话等人接NIO是你隔一会儿查一次手机消息AIO是你放下手机干别的、收到通知再回消息最后各配一句适用场景收尾。第二个问题什么是IO多路复用你抓住两个关键点就够了通过Selector同时监听多个通道的事件把等待多个IO的动作合并成一个线程去等。再往深一层讲不同操作系统有不同实现Linux下是epoll最优。能提到“把等待从O(N)变成O(1)”就更好了。第三个问题NIO如何实现非阻塞我建议从两次系统调用去讲。第一次是accept()和read()不再死等没数据就直接返回第二次是select()让你能同时等一堆通道返回哪个就绪就处理哪个。你看非阻塞其实是NIO的一部分多路复用是另一部分结合在一起才完整。第四个问题有人会问NIO还有没有阻塞的地方注意NIO里的Selector.select()本身是阻塞的它会阻塞到有至少一个事件就绪才返回。所以NIO并不是完全没有阻塞它把阻塞收敛到了唯一的、可控的地方。这个细节说出来面试官一般都会眼前一亮。5.3 我在项目中踩过的几个坑最后分享几个我在实际项目中踩过的坑每一个都是真金白银换来的教训。第一个坑缓冲区没清理干净导致脏数据。那是一次做网关服务用NIO转发数据前期在测试环境一切正常一上生产就偶发乱码。排查到最后发现是ByteBuffer复用的问题每次读完没清干净position和limit的位置不对下一轮就带着旧数据一起发出了。解决方式很简单每次读取前明确clear()读取后记得flip()。但就是这种“基础操作”压力大的时候最容易忽略。第二个坑Selector空轮询导致CPU飙升。Java NIO在Linux上有一个著名的bug在某些条件下select()会立即返回0个事件导致循环空转CPU直接拉满。我在一台服务器上遇到过Java进程一启动就吃掉一个核。解决办法是记录空轮询次数超过一定阈值就重建Selector把旧Channel重新注册进去。Netty内部就做了类似的处理你去看它的源码能找到相关逻辑。第三个坑业务逻辑直接在IO线程里跑。这个用NIO的人特别容易犯。Selector回调里拿到了数据顺手就做了JSON解析、数据库查询、调用外部接口结果把一个IO线程拖了几百毫秒。这个线程被拖住就意味着所有注册在它上面的连接都被拖住了。正确做法是IO线程只做数据读写业务逻辑丢给独立的业务线程池去处理。这一点极其重要你在任何NIO框架里都能看到这个分层思想。第四个坑客户端太少不能用NIO。这话听起来有点反直觉但这是真的。如果并发只有几十NIO并没有优势代码还比BIO复杂得多。我在团队里见过一个老哥把内部一个调用量极低的定时任务改成NIO客户端折腾了好几周最后性能毫无变化徒增维护成本。技术选型一定要拿数据说话不要为了用新技术而用新技术。5.4 从BIO到NIO再到AIO学习路线的个人建议文章写到这里我想说一个学习上的体会。网上铺天盖地的Java八股文把BIO、NIO、AIO概念列得很全但很多刚入门的同学学完仍然不会用。我建议的学习路径很简单先照着BIO代码写一遍感受一下阻塞是什么体验、为什么并发一高就崩再照着NIO的示例手敲一遍感受Selector和ByteBuffer的配合这个阶段多花点时间踩踩flip()、clear()的坑等你把NIO的底层机制吃透了再看Netty你会发现Netty的很多设计你都能看出“原来是想解决这个问题”的感觉至于AIO了解一下模型和局限性就够了至少在当前的生态里不必花太多精力深挖。如果你正在准备Java基础面试这篇内容里的代码不需要背但机制必须能用你自己的话讲明白。面试官最讨厌的就是背概念却答不出“为什么”。你把阻塞与非阻塞、同步与异步这两个维度的组合关系搞清楚了把NIO的多路复用和线程模型的关系搞清楚了再记几个实战里的坑这部分内容基本就能横着走了。我个人在实际项目中的体会是高并发网络编程真正难的从来不是API怎么调而是你清不清楚“等待”“通知”“拷贝”“线程”这四个要素各自是谁在负责。把这四个角色在BIO、NIO、AIO里的分工理顺你写出来的代码才不是照着文档抄而是真的在掌控整个流程。
返回列表