Java · BIO/NIO 与 Netty 模型

阻塞是对线程的绑架 — Selector 用一个线程等所有连接的就绪事件, Buffer 是一台三指针状态机, Netty 只是把这套机制工程化

BIO: 一个连接绑架一个线程 c1 c2 c3 Thread-1: socket1.read() 阻塞中… Thread-2: socket2.read() 阻塞中… Thread-3: socket3.read() 阻塞中… accept() / read() / write() 三处全阻塞, 每处都得有人守着 1000 连接 = 1000 线程 × ~1MB 栈 ≈ 1GB 真正有数据可读的连接不足 1%, 其余线程纯干等 上下文切换吞掉 CPU; 流量再涨 → 线程耗尽 / OOM 拒绝服务 NIO: Selector 一个线程管所有连接 ch1 ch3 ch5 chN 注册 InterestOps Selector 一个线程 select() 阻塞到任一 fd 就绪 ch3 → OP_READ 就绪 就绪集合 (epoll_wait 返回) handler 只处理就绪的, 没数据的连接零成本 OP_ACCEPT / OP_CONNECT OP_READ / OP_WRITE EventLoop 线程 非阻塞 read/write read: >0 / 0 / -1 三态 1 个线程可管上万长连接 线程数与连接数解耦 — 内存和切换成本不再随连接数线性涨 Buffer 状态机: position / limit / capacity ① 写模式: put 了 4 字节之后 d0 d1 d2 d3 pos=4 limit=10 capacity = 10 (格子总数, 永不变) flip() ② 读模式: limit=旧 pos, pos 归 0 d0 d1 d2 d3 不可读 pos=0 limit=4 ③ clear() / compact(): 回到写模式 clear(): pos=0, limit=capacity — 数据还在, 只会被下次写覆盖 compact(): 没读完的字节挪到开头, pos 接着写 — 半读半写的续传场景用它 忘了 flip → 读出空; flip 后没读完就 clear → 丢数据。状态机就是它的全部 堆外内存与零拷贝 HeapByteBuffer vs DirectByteBuffer 堆内 byte[] IO 路径: 堆 → JVM 临时堆外 → 内核 多一次拷贝; GC 搬迁对象, 地址不稳 适合: 小包解析, 分配便宜缓存友好 堆外 native 内存 IO 直达内核缓冲, 少一次拷贝 不受 GC 搬迁; 但分配贵 需手动释放, Cleaner 只是兜底 零拷贝: channel.transferTo (sendfile) 传统 read/write: 数据要进一次用户态 磁盘 内核 buf 用户 buf socket 网卡 4 次拷贝 · 4 次用户/内核态切换, CPU 全程搬运 transferTo (sendfile): 用户态根本不碰数据 磁盘 内核 buf 网卡 (DMA) 2 次拷贝 · 0 次往返 Legend 推荐/高效 阻塞/开销 就绪事件 线程/指针 limit 界限

BIO 的账单

  • • accept/read/write 三处全阻塞, 每连接一线程
  • • 1000 连接 ≈ 1GB 栈内存 + 切换风暴
  • • 线程池只是把"线程数"封顶, 长连接直接耗尽

NIO 三大件

  • • Buffer: position/limit/capacity 三指针状态机
  • • Channel: 双向, 可切非阻塞, FileChannel 不能注册
  • • Selector: epoll 包装, 只返回就绪的 fd 集合

Netty = NIO 工程化

  • • boss 接连接, worker 管 IO, EventLoop 绑线程
  • • Pipeline 责任链拆编解码与业务
  • • ByteBuf 池化 + 引用计数, 半包拆包有现成解码器

💡 一句话理解

BIO 的问题不在"慢", 在阻塞是对线程的绑架: 一个 read() 卡住, 守着它的线程就什么都干不了, 一千个空闲连接就得养一千个线程。NIO 的解法是把"等"这件事集中起来 — 所有 Channel 把 fd 注册给一个 Selector, select() 底下就是 epoll_wait, 内核把就绪的 fd 挑出来, 一个线程只处理"有事发生的"连接, 空闲连接零成本。线程数从此和连接数解耦, 这就是 C10K 的答案。

