0%

NIO基本操作

Java NIO 详解:非阻塞 IO 与多路复用技术

Java NIO(Non-blocking IO,非阻塞 IO)是 JDK 1.4 引入的全新 IO 模型(JDK 1.7 补充 NIO.2),旨在解决传统 IO(BIO)在高并发场景下的性能瓶颈。NIO 基于 “通道(Channel)” 和 “缓冲区(Buffer)” 实现,通过 “多路复用(Selector)” 机制支持单线程处理多个 IO 操作,显著提升系统吞吐量。本文将全面解析 NIO 的核心原理、组件及实践。

NIO 核心概念与优势

阻塞 vs 非阻塞

  • 阻塞 IO(BIO):线程调用 read()write() 时会被挂起,直到操作完成才能继续执行。为处理多个客户端,需为每个连接创建独立线程,导致线程资源耗尽(上下文切换开销大)。
  • 非阻塞 IO(NIO):线程发起 IO 操作后无需阻塞,可继续处理其他任务;若操作未完成,仅返回 “未就绪” 状态,通过定期轮询或事件通知获取结果。单线程可管理多个 IO 通道,减少线程数量。

NIO 与传统 IO 的核心区别

特性 传统 IO(BIO) NIO
数据操作单位 字节流 / 字符流(Stream) 缓冲区(Buffer)
传输方向 单向(输入流 / 输出流分离) 双向(通道 Channel 可读写)
阻塞模式 阻塞(线程挂起) 非阻塞(线程可并发处理多任务)
并发处理 多线程(一个连接一个线程) 单线程 / 少线程(多路复用)
核心模型 流模型 通道 - 缓冲区模型
适用场景 低并发、简单 IO 操作 高并发、大流量场景(如网络服务器)

NIO 核心组件

NIO 的核心由三大组件构成:缓冲区(Buffer)通道(Channel)选择器(Selector),三者协同实现非阻塞 IO 操作。

缓冲区(Buffer):数据的容器

Buffer 是一块内存区域,用于存储 IO 操作的数据。所有 NIO 数据读写都必须通过 Buffer 完成(Channel 仅负责传输,不存储数据)。

(1)核心 Buffer 类型

NIO 为每种基本数据类型提供了对应的 Buffer 实现(除 boolean):

类型 描述 示例
ByteBuffer 字节缓冲区(最常用) 网络数据、文件二进制数据
CharBuffer 字符缓冲区 文本数据(自动处理编码)
IntBuffer/LongBuffer 基本类型缓冲区 结构化数据(如整数数组)
(2)Buffer 的核心变量

Buffer 通过三个核心变量控制数据读写,其关系为:0 ≤ mark ≤ position ≤ limit ≤ capacity

  • capacity:缓冲区总容量(创建时指定,不可修改)。
  • position:当前读写位置(初始为 0,每读写一个元素后自动递增)。
  • limit:读写操作的边界(写模式下 limit = capacity;读模式下 limit 等于写模式下的 position,即实际数据长度)。
  • mark:标记位置(通过 mark() 记录当前 positionreset() 可恢复到该位置)。
(3)Buffer 核心方法与工作流程

ByteBuffer 为例,数据读写流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 分配缓冲区(非直接缓冲区,内存位于JVM堆)
ByteBuffer buf = ByteBuffer.allocate(1024); // capacity=1024,position=0,limit=1024

// 2. 写数据到缓冲区(写模式)
buf.put("hello".getBytes()); // position=5(写入5个字节),limit=1024

// 3. 切换为读模式(flip():limit=position,position=0)
buf.flip(); // position=0,limit=5,capacity=1024

// 4. 从缓冲区读数据
byte[] data = new byte[buf.limit()]; // limit=5,确定实际数据长度
buf.get(data); // position=5,读取5个字节到data
System.out.println(new String(data)); // 输出:hello

// 5. 重置缓冲区(clear():position=0,limit=capacity,数据未真正删除)
buf.clear(); // position=0,limit=1024,capacity=1024(可重新写入)
  • 关键方法:
    • flip():切换为读模式(limit=positionposition=0)。
    • clear():重置缓冲区(用于重新写入,数据仍存在但可被覆盖)。
    • rewind():重复读(position=0limit 不变)。
    • mark()/reset():标记并恢复 position(如中途暂停读取)。
(4)直接缓冲区 vs 非直接缓冲区
  • 非直接缓冲区:通过 allocate(int) 创建,内存位于 JVM 堆,受 GC 管理;读写需拷贝数据到内核缓冲区,性能较低。
  • 直接缓冲区:通过 allocateDirect(int) 创建,内存位于堆外(操作系统直接管理);读写无需拷贝,性能高(适合大文件或频繁操作),但创建 / 销毁开销大,不适合小数据。

通道(Channel):数据的传输通道

