从 NIO 到 Netty:五个坑,和一张把它填平的基础设施网

Selector 只是起点。用 6 张原理图、可运行代码和一张对比表,看清 Netty 到底封装了什么、优化了什么。

Netty 4.1.139.Final · Java 21 · 约 25 分钟 · 2026-10-18

NIO 把「一个线程等很多连接」的能力交给了你,但没有把「消息边界、半包写、内存复用、断线重连」一起交给你。Netty 的全部价值,几乎都可以用这句话解释。

TL;DR

  • NIO 给你的是能力:非阻塞 + 多路复用。它不负责消息边界、写半包、内存复用、断线重连。
  • Netty 给的是运行时:EventLoop + ChannelPipeline + ByteBuf + Codec + 连接生命周期,把「能跑」变成「可运维」。
  • 代价同样明确:EventLoop 里不能阻塞、直接内存要配额、引用计数要还、排障要懂内部结构。简单场景,NIO 或虚拟线程仍然够用。

01. 先把 NIO 讲清楚:它给的到底是什么

先破除一个常见误解:Netty 没有发明新的 I/O 模型。它仍然建立在 Java NIO 的 Selector 之上,也可以换成 Linux 的 native epoll。NIO 解决的是「等待」的效率问题,不是「消息」的问题。

阻塞 I/O 的模型是:一个连接一个线程,read() 没数据就卡住。连接一多,线程数和上下文切换先把机器压垮。NIO 换了一种等法:把 Channel 设为非阻塞,注册到一个 Selector,由一条线程问内核「哪些连接就绪了」,只处理就绪的。一个线程因此可以扛住成千上万条连接。

代价是:read() 不再保证读到你想要的那么多字节,write() 也不保证一次写完;TCP 是字节流,没有消息边界;ByteBuffer 还有一套 flip() / clear() 状态机。NIO 只提供机制,不提供策略。 这正是后面五个坑的来源。

02. 把原生 NIO 拆到最小

NIO 的核心只有四个对象,先把它们摆在一起:

  • Channel:非阻塞的连接或监听套接字,可读可写,注册到 Selector。
  • Buffer :读写共用的字节容器;写模式与读模式之间要 flip(),读完要 clear() 或 compact()。
  • Selector :多路复用器,底层在 Linux 上通常由 epoll 支撑;select() 返回就绪的 SelectionKey。
  • SelectionKey:Channel 在 Selector 上的注册凭证,携带 interestOps 与 attachment。

图 1. 原生 NIO 的多路复用:一条线程通过 Selector 监视 N 条非阻塞 Channel;select() 返回就绪的 SelectionKey,线程只对就绪的 Channel 做 read/write。它解决了"线程数 = 连接数",但没有解决消息边界、半包写和 Buffer 状态机。

图 1 就是 Reactor 的雏形:一条线程、一个 Selector、N 条 Channel。下面是一个「能跑」的最小 echo 服务,核心循环只有四十来行:

java 复制代码
        while (true) {
            selector.select();                       // normally blocks; can return 0 early (epoll bug, see §4)
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();                         // NIO's classic footgun: remove before handling
                if (!key.isValid()) {
                    continue;
                }
                try {
                    if (key.isAcceptable()) {
                        SocketChannel client = server.accept();
                        if (client == null) {
                            continue;                // non-blocking accept can return null
                        }
                        client.configureBlocking(false);
                        client.register(selector, SelectionKey.OP_READ);
                    } else if (key.isReadable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        buffer.clear();              // TODO: one shared buffer is not enough for concurrency
                        int n = client.read(buffer);
                        if (n == -1) {
                            key.cancel();
                            client.close();
                            continue;
                        }
                        if (n == 0) {
                            continue;
                        }
                        buffer.flip();               // TODO: the flip/clear state machine
                        while (buffer.hasRemaining()) {
                            // TODO: write may return 0 (a partial write). A real server
                            // keeps the rest in a per-channel outbound buffer and
                            // registers OP_WRITE. This line is the "simple" lie.
                            client.write(buffer);
                        }
                        buffer.clear();
                    }
                } catch (IOException e) {
                    key.cancel();
                    key.channel().close();
                }
            }
        }
    }
}

