Netty 框架学习:从 BIO → NIO → Netty(小白版)


0. 学习路线总览(一张图先建立全局感)

┌────────────────────────────────────────────────────────────────────┐
│                  Java 网络编程演进线                                  │
│                                                                    │
│  BIO ──────────────►  NIO ──────────────►  Netty                  │
│  (阻塞IO)              (非阻塞IO)            (基于NIO的框架)          │
│                                                                    │
│  每连接一线程            Channel+Buffer         boss/worker线程组     │
│  实现简单               +Selector多路复用        Pipeline流水线        │
│  线程爆炸扛不住          能扛高并发              异步非阻塞回调          │
│  只适合连接少            编程复杂易出错           高性能零拷贝            │
│  │                     │                      │                    │
│  └─ 缺点逼出 NIO        └─ 复杂逼出 Netty       └─ 今天的主角          │
└────────────────────────────────────────────────────────────────────┘

三个必须理解的核心差异:
  ① 阻塞 vs 非阻塞         —— 一个线程能不能同时"看管"很多连接
  ② 线程模型               —— 一个连接一个线程?还是一个线程很多连接?
  ③ 开发体验               —— 手写 NIO 的坑,Netty 帮你填平

下面按顺序:先理解网络编程的最小单位(Socket)BIO 怎么写的它为什么不行NIO 怎么改NIO 为什么还难用Netty 怎么解决


第 1 章 网络编程基础:Socket 到底在干嘛

1.1 一次 TCP 通信的本质

想象两台电脑打电话:

  客户端(打电话的人)                    服务端(接电话的人)
      │                                      │
      │  1. 拨号(new Socket)                 │
      │ ────────────────────────────────────►│  ServerSocket.accept()
      │         3. 建立连接(三次握手)         │      等待电话进来(阻塞)
      │                                      │
      │  2. 说话(输出流.write)               │
      │ ────────────────────────────────────►│  4. 听(输入流.read)
      │         5. 回话                        │
      │ ◄────────────────────────────────────│
      │  6. 挂电话(close)                     │
  • Socket:一条 TCP 连接的两端各有一个 Socket,它是"网络通信的管道口"。
  • ServerSocket:服务端的"电话总机",只负责接电话(accept),接进来后返回一个用于通信的 Socket。
  • InputStream / OutputStream:Socket 上的读写流,读=接收数据,写=发送数据。

记住一句话:网络编程的本质 = 在 Socket 的流上"读"和"写"字节。所有框架(BIO/NIO/Netty)都是在处理"怎么读、怎么写、谁来读、谁来写"这个问题。

1.2 你写的第一个 Socket 服务端(这就是 BIO 的雏形)

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class BioDemo1 {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(9000);
        System.out.println("服务端启动,端口 9000");
        while (true) {
            // ★ 阻塞点1:accept() 会一直停在这里,直到有客户端连进来
            Socket socket = serverSocket.accept();
            System.out.println("收到一个连接: " + socket.getRemoteSocketAddress());

            // 读取客户端发来的数据
            BufferedReader reader = new BufferedReader(
                    new InputStreamReader(socket.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println("收到数据: " + line);
            }
        }
    }
}

运行后你会发现:

  1. 第一个客户端连上,能正常收发。
  2. 第二个客户端连不进来——因为服务端卡在第一个连接的 readLine() 上,while(true) 根本走不到下一次 accept()

这就是 BIO 的"阻塞":一个连接没处理完,服务端就卡死在那,无法服务其他人。下一章正式讲。


第 2 章 BIO:阻塞 IO(Blocking I/O)

2.1 什么是"阻塞"

"阻塞"= 线程停在某个方法上,干等,啥也不做

BIO 有两个著名的阻塞点:

阻塞点 发生位置 表现
阻塞 ① accept() 线程停住,等新的客户端连接,没连接就一直等
阻塞 ② read() / readLine() 线程停住,等对方发数据,没数据就永远停着

最要命的是②:一个连接建立了,即使它半天不说话,服务端线程也必须在 read() 上死等它,不能去管别的连接。

2.2 BIO 的经典写法:一连接一线程(线程池版)

