Netty从0到1系列之NIO
文章目录
- 一、核心思想: 为什么需要NIO?
- 二、NIO三大核心组件
- 2.1 Channel【通道】
- 2.2 Buffer【缓冲区】
- 2.3 Selector【选择器】
- 2.4 工作原理流程图
- 三、代码示例【NIO echo 服务器】
- 3.1 示例代码
- 3.2 客户端测试
- 四、NIO优缺点与实践经验
- 4.1 优点
- 4.2 缺点
- 4.3 实践经验总结
一、核心思想: 为什么需要NIO?
传统的 Java I/O(java.io
,又称 BIO - Blocking I/O)是基于流和阻塞的。
- 流:单向的,要么是输入流(
InputStream
),要么是输出流(OutputStream
),不能同时读写。 - 阻塞:当一个线程调用
read()
或write()
时,该线程会被阻塞,直到数据被读取到或写入完成。在此期间,线程什么也做不了。
BIO 模型在处理大量连接时非常低效。通常采用“一个连接,一个线程”的方案。当连接数暴涨时,线程数量也随之暴涨,导致巨大的上下文切换开销,最终耗尽系统资源。
NIO (New I/O / Non-blocking I/O) 的出现就是为了解决这些问题:
- 非阻塞 I/O:线程可以从通道请求读取数据,但如果尚无数据可用,线程可以立即去做别的事情,而不是被阻塞。
- 面向缓冲区:数据被读入或写出一个缓冲区,你可以根据需要前后移动缓冲区,这提供了更大的灵活性。
- 多路复用器:使用单个(或少量)线程来管理多个通道(连接),这是高性能的关键。
🎯 目标:用少量线程处理成千上万个连接。
NIO vs BIO
场景 | BIO(阻塞IO) | NIO(非阻塞IO) |
---|---|---|
10个连接 | 10个线程 | 1个线程即可 |
1000个连接 | 1000个线程 → 崩溃 | 1~4个线程可支撑 |
CPU开销 | 高(上下文切换) | 低 |
编程复杂度 | 简单 | 较复杂(状态管理) |
二、NIO三大核心组件
Channel、Buffer、Selector
组件 | 说明 |
---|---|
Buffer(缓冲区) | 数据的容器,所有数据都通过 Buffer 读写 |
Channel(通道) | 类似流,但可双向读写,支持非阻塞模式 |
Selector(选择器) | 实现 I/O 多路复用,监听多个 Channel 的事件 |
2.1 Channel【通道】
public interface Channel extends Closeable {// channel是否已经打开public boolean isOpen();// 关闭Channelpublic void close() throws IOException;
}
类似于传统的“流”,但是双向的,既可以读,也可以写。它是连接缓冲区和数据源(如文件、套接字)的桥梁。
- 主要实现:
FileChannel
:用于文件 IO。SocketChannel
&ServerSocketChannel
:用于 TCP 网络 IO。DatagramChannel
:用于 UDP 网络 IO。
channel vs stream
对比项 | Stream | Channel |
---|---|---|
方向 | 单向 | 双向 |
阻塞 | 总是阻塞 | 可设为非阻塞 |
传输方式 | 逐字节 | 与 Buffer 配合批量传输 |
2.2 Buffer【缓冲区】
public abstract class Buffer {// Invariants: mark <= position <= limit <= capacityprivate int mark = -1;private int position = 0;private int limit;private int capacity;// ...
}
一个容器对象,所有数据的读写都直接通过缓冲区进行。它本质上是一个内存块数组,并提供了一组方法以便更轻松地使用该内存。
-
核心属性:
capacity
:容量,缓冲区最大大小,创建后不可变。position
:位置,下一个要读取或写入的元素的索引。limit
:界限,缓冲区中第一个不应读取或写入的元素的索引。mark
:标记,一个备忘位置,通过mark()
标记,可通过reset()
恢复到mark
的位置。- 关系:0 <= mark <= position <= limit <= capacity
-
主要实现:
ByteBuffer
,CharBuffer
,IntBuffer
等,最常用的是ByteBuffer
。 -
核心操作:
allocate(int capacity)
:分配一块新的缓冲区。put()
/get()
:写入/读取数据。flip()
:将缓冲区从写模式切换到读模式。limit = position; position = 0;
clear()
:清空缓冲区,准备再次写入。position = 0; limit = capacity;
rewind()
:重读缓冲区。position = 0;
2.3 Selector【选择器】
NIO 的灵魂。它是一个多路复用器,可以监控多个通道的 IO 状态(例如:连接就绪、读就绪、写就绪)。一个单线程的 Selector 可以管理成千上万的通道。
- SelectionKey:表示一个通道在选择器上的注册 token。它包含:
- 兴趣集合 (Interest Set):你关心通道的什么事件
- OP_ACCEPT
- OP_CONNECT
- OP_READ
- OP_WRITE
- 就绪集合 (Ready Set):通道已就绪的操作集合。
- 通道 (Channel) 和 选择器 (Selector) 的引用。
- 附件 (Attachment):可附加一个对象(如一个
Buffer
或会话对象),是强大的扩展机制。
- 兴趣集合 (Interest Set):你关心通道的什么事件
工作流程图
2.4 工作原理流程图
三、代码示例【NIO echo 服务器】
3.1 示例代码
下面是一个完整的 NIO Echo 服务器实现,它接收客户端的消息并原样返回。
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;public class NioEchoServer {public static void main(String[] args) throws IOException {// 1. 创建选择器 (The Multiplexer)Selector selector = Selector.open();// 2. 创建服务器套接字通道并设置为非阻塞模式ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();serverSocketChannel.configureBlocking(false); // 必须设置为非阻塞serverSocketChannel.bind(new InetSocketAddress(9090)); // 绑定端口// 3. 将通道注册到选择器,并指定感兴趣的事件为 ACCEPT(接受连接)serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);System.out.println("NIO Echo Server started on port 9090...");// 主事件循环while (true) {// 4. 阻塞等待就绪的通道。参数可设置超时时间。selector.select();// 5. 获取就绪的 SelectionKey 集合Set<SelectionKey> selectedKeys = selector.selectedKeys();Iterator<SelectionKey> keyIterator = selectedKeys.iterator();while (keyIterator.hasNext()) {SelectionKey key = keyIterator.next();// 必须先移除,防止下次重复处理keyIterator.remove();try {if (key.isAcceptable()) {// 6. 处理 ACCEPT 事件:新的客户端连接handleAccept(key, selector);} else if (key.isReadable()) {// 7. 处理 READ 事件:客户端发送了数据handleRead(key);}// 可以处理 isWritable(),但通常只在需要写入大量数据时才注册} catch (IOException e) {// 客户端断开连接等异常System.err.println("Error handling client: " + e.getMessage());key.cancel(); // 取消这个键的注册if (key.channel() != null) {key.channel().close(); // 关闭通道}}}}}private static void handleAccept(SelectionKey key, Selector selector) throws IOException {// 获取注册的 ServerSocketChannelServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();// 接受连接,获取客户端的 SocketChannelSocketChannel clientChannel = serverChannel.accept();clientChannel.configureBlocking(false); // 设置为非阻塞模式// 欢迎信息String welcomeMsg = "Welcome to NIO Echo Server. Type 'exit' to quit.\n";ByteBuffer buffer = ByteBuffer.wrap(welcomeMsg.getBytes());clientChannel.write(buffer); // 发送欢迎信息// 将新客户端通道注册到选择器,对 READ 事件感兴趣// 并为每个通道附加一个专属的 BufferclientChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(256));System.out.println("Client connected: " + clientChannel.getRemoteAddress());}private static void handleRead(SelectionKey key) throws IOException {// 获取客户端通道和附加的 BufferSocketChannel clientChannel = (SocketChannel) key.channel();ByteBuffer buffer = (ByteBuffer) key.attachment();// 清空Buffer,准备读取数据buffer.clear();int bytesRead = clientChannel.read(buffer);if (bytesRead == -1) {// 客户端关闭了连接System.out.println("Client disconnected: " + clientChannel.getRemoteAddress());key.cancel();clientChannel.close();return;}// 切换Buffer为读模式buffer.flip();// 将接收到的数据转换为字符串String received = new String(buffer.array(), 0, buffer.limit()).trim();System.out.println("Received from " + clientChannel.getRemoteAddress() + ": " + received);if ("exit".equalsIgnoreCase(received)) {// 如果客户端发送 "exit",则关闭连接clientChannel.write(ByteBuffer.wrap("Goodbye!\n".getBytes()));System.out.println("Client exited: " + clientChannel.getRemoteAddress());key.cancel();clientChannel.close();} else {// Echo 功能:将收到的数据原样发回// 注意:write()可能不会一次性写完,需要注册OP_WRITE事件来持续写,// 但对于Echo这种小数据量场景,通常一次write就能完成,这里做了简化。buffer.rewind(); // 将position重置为0,重新读一遍BufferclientChannel.write(buffer);}}
}
3.2 客户端测试
客户端测试
你可以使用 telnet
或 netcat
命令来测试这个服务器:
telnet localhost 9090
# 或
nc localhost 9090
服务器端
客户端
四、NIO优缺点与实践经验
4.1 优点
- 高性能和高伸缩性:单线程即可管理大量连接,避免了线程创建和上下文切换的巨大开销。这是 NIO 最大的优势。
- 更少的资源消耗:与为每个连接创建一个线程相比,线程资源消耗要少得多。
4.2 缺点
- API 复杂:相比于 BIO,NIO 的 API 调用复杂得多,开发和调试难度大。
- 可靠性编程困难:需要处理许多边缘情况,如
write
不一定能一次写完(需要注册OP_WRITE
并循环写),TCP 粘包/拆包问题(需要在应用层设计协议,如定长消息、分隔符、长度字段等)。 - Bug 与陷阱:如
SelectionKey
必须手动从集合中移除,否则会重复处理;对ByteBuffer
的状态(position
,limit
)管理不当会导致错误。
4.3 实践经验总结
- 不要阻塞 Selector 线程:
Selector.select()
所在的线程是核心,其中的所有操作(如handleRead
)都必须快速非阻塞。任何耗时的操作(如数据库查询、复杂计算)都应该交给专门的业务线程池去处理,以免影响其他连接的响应。 - 管理好 Buffer:
- 考虑使用
Buffer
池来避免频繁的创建和销毁。 - 深刻理解
flip()
,clear()
,rewind()
的含义。
- 考虑使用
- 处理写操作:
channel.write(buffer)
的返回值表示写入的字节数,它可能无法一次性写完缓冲区中的所有数据。如果需要写入大量数据,应在未写完时注册OP_WRITE
事件,在isWritable()
时继续写,写完后再取消注册,以避免 Selector 被不必要的写事件占满。 - 使用 Netty 等框架:99% 的场景下,你不应该直接使用原生 NIO API 来编写网络应用。 框架如 Netty 和 Mina 完美地封装了 NIO 的复杂性,解决了上述所有缺点和陷阱(如粘包拆包、API复杂、线程模型),提供了强大、易用且高性能的抽象。学习 NIO 是为了更好地理解这些框架的原理。