完整文件:code/src/main/java/dev/example/nio/NioEchoServer.java
完整文件在 code/ 下;这是一段刻意保留缺口的教学代码。

它在 localhost 上能回声,但只对「小消息、单客户端、不关心边界」成立。代码里的 TODO 就是 Netty 要补的洞:

  • 共享一个 ByteBuffer:多个连接同时读写会互相踩;真实实现要 per-channel 缓冲区。
  • 写半包 :write() 可能只写一部分,甚至返回 0;正确做法是保留剩余字节、注册 OP_WRITE,等下一次可写。
  • 没有消息边界:读到的可能半条、两条或一条半;需要协议和拆包器。
  • 没有空闲检测、TLS、重连、背压、优雅关闭:这些是「生产可用」的必要条件,不是可选装饰。
  • 没有异常隔离:一个 handler 抛异常,整个 selector 线程都可能受影响。

所以问题不是「NIO 不好用」,而是「NIO 只给你零件」。Netty 做的,是把这些零件装成一台可以上路的车。

03. 坑一:API 与状态机------Netty 的 EventLoop 与 Pipeline

原生 NIO 的代码会随着需求迅速膨胀:今天加 OP_WRITE,明天加心跳,后天加 TLS,再后天处理半包;if/else 很快变成一张网。更麻烦的是线程模型:Reactor 怎么写、业务逻辑放哪、怎样避免一条连接阻塞整个 selector,这些都得自己设计。

图 2. 线程模型对比:原生 NIO 的 select、accept、read、decode、business、encode、write 全在同一条 Reactor 线程上;Netty 用 boss/worker EventLoopGroup 分离 accept 与读写,并把一条 Channel 终身绑定到一个 EventLoop。业务 handler 仍跑在 EventLoop 上,所以阻塞调用必须移到业务线程池。

Netty 的典型线程模型由两个角色组成:boss 只负责 accept,worker 负责已建立连接的读写。每个 worker EventLoop 就是一个 NioEventLoop:一个 Selector、一条线程、一个任务队列、一个定时器。一条 Channel 注册到某个 EventLoop 后,终身由这条线程处理------所以同一条 Channel 的 handler 不需要加锁,天然串行。

业务代码从「管理状态机」变成「实现 handler」。下面这个 echo 服务,业务只有 channelRead0 里的一行 ctx.writeAndFlush(msg):

java 复制代码
public final class NettyEchoServer {

    public static void main(String[] args) throws Exception {
        int port = args.length > 0 ? Integer.parseInt(args[0]) : 8082;

        EventLoopGroup boss = new NioEventLoopGroup(1);   // accepts
        EventLoopGroup worker = new NioEventLoopGroup();  // reads/writes; each Channel is pinned to one EventLoop
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(boss, worker)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline()
                                    // inbound: strip the length prefix, then turn bytes into a String
                                    .addLast(new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4))
                                    .addLast(new StringDecoder(StandardCharsets.UTF_8))
                                    // outbound (tail -> head): String -> bytes, then add the length prefix
                                    .addLast(new LengthFieldPrepender(4))
                                    .addLast(new StringEncoder(StandardCharsets.UTF_8))
                                    // business handler: the only application code
                                    .addLast(new SimpleChannelInboundHandler<String>() {
                                        @Override
                                        protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                            ctx.writeAndFlush(msg);
                                        }

                                        @Override
                                        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
                                            cause.printStackTrace();
                                            ctx.close();
                                        }
                                    });
                        }
                    });

            ChannelFuture future = bootstrap.bind(port).sync();
            System.out.println("Netty echo listening on " + port);
            future.channel().closeFuture().sync();
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

完整文件:code/src/main/java/dev/example/netty/NettyEchoServer.java
完整文件在 code/ 下;addLast 顺序 = 入站方向,出站反向。