为了解决上面"一个连接卡死全部"的问题,最朴素的思路就是:来一个连接,就派一个线程去专门伺候它

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BioServerThreadPool {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(9000);
        System.out.println("BIO 服务端启动,端口 9000");

        // 固定线程池:线程数是有限的,防止连接无限涨
        ExecutorService pool = Executors.newFixedThreadPool(20);

        while (true) {
            // 主线程只做一件事:接电话
            Socket socket = serverSocket.accept();
            System.out.println("收到连接: " + socket.getRemoteSocketAddress());
            // 把连接丢给一个工作线程去处理,主线程立刻回去继续 accept
            pool.execute(() -> handle(socket));
        }
    }

    private static void handle(Socket socket) {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(socket.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(Thread.currentThread().getName() + " 收到: " + line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

图解 BIO 线程池模型:

                    ┌─────────────────────────────┐
  客户端A ──连接──►  │                             │
                    │        ServerSocket         │
  客户端B ──连接──►  │        (main 线程 accept)    │
                    │                             │
  客户端C ──连接──►  └───────────┬─────────────────┘
                                │ 每来一个连接,从线程池取一个线程
        ┌───────────┬───────────┼───────────┬──────────────┐
        ▼           ▼           ▼           ▼              ▼
   线程1(伺候A)  线程2(伺候B)  线程3(伺候C)  ...          线程20
   read()阻塞     read()阻塞    read()阻塞

2.3 BIO 的致命问题(为什么要淘汰它)

问题 说明 后果
① 线程爆炸 一个连接占一个线程 1 万个连接 = 1 万个线程,每个线程默认约 1MB 栈内存,光内存就 10GB,直接 OOM
② 线程大部分时间在空等 绝大多数连接是"挂着不说话的",但线程必须停在 read() 上等它 10 个线程 9 个在干等,CPU 浪费在线程切换上
③ 线程切换开销大 操作系统在线程间切来切去 连接越多越卡
④ 阻塞无法扩展 想加连接数只能加线程 加线程 → 更多切换开销 → 更卡,恶性循环

核心矛盾:连接的数量很多,但每个连接真正干活的时间很少。BIO 的做法是"给每个连接配一个专职线程",等于用线程数量去对抗连接数量,根本扛不住。

一句话总结 BIO:适合连接数少、每个连接都持续通信的场景(如内网 RPC);扛不住大量"长连接但低频通信"的场景(如 500 个 IoT 设备)。

那怎么办?我们能不能让一个线程同时看管很多连接,谁有数据就来谁?→ 这就是 NIO 的思路。


第 3 章 NIO:非阻塞 IO(New IO / Non-blocking IO)

3.1 核心思想:从"等人"变成"查名单"

BIO 是线程死等一个连接。NIO 换了个思路:

一个线程管一堆连接,用一个"名册"(Selector) 不断问:你们谁有数据/谁有新连接?有我就去处理你,没有我继续问。

BIO:  线程A ──死等──► 连接1            (一个线程只能等一个)
      线程B ──死等──► 连接2
      线程C ──死等──► 连接3

NIO:  一个线程 ──► 名册Selector ──► 连接1 有数据? 没有
                                   连接2 有数据? 有!→ 处理
                                   连接3 有数据? 没有
                    (一圈问完再问下一圈)

这就是多路复用(Multiplexing):一个线程复用了管理"很多路"连接的能力。

3.2 NIO 三件套:Channel / Buffer / Selector

组件 是什么 类比
Channel(通道) 双向的通信管道,可读可写(BIO 的流是单向的) 一条双向高速公路
Buffer(缓冲区) 数据存放的"中转仓库",读写都在 Buffer 上进行 高速公路的货站
Selector(选择器) 多路复用器,监听多个 Channel 的事件(可连接/可读/可写) 前台的服务台,帮你盯着谁来了

它们的关系:

   SocketChannel1 ──┐
   SocketChannel2 ──┼──► 注册到 Selector  ──►  线程.select() 阻塞等待事件
   ServerSocketCh. ─┘        │                        │
                             │                        ▼
                             │                有事件发生的通道集合
                             └───►  遍历处理,数据放 Buffer 里读写
  • 一个 Selector 可以注册很多 Channel
  • 线程调 selector.select()依然会阻塞——但这是"等事件"的阻塞,不是"等某个具体连接"的阻塞。只要有任何一个 Channel 有事件,select() 就立刻返回。
  • 关键点:NIO 是"事件驱动"——通道有数据了才处理,没数据不占用线程。

3.3 Buffer 详解(重点,最容易懵)

Buffer 是一个"定长的数组容器",存数据前你要给它分配容量。读和写之间必须 flip() 切换,这是新手最容易踩的坑。

3.3.1 三个核心位置标记

   position    limit     capacity
      │         │           │
      ▼         ▼           ▼
   ┌─────────────────────────────────┐
   │  0  1  2  3  4  5  6 ...  n-1  │
   └─────────────────────────────────┘
标记 含义
capacity 容量,Buffer 最大能装多少,创建后不变
position 当前读/写指针位置,写的时候是"下一个该写哪",读的时候是"下一个该读哪"
limit 界限,写模式下 = capacity(能写满整个缓冲区);读模式下 = 当前写入的字节数(最多读到这里为止)

3.3.2 读写切换三步曲(背下来)

ByteBuffer buffer = ByteBuffer.allocate(1024);

// ── 写模式(默认)────────────────────────────
buffer.put("hello".getBytes());
// position 现在指向 5,表示已写入 5 个字节

// ── 切换读模式:必须 flip() ──────────────────
buffer.flip();
// 作用 = limit = position(5) ; position = 0
// 即"把已写入的内容锁死,从头部开始读"

// ── 读模式 ──────────────────────────────────
byte[] b = new byte[buffer.limit()];
buffer.get(b);          // 读到 limit=5 为止
System.out.println(new String(b));  // hello

// ── 读完想再写:clear() 或 compact() ─────────
buffer.clear();         // 全部重置,position=0, limit=capacity

常见错误:写完不 flip() 直接读,读出来全是一堆 0 或者空。记住一句话:写→读 必须 flip(),读→写 必须 clear()/compact()

3.4 Channel 详解

三种常用 Channel:

Channel 作用
ServerSocketChannel 服务端监听通道,对应 BIO 的 ServerSocket,负责 accept 新连接
SocketChannel 一条 TCP 连接的通道,对应 BIO 的 Socket,负责读写
FileChannel 文件读写通道(一般不用在网络编程)

Channel 的特点:

  • 双向:一个通道既能读也能写(BIO 的流只能单向)。
  • 非阻塞:调用 configureBlocking(false) 后,accept()/read() 不再死等,没数据立刻返回 0 或 null。
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(9000));
server.configureBlocking(false);  // ★ 非阻塞模式:accept 不阻塞

3.5 Selector 详解(多路复用核心,重头戏)

Selector 负责"监听"多个通道的事件。事件有 4 种:

SelectionKey.OP_ACCEPT   // 有新的连接可以被 accept(只用于 ServerSocketChannel)
SelectionKey.OP_CONNECT  // 连接建立成功(客户端用)
SelectionKey.OP_READ     // 通道有数据可读
SelectionKey.OP_WRITE    // 通道可以写数据了

工作流程(背下来):

1. 打开 Selector
2. 把 ServerSocketChannel 注册进去,监听 OP_ACCEPT
3. 死循环:
   a. selector.select()          阻塞,直到有事件发生
   b. 取出所有"有事件"的 key:selectedKeys()
   c. 遍历每个 key:
      - 是 ACCEPT 事件 → accept 新连接,把新 SocketChannel 注册进去监听 OP_READ
      - 是 READ 事件   → 从通道读到 Buffer 里,处理数据
   d. 处理完必须手动删除这个 key(selectedKeys 不会自动清理)

为什么 select() 阻塞也没关系? 因为它是"一有事件就立刻醒",不像 BIO 是"死等某一个连接"。10 万个连接里只要有一个发数据,select() 马上返回,线程去处理那一个,处理完继续 select()。一个线程就顶住了 10 万个连接

3.6 NIO 完整代码实例(带详细注释,务必跑起来)

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Iterator;

public class NioServer {
    public static void main(String[] args) throws Exception {
        // 1. 打开服务端通道,绑定端口,设为非阻塞
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(9000));
        serverChannel.configureBlocking(false);

        // 2. 打开选择器,把服务端通道注册进去,监听"有新连接"事件
        Selector selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("NIO 服务端启动,端口 9000");

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        while (true) {
            // 3. 阻塞等待事件(任何通道有事件就醒)
            selector.select();

            // 4. 取出所有有事件的 key
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();   // ★ 必须手动移除,否则会重复处理

                if (key.isAcceptable()) {
                    // ── 有新的连接进来 ──
                    SocketChannel client = serverChannel.accept();
                    client.configureBlocking(false);
                    // 把新连接也注册到 selector,监听"可读"事件
                    client.register(selector, SelectionKey.OP_READ);
                    System.out.println("新连接: " + client.getRemoteAddress());

                } else if (key.isReadable()) {
                    // ── 某个连接有数据可读 ──
                    SocketChannel client = (SocketChannel) key.channel();
                    buffer.clear();               // 清空,准备写入
                    int len = client.read(buffer); // 读到 buffer,len 是字节数
                    if (len == -1) {
                        // -1 表示对方关闭了连接
                        client.close();
                        System.out.println("连接关闭");
                    } else if (len > 0) {
                        buffer.flip();  // ★ 写→读 必须 flip
                        String msg = Charset.forName("UTF-8")
                                .decode(buffer).toString();
                        System.out.println("收到: " + msg);
                    }
                }
            }
        }
    }
}

