尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java NIO 回显服务器:Selector 多路复用与 Buffer 翻转

Java NIO 回显服务器:Selector 多路复用与 Buffer 翻转 1. 为什么我要动手搭一个回显服务器刚开始学 Java NIO 的时候我踩过一个很典型的坑API 看懂了Selector、Channel、Buffer 三个词也能倒背如流但真让我写一段能跑起来的代码脑子里全是浆糊。尤其是SelectionKey那几个位运算常量OP_READ、OP_WRITE、OP_ACCEPT单看文档都明白放到一个完整的循环里就不知道谁先谁后、谁该注册谁该取消。后来我找到了一个办法就是拿回显服务器来练手。这个东西逻辑足够简单客户端发什么服务端原样吐回去没有业务干扰但网络编程里那些核心机制——连接建立、事件监听、读写切换、半包处理——一个都跑不掉。所以这篇内容的核心就是围绕Java NIO 回显服务器这个练手项目把整个网络编程模型的验证过程掰开揉碎了讲清楚。它是什么一个用 NIO 的非阻塞模式实现的简易服务端能同时处理多个客户端的连接和消息回传。能做什么验证你对 Selector 多路复用的理解是否到位验证ByteBuffer的读写翻转有没有搞反验证阻塞与非阻塞两种模型在代码结构上的本质差异。适合谁正在啃 Java NIO、准备面试、或者想从 BIO 往 NIO 迁移的开发者。哪怕你只看过ServerSocket的写法跟着走一遍也能把 NIO 的骨架搭起来。我之所以强调验证这个词是因为网络编程模型这个东西光看理论特别容易产生一种我会了的错觉。BIO 是一请求一线程NIO 是一个线程管一堆连接AIO 是操作系统帮你回调这些结论谁都能背。但你只有真正写一个回显服务器,让三个客户端同时连上来发消息,然后观察单线程是怎么把它们轮询处理完的,才会对多路复用这四个字有体感。这篇就当我把自己当初踩坑、调试、反复重构的过程整理出来你可以当成一份可复现的实操笔记来用。2. 先把网络编程模型这件事说明白2.1 BIO、NIO、AIO 到底差在哪很多人一上来就背同步阻塞、同步非阻塞、异步非阻塞但背完还是不知道代码里差在哪。我用一个生活化的类比来解释。BIO 就像你去一家小饭馆,一个服务员全程只伺候一桌客人,客人点菜他记、客人吃他等着、客人结账他才去招呼下一桌。人一多,你就得雇一堆服务员,每个服务员占一份工资(一个线程占一份栈内存)。NIO 则是一个服务员管整个大堂,谁举手他就过去,没事的时候他站在门口盯着所有人,不傻等任何一桌。AIO 更狠,客人想好了直接按铃,服务员等铃响才动。落到代码层面,三者的核心区别在**谁来等待数据就绪**这件事上。BIO 里是accept()和read()这两个方法是阻塞的,线程调用它们之后就被挂起,直到有连接或数据才醒过来,这期间线程什么都干不了。NIO 里则是把就绪这件事交给Selector去轮询,线程只需要问 Selector 现在有哪些 Channel 准备好了,然后只处理这些准备好的 Channel。这就从线程等 IO变成了IO 主动告诉线程我好了,前者是阻塞模型,后者是事件驱动模型。至于 AIO,就是AsynchronousServerSocketChannel那一套,你注册一个回调,系统在数据真正读完(注意是读完,不是就绪)之后通知你。这个模型在 Windows 上用的是 IOCP,在 Linux 上底层实现各有说法,实际项目里用得远没有 NIO 广泛。所以我的建议是,把 NIO 啃透比什么都值,面试问网络模型十有八九就是奔着 NIO 的 Selector 去的。2.2 为什么用回显服务器来做验证验证一个网络模型,你需要一个最小但完整的场景。回显服务器恰好满足这个条件。它只需要三个动作:接受连接、读取数据、写回数据。没有数据库,没有业务逻辑,没有序列化协议的干扰。你所有的注意力都能集中在连接是怎么被管理的事件是怎么被分发的缓冲区是怎么被复用的这几个问题上。反过来想,如果你一上来就写一个聊天室或者一个 HTTP 服务器,你会被额外的复杂度拖住。聊天室要考虑消息广播和在线用户列表,HTTP 服务器要解析请求头和状态行。这些都会掩盖掉 NIO 本身的核心机制。回显服务器就像一个显微镜下的样本,结构简单到你能一眼看穿,但里面流动的东西和大型服务端是一模一样的。我实测下来,用回显服务器验证三件事最有效:多客户端并发连接时单个线程能否正确轮询、读写缓冲区在多次操作中是否正确 flip 和 clear、客户端断开时 SelectionKey 和 SocketChannel 是否被正确清理。这三件事恰恰是新手写 NIO 最容易翻车的地方,后面我会逐一展开。2.3 一个关键认知:Selector 不是银弹这里必须泼一盆冷水。很多人以为 NIO 一定比 BIO 快,这是个误区。在小并发、长连接的场景下,比如只有几十个连接,Selector 的轮询开销加上非阻塞编程的复杂度,可能还不如 BIO 来得直接。NIO 的优势体现在连接数多但每个连接数据量不大的场景,比如即时通讯、推送服务。因为这时候 BIO 要为每个连接开一个线程,线程上下文切换的成本会指数级上升,而 NIO 用固定几个线程就能扛住成千上万的连接。我个人的判断标准是:连接数稳定在几百以内、每次交互数据量较大,用 BIO 配线程池足够省心;连接数可能破千、以短消息为主,才值得上 NIO。回显服务器这个小项目呢,它的价值不在于性能,而在于让你亲手触摸到 NIO 的抽象,把知道变成会写。搞清楚了这一点,学起来心态就稳了。3. 回显服务器核心组件拆解与实操准备3.1 ServerSocketChannel、SocketChannel、Selector 三件套NIO 网络编程的核心就三个类,我习惯叫它们三件套。理解它们的分工,是你写出正确代码的前提。ServerSocketChannel是服务端的监听口,你可以把它想象成公司前台。它的职责只有一个,就是等新客人上门,也就是等待accept事件。它自己不读数据也不写数据,只负责接纳连接。每接纳一个客户端,就生出一个SocketChannel。所以服务端通常只有一个ServerSocketChannel,注册的永远是OP_ACCEPT。SocketChannel代表一条具体的客户端连接,前台招进来的每一位客人就是一个它。读写数据都通过它来操作。它既可以注册读事件OP_READ,也可以注册写事件OP_WRITE。绝大多数时候,你注册读事件就够了,因为读是被动的,你不知道客户端什么时候来数据,但写在回显场景里几乎和读同步发生。Selector是整个模型的大脑,它代替线程去盯着所有注册过的 Channel。你在主循环里调用selector.select(),它会阻塞直到有至少一个 Channel 有就绪事件,然后返回就绪的数量。接着你拿到selectedKeys(),遍历,根据 key 上附着的事件类型分派处理。这就是所谓的事件驱动循环,也有人叫 Reactor 模式的单线程版本。注意:Selector 的select()方法和 BIO 的accept()看起来都会阻塞,但性质完全不同。accept()阻塞是在等你一个连接,这段时间它只能干这一件事;select()阻塞是在等所有注册 Channel 中任意一个就绪,一旦有任何一个好了它立刻返回。一个是单点等待,一个是多点监听。3.2 Buffer 的翻转逻辑为什么最容易出错如果说三件套是骨架,那ByteBuffer就是血液。它本身的 API 不复杂,put往里写,get往外读,真正绕人的是flip()和clear()这两个方法什么时候调和有什么后果。你得记住一个核心事实:ByteBuffer内部维护着一个position(游标)和一个limit(上界)。写模式的时候,position 往后走,limit 等于 capacity。你写完一批数据,准备读取,这时候必须调用flip(),它会把 limit 挪到当前 position 的位置,然后把 position 归零。这样读取时就是从 0 读到刚才写到的位置。如果你不 flip 直接读,读到的就是空的,因为 position 还在末尾。反过来,读完之后你要再次写入,这时候通常用clear()或者compact()。clear()简单粗暴,position 归零、limit 等于 capacity,相当于整个缓冲区标为可写,数据虽然还在但不影响覆盖。compact()更聪明,它会把没读完的数据挪到前面,然后 position 放到剩余数据之后,适合数据没读完还想继续读的场景。我当初 debug 了一个多小时,最后发现就是读之前忘了 flip。现象特别迷惑人:客户端明明收到空消息,但服务端日志显示数据已经读进来了。所以这里给你一个口诀,写后读前必 flip,读后写前可 clear。回显服务器的回传逻辑是读进来、再写出去,中间这一步 flip 千万别落下。3.3 环境准备与最小验证清单动手之前把环境对齐,能省掉很多莫名其妙的问题。我这里列一下我的配置,以及动手前你应该确认的几件事。项目推荐配置说明JDK 版本8 或 11 或 17NIO 核心 API 从 1.4 就稳定了,版本影响不大操作系统任意跨平台,注意换行符差异即可测试工具telnet 或自写客户端telnet 用来手动发消息最方便编码UTF-8避免中文乱码,Channel 读写字节要注意端口8080 或 9000选个没被占用的,冲突了换一个动手前确认清单:端口没有被占用(可以用netstat -ano | findstr 端口号检查)、JDK 的javac和java命令能用、防火墙没有拦截本地连接。这几步看着简单,但端口冲突和防火墙拦截是新手最常见的代码没问题但连不上的元凶。动手写代码之前,建议先在脑子里过一遍完整的数据流:服务端启动并注册OP_ACCEPT,客户端发起连接,select()返回,accept()得到SocketChannel并注册OP_READ,客户端发数据,select()再次返回,读进 Buffer,flip,再写回,客户端收到回显。这条链路你能顺畅描述出来,代码基本就稳了。4. 手把手写一个能跑的回显服务器4.1 服务端主循环骨架的搭建先搭骨架,再填血肉。服务端的启动流程是固定的:打开 Selector,打开 ServerSocketChannel,配置为非阻塞,绑定端口,注册到 Selector 上并声明关心OP_ACCEPT。然后进入一个死循环,循环里做的第一件事就是selector.select()。这里有个细节大多数人第一版都会漏掉:注册的时候要带上附加对象。因为一个 Selector 上注册了各种 Channel,有负责 accept 的,有负责 read 的,你遍历的时候怎么区分?靠的就是SelectionKey上的attachment。我习惯给自己定义的连接对象塞进去,把 Channel 和它的读写 Buffer 绑在一起管理。Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(9000)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int ready selector.select(); if (ready 0) continue; IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); handle(key); } }这段骨架里有三个点要划重点。第一,configureBlocking(false)必须调,不调的话注册到 Selector 会直接抛IllegalBlockingModeException。第二,遍历selectedKeys()之后一定要it.remove(),否则下一轮循环还会拿到旧的 key,导致重复处理甚至空指针。第三,handle(key)里要按事件类型分派,别把所有逻辑糊在一起。4.2 处理 ACCEPT 事件并注册读事件当key.isAcceptable()为真,说明有客户端连上来了。这时候你调用serverChannel.accept()拿到SocketChannel,同样要把它设为非阻塞,然后注册到 Selector 上,关心OP_READ。这一套下来,新连接就算纳管成功了。if (key.isAcceptable()) { ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); ByteBuffer buffer ByteBuffer.allocate(1024); client.register(selector, SelectionKey.OP_READ, new ClientSession(client, buffer)); }我实测下来,这里有个坑值得单独说:如果你忘了给新连接configureBlocking(false),那么在后续读的时候,一旦没数据就会阻塞整个线程,整个 Selector 循环停摆,表现就是某个客户端连上来之后,其他客户端全部无响应了。这个现象极具欺骗性,因为它只在特定时序下出现。所以每次accept()拿到新 Channel,第一件事就是设非阻塞,形成肌肉记忆。另外,accept()在非阻塞模式下有可能返回 null,虽然一般在isAcceptable()为真时不会,但严谨起见判断一下没坏处。附加对象我推荐用一个自定义类装 Channel 和它专属的 Buffer,这样每个连接有独立的缓冲区,互不干扰。如果所有人共用一个 Buffer,那一旦有数据没读完,后面的连接就会把前面的数据覆盖掉,这是隐形的串消息 bug。4.3 读取数据、翻转、回写三步走读事件是回显服务器的心脏。当key.isReadable()为真,从 key 上取出 Channel 和 Buffer,把数据读进 Buffer,然后完成翻转和回写。if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); ClientSession session (ClientSession) key.attachment(); ByteBuffer buffer session.getBuffer(); int read client.read(buffer); if (read -1) { key.cancel(); client.close(); return; } if (read 0) { buffer.flip(); client.write(buffer); buffer.clear(); } }这三步是回显的本质,但每一步都有讲究。read()返回 -1 是客户端正常关闭的信号,这时候必须key.cancel()并关闭 Channel,否则会留下失效的 key 造成资源泄漏。read()返回 0 在非阻塞模式下表示这次没读到数据,正常,不用管。buffer.flip()让 position 归零、limit 指向数据末尾,这样write()才能从正确的范围把数据吐出去。write()之后buffer.clear()把整个缓冲区标为可写,为下一轮读取腾地方。这三步顺序错了,要么回显空数据,要么数据错位,要么第二次读取直接异常。注意:回显场景数据量小,一次write()通常能写完。但你要有意识,write()在非阻塞下返回值可能小于 buffer 剩余量,真实项目里需要注册OP_WRITE事件来处理写了一半的情况。回显服务器刻意省略这一步是为了聚焦核心,但你得知道完整模型长什么样。4.4 写一个小客户端把链路跑通服务端写完了,快速搞一个客户端验证。最省事的是用 telnet,命令行敲telnet 127.0.0.1 9000,进去之后随便打字,回车就能看到服务端把内容原样吐回来。但 telnet 一次只能开一个,想验证多客户端并发就得开好几个终端窗口。如果想用代码验证,客户端侧简单写个阻塞版本就行,反正验证的是服务端。连上之后循环读用户输入、写出去、再读回来打印。这样你能清楚看到发什么、回什么的一一对应关系。开三个客户端实例,每个发不同的字符串,你会发现服务端那一个线程把三条连接都伺候得明明白白,回显内容各归各的,不会串。这一刻,多路复用就从概念变成了你能亲眼看到的现象。SocketChannel client SocketChannel.open(new InetSocketAddress(127.0.0.1, 9000)); ByteBuffer buffer ByteBuffer.wrap(hello nio.getBytes(StandardCharsets.UTF_8)); client.write(buffer); buffer.clear(); client.read(buffer); buffer.flip(); System.out.println(StandardCharsets.UTF_8.decode(buffer));5. 从回显服务器看三种模型的真实差异5.1 单线程跑多连接的现场观察搭好之后我特意做了个对比实验,这是理解网络模型最直观的方式。开五个 telnet 客户端,各自发送不同内容。观察服务端控制台的日志,你能清楚看到 Selector 每次select()返回后,逐个处理就绪的连接。它没有为每个连接开线程,就主线程一个,轮着来。这个现象背后就是多路复用的本质:把等待这件事从多个线程收敛到一个 Selector 上。BIO 五个连接要五个线程,每个线程都阻塞在自己的read()上,CPU 大部分时间在空转。NIO 一个线程搞定,它不阻塞,只在真有数据的时候才动。连接数越大,这种收敛带来的线程数差异就越夸张。我建议你做个更细的观察:在handle方法入口打印当前线程名。你会发现所有日志都是同一个线程打出来的,名字是main。这一个细节就能让你对单线程管理多连接产生实感,比看十篇文章都管用。回头面试被问到NIO 为什么能用一个线程处理多个连接,你脑子里有画面,答起来就有底气。5.2 非阻塞带来的编程复杂度代价好处说完了,说说代价。BIO 写起来是线性的:accept、read、write,像读故事一样从头到尾。NIO 则是事件驱动的,代码被拆成一段段回调,你必须在脑子里维护一个状态机,知道每个 key 现在处于什么阶段、它的 attachment 里存了什么。这就是为什么很多人觉得 NIO 代码读起来跳。具体到回显服务器,你会遇到第一个复杂点:半包处理。read()读到的数据不保证是一个完整的业务消息。客户端发了你好世界五个字,可能分两次到达,第一次读到你好,第二次读到世界。回显场景还好,原样吐回去就行,客户端拼起来也对。但如果要按行解析或者按 JSON 解析,你就得自己在 session 里维护一个累积缓冲区,直到遇到分隔符或数据完整才处理。BIO 里因为有流的概念,配合BufferedReader按行读,这个问题被封装掉了,而 NIO 把它暴露给了你。第二个复杂点是写操作的不确定性。前面提过,非阻塞的write()可能写不完。要正确处理,你得把没写完的数据留着,给这个 Channel 注册OP_WRITE,等在下一轮 select 里继续写,写完了再取消OP_WRITE注册。注意,OP_WRITE平时不要注册,因为它几乎总是就绪的,一直注册会让 select 空转把 CPU 打满。这些都是 BIO 里不存在的思考负担。5.3 模型选型的判断取舍看到这你可能会问,那到底什么时候用 NIO。我的经验是看两个维度:连接数量和消息大小。连接数少且消息大,比如文件传输、内部服务调用,BIO 配线程池简单可靠,别给自己找麻烦。连接数多且消息小,比如 IM、弹幕、心跳,那就上 NIO。中间地带靠压测决定,别凭感觉。还有一个现实因素:团队熟悉度。如果团队里没人真正写过 NIO,强行上会让维护成本飙升,毕竟事件驱动的代码调试起来比线性代码难得多。成熟框架像 Netty 之所以流行,就是因为它在 NIO 之上封装了易用的抽象,把半包、心跳、编解码都处理好了。所以我的建议是,学习阶段一定要手写一遍原生的 NIO,理解底层;生产环境则优先考虑成熟框架,别重复造轮子。回显服务器正是这个手写一遍的载体,它的目的不是拿去上线,而是让你把底层的每根线都摸清楚。6. 调试过程中最容易踩的坑6.1 五个高频问题的排查速查表回显服务器虽小,坑可不少。我把当初调试时遇到的问题整理成一张表,你遇到现象可以直接对号入座。现象最可能的原因排查/解决客户端连上但没反应新 Channel 未设非阻塞检查configureBlocking(false)收到空消息读后忘了 flip读前补buffer.flip()第二个客户端连不上遍历后没 remove key循环里加it.remove()重复回显或一次回两条selectedKeys 未清理同上,及时 remove某客户端断开后 CPU 飙高失效 key 未 cancelread()-1时 cancel 并 closeselect 返回 0 却空转OP_WRITE 常驻注册只在需要写时临时注册这张表里,it.remove()和configureBlocking(false)是我见过的最高频的两个坑,十个新手九个中招。第三个坑就是 flip,它不报错但结果错,最考验人。6.2 那些不报错但结果错的陷阱报错的坑好查,堆栈一指基本就定位了。最折磨人的是那些不报错、结果却诡异的问题,我挑两个印象最深的说说。第一个是共用 Buffer 导致的消息串台。我最初图省事,所有连接共用一个 ByteBuffer。单客户端测试一切正常,一开两个客户端就出问题:甲发的消息回显到了乙那里。排查半天才反应过来,是 Buffer 的 position 被另一个连接的操作干扰了。解决方案简单,就是每个连接持有自己的 Buffer,放进 attachment 里管理。这个教训让我明白,NIO 里任何共享状态都要仔细掂量,因为事件是交错处理的。第二个是忘记清理 key 导致的内存泄漏。客户端断开后,如果只关闭了 Channel 而没key.cancel(),或者只 cancel 没 close,都会留下问题。Channel 不关,文件描述符泄漏,连接数一多进程就崩。key 不 cancel,虽然 select 不会返回它,但selectedKeys集合里可能残留引用。正确的做法是断开时两件事都做:cancel key,close channel。我现在的习惯是写一个closeSession方法统一收口,避免漏操作。6.3 我的几个排查心得分享几个我调试 NIO 时总结的小技巧,都比较实用。第一,在每次 select 返回后打印就绪的 key 数量和每个 key 的事件类型,这样你能清楚看到事件流是怎么发生的,哪个连接什么时候就绪。这种日志在排查某个连接卡住的问题时特别有用。第二,用 tcpdump 或者抓包工具看底层字节,当你不确定是服务端没收到还是客户端没发出时,抓包能一击定位。虽然回显项目简单,但养成看底层数据的习惯,对以后排查复杂问题大有裨益。第三,给每个连接分配一个自增 ID,日志里所有输出都带上这个 ID。这样多客户端并发时,你能清晰看到哪个连接发生了什么。没有 ID 的日志在多连接场景下就是一团乱麻,根本分不清谁是谁。第四,先单客户端跑通,再上多客户端。别一上来就五个客户端一起压,问题会互相掩盖。单客户端确认基本链路,多客户端确认并发正确性,分步走效率最高。我还在服务端加了个统计,每隔一段时间打印当前注册的 Channel 总数。这个数字在客户端反复连接断开时特别能说明问题:如果只增不减,那必然是断开时的清理逻辑漏了。这种自检手段能让隐蔽的资源泄漏无处遁形。7. 实测数据与性能观察跑通之后我顺手做了点简单的观察,不是为了跑分,而是为了建立体感。用回显服务器,本地回环地址,单客户端连续发送一万条短消息,服务端处理完没有明显延迟,整体耗时在毫秒级徘徊。这个数字说明不了太多,因为回环地址没有真实网络延迟,但至少证明了单线程的处理能力对短消息是绰绰有余的。然后我试着把客户端数加到几十个,每个持续发消息,单线程服务端依然能扛住,没有出现某个连接被饿死的情况。这印证了 Selector 轮询的公平性,它会依次处理就绪的连接,不会因为某个连接数据量大就完全不管别的。当然,如果某个连接持续高速发数据,它每次就绪都会占用一次处理,其他连接分到的时间片会变少,这就是为什么真实项目里通常还要配合业务层的限流。需要注意的是,这些观察都是在回环地址上做的,真实网络下还要考虑延迟、丢包、带宽。所以我从来不用回显服务器的数据去论证NIO 性能比 BIO 好多少,那是另一个维度的压测话题。回显服务器测的是机制正确性,不是性能上限,这两者别混为一谈。想压测性能,你得用专业的工具在真实网络环境里跑,并且明确你的指标是吞吐量还是连接数还是延迟。不过有一个观察我觉得很有价值:我在服务端记录每次select()的返回时间间隔,可以间接看到事件到达的节奏。单客户端高频发消息时,select 几乎刚返回就又就绪,循环转得飞快;没有消息时,select 会稳稳地阻塞住,CPU 占用接近于零。这个忙时高速转、闲时彻底停的特性,正是事件驱动模型的精髓,也是它相比 BIO 轮询省 CPU 的根本原因。8. 后续可以怎么继续演进回显服务器跑通只是起点,你可以沿着几个方向把它做大,每一个方向都能让你对网络编程的理解更进一步。第一个方向是加上简单的协议解析,比如约定消息以换行符结尾,服务端按行解析回显。这一步会逼你正面处理半包问题,给每个连接维护累积缓冲区,是绕不过去的坎。第二个方向是引入多线程,搞一个 Reactor 的单线程加工作线程池模型。主线程只负责 accept 和分发,耗时的业务处理丢给工作线程池,处理完再回写。这时候你会遇到跨线程操作 Channel 的问题,需要理解 Selector 的线程模型——select()和注册操作不能在并发下随意调,通常用wakeup()配合任务队列来解决。第三个方向就是用 Netty 重写一遍,对比原生 NIO 的写法。你会看到 Netty 是怎么用 ChannelPipeline 和 Handler 把事件处理串起来的,怎么用 ByteBuf 简化 Buffer 的读写翻转的。有了手写原生 NIO 的基础,你能看懂 Netty 每个设计背后的动机,而不是只会照抄模板。我个人在实际操作中的体会是,网络编程这东西,看再多不如亲手调一个。回显服务器的价值不在于它能做什么,而在于它逼你把 Selector 循环、Buffer 翻转、事件分派这些最基础也最容易翻车的环节亲手走一遍。等你哪天写一个真实的即时通讯服务,或者调一个偶发的连接泄漏问题时,今天在回显服务器里踩过的坑,都会变成你排查问题时脑子里第一时间浮现的线索。最后再分享一个小习惯:每次写完一段 NIO 代码,我都会刻意用三个客户端来回测,发中文、发长消息、反复断开重连,把边界情况都蹂躏一遍。这比出了线上问题再回头查要省心得多。
返回列表