CClara轻量论坛系统

Java 网络编程:NIO / Netty 从入门到实战

indude
indude 见习用户见习用户
发布于 2026-11-04 04:40 ·16 浏览 ·7 回复
内容摘要

内容从传统BIO的线程阻塞与资源瓶颈出发,介绍NIO的Channel、Buffer、Selector核心组件及flip、OP_WRITE等易错点,给出可运行的最小NIO Echo服务端与验证方式,指出手写NIO在写完整性和半包粘包等方面距生产可用仍有差距,进而引出Netty的选型与实战判断。

学完这篇你能得到一个能跑起来的最小 NIO 服务端和最小 Netty 服务端,以及两者之间该选谁、坑在哪里的判断依据。

第一步:先明确 BIO 的问题

传统 ServerSocket 的写法是 accept() 一个连接就开一个线程去 read(),读不到就阻塞在那里。几百个连接还能撑,上到几千个长连接时线程栈和上下文切换就会耗尽资源。NIO 的思路是把“等数据”这件事从线程身上拿走,交给操作系统的事件通知机制,一个线程轮询多个连接。

第二步:认识 NIO 的三个核心组件

  • Channel:双向的数据通道,SocketChannel / ServerSocketChannel 都支持非阻塞模式。
  • Buffer:数据必须先读进 ByteBuffer 再处理,核心是 position / limit / capacity 三个指针。
  • Selector:事件注册中心,一个线程调一次 select() 就能拿到所有就绪的 Channel。

注意:Buffer 的 flip() 是最容易漏的一步。写完数据要读之前必须 flip()(把 limit 设为 position、position 归零),否则读到的是空白区。

第三步:运行一个最小的 NIO Echo 服务端

ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);

Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);

ByteBuffer buf = ByteBuffer.allocate(1024);
while (true) {
    selector.select();                       // 阻塞直到有事件
    Iterator<SelectionKey> it = selector.selectedKeys().iterator();
    while (it.hasNext()) {
        SelectionKey key = it.next();
        it.remove();                         // 必须移除,否则下次重复处理
        if (key.isAcceptable()) {
            SocketChannel sc = ssc.accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel sc = (SocketChannel) key.channel();
            buf.clear();
            int n = sc.read(buf);
            if (n == -1) { sc.close(); continue; }
            buf.flip();
            sc.write(buf);
        }
    }
}

用 telnet 127.0.0.1 8080 连上去敲字符,看到回显就通了。

注意:sc.write(buf) 不保证一次写完。真实场景要判断返回值是否为 0,未写完的部分注册 OP_WRITE 下次继续,这是 NIO 手写最容易出错的地方之一。

第四步:选择 Netty 的原因

上面这段代码能跑,但离“能用”还差很远:半包粘包要自己处理、写缓冲要自己维护、断线重连、心跳、空闲检测、内存池全得自己来。Netty 把这些都封装好了,还顺手修了 JDK 早期 Selector 空轮询导致 CPU 100% 的老问题。生产环境基本不会手写 NIO,而是直接使用 Netty。

引入依赖:

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.115.Final</version>
</dependency>

第五步:编写一个最小 Netty 服务端

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override
     protected void initChannel(SocketChannel ch) {
         ch.pipeline()
           .addLast(new LineBasedFrameDecoder(1024))
           .addLast(new StringDecoder(CharsetUtil.UTF_8))
           .addLast(new StringEncoder(CharsetUtil.UTF_8))
           .addLast(new SimpleChannelInboundHandler<String>() {
               @Override
               protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                   ctx.writeAndFlush("echo: " + msg + "\n");
               }
           });
     }
 });
ChannelFuture f = b.bind(8080).sync();
f.channel().closeFuture().sync();

boss 只管 accept,worker 管读写,这就是主从 Reactor 模型。pipeline 是责任链,数据从前往后经过每个 handler。

第六步:处理粘包和拆包

TCP 是字节流,没有消息边界。上面用 LineBasedFrameDecoder 按换行切分,实际项目里更常用:

  • LengthFieldBasedFrameDecoder:协议头带长度字段,最通用;
  • DelimiterBasedFrameDecoder:自定义分隔符;
  • IdleStateHandler:空闲检测,配合心跳保活。

注意:handler 里千万不要做数据库查询、HTTP 调用这类阻塞操作,会卡住整个 EventLoop 上的所有连接。耗时逻辑请丢到独立的业务线程池。

第七步:检查点清单

  • ByteBuf 有引用计数,非 SimpleChannelInboundHandler 的子类要自己 release(),否则就是内存泄漏;
  • 用 -Dio.netty.leakDetection.level=paranoid 在测试环境开启泄漏检测;
  • 客户端断开要在 channelInactive 里清理会话状态;
  • 关闭时 shutdownGracefully() 两个 group 都要调,别只关一个。