nc localhost 9000 或写个简单客户端连上去,开多个窗口,你会发现这一个线程能同时服务所有连接——这就是 NIO 的威力。

图解 NIO 单线程模型:

                    ┌───────────────────────────────┐
  客户端A ──┐       │                               │
  客户端B ──┼─注册─►│        Selector(名册)          │
  客户端C ──┘       │     监听所有通道的事件          │
                    └───────────────┬───────────────┘
                                    │  select() 阻塞等待
                                    ▼
                          ┌──────────────────┐
                          │  一个线程(worker)  │
                          └──────────────────┘
                                    │  谁有事件处理谁
                     ┌──────────────┼──────────────┐
                     ▼              ▼              ▼
                处理A的数据      accept新连接C    处理B的数据

3.7 NIO 的痛点(为什么有了 NIO 大家还是觉得难用)

虽然 NIO 能一线程扛万连接,但手写 NIO 的体验极其痛苦

痛点 说明
① 代码复杂 上面那段 50 行的代码只是"能收发",处理业务逻辑、异常、超时、半包要多出几百行
半包/粘包问题暴露 TCP 是字节流,可能一次 read 读到了两条报文(粘包),或一条报文要多次 read 才读完(半包)。BIO 的 readLine() 好歹有行分隔符,NIO 里你得自己处理边界,极其容易写错
③ 线程模型要自己设计 读事件、业务处理、写事件放哪个线程?要不要多线程?没经验的人写着写着就死锁/数据错乱
④ 内存管理难 Buffer 用完不释放会 OOM;线程上下文切换、ByteBuffer 分配回收都是坑
⑤ 没有现成的协议处理 HTTP、字符串、长度字段协议全要自己写解码器
⑥ 写一个"规范"的服务端很难 优雅关闭、异常处理、半包边界、背压,每个都是大坑