Channel 是连接数据源(文件、网络 socket 等)的 “通道”,负责数据传输,必须与 Buffer 配合使用(数据先写入 Buffer,再通过 Channel 传输)。

(1)Channel 的核心特性
  • 双向性:可同时读写(传统 IO 流是单向的)。
  • 非阻塞性:网络相关 Channel(如 SocketChannel)可设置为非阻塞模式。
  • 基于 Buffer:所有数据读写必须通过 Buffer 完成(channel.read(buffer)channel.write(buffer))。
(2)常用 Channel 类型
通道类型 对应数据源 特性 典型用途
FileChannel 本地文件 只能阻塞模式,支持文件读写、锁定、内存映射。 大文件读写、文件复制
SocketChannel 网络客户端 可非阻塞,支持 TCP 连接和数据传输。 客户端网络通信
ServerSocketChannel 网络服务器 可非阻塞,支持监听 TCP 连接。 服务器接收客户端连接
DatagramChannel UDP 数据报 可非阻塞,支持无连接的 UDP 传输。 实时通信(如游戏、广播)
(3)FileChannel:文件通道

FileChannel 用于文件 IO 操作,始终为阻塞模式,但支持高效的文件传输和内存映射。

① 创建 FileChannel
1
2
3
4
5
6
7
8
// 方式1:通过文件流获取
FileChannel channel = new FileOutputStream("data.txt").getChannel();