读写数据的载体 Buffer 是一台三指针状态机: capacity 定总量, 写数据 position 往前走, flip() 把 limit 压到 position、position 归零, 从写切到读; clear() 全重置, compact() 把没读完的挪到开头。剩下的两块拼图: 堆外 DirectByteBuffer 让 IO 少一次拷贝、不受 GC 搬迁; transferTo 走 sendfile 连用户态都不用进。Netty 没有发明新物理 — boss/worker 只是线程模型工程化, ByteBuf 只是修好了 ByteBuffer 的难用与半包问题。

🧠 必知必会 必考 & 必会

BIO 阻塞点
accept / read / write 三处全阻塞: 每个连接要一个线程守着 read; write 缓冲满也会卡住。线程数 = 连接数是结构性问题, 线程池只是封顶不是解决。
Socket s = server.accept();      // 阻塞点 1: 等连接
InputStream in = s.getInputStream();
int n = in.read(buf);              // 阻塞点 2: 守它的线程干等
// 关键: 1000 空闲连接 = 1000 线程 × ~1MB 栈 ≈ 1GB
NIO 三大件
Buffer (数据容器+状态机) + Channel (双向流, 可非阻塞) + Selector (多路复用器)。FileChannel 不能注册 Selector, 只有 Socket 通道可以。
Selector sel = Selector.open();
SocketChannel ch = SocketChannel.open();
ch.configureBlocking(false);             // Channel 切非阻塞
ch.register(sel, SelectionKey.OP_READ);    // 注册给多路复用器
// 关键: FileChannel 不能注册 Selector, 只有 Socket 通道可以
Buffer 三指针
capacity 总容量不变; position 当前位置; limit 界限。0 ≤ position ≤ limit ≤ capacity 恒成立 — 记住这条不等式, flip 的行为就是推论。
ByteBuffer b = ByteBuffer.allocate(10); // pos=0, limit=cap=10
b.putInt(1); b.putInt(2);           // 写 8 字节 → pos=8
// 关键: 0 ≤ position ≤ limit ≤ capacity 恒成立,
// flip 的行为全是这条不等式的推论
flip / clear / compact
flip(): 写切读 (limit=pos, pos=0); clear(): 全重置回写模式 (数据不清只是等着被覆盖); compact(): 未读字节挪到开头, 适合一轮没读完接着读的续传。
ByteBuffer b = ByteBuffer.allocate(10);
b.put((byte) 42);              // 写: pos 0→1
b.flip();                      // limit=1, pos=0 — 写切读
b.get();                       // → 42, pos=1
b.clear();                     // pos=0, limit=10 回写模式; compact 续传用
SelectionKey 四事件
OP_ACCEPT (有新连接) / OP_CONNECT / OP_READ (可读) / OP_WRITE (可写)。interestOps 声明关心什么, readyOps 告诉你发生了什么。
ssc.register(sel, SelectionKey.OP_ACCEPT);  // 有新连接
ch.register(sel, SelectionKey.OP_READ);     // 可读
// 还有 OP_CONNECT / OP_WRITE; interestOps 声明关心什么,
if (key.isAcceptable()) accept(key); // readyOps 告诉你发生了什么
非阻塞 read 三态
返回 >0 读到字节数; 返回 0 现在没数据 (不是异常也不是 EOF); 返回 -1 对端关闭 — 只有 -1 才该关通道。
int n = ch.read(buf);
if (n > 0)      handle(buf);  // 读到 n 字节
else if (n == 0) return;        // 现在没数据, 不是 EOF
else             ch.close();    // n == -1: 对端关闭
Scatter/Gather
一次 IO 配多个 Buffer: 读时"散"进头部 Buffer + 体 Buffer, 写时"聚"着出去 — 协议头/体分离解析不用手动切数组。
ByteBuffer head = ByteBuffer.allocate(8);
ByteBuffer body = ByteBuffer.allocate(1024);
ch.read(new ByteBuffer[]{ head, body });  // 读时"散"
ch.write(new ByteBuffer[]{ head, body }); // 写时"聚"
// 关键: 头/体各自落位, 不用手动切数组
Heap vs Direct
HeapBuffer 做 IO 时 JVM 要先复制到一块临时堆外缓冲 (GC 可能搬动堆对象, 地址不能直接给内核); DirectBuffer 免这次拷贝、地址稳定, 但分配贵、释放靠显式 recycle/Cleaner。网关/代理等大流量热路径用 direct+池化。
ByteBuffer heap = ByteBuffer.allocate(64);        // 堆内 byte[]
ByteBuffer direct = ByteBuffer.allocateDirect(64); // 堆外
// 关键: heap 做 IO 要先拷到临时堆外; 热路径用 direct + 池化,
// direct 地址稳定不受 GC 搬迁, 但分配贵、要显式释放
零拷贝 transferTo
FileChannel.transferTo 走 sendfile: 数据从 page cache 直达网卡, 不进用户态; 单次上限 2GB, 超了要循环。文件分发/静态资源的标准姿势。
long pos = 0, size = file.size();
while (pos < size)
    pos += file.transferTo(pos, size - pos, socketCh); // sendfile