这段代码值得逐段读:boss/worker 是线程模型;ServerBootstrap 是启动器;ChannelInitializer 在每条新连接上装 pipeline;LengthFieldBasedFrameDecoder 负责拆包,StringDecoder/StringEncoder 负责字节与字符串的转换;最后才是业务 handler。网络层与业务逻辑被 pipeline 解耦了。

pipeline 的顺序不是装饰:入站从 head 走到 tail,出站从 tail 走回 head 。所以 LengthFieldBasedFrameDecoder 必须在 StringDecoder 之前;StringEncoder 必须放在 LengthFieldPrepender 的后面(更靠近 tail),这样出站时先做 String → ByteBuf,再由 LengthFieldPrepender 加长度头。写反了,数据就会在错误的阶段被处理。

图 3. Netty 如何服务一条连接。① 客户端连接到达 boss EventLoop 的 Selector(OP_ACCEPT 就绪);② boss accept 并创建 NioSocketChannel;③ 这个 Channel 被注册到 worker 组中某一个 EventLoop 的 Selector 上,此后终身由该线程处理;④ 该 Selector 等到 READ,把字节读入 ByteBuf 并送入 ChannelPipeline 头部;⑤ 入站 handler 按 head → tail 解码,业务 handler 处理消息并调用 write(),写出方向反向经过 Encoder;⑥ 编码后的字节写入 ChannelOutboundBuffer;⑦ flush() 把缓冲区推给 socket,响应回到客户端。一个 EventLoop 单线程多路复用 N 条 Channel,因此少量线程即可支撑大量连接,同一条 Channel 的状态无需加锁。

这里要提醒一句:handler 默认跑在 EventLoop 线程上 。在 handler 里做一次阻塞的数据库查询,卡住的不只是这条连接,而是这个 EventLoop 上的所有连接。要么用异步客户端,要么把阻塞逻辑交给 DefaultEventExecutorGroup,要么把任务丢到独立的业务线程池。Netty 给你的是线程模型,不是「随便阻塞」的许可。

04. 坑二:epoll 空轮询------不是 Netty 修了 JDK,而是它不接受你的等待被浪费

Linux 上的 JDK epoll selector 有一个出名的问题:select(timeout) 本应在没有事件时阻塞到超时,却可能提前返回,且返回 0 个就绪 key。如果外层是一个 while (true) { selector.select(); ... },这就变成了忙循环:没有事件、没有任务、CPU 却被打满。

Netty 在事件循环里做两件事:识别连续的空返回 ,以及在达到阈值后重建 Selector 。社区常把这类 Linux epoll 下的空轮询叫 epoll bug;相关的 JDK 记录包括 JDK-6403933(Selector 在 Linux 上不阻塞,JDK 7 修复)与 JDK-6670302(NIO selector 空转,Won't Fix)。另外,NioEventLoop 的 static 块里还有一处 Java < 7 的旧兼容(设置 sun.nio.ch.bugLevel,规避 Selector.open() 的 NPE;其中 JDK-6527572 是测试 bugLevel 时 Charset.forName 的 NPE),注释引用 JDK-6427854、JDK-6527572、netty#203,那与空轮询不是同一件事。

简化后的逻辑如下(依据 Netty 4.1.139 的 NioEventLoop,只保留关键判断):

java 复制代码
// NioEventLoop.run()(简化):每轮 select 返回后 selectCnt++;这里省略处理 selected keys 的部分
selectCnt++;

if (selectReturnPrematurely(selectCnt, ranTasks, strategy)) {
    selectCnt = 0;                      // 有任务或就绪 key,计数归零
} else if (unexpectedSelectorWakeup(selectCnt)) {
    selectCnt = 0;                      // 已处理意外唤醒(中断或重建),计数归零
}

// NioEventLoop.unexpectedSelectorWakeup(int selectCnt)(简化)
if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
        selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
    logger.warn("Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
            selectCnt, selector);
    rebuildSelector();                  // 新建 Selector,迁移已注册的 Channel,关闭旧 Selector
    return true;
}
return false;