简单说:NIO 能力强大,但用起来太痛苦,就像给你一台没有方向盘的赛车。这时候 Netty 出现了——把 NIO 的所有复杂细节都封装好,让你只写业务逻辑。


第 4 章 半包 / 粘包问题专题(必须搞懂,本项目直接用到)

这是 TCP 编程的第一大坑,BIO 时代被"隐藏"了(因为 readLine 按行读),NIO 时代必须自己解决。Netty 里用 LengthFieldBasedFrameDecoder 解决。

4.1 什么是粘包 / 半包

假设设备连续发送两条报文:["HELLO", "WORLD"]

理想情况:            |   HELLO   |   WORLD   |

粘包(一次收到两条):    |   HELLOWORLD   |        ← 分不清边界
半包(一条被拆两半):    |   HEL  |   LOWORLD   |  ← 一条不完整
  • 粘包:多次发送的数据被合并在一次 read 里读到了。
  • 半包:一次发送的数据被拆成多次 read 才读完。

4.2 为什么会发生

根本原因:TCP 是"字节流"协议,没有消息边界。

数据到对端后怎么被读,取决于内核缓冲区大小read 的时机

  • 对方 write 两次,但数据在小缓冲区里被合并成一段 → 你一次 read 读到两条 → 粘包
  • 对方 write 一次很大(超过接收缓冲区),或网络分包 → 你一次 read 只读到一部分 → 半包

