学完这篇你能得到一个能跑起来的最小 NIO 服务端和最小 Netty 服务端,以及两者之间该选谁、坑在哪里的判断依据。
第一步:先明确 BIO 的问题
传统 ServerSocket 的写法是 accept() 一个连接就开一个线程去 read(),读不到就阻塞在那里。几百个连接还能撑,上到几千个长连接时线程栈和上下文切换就会耗尽资源。NIO 的思路是把“等数据”这件事从线程身上拿走,交给操作系统的事件通知机制,一个线程轮询多个连接。
第二步:认识 NIO 的三个核心组件
- Channel:双向的数据通道,
SocketChannel / ServerSocketChannel 都支持非阻塞模式。
- Buffer:数据必须先读进
ByteBuffer 再处理,核心是 position / limit / capacity 三个指针。
- Selector:事件注册中心,一个线程调一次
select() 就能拿到所有就绪的 Channel。
注意:Buffer 的 flip() 是最容易漏的一步。写完数据要读之前必须 flip()(把 limit 设为 position、position 归零),否则读到的是空白区。
第三步:运行一个最小的 NIO Echo 服务端
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buf = ByteBuffer.allocate(1024);
while (true) {
selector.select(); // 阻塞直到有事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须移除,否则下次重复处理
if (key.isAcceptable()) {
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
buf.clear();
int n = sc.read(buf);
if (n == -1) { sc.close(); continue; }
buf.flip();
sc.write(buf);
}
}
}
用 telnet 127.0.0.1 8080 连上去敲字符,看到回显就通了。
注意:sc.write(buf) 不保证一次写完。真实场景要判断返回值是否为 0,未写完的部分注册 OP_WRITE 下次继续,这是 NIO 手写最容易出错的地方之一。
第四步:选择 Netty 的原因
上面这段代码能跑,但离“能用”还差很远:半包粘包要自己处理、写缓冲要自己维护、断线重连、心跳、空闲检测、内存池全得自己来。Netty 把这些都封装好了,还顺手修了 JDK 早期 Selector 空轮询导致 CPU 100% 的老问题。生产环境基本不会手写 NIO,而是直接使用 Netty。
引入依赖:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.115.Final</version>
</dependency>
第五步:编写一个最小 Netty 服务端
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder(CharsetUtil.UTF_8))
.addLast(new StringEncoder(CharsetUtil.UTF_8))
.addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
ctx.writeAndFlush("echo: " + msg + "\n");
}
});
}
});
ChannelFuture f = b.bind(8080).sync();
f.channel().closeFuture().sync();
boss 只管 accept,worker 管读写,这就是主从 Reactor 模型。pipeline 是责任链,数据从前往后经过每个 handler。
第六步:处理粘包和拆包
TCP 是字节流,没有消息边界。上面用 LineBasedFrameDecoder 按换行切分,实际项目里更常用:
LengthFieldBasedFrameDecoder:协议头带长度字段,最通用;
DelimiterBasedFrameDecoder:自定义分隔符;
IdleStateHandler:空闲检测,配合心跳保活。
注意:handler 里千万不要做数据库查询、HTTP 调用这类阻塞操作,会卡住整个 EventLoop 上的所有连接。耗时逻辑请丢到独立的业务线程池。
第七步:检查点清单
ByteBuf 有引用计数,非 SimpleChannelInboundHandler 的子类要自己 release(),否则就是内存泄漏;
- 用
-Dio.netty.leakDetection.level=paranoid 在测试环境开启泄漏检测;
- 客户端断开要在
channelInactive 里清理会话状态;
- 关闭时
shutdownGracefully() 两个 group 都要调,别只关一个。
小结
- NIO 的本质是 Selector 事件驱动,一个线程管多个连接,核心是 Channel / Buffer / Selector 三件套。
flip() 和 selectedKeys().remove() 是手写 NIO 最常见的两个低级错误。
- 生产环境直接用 Netty:主从 Reactor 线程模型 + pipeline 责任链 + 现成的编解码器。
- 粘包拆包靠 FrameDecoder 解决,别在业务代码里自己拼字符串。
- handler 里禁止阻塞操作,ByteBuf 记得释放。