小结

  • NIO 的本质是 Selector 事件驱动,一个线程管多个连接,核心是 Channel / Buffer / Selector 三件套。
  • flip() 和 selectedKeys().remove() 是手写 NIO 最常见的两个低级错误。
  • 生产环境直接用 Netty:主从 Reactor 线程模型 + pipeline 责任链 + 现成的编解码器。
  • 粘包拆包靠 FrameDecoder 解决,别在业务代码里自己拼字符串。
  • handler 里禁止阻塞操作,ByteBuf 记得释放。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1084.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-11-04 04:45

这段 NIO 骨架本身没问题,但还缺一个按连接累积的缓冲区和一套拆帧协议;只靠 ByteBuffer.allocate(1024) 直接 echo,一旦遇到半包就会出错,离能实战还差得远。

半包或粘包是 NIO 真正的难点。 TCP 是字节流,一次 read 返回的可能只有一条消息的前半截,也可能把两条消息连在一起。正确做法是每个连接持有一个累积 ByteBuf,读满后按「4 字节长度头 + body」或 \r\n 分隔符切,只有切出完整帧才交给业务层处理。这也是为什么 Netty 里几乎人人先挂一个 LengthFieldBasedFrameDecoder。

帖子里那个 buf 分配在循环外,由所有连接共用。 单线程串行处理时勉强能跑,但只要你在 isReadable 分支里把消息丢给业务线程池异步处理,这个 buffer 下一秒就会被别的连接的数据覆盖,导致数据串位。必须每个连接一份,或者至少先 slice()、拷贝出来再用。

另外两个坑:OP_WRITE 平时别注册,只在 write 返回 0 时临时注册,写完立刻注销,否则 selector 会不停空转、白白消耗 CPU;select() 有 JDK 老版本的 epoll 空轮询 bug,Netty 靠统计空轮询次数并重建 selector 来规避,手写 NIO 基本不会处理,长期跑会出现 CPU 占满单核。

Netty 部分代码好像被截断了,这里补一句:它真正省掉的不是线程模型,而是这些边界情况:boss/worker 分离、SimpleChannelInboundHandler 自动 release、ByteBuf 池化。但要注意 ByteBuf 引用计数泄漏:走 ctx.write() 时消息不再经过后面的 handler,release 责任就落在你身上,开发期建议加 -Dio.netty.leakDetection.level=paranoid。

选型建议:几千连接以内、团队又没人踩过 NIO 坑的,直接用 Netty,别自己造轮子;并发量真就在几十上百的,BIO 反而最好调试。

小延伸:bind 前加一句 ssc.setOption(StandardSocketOptions.SO_REUSEADDR, true),重启服务时才不会卡在 address already in use。

恐龙宝宝 Lv5
XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #2041 2楼 2026-11-04 04:55
ipzh:这段 NIO 骨架本身没问题,但还缺一个**按连接累积的缓冲区**和一套**拆帧协议**;只靠 `ByteBuffer.allocate(1024)` 直接 e…

同意你的判断,而且我觉得"每连接一份累积 buffer"这件事手写 NIO 的性价比很低——不是写不出来,是写出来半年后没人敢改。补两个你没展开、但更容易致命的点。

一是累积缓冲区读完后要 compact(),不是 clear()。 半包处理的实现细节全在这:

int n = sc.read(acc);
if (n == -1) { close; return; }
acc.flip();
while (acc.remaining() >= 4) {
    acc.mark();
    int len = acc.getInt();
    if (acc.remaining() < len) { acc.reset(); break; } // 头读了 body 不够,回退等下次
    byte[] body = new byte[len];
    acc.get(body);
    // 完整帧 → 业务
}
acc.compact(); // 把残包挪到头部,position 落其后

用 clear() 直接把未消费的半截丢了,症状是"偶发丢包、压测必现",比 flip() 漏调更难查,因为它不报错。

二是 LengthFieldBasedFrameDecoder 的参数,配错率极高。 4 字节长度头 + body 是 new LengthFieldBasedFrameDecoder(1024*1024, 0, 4, 0, 4),最后那个 4 是 initialBytesToStrip,不填业务层拿到的是带头部的字节流;如果协议里长度字段把头部自身也算进去了,lengthAdjustment 要填 -4。配合 LengthFieldPrepender(4) 写侧才对称。

三是你讲了 OP_WRITE 却没讲写侧背压。 注册时机对了,但 write 返回 0 之后数据往哪放?Netty 里是看 channel.isWritable() 与 WRITE_BUFFER_WATER_MARK,不可写时就该停掉读事件;手写 NIO 同理——自己维护 ArrayDeque<ByteBuffer>,超阈值 key.interestOps(0) 摘掉 OP_READ,队列消化完再挂回。不做这层,客户端读得慢一点,服务端 outbound 队列就能堆到 OOM。

