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 本质上是一个容器,用于存储不同数据类型的数据(如 ByteBufferCharBufferIntBuffer 等)。数据的读写都必须经过 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 的 markpositionlimitcapacity。通常在子类(如 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,limitcapacity,同时丢弃 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):用于检查操作的起始偏移和长度是否在规定范围内。确保 offlen 的组合不超过指定 size

基本操作

使用缓冲区通常会涉及以下几个步骤:

  1. 写入数据:将数据放入 Buffer 中,此时 position 会递增。
  2. 切换读模式:调用 flip() 方法,将 position 置为 0,同时将 limit 设置为之前写入的数据量。
  3. 读取数据:从 Buffer 中取出数据。
  4. 清理或压缩:调用 clear() 方法重置缓冲区或调用 compact() 方法保留未读数据以继续写入。

字节数据缓冲区

字节数据缓冲区 ByteBuffer 是最常用的缓冲区类型,用于存储和操作字节数据。在文件 I/O、网络通信等场景中,我们经常需要通过 ByteBuffer 来处理原始数据流。ByteBuffer 支持多种操作,如读写、切换模式、标记和复位等。

示例:使用 ByteBuffer 进行基本的读写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ByteBufferDemo {
public static void main(String[] args) {
// 分配一个 32 字节大小的 ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(32);

// 写入数据到缓冲区
String input = "Hello, NIO ByteBuffer!";
buffer.put(input.getBytes(StandardCharsets.UTF_8));

// 切换到读模式
buffer.flip();

// 从缓冲区中读取数据
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String output = new String(bytes, StandardCharsets.UTF_8);

System.out.println("读取数据:" + output);
}
}

字符数据缓冲区

字符数据缓冲区 CharBuffer 专用于存储字符数据,在处理文本时十分常用。与 ByteBuffer 类似,CharBuffer 也具有 positionlimitcapacity 属性,并提供相应的读写操作。它特别适合需要对字符或字符串进行操作的场景,如文件字符编码转换、字符数据处理等。

示例:使用 ByteBuffer 进行基本的读写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CharBufferDemo {
public static void main(String[] args) {
// 分配一个容量为 20 的 CharBuffer
CharBuffer charBuffer = CharBuffer.allocate(20);

// 写入字符数据
String message = "Hello, CharBuffer!";
charBuffer.put(message);

// 切换到读模式
charBuffer.flip();

// 读取字符数据
StringBuilder output = new StringBuilder();
while (charBuffer.hasRemaining()) {
output.append(charBuffer.get());
}

System.out.println("读取字符数据:" + output.toString());
}
}

其他类型缓冲区

除了 ByteBufferCharBufferNIO 还提供了其他多种缓冲区类型,分别对应不同的数据类型,如:

  • IntBuffer:用于存储整数。
  • LongBuffer:用于存储长整型数据。
  • FloatBuffer:用于存储单精度浮点数。
  • DoubleBuffer:用于存储双精度浮点数。

这些缓冲区的使用方式与 ByteBufferCharBuffer 类似,都支持基本的写入、读取、重置等操作。在需要处理非字节数据时,选择合适的缓冲区类型能够大幅提高程序的可读性和性能。

通道 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
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
@Slf4j
public class FileChannelDemo {
public static void main(String[] args) throws Exception {
String filePath = "test.txt";
Charset charset = StandardCharsets.UTF_8; // 确保读写使用相同的字符集

// 写入文件
try (RandomAccessFile file = new RandomAccessFile(filePath, "rw");
FileChannel fileChannel = file.getChannel()) {

String content = "你好,FileChannel!";
ByteBuffer buffer = charset.encode(content); // 将字符串编码为 ByteBuffer
fileChannel.write(buffer);
} catch (Exception e) {
log.error("写入文件失败", e);
}

// 读取文件
try (RandomAccessFile file = new RandomAccessFile(filePath, "r");
FileChannel fileChannel = file.getChannel()) {

ByteBuffer buffer = ByteBuffer.allocate(1024);
fileChannel.read(buffer);

buffer.flip(); // 切换到读模式
String result = charset.decode(buffer).toString(); // 解码为字符串
System.out.println("读取内容: " + result);
} catch (Exception e) {
log.error("读取文件失败", e);
}
}
}