打个比方:快递把你要寄的两封信装进同一个包裹送来了(粘包),或者你寄的一封信被拆成两个包裹分批送到(半包)。应用层必须自己想办法把"一条消息"和"一次 read"解耦。

4.3 三种解决方案对比

方案 思路 缺点
固定长度 每条消息定长 100 字节,不够补齐 浪费带宽,长度必须提前冻结
特殊分隔符 每条消息末尾加 \n\r\n 消息内容里不能出现该字符,需转义
长度字段(本项目用) 消息头里写"体有多长",先读长度再按长度切 最通用、最省、工业标准做法

4.4 本项目(电驰换电云)的解法:长度字段 + LengthFieldBasedFrameDecoder

我们的报文头是 20 字节,其中第 16~19 字节(4 字节)是体长度

0─────2─────3─────4─────────────────16─────────────20
│魔数  │版本 │类型 │  设备ID(12字节)  │   体长度     │  JSON体
│DCDC  │01  │ 02  │  RBT-0001      │   0000002C  │  {...}
└──2B──┴─1B─┴─1B──┴────12B─────────┴────4B───────┴──44B─┘
                                    ↑
                            长度字段:偏移16,占4字节

Netty 一行代码解决粘包/半包:

// 参数:(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip)
new LengthFieldBasedFrameDecoder(65535, 16, 4, 0, 0)
//        │                      │     │  │  │  │
//        │                      │     │  │  │  └─ 切完包后要不要去掉头部(0=保留完整报文)
//        │                      │     │  │  └──── 读完长度后额外偏移(0=不偏移)
//        │                      │     │  └─────── 长度字段占 4 字节
//        │                      │     └────────── 长度字段从偏移 16 处开始
//        │                      └──────────────── 报文最大长度
//        └─────────────────────────────────────── 字节流解码器,自动按长度切出一条条完整报文

它的工作原理(Netty 内部帮你做完的):

  1. 收到字节后,先读偏移 16 处的 4 字节 → 得知"这条报文总共多长"。
  2. 如果手头字节不够,继续攒着等下一批(半包处理)→ 凑够才放行。
  3. 如果手头字节超过一条,正好切出第一条放行,剩下的留到下次再切(粘包处理)。

用上它之后,你后面业务代码里拿到的永远是一条完整的报文,再也不用管粘包半包。这就是框架的价值。


第 5 章 Netty:站在 NIO 肩膀上的网络框架

5.1 Netty 是什么、架构分层

Netty = 对 Java NIO 的二次封装,提供"易用 + 高性能"的异步事件驱动网络框架。

你只管:

  • 定义"收到一条消息后干什么"(业务 Handler);
  • 组装"消息进来要经过哪些处理环节"(Pipeline)。

其余的线程模型、粘包拆包、内存管理、连接生命周期,Netty 全包了。

┌─────────────────────────────────────────────────────┐
│                 你的业务代码 (Handler)                 │
├─────────────────────────────────────────────────────┤
│          ChannelPipeline(流水线/处理器链)             │
├─────────────────────────────────────────────────────┤
│   Channel / ByteBuf / Future / Promise 等抽象         │
├─────────────────────────────────────────────────────┤
│          NIO 底层(Selector / EventLoop)             │
├─────────────────────────────────────────────────────┤
│           操作系统 Socket / TCP                      │
└─────────────────────────────────────────────────────┘

5.2 线程模型:boss 线程组 / worker 线程组(Reactor 主从多线程)

这是 Netty 最核心的模型。对应你在本项目里看到的配置:

EventLoopGroup bossGroup   = new NioEventLoopGroup(1);  // boss 组:1 个线程
EventLoopGroup workerGroup = new NioEventLoopGroup(4);  // worker 组:4 个线程