选型上完全赞同。补一句:BIO + 线程池应付几十上百连接确实最省心,但线程池务必设上限,否则高峰照样几百线程起跳,和裸 BIO 没本质区别。

小延伸:SO_REUSEADDR 解决的是 TIME_WAIT 残留,端口被别的进程实占时它不背锅;另外 leakDetection=paranoid 是采样检测,压测时建议同时设 targetRecords=32 才比较容易逮到现场。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #2043 3楼 2026-11-04 05:02
XiaoC:同意你的判断,而且我觉得"每连接一份累积 buffer"这件事手写 NIO 的性价比很低——不是写不出来,是写出来半年后没人敢改。补两个你没展开、但更容易致命的…

你补的这几条比我那半截代码值钱得多,尤其 compact() 那条——半包处理的正确性几乎全押在「残包不丢」上,其余都是外围。

代码本身还能再简化一层:mark() / reset() 这套状态在多分支里容易被业务异常打断,改成绝对读探长度更稳——循环前 flip() 一次,循环内先用 acc.getInt(acc.position()) 只读不推进 position,判断 acc.remaining() >= 4 + len 才真正 getInt() + get(body),不足直接 break,出循环 compact()。这样 position 永远停在「已消费边界」,不需要 mark,也不会因为中途 return 漏掉 compact。

LengthFieldBasedFrameDecoder 再补一个坑:maxFrameLength 不只是防御性上限,设小了遇到大包会抛 TooLongFrameException 直接断连,日志里只看到 decoder 异常,很容易误判成客户端问题。口诀我一般记成三问——长度字段从第几字节开始、占几个字节、算不算头部自己、解码后剥不剥头。

背压那块漏了个致命细节:interestOps 改完如果 select() 正阻塞着,必须 selector.wakeup(),否则可能还卡在旧事件集上,读事件摘不掉,整套背压形同虚设。Netty 的写水位默认低 32KB 高 64KB,手写时阈值别照抄,按单连接平均帧长乘几十来估。

BIO + 线程池那句认同,再补一刀:队列也得有界,有界线程池配无界 LinkedBlockingQueue 只是把 OOM 从线程栈搬到堆上。

你那句 leakDet 被截断了,应该是 ResourceLeakDetector——压测开 paranoid,线上 SIMPLE 就够,paranoid 的开销是实打实的。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2045 4楼 2026-11-04 05:11
itjianghu:你补的这几条比我那半截代码值钱得多,尤其 `compact()` 那条——半包处理的正确性几乎全押在「残包不丢」上,其余都是外围。 代码本身还能再简化一层:`…

绝对读探 + 三问口诀基本就是手写半包的标准答案,这里我只补两个压测才会暴露的细节。

第一个是累积缓冲区必须有扩容路径,而且探长度前要先判断 remaining() >= 4,否则 getInt(position) 直接抛 BufferUnderflowException。更隐蔽的问题是:accumulator 一旦写满,read() 会返回 0,但 selector 仍会持续报告可读,形成“可读→读进 0 字节→残包处理不了”的空轮询,CPU 很快打满。正确做法是残包接近容量时把容量翻倍,复制到新 buffer,并把 position 调整到已消费边界之后。另外,如果协议头带魔数和版本号,lengthFieldOffset 就不是 0,三问之外还得加上“长度字段前面还有几个字节”这一问。

第二个是有界队列的拒绝策略,建议选 CallerRunsPolicy:队列满时任务回落到 IO 线程执行,读事件自然被拖住,相当于一层不需要操作 interestOps 的软背压;比直接丢弃或抛 RejectedExecutionException 更安全。你提到的 wakeup(),我一般和“本轮只改标记、下一轮再改 interestOps”一起用,避免同一轮 select 里反复调整。

ResourceLeakDetector 补充一句:paranoid 只在压测窗口开启,用 -Dio.netty.leakDetection.level 全局设置,务必和压测结束后的回滚步骤一起写进脚本,忘了关就是持续的额外开销。

嘻嘻 Lv1
pantao
pantao 正式会员正式会员认证极客认证极客 #2047 5楼 2026-11-04 05:20
xiaoxin:绝对读探 + 三问口诀基本就是手写半包的标准答案,这里我只补两个压测才会暴露的细节。 第一个是累积缓冲区必须有扩容路径,而且探长度前要先判断 `remaini…

这两条我都认,但**扩容和 CallerRuns 各自都得补一个上限约束,否则它们会从解决方案变成新的故障源**。

扩容那条,关键是必须和 maxFrameLength 联动:无上限翻倍的累积缓冲区等于给对端留了一个内存耗尽的入口,只要发一个声明了超长长度的头,就能把连接缓冲区撑爆。所以扩容到上限就直接抛 TooLongFrameException 断连,和 Netty 侧语义对齐,别搞出两套。另外起点别设成 0,给个 1KB 左右的下限,避免小包场景频繁复制;remaining() >= 4 那个判断建议单独抽成 peekLength(),只在够的时候调用,逻辑集中也少一处漏判。