// 默认阈值 512,可用 -Dio.netty.selectorAutoRebuildThreshold 调整;设为 0 关闭。
// 参考:NioEventLoop 的 SELECTOR_AUTO_REBUILD_THRESHOLD、unexpectedSelectorWakeup() 与 rebuildSelector()

图 4. epoll 空轮询与 Netty 的兜底:JDK 的 epoll Selector 可能提前返回 0;Netty 统计连续空返回次数,达到阈值(默认 512)后重建 Selector、迁移 Channel 注册,再回到阻塞等待------把不可控的 CPU 100% 降为一次可恢复的告警。

所以「彻底解决」要打个引号:Netty 不能改 JDK,它做的是检测 + 兜底 ,把一次不可控的 CPU 100% 限制成「最多 512 次空转 + 一条告警 + 一次重建」。对生产来说,这已经从事故降级为可恢复事件。阈值可以通过 -Dio.netty.selectorAutoRebuildThreshold 调整,设为 0 则关闭这项兜底。

如果还想更进一步,可以用 Netty 的 native epoll transport(Epoll.isAvailable()、EpollEventLoopGroup、EpollServerSocketChannel),直接走 Linux epoll,绕开 JDK Selector 的部分实现细节。它不是必须的,但在 Linux 高并发场景里是常见选项。

05. 坑三:半包/粘包------TCP 没有消息,只有字节

TCP 是面向字节流的协议:它保证字节按序到达,但不保证「一次 read 对应一条消息」。发送方调了两次 write,接收方可能一次读到两段;发送方写了一条 100 字节的消息,接收方可能先读到 40 字节。于是就有了半包、粘包、以及「一条半」。

图 5. TCP 是字节流:半包、粘包、1.5 条都可能发生。LengthFieldBasedFrameDecoder 按长度字段等待完整帧、切分粘包,并按 initialBytesToStrip 去掉长度头,把字节流还原成消息。

解决办法不是「让 TCP 保留边界」,而是在应用层定义边界 。最常见的是长度前缀:先写 4 字节的长度,再写 payload;接收方先读长度,再读够 payload。Netty 把这个模式做成了 LengthFieldBasedFrameDecoder:

java 复制代码
// 长度字段在 offset 0,占 4 字节;长度值只算 payload;解码后剥掉 4 字节长度头
new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4);

// 出站方向:给 payload 加回 4 字节长度前缀
new LengthFieldPrepender(4);

// pipeline 顺序(入站:head -> tail;出站:tail -> head)
// LengthFieldBasedFrameDecoder -> StringDecoder -> LengthFieldPrepender -> StringEncoder -> business

五个参数值得单独记住:maxFrameLength 防止对端用一个超大长度把你内存打爆;lengthFieldOffset 是长度字段的位置;lengthFieldLength 是它的字节数;lengthAdjustment 用于长度值包含头部等特殊情况;initialBytesToStrip 决定解码后要不要剥掉长度头。

场景 边界 Netty 提供的编解码器
二进制 RPC / 自定义协议 4 字节长度 + payload LengthFieldBasedFrameDecoder + LengthFieldPrepender
文本行协议 换行符 LineBasedFrameDecoder
自定义分隔符 例如 \r\n\r\n DelimiterBasedFrameDecoder
定长包 固定字节数 FixedLengthFrameDecoder

注意:拆包器只负责「把字节流还原成帧」,不负责「你的协议是否合理」。空闲检测(IdleStateHandler)不解决消息边界,属于 §7 的可靠性话题,不在上表。maxFrameLength 一定要设,长度字段的字节序、是否包含头部、是否压缩,都要和客户端严格一致。半包/粘包不是 Netty 的魔法,是协议设计 + 编解码器组合的结果。

06. 坑四:ByteBuf------把内存当成资源来管