图解(主从 Reactor):

                         boss 组(1 个线程,只接电话)
                     ┌──────────────────────────┐
   客户端A ──┐        │  EventLoop(boss)          │
   客户端B ──┼──────► │  accept 新连接            │
   客户端C ──┘        └───────────┬──────────────┘
                                  │ 把连接"分派"下去
         ┌────────────────────────┼────────────────────────┐
         ▼                        ▼                        ▼
   ┌─────────────┐         ┌─────────────┐         ┌─────────────┐
   │worker线程1   │         │worker线程2   │         │worker线程3   │
   │负责连接A,C    │         │负责连接B     │         │负责连接D,E    │
   │读/写/业务     │         │读/写/业务    │         │读/写/业务     │
   └─────────────┘         └─────────────┘         └─────────────┘
线程组 职责 线程数 类比
boss 组 只负责接收新连接(accept),不处理数据 通常 1 个就够 前台接待员
worker 组 负责已建立连接的 IO 读写 + 触发的 Handler 逻辑 默认 = CPU 核数 × 2 真正的服务员

关键特性(Netty 性能的基石):

  1. 一个 Channel 绑定到一个固定的 EventLoop(worker 线程),永不换线
    • 好处:同一连接的读写都在同一个线程里执行,天然无锁,不需要加锁保护共享状态。
  2. boss 和 worker 的职责分离:accept 特别快,一个线程处理所有新连接绰绰有余;worker 专心做 IO。
  3. 业务处理不能直接写在 EventLoop 里(本项目铁律!):
    • EventLoop 是"一个线程管一堆连接",你在里面写数据库/发 MQ 这种耗时操作,就会阻塞这个线程,导致它管的几十上百个连接全部卡住
    • 正确做法:业务逻辑提交到独立的业务线程池ThreadPoolExecutor),EventLoop 只管 IO。
   EventLoop(worker)                                  业务线程池
   ┌───────────────┐    提交任务(异步)      ┌──────────────────┐
   │ 读到一条报文   │ ────────────────────► │  线程1: 写InfluxDB │
   │ 解析出DeviceMsg│                       │  线程2: 发MQ      │
   │ 立刻去处理下一个│                       │  线程3: 更新MySQL  │
   └───────────────┘                       └──────────────────┘

5.3 Channel 与 Pipeline:流水线机制(重点理解)

Pipeline 是 Netty 的灵魂:一条连接进来,数据就像流水线上的工件,依次经过一串 Handler。

  入站方向(网络→应用)                   出站方向(应用→网络)
  数据流入                             数据流出
        │                                 │
        ▼                                 ▼
┌───────────────────────────────────────────────┐
│ ChannelPipeline                              │
│                                               │
│  ┌─────────────────────────────────────────┐  │
│  │  Handler1(解码器)  Handler2(鉴权)       │  │
│  │  Handler3(心跳)    Handler4(业务处理)    │  │
│  └─────────────────────────────────────────┘  │
│       ▲                                   │   │
│       └── 入站方向 ──► (从左到右) ──►        │  │
│       (从右到左) ◄── 出站方向 ◄──            │  │
└───────────────────────────────────────────────┘

入站(Inbound):数据从网络进来。

  • 依次经过 解码器 → 鉴权 → 心跳 → 业务处理。
  • 每个 Handler 处理完,调用 ctx.fireChannelRead(msg) 传给下一个;最后一个 Handler 处理完就结束。
  • 对应方法:channelRead()

出站(Outbound):数据从应用发往网络。

  • 从最后一个出站 Handler 反向经过 编码器 等,最终写进 socket。
  • 对应方法:write() / writeAndFlush()

Handler 生命周期里的经典方法:

public class MyHandler extends SimpleChannelInboundHandler<String> {
    @Override
    public void channelRegistered(ChannelHandlerContext ctx)   {} // 连接注册到 EventLoop
    @Override
    public void channelActive(ChannelHandlerContext ctx)       {} // 连接建立成功
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {} // ★ 收到完整一条消息(核心)
    @Override
    public void channelInactive(ChannelHandlerContext ctx)     {} // 连接断开
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {} // 异常
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt)    {} // 用户/空闲事件(心跳超时)
}

项目里的用法:LengthFieldBasedFrameDecoder(解粘包) → ProtocolCodec(解析成 DeviceMessage) → AuthHandler(鉴权) → HeartbeatHandler(心跳) → DeviceMsgHandler(业务分发)。

5.4 异步非阻塞回调:Future / Promise / ChannelFuture