// 关键: 数据从 page cache 直达网卡不进用户态; 单次上限 2GB 要循环
epoll LT 与空轮询
Linux 上 Selector 是 epoll 的水平触发: 没读干净会一直报告就绪 (所以必须读到返回 0); 旧版 JDK 有空轮询 bug — select() 立即返回 0 导致 CPU 100%, Netty 用"计数重建 Selector"规避。
int n = selector.select(1000);
if (n == 0 && ++emptySpins > 512) rebuildSelector(); // 空轮询规避
// 关键: LT 水平触发 — 就绪后必须读到返回 0, 否则一直报告就绪
Netty EventLoop
boss group 只管 accept, worker group 管 IO 读写; 一个 EventLoop = 一个固定线程 + 一批 Channel, 同一 Channel 的所有事件都在同一线程串行执行 — 天然免锁。
EventLoopGroup boss = new NioEventLoopGroup(1);  // 只管 accept
EventLoopGroup worker = new NioEventLoopGroup();    // 管 IO 读写
// 关键: 一个 EventLoop = 一个固定线程 + 一批 Channel,
// 同一 Channel 的所有事件在同一线程串行执行 — 天然免锁
ByteBuf 引用计数
池化的 PooledDirectByteBuf 用引用计数管理: retain()/release(), 计数归零归还池; 传递给异步回调要 retain, 忘 release 就是堆外泄漏。
ByteBuf buf = (ByteBuf) msg;
try { process(buf); }
finally { buf.release(); }   // 计数归零 → 归还池
// 关键: 传给异步回调先 retain(); 忘 release = 堆外泄漏
OP_WRITE 的语义
socket 几乎永远可写 — 常注册 OP_WRITE 会 busy loop。只在 write 返回小于预期 (内核发送缓冲满) 时注册, 写完立刻取消。
int w = ch.write(buf);
if (!buf.hasRemaining())
    key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
// 关键: socket 几乎永远可写, 常注册 OP_WRITE = busy loop

🏭 生产实战 real world

场景 1 · 静态资源节点 2TB/天: transferTo 零拷贝分发

read/write 循环把文件搬进用户态再搬出去, CPU 全程做搬运工 — 内核能直达的事别经过你。

try (FileChannel file = FileChannel.open(src, StandardOpenOption.READ);
     SocketChannel out = socket.getChannel()) {
    long pos = 0, size = file.size();
    while (pos < size) {
        pos += file.transferTo(pos, size - pos, out);  // 内核里直送 socket
    }
}
// 单次上限 2GB (内部 32 位计数), 循环搬直到返回推进为止
// 实测 1GB 文件: CPU 时间 4.1s → 0.6s, 耗时 18s → 7s (万兆网卡)

