Java IO——NIO
本文最后更新于:2 天前
引言
Java NIO(New I/O)是从 Java 1.4 开始引入,并在后续版本不断完善的一套高性能 I/O 编程模型。它在保留传统流式 I/O 的基础上,提供了更灵活的缓冲区(Buffer) 与通道(Channel)机制,并加入了非阻塞 I/O和多路复用(Selector)等概念,极大地提升了高并发环境下的吞吐量。本篇文章将从缓冲区与通道的基本用法讲起,逐步剖析异步文件读写、网络编程,以及基于 Selector 的非阻塞服务器模型,帮助读者深入掌握 Java NIO。
缓冲区 Buffer
概述
在 NIO 中,所有数据都是通过缓冲区(Buffer)来处理的。Buffer 本质上是一个容器,用于存储不同数据类型的数据(如 ByteBuffer
、CharBuffer
、IntBuffer
等)。数据的读写都必须经过 Buffer,Channel 与 Buffer 搭配使用,完成数据在程序与底层设备间的传输。
主要属性
mark
- 作用: 用于记录一个特定的位置。调用
mark()
方法后,Buffer 会保存当前位置,后续可以通过reset()
方法将position
重置到该位置。 - 注意: 一旦调用
reset()
,如果mark
不存在则会抛出异常。
position
- 作用: 表示当前读写操作的位置。每次读或写操作后,
position
会自动前移。 - 特点: 在切换模式时(如调用
flip()
或rewind()
),position
会被相应调整。
limit
- 作用: 表示当前缓冲区中可以操作的最大范围。对于写操作来说,
limit
通常等于capacity
;而对于读操作来说,limit
指定了数据的末尾位置。 - 注意: 超过
limit
的位置是不可访问的。
capacity
- 作用: 表示缓冲区的固定大小,即能容纳的最大元素数。一经分配便不能改变。
- 特点:
capacity
决定了缓冲区分配时的总内存大小。
address
- 作用: 对于直接缓冲区(Direct Buffer)来说,该属性表示分配的内存地址。
- 注意: 这是底层实现细节,一般仅用于性能优化或与本地代码交互的场景。
关键方法
构造方法
Buffer(int mark, int pos, int lim, int cap)
:构造方法(受保护)用来初始化 Buffer 的mark
、position
、limit
与capacity
。通常在子类(如ByteBuffer
)中调用,不直接在应用中使用。
设置和获取属性
int capacity()
:返回缓冲区的容量。int position()
:返回当前的position
。Buffer position(int newPosition)
:设置新的position
,并返回当前 Buffer 实例,便于链式调用。调用时会检查newPosition
是否在合法范围内。int limit()
:返回当前的limit
。Buffer limit(int newLimit)
:设置新的limit
,返回当前 Buffer 实例,同样会进行范围检查。Buffer mark()
:在当前position
处设置mark
,便于后续的reset
操作。Buffer reset()
:将position
重置为之前保存的mark
。若mark
不存在,将抛出异常。
模式转换操作
Buffer clear()
:重置缓冲区,设置position
为 0,limit
为capacity
,同时丢弃mark
。通常用于写操作前的准备。Buffer flip()
:将缓冲区从写模式切换到读模式。flip()
方法会将limit
设置为当前position
的值,然后将position
重置为 0,同时丢弃mark
。用于写入完毕后准备读取数据。Buffer rewind()
:将position
重置为 0(保留limit
不变),常用于重新读取缓冲区中的数据,而不需要重新写入数据。
数据剩余和状态检查
int remaining()
:返回缓冲区中剩余可读或可写的元素数量(limit - position
)。boolean hasRemaining()
:判断缓冲区中是否还有未处理的数据(remaining() > 0
)。boolean isReadOnly()
:检查当前缓冲区是否为只读模式。boolean hasArray()
:判断缓冲区是否有一个可公开访问的底层数组(例如堆缓冲区通常支持)。Object array()
:返回底层数组(如果存在),用于直接操作缓冲区数据。int arrayOffset()
:返回底层数组中与缓冲区起始位置相关的偏移量。boolean isDirect()
:判断缓冲区是否为直接缓冲区。直接缓冲区在操作系统内核层面分配内存,通常具有更高的 I/O 性能。
内部索引和边界检查方法
以下方法大多为包内或内部使用,用于确保索引操作的安全性。
int nextGetIndex()
/int nextGetIndex(int nb)
:用于在读取数据时获取下一个索引位置,后者支持一次性读取多个数据。内部会检查是否越界。int nextPutIndex()
/int nextPutIndex(int nb)
:用于写入数据时获取下一个写入索引,同样支持批量操作,并进行越界检查。int checkIndex(int i)
/int checkIndex(int i, int nb)
:用于检查单个索引或一段连续区域是否在合法范围内,防止数组越界异常。int markValue()
:返回当前保存的mark
值。void truncate()
:内部方法,可能用于截断缓冲区数据(具体实现依赖于子类)。void discardMark()
:丢弃已设置的mark
,通常在调用clear()
或flip()
时自动执行。void checkBounds(int off, int len, int size)
:用于检查操作的起始偏移和长度是否在规定范围内。确保off
和len
的组合不超过指定size
。
基本操作
使用缓冲区通常会涉及以下几个步骤:
- 写入数据:将数据放入 Buffer 中,此时
position
会递增。 - 切换读模式:调用
flip()
方法,将position
置为 0,同时将limit
设置为之前写入的数据量。 - 读取数据:从 Buffer 中取出数据。
- 清理或压缩:调用
clear()
方法重置缓冲区或调用compact()
方法保留未读数据以继续写入。
字节数据缓冲区
字节数据缓冲区 ByteBuffer
是最常用的缓冲区类型,用于存储和操作字节数据。在文件 I/O、网络通信等场景中,我们经常需要通过 ByteBuffer
来处理原始数据流。ByteBuffer 支持多种操作,如读写、切换模式、标记和复位等。
示例:使用 ByteBuffer
进行基本的读写操作。
1 |
|
字符数据缓冲区
字符数据缓冲区 CharBuffer
专用于存储字符数据,在处理文本时十分常用。与 ByteBuffer
类似,CharBuffer
也具有 position
、limit
和 capacity
属性,并提供相应的读写操作。它特别适合需要对字符或字符串进行操作的场景,如文件字符编码转换、字符数据处理等。
示例:使用 ByteBuffer
进行基本的读写操作。
1 |
|
其他类型缓冲区
除了 ByteBuffer
和 CharBuffer
,NIO
还提供了其他多种缓冲区类型,分别对应不同的数据类型,如:
IntBuffer
:用于存储整数。LongBuffer
:用于存储长整型数据。FloatBuffer
:用于存储单精度浮点数。DoubleBuffer
:用于存储双精度浮点数。
这些缓冲区的使用方式与 ByteBuffer
和 CharBuffer
类似,都支持基本的写入、读取、重置等操作。在需要处理非字节数据时,选择合适的缓冲区类型能够大幅提高程序的可读性和性能。
通道 Channel
概述
通道 Channel 是 Java NIO 中用于进行数据传输的组件。与传统 I/O 中的流不同,Channel 是双向的,即它既能进行读操作,也能进行写操作。常见的 Channel 包括:
FileChannel
:用于文件的读写操作。SocketChannel
:用于网络通信的读写操作。ServerSocketChannel
:用于监听客户端连接请求。DatagramChannel
:用于 UDP 网络通信。
与 Buffer 的配合
Channel 不能直接操作数据,而是需要借助 Buffer。通过 Channel 将数据读入 Buffer,再由 Buffer 将数据写出到 Channel。这样的设计使得数据操作更加灵活,同时也便于实现非阻塞 I/O。
FileChannel
概述
FileChannel
用于文件的读写操作。它不直接存储数据,而是依靠 Buffer 作为数据的容器。FileChannel
支持随机访问,因此可以在文件的任意位置进行读取或写入操作。注意:FileChannel
无法设置为非阻塞模式,但它提供了高效的文件 I/O 操作,例如文件拷贝和内存映射文件等。
常用操作
- 打开文件:通过
open(Path path, OpenOption... options)
方法打开一个文件。 - 读取数据:通过
read(ByteBuffer)
方法将数据从文件读取到缓冲区。 - 写入数据:通过
write(ByteBuffer)
方法将数据从缓冲区写入文件。 - 文件定位:可以通过
position()
方法获取或设置文件中的当前位置,实现随机读取/写入。
打开模式
通过 open
方法打开文件时,支持以下打开模式:
模式 | 作用 |
---|---|
READ |
只读模式 |
WRITE |
只写模式 |
APPEND |
拼接的方式进行写操作 |
TRUNCATE_EXISTING |
先将长度截断为 0(清空文件),再进行写操作 |
CREATE |
文件不存在时创建 |
CREATE_NEW |
只在文件不存在时创建(否则抛异常) |
DELETE_ON_CLOSE |
关闭时删除文件 |
SPARSE |
允许创建稀疏文件 |
SYNC |
文件内容或元数据的每次更新都立即同步文写入底层存储设备 |
DSYNC |
文件内容的每次更新都立即同步文写入底层存储设备 |
示例
下面的示例演示了如何使用 FileChannel
读取一个文本文件,并将读取到的数据输出到控制台。
1 |
|
在该示例中,我们首先打开一个文件并获取其 FileChannel
,然后利用 ByteBuffer
将文件数据分段读取到内存中,并输出到控制台。
关键点:
- 写入时:
Charset.encode(content)
将字符串转换为ByteBuffer
。 - 读取时:
Charset.decode(buffer)
进行解码。
AsynchronousFileChannel
概述
AsynchronousFileChannel
是 Java 7 引入的异步文件 I/O 通道,位于 java.nio.channels
包中。它支持非阻塞文件读写,使文件操作可以异步执行,特别适用于高并发文件访问、日志系统、文件服务器等场景。
相比于 FileChannel
(同步 I/O),AsynchronousFileChannel
允许:
- 读写操作异步执行,不阻塞主线程
- 支持
CompletionHandler
回调 - 支持
Future
机制 - 适用于高性能 I/O(如日志、数据存储)
对比 FileChannel
特性 | FileChannel | AsynchronousFileChannel |
---|---|---|
I/O 模型 | 同步 | 异步 |
线程阻塞 | 可能阻塞 | 不阻塞 |
适用场景 | 小型文件读写、普通 I/O | 高并发文件访问、大型文件 |
调用方式 | read() / write() |
read() / write() (带 Future 或 CompletionHandler ) |
并发支持 | 需要手动同步 | 线程安全,支持多个并发操作 |
适用场景:
FileChannel
适用于单线程文件操作。AsynchronousFileChannel
适用于多线程并发访问。
打开模式
见 FileChannel
。
示例
使用
Future
进行异步读取。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
28public class AsyncReadDemo {
public static void main(String[] args) throws Exception {
Path path = Paths.get("example_utf8.txt");
// 确保文件存在,并使用 UTF-8 编码写入测试内容
if (!Files.exists(path)) {
Files.write(path, "你好,AsynchronousFileChannel!".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
// 异步读取
Future<Integer> future = channel.read(buffer, position);
// 这里可以执行其他任务...
// 获取读取结果
int bytesRead = future.get();
buffer.flip();
String content = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.println("读取内容: " + content);
channel.close();
}
}使用
CompletionHandler
进行异步读取。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
33public class AsyncReadWithHandlerDemo {
public static void main(String[] args) throws IOException, InterruptedException {
Path path = Paths.get("example_utf8.txt");
// 确保文件存在,并使用 UTF-8 编码写入测试内容
if (!Files.exists(path)) {
Files.write(path, "你好,AsynchronousFileChannel!".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
}
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
// 使用 CompletionHandler 进行非阻塞读取
channel.read(buffer, position, buffer, new java.nio.channels.CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer bytesRead, ByteBuffer attachment) {
attachment.flip();
String content = StandardCharsets.UTF_8.decode(attachment).toString();
System.out.println("读取内容: " + content);
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println("读取失败: " + exc.getMessage());
}
});
TimeUnit.SECONDS.sleep(2); // 让主线程等待异步任务完成
channel.close();
}
}
SocketChannel
概述
SocketChannel
是用于网络通信的通道,主要用来实现基于 TCP 的客户端操作。它既支持阻塞模式,也支持非阻塞模式,特别适合需要进行异步网络 I/O 的场景。通过 SocketChannel
,客户端可以连接到远程服务器,并发送/接收数据。
常用操作
- 建立连接:使用
connect(SocketAddress)
方法连接到服务器。 - 读取数据:使用
read(ByteBuffer)
从通道中读取数据。 - 写入数据:使用
write(ByteBuffer)
向通道中写入数据。 - 非阻塞模式:通过
configureBlocking(false)
将SocketChannel
设置为非阻塞模式,以便在进行网络 I/O 时不被阻塞。
ServerSocketChannel
概述
ServerSocketChannel
是用于监听客户端连接请求的通道。它与传统的 ServerSocket
类似,但同样支持非阻塞模式,并且可与 Selector
配合使用,实现高效的多路复用。使用 ServerSocketChannel
可以在单线程中处理大量并发连接。
常用操作
- 绑定端口:通过
bind(SocketAddress)
方法绑定服务器监听的地址和端口。 - 接收连接:通过
accept()
方法接收客户端连接请求,该方法在非阻塞模式下返回 null 表示当前没有连接。 - 设置非阻塞:通过
configureBlocking(false)
将通道设置为非阻塞模式。
选择器 Selector
概述
Selector
是 Java NIO 提供的多路复用(Multiplexing) 机制,可以通过单个线程监听多个 Channel
(通道)的事件(如可读、可写、连接等)。这样可以减少线程数量,提高系统的吞吐量,适用于高并发的网络服务器,如 HTTP 服务器、聊天服务器等。
传统的阻塞 I/O 需要为每个连接创建一个独立的线程,而 Selector
允许一个线程管理多个连接,实现非阻塞 I/O,大大减少了线程切换的开销。
核心组件
Channel(通道)
- 例如
SocketChannel
(TCP 客户端)、ServerSocketChannel
(TCP 服务器)、DatagramChannel
(UDP)。 Channel
必须设置为非阻塞模式,才能配合Selector
工作。
SelectionKey(选择键)
代表
Selector
监听的事件,如:OP_ACCEPT
:连接就绪。OP_READ
:可读。OP_WRITE
:可写。OP_CONNECT
:连接完成。
SelectionKey
绑定了Channel
和Selector
,用于标识哪个通道准备好了特定的操作。
Selector(选择器)
- 负责监视多个
Channel
的事件变化,避免传统while(true)
的轮询方式。
关键方法
方法 | 作用 |
---|---|
Selector.open() |
创建 Selector |
select() |
阻塞等待至少一个通道就绪 |
select(long timeout) |
超时等待指定时间(毫秒) |
selectNow() |
非阻塞检查就绪通道 |
selectedKeys() |
获取已选择的 SelectionKey 集合 |
keys() |
获取所有注册的 SelectionKey |
wakeup() |
让一个阻塞的 select() 方法立即返回 |
close() |
关闭 Selector |
工作流程
创建
Selector
。1
Selector selector = Selector.open();
将
Channel
注册到Selector
。1
2
3ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // 设置非阻塞
serverChannel.register(selector, SelectionKey.OP_ACCEPT);轮询就绪事件。
1
2
3
4
5
6
7
8
9
10while (true) {
selector.select(); // 阻塞,直到至少有一个事件发生
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理 key
iterator.remove(); // 处理后删除,防止重复处理
}
}
SelectionKey 的操作类型
Selector
监听的事件包括:
操作类型 | 常量 | 适用于 | 说明 |
---|---|---|---|
接收连接 | SelectionKey.OP_ACCEPT |
ServerSocketChannel |
监听新的客户端连接 |
连接完成 | SelectionKey.OP_CONNECT |
SocketChannel |
检查客户端连接是否完成 |
读取数据 | SelectionKey.OP_READ |
SocketChannel |
监听通道是否有数据可读 |
写入数据 | SelectionKey.OP_WRITE |
SocketChannel |
监听通道是否可写 |
示例
以下是一个基于 Java NIO(非阻塞 I/O)实现的多人聊天室,包括服务器和客户端。该代码实现了一个基于 Selector
的非阻塞 TCP 服务器,能够同时支持多个客户端进行聊天。
功能概述
- 支持多客户端连接。
- 客户端可以发送消息到服务器。
- 服务器收到消息后会响应客户端发送的消息。
- 使用
Selector
管理多个SocketChannel
,提高性能。 - 非阻塞 I/O,适用于高并发聊天应用。
服务器代码
1 |
|
客户端代码
1 |
|
运行流程
启动服务器,服务器控制台打印如下:
1
NIO 聊天服务器已启动,监听端口:5000
启动客户端,客户端控制台打印如下:
1
已连接到聊天服务器,输入消息发送:
服务器收到客户端的连接信息:
1
2NIO 聊天服务器已启动,监听端口:5000
新客户端连接:/127.0.0.1:49791使用客户端发送消息:
1
2已连接到聊天服务器,输入消息发送:
你好,这里是客户端a。服务器收到客户端发送的消息:
1
2
3NIO 聊天服务器已启动,监听端口:5000
新客户端连接:/127.0.0.1:49791
收到消息:你好,这里是客户端a。客户端收到服务器响应的消息:
1
2
3已连接到聊天服务器,输入消息发送:
你好,这里是客户端a。
你发送的消息是:你好,这里是客户端a。
优势
- 单线程管理多个连接:相比于传统阻塞 I/O(每个连接需要一个线程),
Selector
允许单个线程高效管理多个连接。 - 减少上下文切换:使用非阻塞 I/O 可以避免线程切换的开销,提高性能。
- 使用 ByteBuffer 进行数据传输:确保数据的高效读取和写入。
- 适用于高并发场景:如聊天室、HTTP 服务器、WebSockets、游戏服务器等。
非阻塞模型总结
工作流程
打开通道(Channel)
- 服务器端:调用
ServerSocketChannel.open()
创建服务器通道,用于监听客户端连接请求。 - 客户端:调用
SocketChannel.open()
创建客户端通道,用于发起连接。
- 服务器端:调用
配置通道为非阻塞模式
- 对于
ServerSocketChannel
或SocketChannel
执行configureBlocking(false)
,表示使用非阻塞模式。
- 对于
创建选择器(Selector)
- 调用
Selector.open()
获得一个选择器实例。
- 调用
通道注册到选择器(register)
服务器端示例:
1
2
3
4ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(port));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);表示该服务器通道对 “接受连接(OP_ACCEPT)” 事件感兴趣。
事件循环(轮询就绪通道)
- 在主循环中调用
selector.select()
,阻塞等待就绪事件,或调用selector.selectNow()
做非阻塞检测。 - 当有就绪事件时,
selector.selectedKeys()
中包含了所有就绪的SelectionKey
。然后对这些 key 逐一处理:
1
2
3
4
5
6
7
8
9
10
11
12
13Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {
// 处理接受连接
} else if (key.isReadable()) {
// 处理读事件
} else if (key.isWritable()) {
// 处理写事件
}
iter.remove(); // 移除已处理的key
}- 在主循环中调用
处理具体读/写操作
- 如果事件是
isReadable()
,就拿到对应的通道(如SocketChannel
)进行read()
操作。读操作会将数据读入到ByteBuffer
。在非阻塞模式下,如果此时对方并没有发送数据可读,read()
调用会返回 0 或 -1(连接关闭),而不会阻塞线程。 - 如果事件是
isWritable()
,就可以将数据从ByteBuffer
写到通道中。如果通道暂时无法写满数据,也不会阻塞,可以下次继续写。 - 在需要的情况下,还可以根据当前处理流程动态修改对通道感兴趣的事件,比如读完后需要写,就把通道在选择器上注册为对写事件感兴趣。
- 如果事件是
通过上述流程,便可以在单线程或少量线程下管理成百上千的连接。只有当某个通道真正就绪时,才会触发相应的处理逻辑,大大提高了并发效率。
注意事项
- 选择器实现依赖于操作系统
- 在不同平台上,
Selector
可能基于select
、poll
或epoll
。在高并发场景下,需要注意操作系统对文件描述符数量或内核参数的限制。
- 在不同平台上,
- Selector 的空轮询(空转)问题
- 某些版本的 JDK(尤其在 Linux epoll 上)可能会出现空轮询 bug,导致 CPU 飙升。可以通过升级 JDK 或更改配置来解决。
- 读写时的缓冲区管理
- 建议重复使用
ByteBuffer
,以减少对象创建的开销。或者使用内存池做统一管理。
- 建议重复使用
- 与传统阻塞 IO 的配合
- 同一个通道要么是阻塞模式,要么是非阻塞模式。若业务中有阻塞操作,需确保不会影响非阻塞通道的高并发特性。
- 适用场景
- 非阻塞 IO 在大量短连接(如聊天服务器、游戏服务器、消息推送)或需要同时处理成百上千连接的场景特别有效。
- 如果连接数较少,但数据吞吐量大且需要稳定的流式处理,传统阻塞式 BIO 或者基于多线程的 I/O 也可能是可行的选择。
总结
Java NIO 的核心在于使用 Channel + Buffer 的数据传输模型,以及通过 Selector 实现单线程管理多连接的高并发特性。缓冲区让数据的读写更灵活可控,通道抽象出多种 I/O 形态(文件、网络、异步),Selector 解决了大量并发连接的线程切换难题。在实际项目中,NIO 非阻塞模式尤其适合网络服务器、代理、聊天系统等需要同时处理大量连接的场景。当然,对于小规模文件处理或简单网络通信,传统的阻塞 I/O 依然易于理解和维护。根据应用场景选择合适的 I/O 模型,才能更好地发挥 Java I/O 的威力。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!