什么是异步? 发起一个操作后不等待它完成,先去做别的事,等完成后再通过回调/通知来拿结果。

Netty 里所有 IO 操作(bind、connect、write)默认都是异步的,返回一个 ChannelFuture(占位符/小票):

// 异步发起绑定,不阻塞主线程
ChannelFuture future = b.bind(9000);
System.out.println("绑定命令已发出,继续往下执行...");  // 这行会立刻执行

// 方式一:阻塞等待结果(把异步转同步,初学者常用)
future.sync();

// 方式二:加监听器,完成后回调(真正的异步风格)
future.addListener((ChannelFutureListener) f -> {
    if (f.isSuccess()) {
        System.out.println("绑定成功!");
    } else {
        System.out.println("绑定失败: " + f.cause());
    }
});

类比:

  • 你下单点外卖(发起异步操作)→ 拿到订单号 ChannelFuture
  • 你不会站在店门口等,而是继续干别的(非阻塞)。
  • 外卖到了(操作完成),骑手打电话给你(回调 Listener)。
  • sync() 相当于"饿得不行,就站在门口等到饭来"。

核心 API 速记:

API 含义
write() 写数据,异步,不一定立即发出去
writeAndFlush() 写数据并立即冲刷发出(最常用)
addListener(...) 给 Future 加回调,完成时触发
sync() / await() 阻塞等待完成
isSuccess() / cause() 是否成功 / 失败原因

5.5 零拷贝与高性能优化(Netty 凭什么快)

5.5.1 内存层面优化

技术 说明
堆外内存(DirectBuffer) 直接在系统内存开辟,不走 JVM 堆,省去"内核→JVM堆"的一次拷贝;GC 也不受影响
池化 ByteBuf 用一个内存池复用 ByteBuf,用完归还,避免反复创建销毁(类似数据库连接池)
引用计数 每个 ByteBuf 有引用计数,用 retain()/release() 管理生命周期,防止内存泄漏
CompositeByteBuf 多条报文可以"组合"成一个逻辑视图,不用物理拼接拷贝

5.5.2 真正的"零拷贝"

传统的网络发送:用户态buffer → 内核buffer → 网卡,数据在内存间拷贝多次。Netty 用 FileRegion(sendfile) 让数据直接从磁盘 → 网卡,中间不经过用户空间,这就是真正的零拷贝。

5.5.3 线程层面优化

  • 无锁串行化:一个连接绑定一个 EventLoop,同一连接的所有操作串行执行,无需加锁。
  • IO 与业务线程分离:EventLoop 只做 IO,耗时业务丢给业务线程池,EventLoop 永不阻塞。

5.5.4 其他

  • 背压处理:netty 的 AutoRead / 写缓冲高水位,防止内存被未消费的数据撑爆。