场景 2 · 网关 40 万包/秒: 池化 DirectByteBuffer 降 GC

每包 new 一个堆内缓冲, 年轻代直接被 IO 垃圾淹没; Netty 默认就是池化 direct, 显式配明白。

Bootstrap b = new Bootstrap()
    .group(new NioEventLoopGroup())
    .channel(NioSocketChannel.class)
    .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
    .option(ChannelOption.RCVBUF_ALLOCATOR,
            new AdaptiveRecvByteBufAllocator(64, 4096, 65536));
// 容器兜底: -XX:MaxDirectMemorySize=2g -Dio.netty.maxDirectMemory=2147483648
// 效果: IO 缓冲不进堆, young GC 38 次/分钟 → 4 次/分钟

场景 3 · TCP 拆包: 长度前缀解码的两种写法

TCP 是字节流没有消息边界, 半包粘包是常态 — 二进制协议首选现成解码器。

// 2 字节长度头 + body: 用现成的, 别手撕边界
pipeline.addLast(new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
//              maxFrameLength  lengthFieldOffset=0  lengthFieldLength=2  剥掉 2B 头
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new BusinessHandler());

// 手写才需要: 累积 ByteBuf → 够读头再读长度 → body 够了才切给下游,
// 不够就 return 等下次 channelRead 续 — "半包"不是错误是常态

场景 4 · IM 接入层: 单 Selector 线程挂 5 万长连接

长连接心跳用轮询扫表是灾难 — select 带超时醒来顺手清理空闲连接。

Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8899), 1024);   // backlog
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);

while (!Thread.currentThread().isInterrupted()) {
    int n = selector.select(1000);            // 最多等 1s, 醒来顺带清心跳
    if (n == 0) { reapIdleConnections(); continue; }
    var it = selector.selectedKeys().iterator();
    while (it.hasNext()) {
        var key = it.next();
        it.remove();                        // 不 remove 会重复处理同一事件
        dispatch(key);
    }
}

4C8G 单机 5 万连接, CPU 常态 15% — BIO 同规模要 5 万线程, 直接不可行。

场景 5 · write 半包: OP_WRITE 只在写不完时注册

大响应写一半内核缓冲满了, 剩余数据挂起等插座可写 — 注册时机就是全部。

void flushPending(SocketChannel ch, SelectionKey key, ByteBuffer remain) {
    while (remain.hasRemaining()) {
        int w = ch.write(remain);
        if (w == 0) {                          // 内核发送缓冲满, 写不动了
            key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
            key.attach(remain);                   // 挂在 key 上, 可写事件里接着写
            return;
        }
    }
    key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);  // 写完立刻取消
}

场景 6 · 500MB 视频上传: 通道对通道流式写盘

不能 readAllBytes (OOM), 也不必用户态中转 — transferFrom 直接落盘。

try (SocketChannel in = socket.getChannel();
     FileChannel out = FileChannel.open(tmp, CREATE, WRITE, TRUNCATE_EXISTING)) {
    long total = 0;
    while (total < contentLength) {
        long n = out.transferFrom(in, total, 1 << 20);  // 每次 1MB
        if (n <= 0) break;                // 对端提前断开, 保留已完成部分
        total += n;
        progress.report(total, contentLength);
    }
    out.force(true);                   // 数据+元数据强制落盘, 防掉电
}

场景 7 · 配置热加载: WatchService 替代 30s 轮询

NIO.2 的目录监听让"改完秒级生效"变成一行注册, 不再空转 lastModified。

WatchService watcher = FileSystems.getDefault().newWatchService();
dir.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY);