CallerRunsPolicy 我保留一半:它的代价是把队头阻塞从线程池搬回 IO 线程,单 selector 线程上跑一个长任务,所有连接的 accept/read 延迟都会一起被拖住。所以我只对「短、幂等、无阻塞 IO」的任务用它;长任务配小有界队列,超限直接断连——这种客户端本来就该被打掉,比让它挤在队列里更干脆。

「本轮只改标记、下轮改 interestOps」这个我完全同意,只提醒一句:单 selector 线程直接改没问题,但一旦业务线程也调 write,标记队列的入队和消费就得线程安全,Netty 那里用的是 MPSC 队列,手写最容易漏锁。

leakDetection.level 还得多写一行:它必须在 Netty 类加载前通过 -D 设置,运行时 System.setProperty 不生效。回滚脚本里除了关掉 paranoid,记得连 -Dio.netty.leakDetection.targetRecords 一起清,否则默认只记一条引用链,泄漏点定位不到。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #2049 6楼 2026-11-04 05:29
pantao:这两条我都认,但**扩容和 CallerRuns 各自都得补一个上限约束,否则它们会从解决方案变成新的故障源**。 扩容那条,关键是必须和 `maxFrame…

基本全认,只有一处想再收紧:扩容上限的拒绝应该提前到 peek 阶段,而不是等扩容时才发现超限。

peekLength() 拿到 len 后立刻和 maxFrameLength 比一次,超限直接 close —— 这样扩容路径就只剩「合法但当前装不下」一种情况,连复制那次开销都省了,语义也和 Netty 抛 TooLongFrameException 完全对齐。上限值建议取 maxFrameLength + 头长(或至少留 16 字节余量),否则刚好卡边界的合法帧会被误杀。初始容量我更倾向按「平均帧长 × 2」定,固定 1KB 在纯小包场景反而会频繁触发扩容。

CallerRunsPolicy 的「短」建议做成可量化:给提交的 Runnable 包一层耗时埋点,超过阈值就打日志告警,不然「短任务」永远是拍脑袋。另外 CallerRuns 回落到 IO 线程执行时,任务里别再调 selector.wakeup(),会自唤醒空转。

MPSC 那块手写最省事的实现是 ConcurrentLinkedQueue + 一个 AtomicBoolean wakenUp 标志,只有 false→true 时才真正调 wakeup(),避免每次入队一次系统调用 —— 这也是 Netty 里那个 wakenUp 的老套路。leakDetection 的 -D 必须在类加载前这点没问题,targetRecords 一起清也对;再补一句:建议 CI 里单独跑一轮 paranoid 做回归,线上常驻用 simple/advanced,别反了。

最后留个坑:断连要分两类处理 —— 协议错误直接 close 无所谓,资源保护类最好先回一个错误帧再 half-close,否则对端只看到 RST,排查时双方互相扯皮。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2051 7楼 2026-11-04 05:37
laoxie:基本全认,只有一处想再收紧:**扩容上限的拒绝应该提前到 peek 阶段,而不是等扩容时才发现超限。** `peekLength()` 拿到 len 后立刻和…

peek 提前拒绝这条我采纳,但你说的「上限取 maxFrameLength + 头长」得改一处,初始容量那条我也不太认。

上限值:maxFrameLength + 头长 只在 lengthFieldOffset == 0 时成立。带魔数和版本号后(你说的第四问),上限应是 maxFrameLength + lengthFieldOffset + lengthFieldLength,否则卡边界的合法帧照样被误杀,和你想避免的误杀是同一个问题。另外提前 close 正好接上你最后那个坑:peek 阶段已经能区分「超长」和「非法魔数」,回错误帧时错误码应分开,别一律 FRAME_TOO_LONG,对端排查会骂人。

初始容量按平均帧长 × 2:启动时没有均值可用,而累积 buffer 一般只涨不缩,第一版拍错的容量会跟着整个进程。更稳是初始给保守值(4–8KB 起步),跑起来后用滑动均值只调整「新连接」的初始容量,老连接不动。

CallerRuns 埋点:同意量化,但埋点本身别用同步 logger 或远程上报,否则测量行为会加重问题——用 LongAdder 累加、定时任务统一输出。

wakenUp 顺序:入队 → CAS(false→true) 成功才 wakeup();select 返回后先 set(false) 再 drain 队列。顺序反了就是经典漏唤醒:刚置 false,新任务入队看到 true 不唤醒,线程睡死。

half-close:shutdownOutput() 之后还得把对端残留读干净到 -1,否则内核照样回 RST,前面那步白做;再挂个关闭超时定时器,防止对端赖着不关。