NIO就是Java处理网络IO的另一种方式。就这么简单。
但很多人学NIO学不明白,不是因为它难,是因为教程都在讲错重点。
随便打开一个NIO教程,上来就是:Channel、Buffer、Selector三大组件。然后画个图,告诉你Buffer怎么flip()、怎么rewind(),Channel怎么读怎么写。你看懂了,然后呢?还是不知道NIO到底解决什么问题。
就像你问"汽车变速箱是干啥的",对方给你讲齿轮比、扭矩转换、行星齿轮组结构。听完了还是一头雾水。
一、NIO解决的核心问题:一个线程怎么处理一万个连接
2005年前后,我司有台服务器,8核16G,跑一个Web服务。高峰期5000个并发连接,服务器就扛不住了。top一看,8000多个线程,CPU全在做上下文切换,真正干活的时间不到20%。
这就是传统BIO(阻塞IO)的问题:一个连接就得占一个线程。
你写个Socket服务器,BIO的代码长这样:
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept(); // 阻塞等待连接
new Thread(() -> {
// 处理这个客户端的请求
InputStream in = client.getInputStream();
byte[] data = new byte[1024];
int len = in.read(data); // 阻塞等待数据
// 处理数据...
}).start();
}
每来一个连接,就new一个Thread。1万个连接就是1万个线程。
问题在哪?
- 线程本身要占内存(栈空间,默认1MB)
- 线程切换要消耗CPU(上下文切换、缓存失效)
- 大部分时间线程都在等(等客户端发数据)
你想想,聊天室服务器,1万个在线用户,99%的时间没人发消息。你给每个用户分配一个线程,这9900个线程都在in.read()那傻等。
阻塞IO的致命问题就是:线程在等IO的时候,啥也干不了。
NIO的核心思路:不等。
一个线程管一万个连接。谁有数据处理谁,没数据的别占线程。
二、NIO是怎么做到"不等"的
传统IO是这样的:
in.read(data); // 这里会阻塞,直到有数据
你调read(),客户端没发数据的话,线程就卡这了。操作系统把这个线程挂起,等网卡收到数据,再把线程唤醒。
中间线程啥也没干,就在等。
NIO的Channel可以设置成非阻塞:
channel.configureBlocking(false);
int n = channel.read(buffer); // 立刻返回,n可能是0
调用read(),如果没数据,立刻返回0,不等。有多少数据读多少,读完了继续干别的。
但这还不够。
如果你写个死循环,一直轮询1万个Channel:
while (true) {
for (Channel ch : channels) {
int n = ch.read(buffer);
if (n > 0) {
// 处理数据
}
}
}
这不是更浪费?CPU 100%空转,大部分时间都在查没数据的Channel。
所以Selector出现了。
Selector是操作系统提供的能力(Linux的epoll、Windows的IOCP)。把1万个Channel注册到Selector上,然后调:
int n = selector.select(); // 阻塞,直到有Channel准备好
这行代码会阻塞,但任何一个Channel有数据了,立刻返回。你再遍历准备好的Channel,处理它们。
这就是"IO多路复用":一个线程,通过Selector监听1万个Channel,谁准备好了处理谁。
三、真实项目里的NIO长什么样
我2010年第一次用NIO写服务器,踩了无数坑。给你看个最简化的代码:
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞,直到有事件
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
// 有新连接
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
}
else if (key.isReadable()) {
// 有数据可读
SocketChannel client = (SocketChannel) key.channel();
https://zhida.zhihu.com/search?content_id=768608875&content_type=Answer&match_order=1&q=ByteBuf&zhida_source=entityfer buffer = ByteBuffer.allocate(1024);
int n = client.read(buffer);
if (n > 0) {
// 处理数据
}
}
}
keys.clear(); // 必须手动清空,否则下次还会处理
}
这就是NIO的完整流程:
- 创建Selector
- 把ServerSocketChannel注册到Selector上,关注ACCEPT事件(新连接)
- 循环调用selector.select(),等待事件
- 有新连接了,accept()拿到SocketChannel,设为非阻塞,注册到Selector上,关注READ事件
- 某个Channel有数据了,read()读取数据,处理
- 处理完继续循环
一个线程,几十行代码,轻松处理几万个连接。
但问题是------敢把这代码上线吗?
我当时就上了,然后炸了。半夜被oncall电话叫醒,服务器CPU打满,处理量暴跌。排查了一夜才发现:某个客户端发了半截数据就断了,我代码里没处理半包,Buffer一直在等完整数据,这个Channel就一直占着key。
还有一次,某个客户端发了个100MB的数据包,我代码里buffer只有1KB,读了几个小时还没读完,其他正常请求全被饿死。
NIO代码看起来简单,生产级的NIO服务器要处理的东西太多:
- 半包、粘包
- 异常断线、心跳检测
- 读写缓冲区管理
- 流量控制、背压
- 优雅关闭、资源清理
自己手撸NIO,99%要出问题。
四、学完NIO能自己写服务器吗?别想了
很多人学完NIO,以为自己能写服务器了。醒醒。
Netty是基于NIO的框架,它帮你解决了上面说的所有坑:零拷贝、池化ByteBuf、内存泄漏检测、Reactor线程模型、编解码器链、粘包半包处理、背压流控、优雅关闭。
哪一样都不简单。
我们2013年从自己手撸的NIO改成Netty,代码从2000行变成200行。稳定性?以前三天两头出问题,改完之后半年没出过事故。
学NIO不是让你自己写服务器,是让你看懂Netty底层在干什么。Netty的EventLoop就是在跑selector.select()循环,ChannelHandler就是在处理SelectionKey的回调。理解了NIO,Netty的源码就不神秘了。
五、NIO性能一定比BIO好?不一定
看场景。
短连接、请求立刻响应的(比如HTTP API),BIO可能更合适。每个请求处理10ms,配合线程池(比如Tomcat默认200个线程),够用了。NIO的优势根本发挥不出来。
NIO适合长连接、大量空闲连接的场景:
- WebSocket服务器(几万个在线用户,大部分时间没在发消息)
- 游戏服务器(玩家在线但没操作)
- 消息推送服务(大量连接在等推送)
- RPC框架(连接池里的连接大部分时间空闲)
这些场景用BIO,光线程就占死了。用NIO,一个线程轻松扛几万连接。
但NIO有代价:代码复杂度高。BIO用同步代码就能写完,NIO要拆成异步回调。可读性、可维护性都差。
所以才会出现各种框架:Netty、Vert.x、Mina,本质都是在NIO上封装一套好用点的API。
六、但2026年了,虚拟线程来了
聊了半天NIO,现在得说个扎心的事:Java 21的虚拟线程,把上面这套理论全颠覆了。
去年我们把一个WebSocket服务从Netty改成虚拟线程+BIO,代码量少了一半,性能没降反升。当时都怀疑是不是测错了,反复压测了三天,确认没问题。
虚拟线程是什么?JVM自己实现的协程,让阻塞式代码可以承载百万级并发。
传统线程(平台线程)一个对应一个操作系统线程,创建1万个就炸了。虚拟线程是JVM管的,创建100万个都行。虚拟线程阻塞的时候,JVM自动把它从平台线程上摘下来,换另一个虚拟线程上去跑。
然后你继续写阻塞式代码就行:
// Java 21 + 虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
executor.submit(() -> {
var in = client.getInputStream();
var data = in.read(); // 阻塞,但不占平台线程
// 处理数据...
});
}
}
看起来和2005年的BIO代码一模一样,但现在轻松扛住10万个连接。这10万个虚拟线程,底层只用了几个平台线程。
去年双11压测,推送服务20万在线连接,8核16G服务器CPU占用不到30%。以前用Netty的时候,为了优化这30%,代码里全是回调套回调。现在虚拟线程,代码就是最直白的同步写法,性能反而更好。
那NIO是不是没用了?
不是。底层框架(Netty、Dubbo、gRPC)还是基于NIO的。虚拟线程虽然轻量,但调度开销比不过epoll直接通知。极致性能场景(消息队列、网关),NIO还是最优解。
但业务代码------写个微服务接口、搞个WebSocket聊天室、做个实时推送------虚拟线程够了。不用学NIO,不用学Netty,写最简单的阻塞代码就行。
现在给新人培训,我都让他们先用虚拟线程写业务,碰到性能瓶颈再说NIO。实际上99%的业务根本到不了需要NIO的地步。
所以2026年,学NIO还有意义吗?
有,但意义变了。
以前学NIO是为了写高性能服务器,现在是为了理解底层框架怎么实现的。Spring Boot底层的Tomcat用NIO,Dubbo的通信层用Netty,Netty用NIO。你调一个RPC接口,底层几万行NIO代码在跑。
但自己写NIO?不需要了。虚拟线程已经把高并发问题解决得够好。
七、Buffer的那些反人类设计
ByteBuffer有四个指针:position、limit、capacity、mark。每次读写完要flip()、clear()、compact()。我第一次用的时候,对着文档看了一下午,还是搞不懂什么时候该用哪个。
最经典的坑:
buffer.put(data); // 写入数据
channel.write(buffer); // 发现什么都没发出去
写完数据,position在最后了。你直接write,Channel从position读到limit,读到的是空的。必须先flip():
buffer.put(data);
buffer.flip(); // position=0, limit=原来的position
channel.write(buffer);
还有clear()和compact()的区别。clear()是清空整个Buffer,compact()是把未读的数据移到开头。半包处理的时候,用错了直接丢数据,客户端收到的都是乱码。
2011年有次线上事故,就是这个原因。某个socket半包了,我代码里用了clear(),把前半截数据清掉了。客户端一直等后半截,等了5分钟超时,投诉打到老板那。
所以生产环境别直接用ByteBuffer。要么用Netty的ByteBuf(read/write指针分开,怎么用都不会错),要么现在直接上虚拟线程,连Buffer都不用碰。
八、AIO是个什么东西?没用
Java 7加了AIO(异步IO),Channel.read()不阻塞,回调告诉你数据来了。听起来很美好,实际上------Linux下AIO的实现就是epoll+线程池模拟出来的,性能还不如直接用NIO。Windows的IOCP倒是真异步,但谁在Windows上跑生产服务器?
Netty 5.0曾经准备支持AIO,折腾了一年,发现性能提升不到5%,反而代码复杂度翻倍。后来直接砍了,退回NIO。
所以AIO在Java生态里基本是死的。你看市面上所有的框架,没有一个用AIO的。别被这个名词忽悠了。
九、总结一下
2005年的时候,一个连接一个线程,服务器扛不住几千个并发。NIO出来了,用Selector让一个线程管一万个连接,解决了C10K问题。
2026年了,虚拟线程让阻塞式代码可以承载百万级并发。大部分业务场景,你写最简单的BIO代码就够了。
NIO还有用吗?有,但不是用来写业务代码。
底层框架(Netty、Dubbo、Spring WebFlux)还是基于NIO。你用Spring Boot写接口,底层Tomcat用的是NIO。你调RPC,底层Dubbo用的是Netty,Netty用的是NIO。
学NIO不是为了自己写服务器,是为了看懂这些框架是怎么实现的。理解Selector为什么能让一个线程管一万个连接,理解为什么Netty的EventLoop要用单线程,理解为什么Dubbo能在一台机器上保持几万个RPC连接。
至于Buffer怎么flip、compact怎么用------别纠结。你用虚拟线程写业务代码,根本碰不到Buffer。你看框架源码,看到Buffer就跳过,先理解Selector这个核心概念。
最后一句话:2026年写Java,业务代码用虚拟线程,想看框架源码再学NIO。