阻塞是对线程的绑架 — Selector 用一个线程等所有连接的就绪事件, Buffer 是一台三指针状态机, Netty 只是把这套机制工程化
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 的难用与半包问题。
Socket s = server.accept(); // 阻塞点 1: 等连接 InputStream in = s.getInputStream(); int n = in.read(buf); // 阻塞点 2: 守它的线程干等 // 关键: 1000 空闲连接 = 1000 线程 × ~1MB 栈 ≈ 1GB
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 通道可以
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(): 写切读 (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 续传用
ssc.register(sel, SelectionKey.OP_ACCEPT); // 有新连接 ch.register(sel, SelectionKey.OP_READ); // 可读 // 还有 OP_CONNECT / OP_WRITE; interestOps 声明关心什么, if (key.isAcceptable()) accept(key); // readyOps 告诉你发生了什么
>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: 对端关闭
ByteBuffer head = ByteBuffer.allocate(8); ByteBuffer body = ByteBuffer.allocate(1024); ch.read(new ByteBuffer[]{ head, body }); // 读时"散" ch.write(new ByteBuffer[]{ head, body }); // 写时"聚" // 关键: 头/体各自落位, 不用手动切数组
ByteBuffer heap = ByteBuffer.allocate(64); // 堆内 byte[] ByteBuffer direct = ByteBuffer.allocateDirect(64); // 堆外 // 关键: heap 做 IO 要先拷到临时堆外; 热路径用 direct + 池化, // direct 地址稳定不受 GC 搬迁, 但分配贵、要显式释放
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 要循环
int n = selector.select(1000); if (n == 0 && ++emptySpins > 512) rebuildSelector(); // 空轮询规避 // 关键: LT 水平触发 — 就绪后必须读到返回 0, 否则一直报告就绪
EventLoopGroup boss = new NioEventLoopGroup(1); // 只管 accept EventLoopGroup worker = new NioEventLoopGroup(); // 管 IO 读写 // 关键: 一个 EventLoop = 一个固定线程 + 一批 Channel, // 同一 Channel 的所有事件在同一线程串行执行 — 天然免锁
retain()/release(), 计数归零归还池; 传递给异步回调要 retain, 忘 release 就是堆外泄漏。ByteBuf buf = (ByteBuf) msg; try { process(buf); } finally { buf.release(); } // 计数归零 → 归还池 // 关键: 传给异步回调先 retain(); 忘 release = 堆外泄漏
int w = ch.write(buf); if (!buf.hasRemaining()) key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE); // 关键: socket 几乎永远可写, 常注册 OP_WRITE = busy loop
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 (万兆网卡)
每包 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 次/分钟
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 续 — "半包"不是错误是常态
长连接心跳用轮询扫表是灾难 — 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 万线程, 直接不可行。
大响应写一半内核缓冲满了, 剩余数据挂起等插座可写 — 注册时机就是全部。
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); // 写完立刻取消 }
不能 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); // 数据+元数据强制落盘, 防掉电 }
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 惰性迭代, 不吃内存
引用计数的内存, 谁消费谁释放 — 忘 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 — 但只对"进来的"消息负责
压测报告 "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)
症状: 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 池还小
写 → flip() → 读 → clear()/compact(), 每轮循环都对齐。// 错: buf.put(x); buf.get(); pos==limit → 读到空 // 对: 写 → flip() → 读 → clear()/compact(); 每轮循环都对齐
OutOfMemoryError: Direct buffer memory 而堆一切正常。正解: 池化复用 + 显式 release; Netty 开 leak detection。// 错: 每次 ByteBuffer.allocateDirect(1<<20) 用完不管, 赌 Cleaner 兜底 // 堆充裕时 Cleaner 迟迟不跑 → 堆外先 OOM, 堆 dump 却正常 // 对: 池化复用 + 显式 release; -Dio.netty.leakDetection.level=paranoid
// 错: while (true) { selector.select(); ... } 某 JDK/内核组合下 // select() 立即返回 0, 死循环把 CPU 打满 100% // 对: 数连续空转次数, 超阈值重建 Selector; 根治靠升级 JDK
>0 字节数 / 0 无数据 / -1 对端关闭, 只有 -1 关通道。// 错: int n = ch.read(buf); if (n <= 0) ch.close(); 把好连接踢了 // 对: n>0 处理; n==0 返回等下次; n==-1 才是对端关闭
interestOps &= ~OP_WRITE。// 错: ch.register(sel, OP_READ | OP_WRITE); 几乎永远可写 → busy loop // 对: write 返回 0 时才 | OP_WRITE; 写完 &= ~OP_WRITE 立刻取消
// 错: 线程 A 正用 key 读写, 线程 B 同时 key.cancel() // → CancelledKeyException / 行为未定义 // 对: Selector 归单线程所有; 跨线程操作队列投递到 owner 线程
hasRemaining() 精确控制; 调试用 buf.duplicate() 检查内容。// 错: clear() 后只写 2 字节就读 4 字节 → 读出上一轮旧字节混进协议体 // 对: 按 hasRemaining() 精确控制读写量, 只读自己写的范围
// 错: 收到多少解析多少 → 半条 JSON 报错 / 两条粘一起解析出脏数据 // 对: new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2); // 或分隔符/定长; 边界协议三选一, 别手撕
// 错: 两个线程同时对同一 SocketChannel write → 字节流交错写花 // 对: 同一连接 IO 串行化 — 固定线程或队列投递 (EventLoop 绑定)
BufferedReader 按行 / FileChannel + 固定大小 Buffer 分块; 只要"扫一遍"就用流式。// 错: byte[] all = Files.readAllBytes(path); 3GB 日志 → OOMKilled // 对: BufferedReader 按行 / FileChannel + 固定 Buffer 分块流式
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);
// 错: 热路径每次 IO 用 allocate() 堆内 buffer // → JVM 每次白拷一遍到临时 direct 缓冲 // 对: 网络热路径 allocateDirect + 池化; heap 留给小包解析
it.remove() 是固定动作, 或用 Netty 根本不碰这个细节。// 错: for (key : selectedKeys) dispatch(key); 不 remove // → 已处理事件残留集合, 同一连接被重复消费 // 对: 迭代器里 it.remove() 是固定动作 (处理前先删)
wakeup() 唤醒, 等 select 循环退出后再 close; 循环里捕获并优雅退出。// 错: 直接 selector.close(); 另一线程还阻塞在 select() // → ClosedSelectorException // 对: 先 selector.wakeup() 唤醒, 等循环退出后再 close
new 到 pipeline (每次 initChannel 新建)。// 错: @Sharable class MyHandler { ByteBuffer halfPkg; } // 一个实例被所有连接共享, 半包缓存互相踩 // 对: 有状态就每次 initChannel 里 new; 无状态才 @Sharable
buf.retain(), 消费方负责 release。// 错: pool.submit(() -> parse(buf)); ReferenceCountUtil.release(msg); // 回调执行时读到回收内存 → IllegalReferenceCountException // 对: 传异步前 buf.retain(); 消费方用完自己 release
// 错: 就绪后 ch.read(buf) 一次就回去 select // → 缓冲还有数据, LT 立刻再报告, select 频繁醒吞吐反低 // 对: 就绪后循环读到返回 0, 或 compact 保留未读续读
channel.force(true) (数据+元数据), 再回ack给上游。// 错: ch.write(buf) 返回就回 ack — 数据可能还在 page cache // 对: 关键文件写完 ch.force(true); (数据+元数据) 再回 ack
Thread.currentThread().interrupt() 恢复中断位, 循环条件检查 isInterrupted。// 错: catch (InterruptedException e) { } 中断状态被吞 // → 上层关停逻辑永远等不到它退出 // 对: Thread.currentThread().interrupt(); 循环条件查 isInterrupted
// 错: catch (ClosedByInterruptException e) { retry(); } // → 当 bug 重试, 反而复活了已关资源 // 对: 这是"被中断"的正常信号 — 恢复中断位, 走优雅关停路径