5.6 Netty 完整代码实例(Echo 服务端,带详细注释)

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class NettyEchoServer {
    public static void main(String[] args) throws Exception {
        // ── 1. 两个线程组 ──────────────────────────
        // boss:1 个线程,只负责 accept 新连接
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        // worker:默认 CPU 核数*2 个线程,负责连接的 IO 读写
        EventLoopGroup workerGroup = new NioEventLoopGroup(4);

        try {
            // ── 2. ServerBootstrap:服务端启动引导器 ──
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)           // 指定两组线程
             .channel(NioServerSocketChannel.class)   // 底层用 NIO
             .option(ChannelOption.SO_BACKLOG, 1024)  // 连接等待队列长度
             .childOption(ChannelOption.TCP_NODELAY, true) // 禁用 Nagle 算法,降低延迟
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     // ── 3. 给每个连接装一条流水线(Pipeline) ──
                     ch.pipeline().addLast(
                         new StringDecoder(),          // 入站:字节 → 字符串
                         new StringEncoder(),          // 出站:字符串 → 字节
                         new EchoHandler()             // 业务处理
                     );
                 }
             });

            // ── 4. 异步绑定端口,sync 等待成功 ──
            ChannelFuture f = b.bind(9000).sync();
            System.out.println("Netty Echo 服务端启动,端口 9000");

            // ── 5. 阻塞等待,让服务一直运行,直到被关闭 ──
            f.channel().closeFuture().sync();
        } finally {
            // ── 6. 优雅关闭:先停 worker 再停 boss ──
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

    // 业务处理器:收到消息就原样回写
    static class EchoHandler extends SimpleChannelInboundHandler<String> {
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, String msg) {
            System.out.println(Thread.currentThread().getName() + " 收到: " + msg);
            ctx.writeAndFlush(msg);   // 异步回写,不用管底层
        }

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

对照前面 50 行还只能简单收发的 NIO 代码——Netty 用同样甚至更少的代码,就拿到了完整、健壮、高性能的服务端。这就是框架的意义。

对比:new NioEventLoopGroup 的底层还是 NIO 的 Selector + Channel,只是 Netty 把"谁管哪个连接、怎么处理事件、半包怎么办、内存怎么管理"全部封装掉了。所以学 Netty 前先看懂 NIO 三件套,你会非常明白它内部在干什么。


第 6 章 三阶段对比总结(一页看懂全貌)

维度 BIO NIO Netty
线程模型 一连接一线程 一线程多连接(Selector 多路复用) boss 组 + worker 组(主从 Reactor)
阻塞性 阻塞(accept/read 死等) 非阻塞 + select() 事件驱动 异步非阻塞 + 回调
连接数量 几百就撑不住 数万没问题 数万~数十万
代码难度 简单 非常复杂易出错 简单(框架封装)
半包/粘包 readLine 按行,被"隐藏" 要自己处理,极难 LengthFieldBasedFrameDecoder 一行解决
线程安全问题 每连接一线程,天然隔离 自己管理,易死锁 连接绑定固定线程,天然无锁
内存管理 JVM 托管 ByteBuffer 手动管理 池化 + 引用计数 + 零拷贝
适用场景 连接少、纯内网 需要高性能但不想用框架 高性能网络应用的工业标准
本项目结论 扛不住 500 设备 能扛但代码没法维护 ✅ 用它

演进逻辑一句话:BIO 用"线程数量"对抗"连接数量" → 不行;NIO 用"一个线程的轮询"对抗"大量连接" → 可行但难用;Netty 把 NIO 的"高性能"和 BIO 的"易用"结合起来 → 完美。


第 7 章 学习路径与练手建议

7.1 学习顺序(强烈建议按这个来)

阶段 做什么 检验标准
① BIO 跑通 2.2 的线程池版代码 能用 telnet/nc 连上并发消息
② NIO 跑通 3.6 的完整代码,打断点看 select/selectionKeys 多开几个连接,单线程都能收到数据
③ Buffer 手写 flip/clear 读写切换的测试代码 明白 position/limit 变化
④ 半包/粘包 用一个客户端连续快速发多条消息,观察粘包;Netty 里用 LengthFieldBasedFrameDecoder 解决 能画出报文头结构
⑤ Netty Echo 跑通 5.6 的 Echo 服务端 多连接收发正常
⑥ 进阶 加 IdleStateHandler 心跳、首包鉴权 心跳超时能触发离线
⑦ 项目落地 对照《Netty接入SpringBoot-iot-gateway实现.md》写 iot-gateway 模拟设备→Netty→InfluxDB3 通路打通

7.2 练手建议

  1. 加个主动推送:服务端定时给所有连接广播当前时间(练习 outbound 和 ChannelGroup)。
  2. 改成长度字段协议:自己定义"魔数+版本+长度+JSON体"报文,写客户端发过来解析(这就是 iot-gateway 的前置练习)。
  3. 加心跳:IdleStateHandler 60 秒读超时判离线,写日志观察触发。
  4. 压测:用你之后的 device-simulator 拉 500 个连接、1000 报文/秒,看 Netty 是否无堆积。

7.3 常见坑自查表

  1. NIO 写读切换忘记 flip() → 数据全是乱码/空。
  2. selectedKeys 忘记 remove() → 同一事件重复处理。
  3. 粘包不处理 → 报文错乱。
  4. 业务写在 EventLoop → 一个连接卡死一片连接。
  5. Handler 里存可变状态 → 多连接共享冲突,用 Channel.attr() 或独立管理类。
  6. 忘记释放 ByteBuf → 内存泄漏(用 SimpleChannelInboundHandler 会自动释放)。
  7. write()flush() → 数据发不出去,要用 writeAndFlush()
  8. 忘记优雅关闭 → 重启时端口占用。