在该示例中,我们首先打开一个文件并获取其 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()(带 FutureCompletionHandler
并发支持 需要手动同步 线程安全,支持多个并发操作

适用场景

  • 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
    28
    public 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
    33
    public 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 绑定了 ChannelSelector,用于标识哪个通道准备好了特定的操作。

Selector(选择器)

  • 负责监视多个 Channel 的事件变化,避免传统 while(true) 的轮询方式。

关键方法

方法 作用
Selector.open() 创建 Selector
select() 阻塞等待至少一个通道就绪
select(long timeout) 超时等待指定时间(毫秒)
selectNow() 非阻塞检查就绪通道
selectedKeys() 获取已选择的 SelectionKey 集合
keys() 获取所有注册的 SelectionKey
wakeup() 让一个阻塞的 select() 方法立即返回
close() 关闭 Selector

工作流程

  1. 创建 Selector

    1
    Selector selector = Selector.open();
  2. Channel 注册到 Selector

    1
    2
    3
    ServerSocketChannel serverChannel = ServerSocketChannel.open();
    serverChannel.configureBlocking(false); // 设置非阻塞
    serverChannel.register(selector, SelectionKey.OP_ACCEPT);
  3. 轮询就绪事件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    while (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
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
public class NioChatServer {
private static final int PORT = 5000;

public static void main(String[] args) throws IOException {
// 1. 创建 Selector
Selector selector = Selector.open();

// 2. 创建 ServerSocketChannel 并绑定端口
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(PORT));
serverChannel.configureBlocking(false); // 设置非阻塞
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

System.out.println("NIO 聊天服务器已启动,监听端口:" + PORT);

// 3. 轮询 Selector 监听事件
while (true) {
selector.select(); // 阻塞直到有事件发生
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();

while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove(); // 处理完后删除,防止重复处理

if (key.isAcceptable()) {
// 处理新的客户端连接
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = server.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
System.out.println("新客户端连接:" + clientChannel.getRemoteAddress());
} else if (key.isReadable()) {
// 处理客户端发送的消息
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = clientChannel.read(buffer);

if (bytesRead == -1) {
// 客户端断开连接
System.out.println("客户端断开:" + clientChannel.getRemoteAddress());
clientChannel.close();
} else {
buffer.flip();
String message = new String(buffer.array(), 0, buffer.limit());
System.out.println("收到消息:" + message);
// 为了方便测试,这里只回复消息给发送者
clientChannel.write(ByteBuffer.wrap(("你发送的消息是:" + message).getBytes()));
}
}
}
}
}
}

客户端代码

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
public class NioChatClient {
private static final String SERVER_ADDRESS = "localhost";
private static final int PORT = 5000;

public static void main(String[] args) throws IOException {
// 1. 创建 SocketChannel 并连接服务器
SocketChannel clientChannel = SocketChannel.open();
clientChannel.configureBlocking(false);
clientChannel.connect(new InetSocketAddress(SERVER_ADDRESS, PORT));

while (!clientChannel.finishConnect()) {
// 等待连接完成
}

System.out.println("已连接到聊天服务器,输入消息发送:");

// 启动新线程监听服务器消息
new Thread(() -> {
try {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
buffer.clear();
int bytesRead = clientChannel.read(buffer);
if (bytesRead > 0) {
buffer.flip();
System.out.println(new String(buffer.array(), 0, buffer.limit()));
}
}
} catch (IOException e) {
System.out.println("服务器已断开!");
}
}).start();

// 主线程负责发送消息
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String message = scanner.nextLine();
clientChannel.write(ByteBuffer.wrap(message.getBytes()));
}

clientChannel.close();
scanner.close();
}
}

