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);
}
}
}
}
运行后你会发现:
- 第一个客户端连上,能正常收发。
- 第二个客户端连不进来——因为服务端卡在第一个连接的
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 内部帮你做完的):
- 收到字节后,先读偏移 16 处的 4 字节 → 得知"这条报文总共多长"。
- 如果手头字节不够,继续攒着等下一批(半包处理)→ 凑够才放行。
- 如果手头字节超过一条,正好切出第一条放行,剩下的留到下次再切(粘包处理)。
用上它之后,你后面业务代码里拿到的永远是一条完整的报文,再也不用管粘包半包。这就是框架的价值。
第 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 性能的基石):
- 一个 Channel 绑定到一个固定的 EventLoop(worker 线程),永不换线。
- 好处:同一连接的读写都在同一个线程里执行,天然无锁,不需要加锁保护共享状态。
- boss 和 worker 的职责分离:accept 特别快,一个线程处理所有新连接绰绰有余;worker 专心做 IO。
- 业务处理不能直接写在 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 练手建议
- 加个主动推送:服务端定时给所有连接广播当前时间(练习 outbound 和 ChannelGroup)。
- 改成长度字段协议:自己定义"魔数+版本+长度+JSON体"报文,写客户端发过来解析(这就是 iot-gateway 的前置练习)。
- 加心跳:IdleStateHandler 60 秒读超时判离线,写日志观察触发。
- 压测:用你之后的 device-simulator 拉 500 个连接、1000 报文/秒,看 Netty 是否无堆积。
7.3 常见坑自查表
- NIO 写读切换忘记 flip() → 数据全是乱码/空。
- selectedKeys 忘记 remove() → 同一事件重复处理。
- 粘包不处理 → 报文错乱。
- 业务写在 EventLoop → 一个连接卡死一片连接。
- Handler 里存可变状态 → 多连接共享冲突,用
Channel.attr()或独立管理类。 - 忘记释放 ByteBuf → 内存泄漏(用
SimpleChannelInboundHandler会自动释放)。 write()没flush()→ 数据发不出去,要用writeAndFlush()。- 忘记优雅关闭 → 重启时端口占用。
Netty框架学习-BIO到NIO再到Netty
https://xiaochenblog.icu/archives/nettykuang-jia-xue-xi-biodao-niozai-dao-netty
评论