从零系统理解 Netty 通讯
如果你之前只用过 Socket / HttpURLConnection / OkHttp,第一次接触 Netty 会觉得概念特别多。这篇从为什么需要 Netty → 核心概念 → 最小可用例子 → 逐步理解你的代码,帮你建立完整认知。
要真正理解 Netty,必须先搞懂它脚下的三层地基:BIO(阻塞 IO)→ NIO(非阻塞 IO + 多路复用)→ Epoll(Linux 下的高效多路复用实现)。
一、先回顾:传统 BIO 通讯长什么样
你以前大概率写过这种代码:
服务端
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept(); // 阻塞,等连接
new Thread(() -> {
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) != -1) { // 阻塞,等数据
// 处理数据
}
}).start();
}
客户端
Socket socket = new Socket("127.0.0.1", 8080);
OutputStream out = socket.getOutputStream();
out.write(data); // 阻塞,写数据
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int len = in.read(buf); // 阻塞,等响应
这种模型的问题
| 问题 | 说明 |
|---|---|
| 一连接一线程 | 1 万个连接就要 1 万个线程,内存和上下文切换吃不消 |
| 阻塞 |
accept() / read() / write() 都会卡住线程 |
| 线程利用率低 | 大部分线程在等待,啥也不干 |
| 手写协议 | 粘包/拆包、心跳、重连全得自己处理,容易出错 |
| 异常处理繁琐 | 连接断了、超时、半包……每个都要手动写 |
结论:BIO 适合连接少、逻辑简单的场景。一旦高并发或需要长连接、自定义协议,就非常痛苦。
二、Netty 解决了什么
Netty 是一个异步事件驱动的网络框架。它把底层 NIO(或 Epoll)包装成易用的 API,帮你搞定:
- 高并发连接:基于 IO 多路复用,少量线程管理海量连接
-
事件驱动:不用写
while循环读,数据来了自动回调 Handler - 协议编解码:粘包/拆包、各种协议内置支持
- 连接管理:心跳、重连、超时、空闲检测都有现成 Handler
- 线程模型:IO 线程与业务线程分离,性能可控
一句话:Netty = 高性能网络编程的"标准答案",Dubbo、RocketMQ、gRPC-Java、Spring WebFlux 底层都用它。
三、核心概念地图
先用一张图建立全局印象:
┌─────────────────────────────────────────────────────────┐
│ 你的业务代码 │
│ (Handler / ChannelHandler) │
├─────────────────────────────────────────────────────────┤
│ ChannelPipeline │
│ [Handler1] → [Handler2] → [Handler3] │
├─────────────────────────────────────────────────────────┤
│ Channel │
│ (一条连接的抽象,读写都在它身上) │
├─────────────────────────────────────────────────────────┤
│ EventLoop / EventLoopGroup │
│ (IO 线程,负责 select、读写、回调 Handler) │
├─────────────────────────────────────────────────────────┤
│ Bootstrap │
│ (启动器,配置并连接/绑定) │
├─────────────────────────────────────────────────────────┤
│ 底层 NIO / Epoll │
└─────────────────────────────────────────────────────────┘
逐个解释:
1. Channel(通道)
一条连接的抽象。NioSocketChannel 是 TCP 客户端连接。所有读写都通过 Channel。
2. ChannelHandler(处理器)
处理事件的逻辑单元。你写业务就是写 Handler。
-
ChannelInboundHandler:处理入站(读) -
ChannelOutboundHandler:处理出站(写)
3. ChannelPipeline(管道)
每个 Channel 独有的一条 Handler 责任链。数据在链上依次经过每个 Handler。入站从 Head 流向 Tail,出站逆序。
4. EventLoop / EventLoopGroup(事件循环 / 组)
Netty 的 IO 线程。每个 EventLoop 内部是 while(true) select(),负责:
- 检测网络事件(可读/可写/连接)
- 触发 Pipeline 中对应 Handler 的回调
- 执行提交给它的任务
EventLoopGroup 是 EventLoop 的集合。一个 Channel 一生只绑定一个 EventLoop,所以同一连接的 Handler 回调天然串行,不用加锁。
5. Bootstrap(启动器)
配置并启动的入口。客户端用 Bootstrap,服务端用 ServerBootstrap。
6. ByteBuf(字节缓冲区)
Netty 的字节容器,比 JDK ByteBuffer 更好用(读写指针分离、动态扩容、引用计数、池化)。
7. ChannelFuture(异步结果)
Netty 所有 IO 操作都是异步的,返回 ChannelFuture。可以 .sync() 阻塞等,也可以 .addListener() 异步回调。
8. Codec(编解码器)
ByteBuf ↔ 业务对象 的转换器。例如 StringDecoder、LengthFieldBasedFrameDecoder(解决粘包)。
四、最小可用例子:客户端
public class SimpleClient {
public static void main(String[] args) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new SimpleClientHandler());
}
});
// 连接服务器
ChannelFuture future = bootstrap.connect("127.0.0.1", 8080).sync();
Channel channel = future.channel();
// 发送数据
channel.writeAndFlush(Unpooled.copiedBuffer("hello", CharsetUtil.UTF_8));
// 等待关闭
channel.closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}
class SimpleClientHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
public void channelActive(ChannelHandlerContext ctx) {
System.out.println("已连接");
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
System.out.println("收到: " + msg.toString(CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
对比 BIO 客户端,你会发现:
- 没有
while(read)循环 → 改成channelRead0回调 - 没有
Thread→ 线程由 EventLoop 管理 -
writeAndFlush是异步的 → 返回 Future
五、最小可用例子:服务端
public class SimpleServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 接受连接
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 处理读写
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new SimpleServerHandler());
}
});
ChannelFuture future = bootstrap.bind(8080).sync();
System.out.println("服务端启动,监听 8080");
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
class SimpleServerHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
System.out.println("收到: " + msg.toString(CharsetUtil.UTF_8));
// 回复
ctx.writeAndFlush(Unpooled.copiedBuffer("world", CharsetUtil.UTF_8));
}
}
服务端为什么要两个 group?
-
bossGroup:只负责accept新连接,然后把连接注册到 worker -
workerGroup:负责已建立连接的读写 - 这是 Netty 服务端标准模式(客户端只需要一个 group)
六、把概念串起来:一次请求的完整流程
1. bootstrap.connect(host, port)
↓ 底层发起 TCP 连接,注册到某个 EventLoop
2. 连接建立 → 触发 pipeline 里所有 Handler 的 channelActive()
3. channel.writeAndFlush(data)
↓ 出站:从 Tail 往 Head 走,经过各出站 Handler
↓ 最终 Head 把字节写到 socket
4. 服务器响应到达 → EventLoop select 到可读
↓ 入站:从 Head 往 Tail 走,经过各入站 Handler
5. channelRead0(ctx, msg) 被回调
↓ 你的业务逻辑在这里处理
6. 处理完,若想回复:ctx.writeAndFlush(response)
核心心智模型:Netty 把"读-处理-写"从主动循环变成了被动回调。你只需要在 Handler 里写"当发生 X 时做什么"。
七、回到你的 SyncTcpClient:逐块对照
现在再看你那份代码,每个部分都有了归属:
1. 线程池配置
this.workerGroup = new NioEventLoopGroup(); // IO 线程组
this.executorService = Executors.newCachedThreadPool(...); // 业务线程池
-
workerGroup:Netty 的 IO 引擎 -
executorService:给sendAsync用来承载阻塞等待
2. Bootstrap 配置
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(workerGroup)
.channel(NioSocketChannel.class)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, ...)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() { ... });
- 客户端标准写法:
Bootstrap+NioSocketChannel -
TCP_NODELAY = true:禁用 Nagle 算法,小包立即发,降低延迟(对实时通讯重要)
3. Pipeline 装配
pipeline.addFirst(proxyHandler); // SOCKS5 代理
pipeline.addLast(new ReadTimeoutHandler(...)); // 读超时检测
pipeline.addLast("persistent-handler", persistentHandler); // 业务 Handler
- 三个 Handler 各司其职,典型的 Pipeline 组合
-
addFirst让代理 Handler 最贴近网络,能自动完成握手
4. 连接
ChannelFuture connectFuture = bootstrap.connect(config.getHost(), config.getPort());
channel = connectFuture.sync().channel(); // sync() 阻塞等连接成功
connected = true;
5. 发送 + 等待响应
CompletableFuture<TcpMessage> responsePromise = new CompletableFuture<>();
persistentHandler.setResponsePromise(responsePromise);
channel.writeAndFlush(Unpooled.wrappedBuffer(data)); // 异步发送
TcpMessage response = responsePromise.get(timeout, MS); // 阻塞等响应
-
writeAndFlush是异步的,但通过CompletableFuture桥接成"同步等待" - 这就是你最早问的
sendSync原理
6. Handler 收数据
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
byte[] data = new byte[msg.readableBytes()];
msg.readBytes(data);
TcpMessage response = new TcpMessage(data, remoteIp, remotePort);
currentResponse.complete(response); // 唤醒阻塞的业务线程
}
7. 关闭
executorService.shutdown();
workerGroup.shutdownGracefully().sync();
八、几个新手最容易混的点
1. sync() 和 addListener() 的区别
// 阻塞等结果
ChannelFuture f = bootstrap.connect(...).sync();
// 异步回调,不阻塞
bootstrap.connect(...).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) { /* ... */ }
});
Netty 内部全是异步,sync() 只是给"我想同步"的人用的糖。
2. ChannelHandlerContext 和 Channel 的区别
-
Channel:整条连接 -
ChannelHandlerContext:当前 Handler 在 Pipeline 中的位置 -
ctx.write()从当前位置往 Head 走;channel.write()从 Tail 走完整链
3. 为什么 Handler 回调里不能做耗时操作
因为跑在 EventLoop 线程上。这个线程要处理它管理的所有 Channel 的 IO。你阻塞它 1 秒,成百上千个连接都停 1 秒。
耗时逻辑丢给业务线程池(就是 executorService 的用途之一)。
4. 粘包/拆包
TCP 是字节流,没有"消息边界"。你发两次 "hello"、"world",对端可能一次收到 "helloworld"。Netty 提供 LengthFieldBasedFrameDecoder、LineBasedFrameDecoder 等解决,必须装,否则协议解析会出错。
你代码里没装,说明这个协议要么每条消息一次发送一次接收(靠业务约定),要么在应用层自己处理边界。
5. ByteBuf 要释放
Netty 用引用计数管理 ByteBuf。用 SimpleChannelInboundHandler 会自动释放;用 ChannelInboundHandlerAdapter 得手动 ReferenceCountUtil.release(msg),否则内存泄漏。
6. 一个 Channel 只属于一个 EventLoop
这是 Netty 无锁化的关键。所以 Handler 里的字段如果只被本 Channel 的回调访问,可以不用 volatile/synchronized。但你的 PersistentTcpClientHandler 里的 currentResponse 被业务线程写、IO 线程读,跨线程了,所以必须 volatile。
九、学习路径建议
如果你想系统掌握 Netty,建议按这个顺序:
- 先跑通:把上面最小客户端/服务端例子跑起来,打印日志
- 理解 Pipeline:加几个自定义 Handler,观察入站出站顺序
-
理解线程模型:打印
Thread.currentThread().getName()看回调在哪个线程 -
学编解码:写一个
ByteToMessageDecoder解决粘包 -
学心跳/重连:
IdleStateHandler+ 自定义重连逻辑 -
看源码:
NioEventLoop.run()、DefaultChannelPipeline、AbstractChannelHandlerContext -
实战:仿写你的
SyncTcpClient,把同步/异步、代理、超时都实现一遍
推荐资料
- 官方文档:https://netty.io/wiki/
- 《Netty 权威指南》(入门友好)
- 《Netty 实战》(Netty in Action,最经典)
- 源码:
io.netty.example包下有大量官方示例
十、一句话总结
Netty 把传统 BIO 的"主动循环 + 阻塞 + 一连接一线程"改造成了"事件驱动 + 异步回调 + 少量 IO 线程管理海量连接"。你只需要:用 Bootstrap 启动,往 Pipeline 里塞 Handler,在 Handler 回调里写业务。剩下的连接管理、线程调度、协议解析,Netty 都帮你做了。
你的 SyncTcpClient 就是在这个基础上,再用 CompletableFuture 把 Netty 的异步回调"包装"成同步接口,方便业务代码调用。理解了这一层,那份代码就没有神秘感了。
重新理解:BIO / NIO / Epoll(从"为什么"讲起)
上一篇信息太多、太杂。这次我换一种方式:只讲一条主线——"服务器怎么同时处理很多连接",沿着这条线走,每一步只解决上一步的痛点。
第 0 步:先明确问题
假设你写一个服务器,要同时服务 1 万个客户端。
每个客户端会:连上来 → 偶尔发个请求 → 你回个响应 → 连接保持(长连接)。
难点:你怎么知道哪个客户端发数据来了?
第 1 步:BIO —— 一个连接配一个线程
最直觉的做法:
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 等新连接
new Thread(() -> {
while (true) {
int len = socket.getInputStream().read(buf); // 等这个连接的数据
// 处理
}
}).start();
}
思路:每个连接开一个线程,线程里 read() 阻塞着等。数据来了就处理。
问题:
- 1 万个连接 = 1 万个线程
- 每个线程 99% 时间在
read()上干等 - 线程很贵(内存 ~1MB/线程),1 万线程 = 10GB,直接崩
结论:连接一多就死。为什么?因为线程被"等"这件事占满了。
第 2 步:BIO 的痛点本质
关键洞察:
大部分线程在大部分时间里,都卡在
read()上等数据。这不是在干活,是在浪费。
那能不能用一个线程去"等"所有连接?
这是所有后续方案的核心动机。
第 3 步:NIO(非阻塞)—— 线程不再干等
改造 read():没数据就立刻返回,不阻塞。
socket.configureBlocking(false); // 设为非阻塞
while (true) {
for (Socket s : allSockets) {
int len = s.read(buf); // 有数据返回长度,没数据立即返回 0/-1
if (len > 0) { 处理; }
}
}
好处:一个线程能"照顾"所有连接了。
新问题:这叫忙轮询。1 万个连接,每次循环要问 1 万次 read():
- 99% 的调用都返回"没数据"
- 大量无用的系统调用,CPU 空转
结论:线程不阻塞了,但 CPU 被烧掉了。还是不行。
第 4 步:多路复用 —— 让内核帮你"盯"着
新思路:
别让程序自己一个个问,把"监视"这件事交给内核。程序只问内核一句:"哪些连接有数据了?"
这就是 IO 多路复用(multiplexing = 多路合一)。
程序: "内核,帮我盯着这 1 万个连接,谁有数据了告诉我"
内核: [盯着...]
内核: "3 号、57 号、8200 号有数据了"
程序: [只处理这 3 个]
-
select/poll/epoll都是实现这个思路的具体工具 - 程序调用一次,就能一次性拿到"所有就绪的连接"
- 不再需要遍历 1 万次
这一步是质变:从"程序轮询"变成"内核通知"。
第 5 步:select/poll 的问题
最早的实现是 select:
select(10000个fd, ...); // 把 1 万个 fd 传进去
// 内核遍历这 1 万个,返回就绪的
fd 是 File Descriptor(文件描述符) 的缩写,fd 就是操作系统给每一个打开的文件(包括网络连接 Socket)分配的一个“编号(整数索引)”
问题:
- 每次调用都要把全部 1 万个 fd 从用户空间拷到内核空间 → 慢
- 内核要遍历全部 1 万个才能找出谁就绪 → O(n)
- 返回后程序还要再遍历一遍 → O(n)
连接越多越慢。poll 修了数量限制,但遍历问题还在。
什么是 n?在我们的场景里, 非常简单,就是连接数(也就是客户端的个数、或者你要检查的桌子数)。
么是 O(代表“规模和代价”)? 表示的是:当你的工作量
变大时,你花的时间或力气会以什么规律跟着暴涨。
第 6 步:Epoll —— 只关心"就绪的"
epoll 把上面两个 O(n) 都干掉了:
而 epoll 是把这些 fd 先“注册”到内核的红黑树里。当某个 fd(比如 8200 号连接)有数据到了,内核就会把这个具体的数字放进“就绪链表”里,然后告诉程序:“8200 号 fd 有数据了,你直接去读它吧!”
改进 1:注册一次,不用每次传
epoll_ctl(ADD, fd); // fd 只需注册一次,存在内核的红黑树里
epoll_wait(); // 之后调用不用再传 fd 列表
改进 2:内核主动维护"就绪链表"
- 数据到达 → 内核把该 fd 放进就绪链表
-
epoll_wait()直接读这个链表,只返回就绪的 fd - 不用遍历全部 → O(1)(相对于连接总数)
一句话对比:
| select/poll | epoll | |
|---|---|---|
| 每次调用传 fd | 全传,O(n) | 只注册一次 |
| 找就绪 fd | 遍历全部 O(n) | 直接读就绪链表 |
| 1 万连接 | 很慢 | 很快 |
这就是 Linux 高并发的秘密。Nginx、Redis、Netty 全靠它。
第 7 步:把整条线串起来
BIO: 1 连接 1 线程,线程全卡在 read() 等
↓ 痛点:线程太多
非阻塞: 1 线程轮询所有连接
↓ 痛点:CPU 空转
多路复用: 内核帮你盯,有事件才通知
↓ select/poll 痛点:每次遍历全部
Epoll: 红黑树管理 + 就绪链表,只返回有事件的
↓
Netty: 把 epoll 包装成好用的框架,你只写 Handler
每一步都在解决上一步的痛点,而不是凭空出现的。
第 8 步:Netty 在哪一层
Netty 不是新发明,它是第 6 步 epoll 的包装。
你的 Handler 代码
↑
Netty 的 EventLoop(内部就是 while(epoll_wait) 循环)
↑
Linux epoll
EventLoop 线程做的事,本质上就是:
while (true) {
events = epoll_wait(); // 内核返回"哪些连接有事件"
for (event : events) {
if (event 可读) 触发 channelRead0(); // 调用你的 Handler
if (event 可写) 继续写数据;
}
}
你在 Handler 里写的 channelRead0,就是被这个循环调起来的。
第 9 步:对应到你的 SyncTcpClient
this.workerGroup = new NioEventLoopGroup();
这行创建了几个线程,每个线程内部就是上面那个 while(epoll_wait) 循环。
channel.writeAndFlush(data);
- 数据不是立刻发的,先挂到 Channel 上
- 等 epoll 通知"这个 socket 可写"了,EventLoop 才真正写出去
protected void channelRead0(ctx, msg) { ... }
- 对端数据到达 → epoll 返回"这个 fd 可读" → EventLoop 读出来 → 调你的
channelRead0
整个过程:没有线程在"等",全靠 epoll 通知 + EventLoop 循环。
十、用生活比喻收尾
| 概念 | 比喻 |
|---|---|
| BIO | 你雇 1 万个服务员,每人站一个餐桌旁,等客人叫 |
| 非阻塞 | 你雇 1 个服务员,挨个桌子问"要点菜吗?",问完一圈再问一圈 |
| 多路复用 | 你雇 1 个服务员,装了个"呼叫铃总台",客人按铃你才知道谁要服务 |
| select/poll | 呼叫铃总台每次都得把所有桌号报一遍给你 |
| epoll | 呼叫铃总台只报"正在响的桌号",其他不管 |
| Netty | 你直接告诉总台"3 号桌按铃时给我做红烧肉",不用管铃怎么响 |
十一、三个最容易混的点,一次说清
1. 非阻塞 IO ≠ 多路复用
-
非阻塞:
read()不阻塞,但你得自己轮询 -
多路复用:用
select/epoll让内核帮你盯,不需要轮询
Netty 用的是多路复用,不是单纯的"非阻塞轮询"。
2. epoll 不是"异步 IO"
epoll 仍然是同步的——数据到了你还得自己去 read() 拷贝。真正的异步 IO(AIO)是内核帮你拷好再通知你。Linux 的 AIO 不成熟,所以 Netty 用 epoll。
3. "一个线程管一万连接"靠的是什么
靠两点:
- 非阻塞:读不到数据不卡住
- epoll:内核告诉你"哪些连接有数据",你只处理这些
两者缺一不可。
十二、一张图记住全部
怎么同时处理 1 万连接?
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
BIO 方案 轮询方案 多路复用方案
1连接1线程 1线程轮询全部 内核帮忙盯
│ │ │
线程太多,崩 CPU 空转 select/poll
│
遍历全部,慢
│
epoll
红黑树 + 就绪链表
O(1) 拿就绪
│
Netty
包装成 Handler 框架
一句话总结
BIO 的问题是"线程都在等";非阻塞解决了"等"但带来"CPU 空转";多路复用让内核替程序盯连接;epoll 用红黑树 + 就绪链表让"找就绪连接"变成 O(1);Netty 把 epoll 包装成
while(epoll_wait)循环 + Handler 回调,你在 Handler 里写业务,线程全程不阻塞、不空转。