原生 ByteBuffer 的问题不是「不能用」,而是「在高频网络 I/O 里不够用」:容量固定,扩容要自己 allocate + copy;读写共用一个 position,flip()/clear() 容易写错;没有池化,频繁分配直接内存会给 GC 和操作系统都造成压力;没有引用计数,泄漏只能靠猜。

Netty 的 ByteBuf 用两个索引把读写分开:readerIndex 之前是已读可丢弃区,readerIndex 到 writerIndex 是可读区,writerIndex 到 capacity 是可写区,capacity 到 maxCapacity 是扩容空间。writeXxx() 推进 writerIndex,readXxx() 推进 readerIndex;需要腾空间时 discardReadBytes(),需要更多空间时 ensureWritable()。

图 6. ByteBuf 的两个索引、三个主要区间 + 扩容余量,以及池化分配与引用计数:读写指针分开,不必手动 flip/clear;扩容交给 ensureWritable();池化减少分配与 GC;引用计数让释放时机可控,也要求你只释放一次。

更关键的是内存管理:Netty 4.1 默认使用池化的 PooledByteBufAllocator,按 Arena / Chunk / Page / Subpage 复用内存(默认 Page 8 KiB、Chunk 4 MiB,Arena 数量与 CPU 和最大内存相关)。池化减少了分配和 GC,但也意味着你必须用引用计数告诉它「什么时候可以回收」:

java 复制代码
    public static void main(String[] args) {
        ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
        ByteBuf buf = alloc.buffer(16);              // pooled, refCnt = 1
        try {
            buf.writeInt(42);
            buf.writeBytes("hi".getBytes(StandardCharsets.UTF_8));
            System.out.printf("reader=%d writer=%d readable=%d writable=%d%n",
                    buf.readerIndex(), buf.writerIndex(), buf.readableBytes(), buf.writableBytes());

            int n = buf.readInt();                   // readerIndex += 4
            System.out.println("readInt=" + n);

            buf.discardReadBytes();                  // compact: discard the read region
            System.out.printf("after discard: reader=%d writer=%d%n",
                    buf.readerIndex(), buf.writerIndex());

            ByteBuf slice = buf.retainedSlice(0, buf.readableBytes()); // parent refCnt becomes 2
            System.out.println("parent refCnt=" + buf.refCnt() + ", slice refCnt=" + slice.refCnt());
            slice.release();                         // parent refCnt back to 1; slice refCnt 0
        } finally {
            buf.release();                           // refCnt 0, back to the pool
        }
        System.out.println("buf refCnt after release=" + buf.refCnt());
    }
}

完整文件:code/src/main/java/dev/example/netty/ByteBufDemo.java
运行输出:readerIndex/writerIndex、retainedSlice 的父子 refCnt、release 后归零。

引用计数是 ByteBuf 最容易被忽略、也最容易出事的部分。ctx.alloc().buffer() 返回的 buf 初始 refCnt = 1;retainedSlice() 会让父 buf 的 refCnt 加一。切片自己的 refCnt 是否独立取决于实现:池化 ByteBuf(本文 demo)的切片有独立 refCnt(parent=2、slice=1),而 Unpooled 的 retainedSlice() 切片与父 buf 共享 refCnt(parent=2、slice=2)。无论哪种,每 release() 一次减一,归零后才回到池里;谁 retain,谁 release;只 release 一次。

  • SimpleChannelInboundHandler 会自动 release 它读到的消息;ChannelInboundHandlerAdapter 不会,需要你手动 ReferenceCountUtil.release(msg)。
  • slice() / duplicate() 不增加引用计数,生命周期跟随父 buf;retainedSlice() 会增加,必须配对 release。
  • 跨线程传递 ByteBuf 时,先 retain() 再交给另一个 EventLoop,否则可能已被回收。
  • 调试泄漏:-Dio.netty.leakDetection.level=paranoid(或 ResourceLeakDetector.setLevel),它会打印泄漏对象的创建栈;级别有 DISABLED / SIMPLE / ADVANCED / PARANOID,默认 SIMPLE。
  • 直接内存要设上限:-XX:MaxDirectMemorySize 与 -Dio.netty.maxDirectMemory 都值得关注;池化内存不一定会立刻还给操作系统。