// 方式2:通过 FileChannel.open()(推荐,可指定打开模式)
FileChannel channel = FileChannel.open(
Paths.get("data.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE
);
② 读写文件示例
1
2
3
4
5
6
7
8
9
10
11
12
13
// 写文件
try (FileChannel channel = FileChannel.open(Paths.get("out.txt"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
ByteBuffer buf = ByteBuffer.wrap("Hello NIO".getBytes());
channel.write(buf); // 将缓冲区数据写入文件
}

// 读文件
try (FileChannel channel = FileChannel.open(Paths.get("out.txt"), StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buf); // 从文件读数据到缓冲区
buf.flip(); // 切换为读模式
System.out.println(new String(buf.array(), 0, bytesRead)); // 输出:Hello NIO
}
③ 高效文件传输

FileChannel 提供 transferFromtransferTo 方法,直接通过操作系统内核复制数据(零拷贝),效率远高于传统流复制:

1
2
3
4
5
// 复制文件(零拷贝)
try (FileChannel from = FileChannel.open(Paths.get("src.txt"), StandardOpenOption.READ);
FileChannel to = FileChannel.open(Paths.get("dest.txt"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
from.transferTo(0, from.size(), to); // 从源通道传输数据到目标通道
}
(4)网络通道:SocketChannel 与 ServerSocketChannel

网络通道支持非阻塞模式,是 NIO 实现高并发网络编程的核心。

① 客户端(SocketChannel)
1
2
3
4
5
6
7
8
9
10
11
12
13
// 连接服务器并发送数据
try (SocketChannel channel = SocketChannel.open()) {
channel.configureBlocking(false); // 设置为非阻塞
// 连接服务器(非阻塞模式下,connect() 可能立即返回 false,需通过 finishConnect() 确认)
if (!channel.connect(new InetSocketAddress("localhost", 8080))) {
while (!channel.finishConnect()) {
System.out.println("连接中..."); // 可处理其他任务
}
}
// 发送数据
ByteBuffer buf = ByteBuffer.wrap("Client message".getBytes());
channel.write(buf);
}
② 服务器(ServerSocketChannel)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 监听端口并接收连接(非阻塞模式)
try (ServerSocketChannel serverChannel = ServerSocketChannel.open()) {
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 非阻塞
System.out.println("服务器启动,监听 8080 端口...");

while (true) {
// 非阻塞模式下,accept() 无连接时返回 null
SocketChannel clientChannel = serverChannel.accept();
if (clientChannel != null) {
clientChannel.configureBlocking(false); // 客户端通道也设为非阻塞
System.out.println("新客户端连接:" + clientChannel.getRemoteAddress());
// 处理客户端数据(后续结合 Selector 实现)
}
}
}

选择器(Selector):多路复用核心

Selector 是 NIO 的 “多路复用器”,允许单线程同时监控多个 Channel 的事件状态(如 “可读”“可写”),实现 “一个线程处理多个连接”,从根本上减少线程资源消耗。

(1)Selector 工作原理
  1. 注册 Channel:将非阻塞 Channel 注册到 Selector,指定关注的事件(如 OP_READ 表示 “可读”)。
  2. 事件监听:调用 selector.select() 阻塞等待,直到至少一个 Channel 事件就绪。
  3. 处理事件:通过 selector.selectedKeys() 获取就绪事件的 SelectionKey,遍历并处理对应 Channel 的 IO 操作。
(2)核心事件类型

SelectionKey 定义了 Channel 可能触发的事件:

事件常量 描述 适用 Channel 类型
OP_READ 通道可读(数据已到达) SocketChannelDatagramChannel
OP_WRITE 通道可写(缓冲区有空闲空间) SocketChannelDatagramChannel
OP_CONNECT 客户端连接成功 SocketChannel
OP_ACCEPT 服务器接收新连接 ServerSocketChannel
(3)Selector 实战:单线程处理多客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class NioServer {
public static void main(String[] args) throws IOException {
// 1. 创建 Selector 和服务器通道
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 非阻塞

// 2. 注册服务器通道到 Selector,关注 ACCEPT 事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动,等待连接...");

while (true) {
// 3. 阻塞等待事件就绪(返回就绪的事件数)
int readyChannels = selector.select();
if (readyChannels == 0) continue;

// 4. 获取就绪事件的 SelectionKey 集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();

// 5. 遍历处理事件
while (iterator.hasNext()) {
SelectionKey key = iterator.next();

// 处理“接收连接”事件(服务器通道)
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept(); // 获取客户端通道
client.configureBlocking(false);
// 客户端通道注册到 Selector,关注 READ 事件
client.register(selector, SelectionKey.OP_READ);
System.out.println("客户端连接:" + client.getRemoteAddress());
}

// 处理“可读”事件(客户端通道)
else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = client.read(buf); // 读数据到缓冲区
if (bytesRead > 0) {
buf.flip();
String msg = new String(buf.array(), 0, bytesRead);
System.out.println("收到消息:" + msg);
// 回复客户端(关注 WRITE 事件)
client.register(selector, SelectionKey.OP_WRITE);
} else if (bytesRead == -1) {
// 客户端断开连接
client.close();
System.out.println("客户端断开连接");
}
}

// 处理“可写”事件(回复客户端)
else if (key.isWritable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.wrap("已收到消息".getBytes());
client.write(buf);
// 写完后重新关注 READ 事件
client.register(selector, SelectionKey.OP_READ);
}

// 移除已处理的 key(避免重复处理)
iterator.remove();
}
}
}
}

关键逻辑

  • 服务器通道仅关注 OP_ACCEPT,接收新连接后将客户端通道注册到 Selector 并关注 OP_READ
  • 客户端通道触发 OP_READ 时读取数据,处理后切换为关注 OP_WRITE 以回复消息。
  • 单线程通过 Selector 轮询所有事件,避免为每个连接创建线程。

NIO 高级特性

内存映射文件(MappedByteBuffer)

内存映射文件是将磁盘文件的部分或全部映射到虚拟内存,通过 MappedByteBuffer 直接操作内存(无需传统 IO 拷贝),适用于大文件高效读写。

1
2
3
4
5
6
7
8
9
10
11
12
// 映射文件到内存(可读可写,映射0到1024字节)
try (RandomAccessFile raf = new RandomAccessFile("largeFile.dat", "rw");
FileChannel channel = raf.getChannel()) {
MappedByteBuffer mappedBuf = channel.map(
FileChannel.MapMode.READ_WRITE, 0, 1024 * 1024 // 映射1MB
);

// 直接操作内存(修改即同步到磁盘)
mappedBuf.put("内存映射文件测试".getBytes());
mappedBuf.flip();
// 无需显式关闭,虚拟内存由操作系统管理
}

优势

  • 避免 JVM 堆内存占用(映射内存位于堆外)。
  • 数据修改直接同步到磁盘,无需 write() 调用。

文件锁定(FileLock)

文件锁定用于多进程或线程同步访问文件,确保数据一致性。FileChannel 提供 tryLock()(非阻塞)和 lock()(阻塞)方法获取锁。

1
2
3
4
5
6
7
8
9
try (FileChannel channel = FileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE)) {
// 非阻塞获取整个文件的排他锁(其他进程无法写入)
FileLock lock = channel.tryLock();
if (lock != null) {
// 操作文件(此时其他进程写入会失败)
channel.write(ByteBuffer.wrap("锁定状态写入".getBytes()));
lock.release(); // 释放锁
}
}
  • 排他锁:默认模式,持有锁的进程可读写,其他进程无法操作。
  • 共享锁:通过 tryLock(0, Long.MAX_VALUE, true) 获取,允许多进程读,但禁止写。

NIO 适用场景与总结

适用场景

  • 高并发网络编程:如服务器需处理 thousands 级客户端连接(如聊天服务器、API 网关)。
  • 大文件处理:通过内存映射文件提升读写效率(如日志分析、大数据导入)。
  • 需要减少线程开销的场景:单线程或固定线程池即可处理多连接,避免线程上下文切换。

核心总结

  • NIO 三大组件:Buffer(数据容器)、Channel(双向传输通道)、Selector(多路复用器)。
  • 核心优势:非阻塞 + 多路复用,单线程处理多 IO 操作,降低线程资源消耗。
  • 关键机制:通过 Selector 监听 Channel 事件,仅在事件就绪时处理,避免无效阻塞

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10