Netty(二):ByteBuf 内存管理、粘包拆包与实战优化
Netty(二):ByteBuf 内存管理、粘包拆包与实战优化
导语:承接第一篇,本篇啃最后一座山——内存管理与实战。先对比
ByteBuf与 JDKByteBuffer的差异,讲清 ByteBuf 的四种分类与选型;再深入 引用计数与内存泄漏防护(线上最常见的踩坑点);然后图解 零拷贝的五个层次;最后收尾于 TCP 粘包拆包与 Netty 拆包器、编解码器体系、IO 线程阻塞问题 与 心跳保活机制,共 9 题。
一、ByteBuf 内存管理
1. ByteBuf 相比 JDK ByteBuffer 好在哪里?
答: JDK ByteBuffer 有四个致命缺陷,ByteBuf 逐一解决:
ByteBuffer 的缺陷 | ByteBuf 的改进 |
|---|---|
① 只有一个 position 指针,读写切换必须 flip() / clear() / rewind(),忘记调用就出错 | 读写指针分离:readerIndex 与 writerIndex 独立,无需 flip() |
② 定长,容量不足需手工 new 更大 buffer 再拷贝 | 动态扩容:写满时自动扩容至 maxCapacity |
③ 没有引用计数,堆外内存只能等 GC 或 Cleaner,释放时机不可控 | 实现 ReferenceCounted:retain() / release() 精确控制生命周期 |
| ④ API 贫瘠(没有查找、合并、拷贝等常用操作) | 提供 indexOf、forEachByte、slice、duplicate、CompositeByteBuf 等丰富 API |
ByteBuf 的三段式结构:
* +-------------------+------------------+------------------+
* | discardable bytes | readable bytes | writable bytes |
* +-------------------+------------------+------------------+
* | | | |
* 0 <= readerIndex <= writerIndex <= capacity核心 API:
| 操作 | API | 影响的指针 |
|---|---|---|
| 读 | readByte() / readInt() / readBytes(dst) | readerIndex 前进 |
| 写 | writeByte() / writeInt() / writeBytes(src) | writerIndex 前进 |
| 只读不移动 | getByte(index) | 不变 |
| 只写不移动 | setByte(index, val) | 不变 |
| 丢弃已读 | discardReadBytes() | readerIndex → 0,writerIndex 前移 |
| 清空 | clear() | 两个指针归 0(不擦除内存) |
ByteBuf buf = Unpooled.buffer(16);
System.out.println(buf.readableBytes()); // 0 (可读取的字节数)
System.out.println(buf.writableBytes()); // 16 (可写入的字节数)
buf.writeInt(100);
System.out.println(buf.readableBytes()); // 4
System.out.println(buf.readerIndex()); // 0
System.out.println(buf.writerIndex()); // 4
int v = buf.readInt(); // 无需 flip()
System.out.println(buf.readerIndex()); // 4一句话:
ByteBuf= 读写分离 + 自动扩容 + 引用计数 + 池化支持 + 丰富 API。
2. ByteBuf 有哪些分类?生产环境怎么选?
答: 按两个正交维度划分,组合出四种:
维度一:内存位置
| 类型 | 底层 | 优点 | 缺点 |
|---|---|---|---|
| Heap(堆内) | JVM 堆上的 byte[] | 分配回收快;受 GC 管理;支持 array() 直接操作数组 | Socket 读写时会多一次拷贝(JVM 堆 → 直接内存) |
| Direct(堆外) | 堆外直接内存 | 少一次内存拷贝,IO 性能更好;不受 GC 暂停影响 | 分配回收成本高;需引用计数手动释放,泄漏难排查 |
维度二:是否池化
| 类型 | 说明 |
|---|---|
| Pooled(池化) | 从预分配的内存池中复用,类似 jemalloc 的分级分配思路,大幅降低分配开销与 GC 压力 |
| Unpooled(非池化) | 每次都新申请内存,用完释放 |
生产选型建议:
| 场景 | 推荐 |
|---|---|
| 网络 IO 读写(绝大多数场景) | PooledByteBufAllocator 的 Direct —— 这正是 Netty 的默认行为 |
| 业务编解码、协议解析 | Heap(GC 更友好,array() 可用) |
| 一次性、极短生命周期 | Unpooled,避免占用池资源 |
| 需要拼接多段数据 | CompositeByteBuf(零拷贝,见第二篇第 4 题) |
// 推荐:始终通过 Allocator 分配,而不是直接 new
ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
ByteBuf buf = alloc.directBuffer(1024); // 池化 + 堆外
ByteBuf heap = alloc.heapBuffer(1024); // 池化 + 堆内
// 从 Channel 拿到绑定的 Allocator(推荐,能继承 channel 的配置)
ByteBuf buf2 = ctx.alloc().buffer(1024);
// 系统属性调整
// -Dio.netty.allocator.type=unpooled 切换为非池化
// -Dio.netty.allocator.numHeapArenas=4 堆内池的 Arena 数量
// -Dio.netty.allocator.numDirectArenas=4 堆外池的 Arena 数量关键结论:
- Netty 4.1 的
ByteBufAllocator.DEFAULT默认就是PooledByteBufAllocator;而网络 IO 读写路径上 Netty 默认使用直接内存,以避免「JVM 堆 → 直接内存」的一次拷贝。- 池化按 Arena 分块管理,默认每个 EventLoop 线程绑定一个 Arena 来减少竞争。
- 不要跨线程传递 ByteBuf 后并发读写;也不要把池化的 ByteBuf 存进静态容器长期持有,否则池会失效并泄漏。
3. 什么是引用计数?Netty 的内存泄漏是怎么产生和防护的?
答:
① 为什么需要引用计数
堆外内存不在 GC 管辖范围内。GC 只能回收堆里的 DirectByteBuffer 壳子(其 Cleaner 会在不可达时释放堆外内存),但释放时机完全不可控——堆内存还很宽裕时 GC 不触发,堆外内存可能已经耗尽。
Netty 的答案是:用引用计数(Reference Counting)来手动、精确地管理 ByteBuf 的生命周期。
② 引用计数规则
| 操作 | 语义 |
|---|---|
| 初始 | 新分配的 ByteBuf,refCnt = 1 |
retain() | 引用计数 +1;表示「还有人要用」 |
release() | 引用计数 -1;降到 0 时真正释放内存 |
| 已释放再访问 | 抛 IllegalReferenceCountException: refCnt: 0 |
③ 谁负责 release?——「最后使用者释放」原则
| 场景 | 处理方式 |
|---|---|
继承 SimpleChannelInboundHandler | 框架自动释放,channelRead0() 返回后 release |
继承 ChannelInboundHandlerAdapter | 需自己 ReferenceCountUtil.release(msg),否则一路传到 TailContext 兜底(会打 warn 日志) |
把 msg 交给异步线程处理 | 先 msg.retain(),业务线程用完再 release()(否则主线程已释放,异步线程踩到已释放内存) |
writeAndFlush(msg) 之后 | Netty 会自动释放,不需要手动 release(会打 toLeakAwareBuffer 的标记) |
继承 ByteToMessageDecoder | 内部的累积缓冲区由解码器自动释放 |
// 正确:异步处理要先 retain
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
buf.retain(); // ← 关键
bizExecutor.execute(() -> {
try {
doSomething(buf);
} finally {
buf.release(); // ← 在业务线程释放
}
});
}④ 泄漏检测机制
Netty 内置 ResourceLeakDetector,通过弱引用 + 引用队列追踪 ByteBuf,当 ByteBuf 被 GC 但引用计数未归零时,判定为泄漏并打日志:
LEAK: ByteBuf.release() was not called before it's garbage-collected.| 级别 | 说明 | 性能影响 |
|---|---|---|
DISABLED | 完全关闭 | 无 |
SIMPLE(默认) | 抽样约 1% 检测,只报「泄漏了」 | 极小 |
ADVANCED | 抽样检测并报告泄漏发生的位置(访问栈) | 中等 |
PARANOID | 全量检测(每个 ByteBuf 都跟踪) | 大,仅压测/排查用 |
# 排查线上泄漏时临时开启
-Dio.netty.leakDetection.level=advanced
# 或运行时修改
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.ADVANCED);⑤ 常见泄漏原因与规避
| 泄漏原因 | 规避方式 |
|---|---|
忘记 release() | 统一用 SimpleChannelInboundHandler;try/finally 释放 |
异常路径跳过了 release() | 放到 finally 块 |
异步传递未 retain() 提前释放 / 忘了 release() | 成对使用 retain() / release() |
ByteToMessageDecoder 子类中 decode() 手动 retain 了 in | 不要对累积 buffer 调 retain() |
| 把 ByteBuf 缓存进静态集合 | 禁止长期持有池化 ByteBuf |
一句话:分配时
refCnt=1,retain()+1、release()-1,归零即释放;谁最后用谁释放;靠ResourceLeakDetector兜底排查。
4. Netty 的零拷贝(Zero-Copy)体现在哪里?
答: 先厘清概念——Netty 语境下的「零拷贝」与操作系统层面的 zero-copy 不完全等价,它更准确的含义是「在用户态层面避免不必要的数据复制」。共体现在 5 处:
① CompositeByteBuf:组合多个 Buffer 为一个逻辑整体
CompositeByteBuf message = Unpooled.compositeBuffer();
ByteBuf header = Unpooled.wrappedBuffer("HEAD".getBytes());
ByteBuf body = Unpooled.wrappedBuffer("BODY".getBytes());
message.addComponents(true, header, body); // 不复制底层数据② slice() / duplicate():共享底层内存的视图
| 方法 | 说明 | 指针 |
|---|---|---|
slice() | 截取 readableBytes 区域的视图,共享底层内存 | 独立 |
duplicate() | 整个 buffer 的视图,共享底层内存 | 独立 |
copy() | 真正复制出新 buffer(非零拷贝) | 独立 |
坑点:
slice()/duplicate()共享内存,修改会影响原 buffer;且它们不持有引用计数,原 bufferrelease()后就不可用了。需要延长生命周期时用retainedSlice()/retainedDuplicate()(内部会retain())。
③ wrap:包装已有字节数组
byte[] bytes = new byte[1024];
ByteBuf wrapped = Unpooled.wrappedBuffer(bytes); // 零拷贝,直接复用 bytes④ FileRegion + sendfile:文件传输的真正零拷贝
// Netty 封装:底层调用 FileChannel.transferTo → sendfile
RandomAccessFile raf = new RandomAccessFile(file, "r");
FileRegion region = new DefaultFileRegion(raf.getChannel(), 0, raf.length());
ctx.writeAndFlush(region); // 数据不经过用户态说明:Linux 2.4+ 支持 SG-DMA(分散/聚集),此时内核只把数据描述符拷给 Socket 缓冲区,无 CPU 数据拷贝,即真正的零拷贝。旧版本仍有 1 次内核缓冲区 → Socket 缓冲区的 CPU 拷贝(共 3 次)。
⑤ 堆外直接内存:IO 读写少一次拷贝
| 层次 | 说明 |
|---|---|
| ① CompositeByteBuf | 逻辑组合,避免合并拷贝 |
| ② slice / duplicate | 共享底层内存的视图 |
| ③ wrap | 复用已有 byte[],不复制 |
| ④ FileRegion / sendfile | 内核态直传,真正的 OS 级零拷贝 |
| ⑤ Direct Buffer | 避免「堆 → 直接内存」的一次拷贝 |
一句话:Netty 的零拷贝 = 「少复制」:用视图代替复制、用组合代替合并、用 sendfile 绕过用户态、用堆外内存少一次搬运。
二、粘包、拆包与编解码
5. 什么是 TCP 粘包/拆包?产生原因是什么?
答:
先澄清一个常见误解:粘包/拆包不是 TCP 的 Bug,而是 TCP 作为「面向字节流」协议的固有特性。TCP 只保证字节顺序与可靠性,不理解也不保留应用层「消息」的边界,所以「包」是应用层概念,TCP 层根本不存在。
四种典型情形(假设应用发了两个独立包 D1、D2):
产生原因(四层):
| 层面 | 原因 | 说明 |
|---|---|---|
| ① 协议本质 | TCP 面向字节流,无消息边界 | 根本原因。发送方 write 的次数与接收方 read 的次数没有一一对应关系 |
| ② 发送方 | Nagle 算法 | 为减少小包数量,会累积/合并小数据再发送 → 粘包 |
| ③ 传输层 | MSS(最大报文段)/ MTU(最大传输单元) | 报文超过 MSS 会被拆分 → 拆包;IP 层超过 MTU 还会再分片 |
| ④ 接收方 | 接收缓冲区 | 应用层未及时读取,多个包在缓冲区堆积 → 粘包 |
解决方案(三类思路):
| 方案 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| ① 消息定长 | 每个报文固定 N 字节,不足补空格 | 简单 | 浪费带宽,无法变长 |
| ② 分隔符 | 包尾加 \n / $_ 等特殊分隔符 | 简单,文本协议常用 | 需转义消息体内的分隔符,且要逐字节扫描 |
| ③ 长度字段 | 包头带一个「消息体长度」字段 | 最通用、性能最好,工业级首选 | 需设计协议头 |
| ④ 自定义协议 | 魔数 + 版本 + 序列化方式 + 指令 + 长度 + 数据 | 生产最完整形态 | 复杂度最高 |
关键认知:UDP 没有粘包问题,因为 UDP 是面向报文的,有天然的消息边界(一次
sendto对应一次recvfrom)。所以粘包拆包是 TCP 特有的话题。
6. Netty 提供了哪些拆包器?LengthFieldBasedFrameDecoder 的参数怎么配?
答:
Netty 内置四大拆包器:
| 拆包器 | 原理 | 适用场景 |
|---|---|---|
FixedLengthFrameDecoder | 按固定长度切分 | 定长协议 |
LineBasedFrameDecoder | 按 \n / \r\n 切分 | 文本行协议(Redis 的 RESP 部分场景) |
DelimiterBasedFrameDecoder | 按自定义分隔符切分 | 自定义文本协议 |
LengthFieldBasedFrameDecoder | 按头部的长度字段切分 | 工业级首选,二进制协议 |
// 定长:每帧 20 字节
ch.pipeline().addLast(new FixedLengthFrameDecoder(20));
// 行分隔:单帧最大 1024 字节,超过抛 TooLongFrameException
ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
// 自定义分隔符
ByteBuf delimiter = Unpooled.copiedBuffer("$_".getBytes());
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, delimiter));
// 长度字段(见下方详解)
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));LengthFieldBasedFrameDecoder 五参数详解:
public LengthFieldBasedFrameDecoder(
int maxFrameLength, // ① 单帧最大长度,超过抛 TooLongFrameException
int lengthFieldOffset, // ② 长度字段的起始偏移量(相对帧头)
int lengthFieldLength, // ③ 长度字段本身占几个字节(1/2/3/4/8)
int lengthAdjustment, // ④ 长度值的修正量(可正可负)
int initialBytesToStrip) // ⑤ 解码后跳过(丢弃)的字节数核心公式(面试必背):
帧总长度 = lengthFieldValue + lengthAdjustment + lengthFieldOffset + lengthFieldLength即:「长度字段的值」加上「修正量」,再加上「长度字段之前」和「长度字段本身」的长度。
帧结构示意:
四种典型配置(对应 Netty 官方 javadoc):
① 长度字段在最前,值 = 消息体长度
解码前 (14 B) 解码后 (12 B)
+--------+----------------+ +----------------+
| Length | Actual Content | | Actual Content |
| 0x000C | "HELLO, WORLD" | | "HELLO, WORLD" |
+--------+----------------+ +----------------+new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2);
// 帧长 = 12 + 0 + 0 + 2 = 14 ✓② 长度字段在最前,值 = 帧总长度
解码前 (14 B) 解码后 (14 B)
+--------+----------------+ +--------+----------------+
| Length | Actual Content | | Length | Actual Content |
| 0x000E | "HELLO, WORLD" | | 0x000E | "HELLO, WORLD" |
+--------+----------------+ +--------+----------------+new LengthFieldBasedFrameDecoder(65535, 0, 2, -2, 0);
// 长度值 14 已包含 2 字节的长度字段本身 → 修正 -2
// 帧长 = 14 + (-2) + 0 + 2 = 14 ✓③ 长度字段前有 Header
解码前 (16 B) 解码后 (13 B)
+------+--------+------+----------------+ +------+----------------+
| HDR1 | Length | HDR2 | Actual Content | | HDR2 | Actual Content |
| 0xCA | 0x000C | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+ +------+----------------+new LengthFieldBasedFrameDecoder(65535, 1, 2, 1, 3);
// lengthFieldOffset=1(跳过 HDR1),lengthAdjustment=1(长度值不含 HDR2 的 1 字节)
// 帧长 = 12 + 1 + 1 + 2 = 16 ✓;initialBytesToStrip=3 丢掉 HDR1+Length④ 长度字段位于帧的最末尾
解码前 (17 B) 解码后 (13 B)
+------+----------------+------+--------+ +------+----------------+
| HDR1 | Actual Content | HDR2 | Length | | HDR2 | Actual Content |
| 0xCA | "HELLO, WORLD" | 0xFE | 0x000C | | 0xFE | "HELLO, WORLD" |
+------+----------------+------+--------+ +------+----------------+new LengthFieldBasedFrameDecoder(65535, 12, 2, 1, 3);
// 长度字段起始在偏移 12 处一句话:
lengthAdjustment是「长度字段的值」与「真实消息体长度」的差值,initialBytesToStrip决定解码后要不要保留协议头。
7. Netty 的编解码器体系是怎样的?
答:
按「数据形态」划分,Netty 的编解码器是四个基类 + 两个组合类:
| 基类 | 方向 | 抽象方法 | 说明 |
|---|---|---|---|
ByteToMessageDecoder | 入站 | decode(ctx, in, out) | 核心拆包器父类,内部维护 cumulation 累积缓冲区 |
MessageToByteEncoder<I> | 出站 | encode(ctx, msg, out) | 通过 TypeParameterMatcher 泛型匹配,不匹配则透传 |
MessageToMessageDecoder<I> | 入站 | decode(ctx, msg, out) | 消息转消息 |
MessageToMessageEncoder<I> | 出站 | encode(ctx, msg, out) | 消息转消息 |
ByteToMessageCodec<I> | 双向 | decode + encode | = Decoder + Encoder |
MessageToMessageCodec<I,O> | 双向 | decode + encode | = Decoder + Encoder |
ByteToMessageDecoder 的工作流程(高频考点):
两个关键细节:
decode()会被循环调用,直到out不再产生新元素或in无可读数据——所以一次decode可以产出多条消息。- 累积缓冲区由解码器自动释放,子类不需要(也不应该)手动
release(in)。
// 自定义整数编解码器
public class IntegerCodec extends ByteToMessageCodec<Integer> {
@Override
protected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) {
out.writeInt(msg);
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) return; // 不足 4 字节,等下一包
out.add(in.readInt());
}
}序列化协议怎么选?(紧跟着编解码的高频追问)
| 协议 | 类型 | 跨语言 | 体积 | 性能 | 说明 |
|---|---|---|---|---|---|
| JDK Serializable | 二进制 | × | 大 | 差 | 生产禁用:性能低、体积大、无法跨语言、反序列化有 RCE 风险 |
| JSON(Jackson / Fastjson2) | 文本 | ✓ | 较大 | 中 | 可读性好,Web 与对外接口首选;无类型信息,需显式指定 |
| Hessian | 二进制 | ✓ | 中 | 中 | Dubbo 默认之一,跨语言友好 |
| Kryo | 二进制 | × | 小 | 很好 | Java 专用,非线程安全需 ThreadLocal/池化;需注册类 |
| Protobuf | 二进制 | ✓ | 最小 | 最好 | 需 .proto IDL,向后兼容性好,gRPC 默认 |
选型建议:
一句话:对外用 JSON,内部高性能用 Protobuf,Java 内部图省事用 Kryo,永远别用 JDK 原生序列化。
三、实战与调优
8. 为什么不能在 Netty 的 IO 线程里执行耗时操作?怎么解决?
答:
危害:一个 EventLoop 串行驱动多条 Channel,一次阻塞 = 一片连接全挂。
具体后果:
| 后果 | 说明 |
|---|---|
| 同一 EventLoop 上所有 Channel 被阻塞 | 串行执行,排队等待 |
| 心跳超时 | 心跳包发不出 / 收不到,被对端判定为死连接 |
| 响应延迟飙升 | 吞吐骤降 |
| 连接假死 | 客户端超时重试,进一步放大流量 |
哪些是「耗时操作」? 数据库查询、HTTP 调用、Redis 慢查询、加解密、大文件 IO、复杂计算、Thread.sleep()、CountDownLatch.await()、同步锁竞争……
三种解决方案:
方案一(推荐):Handler 绑定到独立的 EventExecutorGroup
static final EventExecutorGroup BIZ_GROUP = new DefaultEventExecutorGroup(16);
pipeline.addLast(new StringDecoder());
pipeline.addLast(new StringEncoder());
pipeline.addLast(BIZ_GROUP, "bizHandler", new BizHandler()); // ← 指定线程池关键语义:
addLast(group, name, handler)后,该 Handler 的所有回调(channelRead、channelActive等)都在group的线程中执行,不再由 EventLoop 线程执行。
方案二:Handler 内部手动投递到业务线程池
private final ExecutorService bizPool = Executors.newFixedThreadPool(16);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
buf.retain(); // 跨线程必须 retain
bizPool.execute(() -> {
try {
Object resp = doSlowWork(buf);
ctx.writeAndFlush(resp); // write 会在 EventLoop 排队执行,线程安全
} finally {
buf.release();
}
});
}方案三:定时任务走 ctx.executor().schedule()
ctx.executor().schedule(() -> doSomething(), 30, TimeUnit.SECONDS);三个必须记住的坑:
| 坑 | 说明 |
|---|---|
跨线程要 retain() | 把 ByteBuf 交给别的线程前必须 retain(),否则主线程可能已 release() |
ctx.write() 是线程安全的 | Netty 会把 write 任务封装后投递回该 Channel 的 EventLoop 串行执行,可从任意线程调用 |
绑定 EventExecutorGroup 后需注意线程安全 | 此时 Handler 可能在业务线程并发执行(不同 Channel),有状态就必须加锁或用无状态设计 |
一句话:IO 线程只做「快」的事(编解码、协议处理、转发),凡是「慢」的(DB、HTTP、文件、重计算)一律丢给业务线程池。
9. Netty 是怎么实现心跳机制和长连接保活的?
答:
① 核心:IdleStateHandler
public IdleStateHandler(
int readerIdleTime, // 读空闲:多久没读到数据
int writerIdleTime, // 写空闲:多久没写出数据
int allIdleTime, // 读写都空闲
TimeUnit unit)它的作用边界(面试高频陷阱):
关键认知:
IdleStateHandler只是个「空闲检测器」,它不会自动发心跳,也不会自动断连。 心跳包的发送、超时重试、断连逻辑都必须在userEventTriggered()里自己实现。
② 典型实现(服务端):
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
private int lossConnectCount = 0; // 连续丢失心跳次数
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
lossConnectCount++;
if (lossConnectCount >= 3) { // 连续 3 次读空闲 → 判定死亡
ctx.close(); // ← 断连逻辑自己写
}
}
// WRITER_IDLE:服务端可以在这里主动发心跳给客户端
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
lossConnectCount = 0; // 收到任何数据就重置计数
ctx.fireChannelRead(msg);
}
}
// 装配:60 秒读空闲、0 秒写空闲、0 秒全空闲
pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
pipeline.addLast(new HeartbeatHandler());③ 典型实现(客户端):
| 角色 | 策略 |
|---|---|
| 客户端 | 监听 WRITER_IDLE → 发心跳 Ping;同时监听 READER_IDLE → 检测服务端是否还活着,超时则断线重连 |
| 服务端 | 监听 READER_IDLE → 累计次数,超过阈值主动关闭连接释放资源 |
④ TCP keepalive 与应用层心跳的区别(必考)
| 维度 | TCP keepalive(SO_KEEPALIVE) | 应用层心跳 |
|---|---|---|
| 所在层 | 传输层(内核实现) | 应用层(业务代码) |
| 默认周期 | 2 小时(Linux 内核参数 tcp_keepalive_time) | 自定义,通常 秒级 |
| 检测内容 | 连接是否存活(对端主机是否可达) | 连接存活 + 服务进程是否还能正常响应 |
| 故障感知速度 | 慢(分钟~小时级) | 快(秒级) |
| 穿透代理/LB | ✗ 常被中间设备(LVS/Nginx/SLB)吃掉或重置 | ✓ 正常穿透 |
| 可控性 | 弱,依赖操作系统参数 | 强,随时调整 |
| 代表配置 | .childOption(ChannelOption.SO_KEEPALIVE, true) | IdleStateHandler + 自定义心跳协议 |
为什么 TCP keepalive 不能替代应用层心跳?
- 周期太长:2 小时默认,故障感知太慢;
- 检测不到应用假死:进程死锁 / 线程池打满时,内核 TCP 栈仍会正常回 ACK,keepalive 认为连接正常,但业务已经不可用;
- 无法穿透中间设备:负载均衡器会重置空闲连接,keepalive 无能为力。
⑤ 长连接保活的完整实践清单
| 手段 | 说明 |
|---|---|
| 应用层心跳 | 主力手段,秒级感知 |
| 断线重连 | 指数退避(1s、2s、4s… 上限 30s),避免雪崩 |
| 空闲连接清理 | 服务端主动关闭长期无业务数据的连接,释放 fd 与内存 |
SO_KEEPALIVE 兜底 | 作为最后一道防线,检测主机级故障 |
| 优雅关闭 | boss.shutdownGracefully() / worker.shutdownGracefully(),配合 closeFuture() |
一句话:心跳 =
IdleStateHandler检测 + 自己写 Ping/Pong;SO_KEEPALIVE只能兜底,不能替代。
小结
| 主题 | 核心结论 |
|---|---|
| ByteBuf 优势 | 读写指针分离、自动扩容、引用计数、池化 |
| ByteBuf 选型 | 网络 IO 用 PooledDirect(默认),业务处理用 Heap |
| 引用计数 | refCnt 1 → retain +1、release -1,归零释放;异步传递必先 retain |
| 零拷贝 | Composite / slice / wrap / FileRegion-sendfile / DirectBuffer,共 5 处 |
| 粘包拆包 | TCP 字节流无边界所致;UDP 无此问题 |
| 拆包器 | LengthFieldBasedFrameDecoder 最通用,牢记五参数与帧长公式 |
| 编解码器 | ByteToMessageDecoder 自动释放累积缓冲;SimpleChannelInboundHandler 自动释放 msg |
| IO 线程 | 绝不做耗时操作,用 DefaultEventExecutorGroup 隔离 |
| 心跳 | IdleStateHandler 只检测不发心跳;SO_KEEPALIVE 仅兜底 |