WatchKey key = watcher.take();               // 阻塞到有事件, 不烧 CPU
for (WatchEvent<?> e : key.pollEvents()) {
    log.info("config changed: {}", e.context());
    configReloader.reload();
}
key.reset();                                // 不 reset 后续事件收不到
// 遍历大目录同理: Files.newDirectoryStream 惰性迭代, 不吃内存

场景 8 · Netty ByteBuf 释放的 finally 模板

引用计数的内存, 谁消费谁释放 — 忘 release 池化堆外只会越涨越高。

public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    try {
        int len = buf.readShort() & 0xFFFF;    // 无符号语义, 手动去符号位
        process(buf.readBytes(len));
    } finally {
        ReferenceCountUtil.release(msg);   // 不释放 → 池化堆外泄漏
    }
}
// SimpleChannelInboundHandler 会自动 release — 但只对"进来的"消息负责

场景 9 · 压测连接风暴: SO_BACKLOG / SO_REUSEADDR 调优

压测报告 "connection reset / accept queue overflow" — 全连接队列被打满, Java 侧参数还要和内核对齐。

ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.setOption(StandardSocketOptions.SO_REUSEADDR, true);   // 重启不等 TIME_WAIT
ssc.bind(new InetSocketAddress(port), 4096);      // 全连接队列 (backlog)

// 配套内核参数: net.core.somaxconn=4096 — Java 侧大于它会被截断
// Netty 写法: .option(ChannelOption.SO_BACKLOG, 4096)
// 心跳踢人: .childOption(SO_KEEPALIVE, true) + IdleStateHandler(60, 0, 0)

场景 10 · Direct buffer memory OOM 排查 (堆正常)

症状: OutOfMemoryError: Direct buffer memory, 但堆 dump 干干净净 — 泄漏在堆外。

// 原因: 堆外 direct 用量撞上 -XX:MaxDirectMemorySize (默认 ≈ Xmx)