还有一个容易踩的坑:池化 ByteBuf 不保证内容为零 。如果你依赖「新 buffer 是干净的」,请显式清零;不要假设 alloc().buffer() 返回的是全 0。

07. 坑五:可靠性------那些「顺手也要做」的事

一个能跑的 echo 和一个能上生产的服务之间,还差一长串「顺手也要做」的事:连接空闲要关、TLS 要握手、断线要重连、写不出去要背压、进程退出要优雅关闭、异常要隔离。原生 NIO 上这些都要自己写;Netty 把它们做成了可组合的 handler 和配置。

例如空闲检测:IdleStateHandler 在指定时间内没有读/写/全部事件时触发 IdleStateEvent,业务 handler 收到后决定关闭还是发心跳。

java 复制代码
    /** Close a connection that has been quiet for 30 s. */
    static void heartbeat(ChannelPipeline pipeline) {
        pipeline.addLast(new IdleStateHandler(30, 0, 0, TimeUnit.SECONDS));
        pipeline.addLast(new ChannelInboundHandlerAdapter() {
            @Override
            public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
                if (evt instanceof IdleStateEvent) {
                    ctx.close();
                } else {
                    super.userEventTriggered(ctx, evt);
                }
            }
        });
    }

完整文件:code/src/main/java/dev/example/netty/BestPractices.java
完整文件还包含 TLS、重连、背压与优雅关闭;这些方法已编译,未在本机跑 TLS(需要证书)。

能力 Netty 的入口 要点
TLS/SSL SslContextBuilder + SslHandler 放在 pipeline 最前面;握手完成前不要发业务数据
断线重连 Bootstrap.connect + ChannelFutureListener + schedule 加退避与抖动;避免风暴
背压 isWritable()、WriteBufferWaterMark、setAutoRead + channelWritabilityChanged 写不出去时先别读,恢复可写后再打开
优雅关闭 shutdownGracefully(quietPeriod, timeout, unit) 停止接收新任务,排空队列,再关连接
异常隔离 exceptionCaught + 业务线程池 别让一个 handler 拖垮 EventLoop
阻塞逻辑 DefaultEventExecutorGroup 或在 handler 里异步化;绝不在 EventLoop 上 sleep/查库

BestPractices.java 里的 reconnect() 是一个最小示例:连接失败时,在这个 EventLoop 上 schedule 3 秒后重试。生产环境还要考虑指数退避、随机抖动、最大重试次数和「重连期间请求缓存」。

08. 一张总表,和 Netty 的代价

把五个坑放在一起,Netty 的封装和优化可以压缩成一张表:

痛点 原生 NIO 的现实 Netty 的答案 本文对应
API 与状态机 Selector/Channel/Buffer 手工编排;flip/clear、OP_WRITE、Reactor 全靠自己 EventLoop + ChannelPipeline + ChannelFuture §3、图 2/图 3
epoll 空轮询 JDK selector 提前返回 0,while 循环空转,CPU 100% selectCnt 检测 + 重建 Selector;可选 native epoll §4、图 4
半包/粘包 TCP 字节流没有消息边界,完全靠应用层协议 Length/Line/Delimiter/Fixed 解码器 + Prepender §5、图 5
内存管理 ByteBuffer 定长、flip/clear、无池化、直接内存靠 GC、易泄漏 ByteBuf 双索引 + 池化分配 + 引用计数 + 泄漏检测 §6、图 6
可靠性 空闲、TLS、重连、背压、优雅关闭都要自建 IdleStateHandler/SslHandler/背压/graceful shutdown §7