运行流程

  1. 启动服务器,服务器控制台打印如下:

    1
    NIO 聊天服务器已启动,监听端口:5000
  2. 启动客户端,客户端控制台打印如下:

    1
    已连接到聊天服务器,输入消息发送:
  3. 服务器收到客户端的连接信息:

    1
    2
    NIO 聊天服务器已启动,监听端口:5000
    新客户端连接:/127.0.0.1:49791
  4. 使用客户端发送消息:

    1
    2
    已连接到聊天服务器,输入消息发送:
    你好,这里是客户端a。
  5. 服务器收到客户端发送的消息:

    1
    2
    3
    NIO 聊天服务器已启动,监听端口:5000
    新客户端连接:/127.0.0.1:49791
    收到消息:你好,这里是客户端a。
  6. 客户端收到服务器响应的消息:

    1
    2
    3
    已连接到聊天服务器,输入消息发送:
    你好,这里是客户端a。
    你发送的消息是:你好,这里是客户端a。

优势

  • 单线程管理多个连接:相比于传统阻塞 I/O(每个连接需要一个线程),Selector 允许单个线程高效管理多个连接。
  • 减少上下文切换:使用非阻塞 I/O 可以避免线程切换的开销,提高性能。
  • 使用 ByteBuffer 进行数据传输:确保数据的高效读取和写入。
  • 适用于高并发场景:如聊天室、HTTP 服务器、WebSockets、游戏服务器等。

非阻塞模型总结

工作流程

  1. 打开通道(Channel)

    • 服务器端:调用 ServerSocketChannel.open() 创建服务器通道,用于监听客户端连接请求。
    • 客户端:调用 SocketChannel.open() 创建客户端通道,用于发起连接。
  2. 配置通道为非阻塞模式

    • 对于 ServerSocketChannelSocketChannel 执行 configureBlocking(false),表示使用非阻塞模式。
  3. 创建选择器(Selector)

    • 调用 Selector.open() 获得一个选择器实例。
  4. 通道注册到选择器(register)

    • 服务器端示例:

      1
      2
      3
      4
      ServerSocketChannel serverChannel = ServerSocketChannel.open();
      serverChannel.configureBlocking(false);
      serverChannel.bind(new InetSocketAddress(port));
      serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    • 表示该服务器通道对 “接受连接(OP_ACCEPT)” 事件感兴趣。

  5. 事件循环(轮询就绪通道)

    • 在主循环中调用 selector.select(),阻塞等待就绪事件,或调用 selector.selectNow() 做非阻塞检测。
    • 当有就绪事件时,selector.selectedKeys() 中包含了所有就绪的 SelectionKey。然后对这些 key 逐一处理:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Set<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
    }
  6. 处理具体读/写操作

    • 如果事件是 isReadable(),就拿到对应的通道(如 SocketChannel)进行 read() 操作。读操作会将数据读入到 ByteBuffer。在非阻塞模式下,如果此时对方并没有发送数据可读,read() 调用会返回 0 或 -1(连接关闭),而不会阻塞线程。
    • 如果事件是 isWritable(),就可以将数据从 ByteBuffer 写到通道中。如果通道暂时无法写满数据,也不会阻塞,可以下次继续写。
    • 在需要的情况下,还可以根据当前处理流程动态修改对通道感兴趣的事件,比如读完后需要写,就把通道在选择器上注册为对写事件感兴趣。

通过上述流程,便可以在单线程或少量线程下管理成百上千的连接。只有当某个通道真正就绪时,才会触发相应的处理逻辑,大大提高了并发效率。

注意事项

  1. 选择器实现依赖于操作系统
    • 在不同平台上,Selector 可能基于 selectpollepoll。在高并发场景下,需要注意操作系统对文件描述符数量或内核参数的限制。
  2. Selector 的空轮询(空转)问题
    • 某些版本的 JDK(尤其在 Linux epoll 上)可能会出现空轮询 bug,导致 CPU 飙升。可以通过升级 JDK 或更改配置来解决。
  3. 读写时的缓冲区管理
    • 建议重复使用 ByteBuffer,以减少对象创建的开销。或者使用内存池做统一管理。
  4. 与传统阻塞 IO 的配合
    • 同一个通道要么是阻塞模式,要么是非阻塞模式。若业务中有阻塞操作,需确保不会影响非阻塞通道的高并发特性。
  5. 适用场景
    • 非阻塞 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 协议 ,转载请注明出处!