// 排查三连:
// 1. NMT: java -XX:NativeMemoryTracking=summary -jar app.jar (压测后 jcmd 1 VM.native_memory)
// 2. Netty 自检: -Dio.netty.leakDetection.level=paranoid — 日志直接打泄漏栈
// 3. pmap <pid> | grep -i anon 定位增长区间, 对照请求量找源头
// 常见根因: 忘 release / release 后继续用 / 上限设得比 Netty 池还小

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 忘 flip 或顺序错 — 写完直接 read: 读到空 (pos==limit); flip 两次: limit=0 读不动。正解: 状态机背熟 — 写 → flip() → 读 → clear()/compact(), 每轮循环都对齐。
// 错: buf.put(x); buf.get();  pos==limit → 读到空
// 对: 写 → flip() → 读 → clear()/compact(); 每轮循环都对齐
坑 2 · DirectByteBuffer 只靠 GC — 堆内存充裕时 Cleaner 迟迟不跑, 堆外先撞 OutOfMemoryError: Direct buffer memory 而堆一切正常。正解: 池化复用 + 显式 release; Netty 开 leak detection。
// 错: 每次 ByteBuffer.allocateDirect(1<<20) 用完不管, 赌 Cleaner 兜底
//     堆充裕时 Cleaner 迟迟不跑 → 堆外先 OOM, 堆 dump 却正常
// 对: 池化复用 + 显式 release; -Dio.netty.leakDetection.level=paranoid
坑 3 · Selector 空轮询 bug — select() 不阻塞立即返回 0, 死循环把 CPU 打满 100% (旧版 JDK/内核组合)。正解: 升级 JDK; 自己数"连续空转次数", 超阈值重建 Selector — Netty 内置该策略。
// 错: while (true) { selector.select(); ... }  某 JDK/内核组合下
//     select() 立即返回 0, 死循环把 CPU 打满 100%
// 对: 数连续空转次数, 超阈值重建 Selector; 根治靠升级 JDK
坑 4 · read 返回 0 当异常或 EOF — 非阻塞 read 返回 0 只是"现在没数据", 当成关闭就把好端端的连接踢了。正解: 记住三态 — >0 字节数 / 0 无数据 / -1 对端关闭, 只有 -1 关通道。
// 错: int n = ch.read(buf); if (n <= 0) ch.close();  把好连接踢了
// 对: n>0 处理; n==0 返回等下次; n==-1 才是对端关闭
坑 5 · OP_WRITE 常开 — socket 几乎永远可写, 常注册让 select 每轮都醒, busy loop 空烧 CPU。正解: 只在 write 返回 0 (发送缓冲满) 时注册, 数据写完立刻 interestOps &= ~OP_WRITE。
// 错: ch.register(sel, OP_READ | OP_WRITE);  几乎永远可写 → busy loop
// 对: write 返回 0 时才 | OP_WRITE; 写完 &= ~OP_WRITE 立刻取消
坑 6 · 多线程共享 SelectionKey — 一个线程 cancel key, 另一个线程正在用, 抛 CancelledKeyException 或行为未定义。正解: Selector 归单线程所有; 跨线程操作用队列投递到 owner 线程 (Netty EventLoop 的 invokeLater 就是这么来的)。
// 错: 线程 A 正用 key 读写, 线程 B 同时 key.cancel()
//     → CancelledKeyException / 行为未定义
// 对: Selector 归单线程所有; 跨线程操作队列投递到 owner 线程
坑 7 · Buffer 复用不清零 — clear() 不擦数据, 只重置指针: 下一轮写不满就读, 读出上一轮的旧字节混进协议体。正解: 读多少写多少按 hasRemaining() 精确控制; 调试用 buf.duplicate() 检查内容。
// 错: clear() 后只写 2 字节就读 4 字节 → 读出上一轮旧字节混进协议体
// 对: 按 hasRemaining() 精确控制读写量, 只读自己写的范围
坑 8 · 不处理拆包粘包 — 半条 JSON 交给解析器报错, 两条消息粘一起解析出脏数据。正解: 长度前缀 / 分隔符 / 定长三选一; Netty 用 LengthFieldBasedFrameDecoder / DelimiterBasedFrameDecoder。
// 错: 收到多少解析多少 → 半条 JSON 报错 / 两条粘一起解析出脏数据
// 对: new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2);
//     或分隔符/定长; 边界协议三选一, 别手撕
坑 9 · Channel 线程安全的误解 — SocketChannel 本身多线程可安全"调用", 但同一连接的读写乱序并发会把字节流写花。正解: 同一连接的 IO 串行化 — 固定一个线程 (或队列投递), 这正是 EventLoop 绑定模型的意义。
// 错: 两个线程同时对同一 SocketChannel write → 字节流交错写花
// 对: 同一连接 IO 串行化 — 固定线程或队列投递 (EventLoop 绑定)
坑 10 · Files.readAllBytes 读大文件 — 3GB 日志一把进内存, 容器直接 OOMKilled。正解: BufferedReader 按行 / FileChannel + 固定大小 Buffer 分块; 只要"扫一遍"就用流式。
// 错: byte[] all = Files.readAllBytes(path);  3GB 日志 → OOMKilled
// 对: BufferedReader 按行 / FileChannel + 固定 Buffer 分块流式
坑 11 · transferTo 超 2GB 只搬一半 — 内部用 32 位计数, 单次最多 ~2GB, 超过部分静默不搬。正解: while (pos < size) pos += ch.transferTo(pos, size - pos, dst); 循环到搬完。
// 错: file.transferTo(0, file.size(), dst);  >2GB 部分静默不搬
// 对: while (pos < size) pos += file.transferTo(pos, size - pos, dst);
坑 12 · HeapByteBuffer 做大流量 IO — 每次 IO JVM 都要复制到临时 direct 缓冲, 白拷一遍还多分配。正解: 热路径网络 IO 用 direct + 池化; heap 留给小包解析与测试代码。
// 错: 热路径每次 IO 用 allocate() 堆内 buffer
//     → JVM 每次白拷一遍到临时 direct 缓冲
// 对: 网络热路径 allocateDirect + 池化; heap 留给小包解析
坑 13 · selectedKeys 忘 remove — 集合是累积的, 不删下次 select 返回还带着已处理的事件, 同一连接被重复消费。正解: 迭代里 it.remove() 是固定动作, 或用 Netty 根本不碰这个细节。
// 错: for (key : selectedKeys) dispatch(key);  不 remove
//     → 已处理事件残留集合, 同一连接被重复消费
// 对: 迭代器里 it.remove() 是固定动作 (处理前先删)
坑 14 · wakeup 与 close 时序 — 先 close 了 Selector, 另一个线程还阻塞在 select(), 抛 ClosedSelectorException。正解: 先 wakeup() 唤醒, 等 select 循环退出后再 close; 循环里捕获并优雅退出。
// 错: 直接 selector.close(); 另一线程还阻塞在 select()
//     → ClosedSelectorException
// 对: 先 selector.wakeup() 唤醒, 等循环退出后再 close
坑 15 · 有状态 handler 标 @Sharable — 一个 handler 实例被所有连接共享, 计数器/半包缓存互相踩, 数据张冠李戴。正解: 无状态才 @Sharable; 有状态就 new 到 pipeline (每次 initChannel 新建)。
// 错: @Sharable class MyHandler { ByteBuffer halfPkg; }
//     一个实例被所有连接共享, 半包缓存互相踩
// 对: 有状态就每次 initChannel 里 new; 无状态才 @Sharable
坑 16 · ByteBuf 传异步回调后已被 release — handler 里 release 了又把引用丢给线程池, 回调执行时读到回收内存 — use-after-free, 数据错乱或 IllegalReferenceCountException。正解: 传递前 buf.retain(), 消费方负责 release。
// 错: pool.submit(() -> parse(buf)); ReferenceCountUtil.release(msg);
//     回调执行时读到回收内存 → IllegalReferenceCountException
// 对: 传异步前 buf.retain(); 消费方用完自己 release
坑 17 · LT 模式没读干净 — 每次就绪只读一次就回去 select, 缓冲区还有数据, epoll (水平触发) 立刻又报告就绪 — 看似 select 频繁返回, 吞吐反而低。正解: 就绪后循环读到返回 0, 或用 compact 保留未读续读。
// 错: 就绪后 ch.read(buf) 一次就回去 select
//     → 缓冲还有数据, LT 立刻再报告, select 频繁醒吞吐反低
// 对: 就绪后循环读到返回 0, 或 compact 保留未读续读
坑 18 · FileChannel 写完不 force — write 返回成功只代表进了 page cache, 掉电/宕机数据就没了。正解: 关键文件写完 channel.force(true) (数据+元数据), 再回ack给上游。
// 错: ch.write(buf) 返回就回 ack — 数据可能还在 page cache
// 对: 关键文件写完 ch.force(true); (数据+元数据) 再回 ack
坑 19 · InterruptedException 吞掉 — catch 住什么都不干, 线程的取消状态丢失, 上层关停逻辑永远等不到它退出。正解: 要么抛出, 要么 Thread.currentThread().interrupt() 恢复中断位, 循环条件检查 isInterrupted。
// 错: catch (InterruptedException e) { }  中断状态被吞
//     → 上层关停逻辑永远等不到它退出
// 对: Thread.currentThread().interrupt(); 循环条件查 isInterrupted
坑 20 · InterruptedIOException 当致命错 — 关停时 NIO 抛 ClosedByInterruptException/InterruptedIOException, 当成 bug 去重试反而复活了已关资源。正解: 这两个异常是"被中断"的正常信号 — 恢复中断位, 走优雅关停路径。
// 错: catch (ClosedByInterruptException e) { retry(); }
//     → 当 bug 重试, 反而复活了已关资源
// 对: 这是"被中断"的正常信号 — 恢复中断位, 走优雅关停路径