但 Netty 不是免费的。它把复杂度从「网络编程」转移到了「框架使用」:

  • EventLoop 不能阻塞:一条连接的阻塞会拖住整个 EventLoop;数据库、文件、同步 RPC 都要移出去或异步化。
  • 直接内存要配额 :池化内存不是无限的;-XX:MaxDirectMemorySize、-Dio.netty.maxDirectMemory 和 RSS 都要看。
  • 引用计数要还 :double release、use-after-release、跨线程没 retain,都是生产事故;PARANOID 泄漏检测有开销,按需开启。
  • 排障门槛更高 :pipeline 顺序、@Sharable、EventLoop 线程名、ChannelOutboundBuffer 的水位,都需要理解。
  • 版本要选对 :示例基于 4.1.139.Final(截至 2026-10,4.1 线最新;4.2 线为 4.2.19.Final)。4.2 基本兼容 4.1 API;5.0 仍是 alpha,不建议生产使用。

什么时候不该上 Netty?连接数不多、协议简单、团队更熟悉阻塞模型时,ServerSocket + 虚拟线程(JDK 21 的 Loom)可能更直接。Netty 的优势在「高并发连接 + 多协议编解码 + 成熟的生命周期管理」;如果你的问题不在这里,引入它只是多背一套概念。

09. 结语:Netty 把「正确使用 I/O」变便宜了

回到开头那句话:NIO 提供机制,Netty 提供策略。它没有让 read() 更快,也没有让 TCP 产生消息边界;它做的是把「非阻塞 I/O 的正确用法」封装成 EventLoop、Pipeline、ByteBuf、Codec 和一组可组合的 handler,让一个普通业务团队也能写出能上生产的网络服务。

所以,Netty 真正解决的从来不是「NIO 的 API 太啰嗦」,而是:当连接数、协议复杂度、可靠性要求一起上来时,你愿不愿意把网络层的脏活交给一套被大量生产系统验证过的运行时。

10. 参考与运行示例

本文的示例在 Java 21 + Netty 4.1.139.Final 上编译并实际跑通:原生 NIO echo、Netty echo(含完整帧、半包、粘包三条写入)、ByteBuf 引用计数 demo。代码在 code/ 下:

运行(需要 JDK 17+、Maven 与 Python 3;示例在 Java 21 上跑通):cd code && ./run-demo.sh。它会先起原生 NIO 服务(18081)和 Netty 服务(18082),用 Python 验证前者、用 EchoClient 验证后者,最后跑 ByteBuf demo。预期最后几行是:

text 复制代码
native NIO echo: b'hello'
echo <- hello
echo <- world
echo <- again
echo <- done
OK: four framed echoes received
reader=0 writer=6 readable=6 writable=10
readInt=42
after discard: reader=0 writer=2
parent refCnt=2, slice refCnt=1
buf refCnt after release=0

参考资料:


示例基于 Netty 4.1.139.Final 与 Java 21,已编译并跑通;图 3 复用之前的 Netty 机制图。运行:cd code && ./run-demo.sh。

相关推荐
福大大架构师每日一题1 小时前
2026-10-18:转换字符串的最小成本Ⅲ。用go语言,给定两个字符串 source 和 target,以及若干条替换规则。每条规则包含一个模式串、一个替换串
后端
韩振方1 小时前
Kubernetes Secret 让数据库密码离开镜像和 Pod 配置
后端
闽林致远1 小时前
基于标准 C++ 的插件化多线程通用数据采集框架|支持自定义业务模块,Oracle/MySQL 入库、FTP 图片上传,Windows/Linux 跨平台部署
后端
denlaku1 小时前
JDK 15 新特性详解
java·开发语言
doujiaozh1 小时前
协议演进实录:从明文哈希到 AES-GCM 加密,如何平滑升级?
后端·node.js
花卷持续成长1 小时前
慢SQL的整体优化思路
后端·mysql
荣码1 小时前
LangSmith可观测性:AI应用出bug根本不知道哪里出了问题,我踩了4个坑
java·python
Sashtok1 小时前
cpp基础知识(4)
后端
Sashtok1 小时前
cpp基础知识(6)
后端