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() 记录当前 position,reset() 可恢复到该位置)。
(3)Buffer 核心方法与工作流程

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

// 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=position,position=0)。
    • clear():重置缓冲区(用于重新写入,数据仍存在但可被覆盖)。
    • rewind():重复读(position=0,limit 不变)。
    • 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:通过文件流获取
FileChannel channel = new FileOutputStream("data.txt").getChannel();

// 方式2:通过 FileChannel.open()(推荐,可指定打开模式)
FileChannel channel = FileChannel.open(
  Paths.get("data.txt"), 
  StandardOpenOption.READ, StandardOpenOption.WRITE
);
② 读写文件示例
// 写文件
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 提供 transferFrom 和 transferTo 方法,直接通过操作系统内核复制数据(零拷贝),效率远高于传统流复制:

// 复制文件(零拷贝)
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)
// 连接服务器并发送数据
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)
// 监听端口并接收连接(非阻塞模式)
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 通道可读(数据已到达) SocketChannel、DatagramChannel
OP_WRITE 通道可写(缓冲区有空闲空间) SocketChannel、DatagramChannel
OP_CONNECT 客户端连接成功 SocketChannel
OP_ACCEPT 服务器接收新连接 ServerSocketChannel
(3)Selector 实战:单线程处理多客户端
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 拷贝),适用于大文件高效读写。

// 映射文件到内存(可读可写,映射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()(阻塞)方法获取锁。

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 事件,仅在事件就绪时处理,避免无效阻塞