Java IO 解析(BIO、NIO)

73 阅读6分钟

一、Java IO核心认知:阻塞与非阻塞的本质

Java 中 IO 操作的阻塞与非阻塞,本质区别在于线程发起 IO 后是否需要等待操作完成才能继续执行。这一核心差异直接决定了不同 IO 模型的性能特性和适用场景,也是理解 BIO 与 NIO 的关键前提。

在深入具体模型前,我们先明确 Java IO 的基础体系框架。Java IO 按数据处理类型可分为字节流和字符流,前者处理二进制数据,基类为 InputStreamOutputStream;后者处理文本数据,基类为 ReaderWriter。而按阻塞特性,则可分为阻塞 IO(BIO)、非阻塞 IO(NIO)和异步非阻塞 IO(AIO)三大模型。

二、阻塞IO(BIO):简单直观的流模型

2.1 核心定义与特点

阻塞 IO(Blocking IO)是最基础的 IO 模型,线程发起 IO 操作(如读文件、建立网络连接)后,必须等待 IO 操作完全完成(数据读取/写入结束、连接建立成功)才能继续执行后续代码,期间线程会被"阻塞"——暂停运行且不占用 CPU 资源。其核心特点可概括为三点:

  • 面向流(Stream) :数据只能单向传输,如 InputStream 负责读、OutputStream 负责写,无法双向同时操作;
  • 同步阻塞:IO 操作与线程强绑定,一个 IO 操作必须占用一个线程,线程的生命周期与 IO 操作完全同步;
  • 简单易用但高并发瓶颈明显:API 直观易懂,开发成本低,但在大量并发连接场景下,会因线程资源耗尽导致性能崩溃。

2.2 核心组件与实战示例

BIO 的核心组件围绕字节流、字符流及 Socket 编程展开,不同场景对应不同的实现类:

文件 IO 操作

所有基于 InputStream/OutputStreamReader/Writer 的文件操作默认均为阻塞模式,其中带缓冲的流(如 BufferedInputStream)通过减少系统 IO 调用次数提升性能,是实际开发中的首选。

// 带缓冲的阻塞读文件示例(推荐实战用法)
try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) {
    String line;
    // readLine() 方法会阻塞直到读取到一行数据或文件结束
    while ((line = br.readLine()) != null) {
        System.out.println("读取内容:" + line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

网络 IO 操作

Java 原生 Socket 编程默认采用阻塞模式,服务端的 ServerSocket 会阻塞等待客户端连接,Socket 会阻塞等待数据读写。这种模型在低并发场景下可行,但高并发时会创建大量线程导致资源耗尽。

// 阻塞 Socket 服务端示例
try (ServerSocket serverSocket = new ServerSocket(8080)) {
    System.out.println("服务端启动,等待客户端连接...");
    while (true) {
        // accept() 阻塞:直到有客户端连接建立
        Socket socket = serverSocket.accept();
        System.out.println("客户端连接成功:" + socket.getInetAddress());
        // 读取客户端数据(阻塞)
        try (BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
            String data = br.readLine();
            System.out.println("收到客户端数据:" + data);
        }
    }
} catch (IOException e) {
    e.printStackTrace();
}

三、非阻塞IO(NIO):高并发的缓冲区模型

为解决 BIO 高并发瓶颈,JDK 1.4 引入 java.nio 包(New IO),其核心是"面向缓冲区 + 多路复用"的非阻塞模型。线程发起 IO 操作后无需等待完成,可立即返回并执行其他任务,后续通过"轮询"或"事件通知"获取结果,大幅提升线程效率。

3.1 核心组件三要素

NIO 的非阻塞能力依赖三大核心组件的协同工作,这也是理解 NIO 的关键:

  • Channel(通道) :IO 操作的载体,双向传输数据(区别于 BIO 的单向流),支持阻塞与非阻塞模式(需手动配置 configureBlocking(false))。常见实现有 SocketChannel(客户端 TCP)、ServerSocketChannel(服务端 TCP)等;
  • Buffer(缓冲区) :数据临时存储容器,NIO 所有数据读写都必须通过缓冲区完成。通过批量读写减少 IO 次数,提升性能,核心实现为 ByteBuffer
  • Selector(多路复用器) :NIO 高并发的核心,一个 Selector 可管理多个 Channel 的事件(如连接、读、写)。线程通过 Selector 监听多个 Channel 状态,仅在事件就绪时处理,实现"一个线程管理多通道"。

3.2 非阻塞的底层原理:为什么能"立即返回"?

NIO 非阻塞读/写的"立即返回"特性,是操作系统非阻塞机制与 Java 封装共同作用的结果,核心是"查询式 IO"——线程仅查询 IO 状态而非等待数据就绪。

核心前提:开启非阻塞模式

Channel 默认是阻塞模式,必须通过以下代码开启非阻塞,这一步会直接通知操作系统:"对该通道的 IO 操作,不要挂起线程"。

// 关键:将通道设置为非阻塞模式
SocketChannel clientChannel = SocketChannel.open();
clientChannel.configureBlocking(false);

底层执行流程(以非阻塞读为例)

非阻塞读的整个流程可分为 3 步,涉及 Java 层与操作系统层的协同:

  1. Java 调用触发系统调用:线程调用 clientChannel.read(buf) 时,Java 通过 JNI 调用操作系统的非阻塞读系统调用(如 Linux 的 read());
  2. 操作系统快速查询状态:操作系统立即检查内核缓冲区是否有数据: 有数据:将数据从内核缓冲区拷贝到 Java 缓冲区,返回实际读取字节数(正整数);
  3. 无数据:不阻塞线程,直接返回"未就绪"标识(Java 层封装为 0);
  4. 通道关闭:返回 -1 表示客户端断开连接。
  5. Java 封装结果返回:Java 将操作系统返回值封装后返回给线程,线程可立即执行其他任务。

与 BIO 阻塞的本质区别

BIO 之所以阻塞,是因为内核缓冲区无数据时,操作系统会将线程从"运行队列"移到"等待队列",线程暂停运行;而 NIO 非阻塞模式下,无论有无数据,操作系统都不会挂起线程,仅快速返回状态,线程始终处于运行状态可处理其他任务。形象比喻:

BIO 像点餐后站在柜台前死等取餐,期间什么都做不了;NIO 像点餐后拿取餐号,可自由活动,每隔一段时间看一眼是否叫号(轮询),叫到号再取餐。

3.3 实战:Selector 多路复用实现高并发

非阻塞读单独使用时会因反复轮询导致 CPU 空转,需结合 Selector 实现多路复用。下面通过服务端示例展示三者协同工作流程:

// 非阻塞 Socket 服务端(结合 Selector)
try (Selector selector = Selector.open();
     ServerSocketChannel serverChannel = ServerSocketChannel.open()) {

    serverChannel.bind(new InetSocketAddress(8080));
    serverChannel.configureBlocking(false); // 服务端通道非阻塞
    // 注册"接受连接"事件到 Selector
    serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    System.out.println("NIO 服务端启动,监听 8080 端口...");

    while (selector.select() > 0) { // 阻塞等待事件就绪(无事件时释放CPU)
        // 遍历就绪事件
        Iterator<SelectionKey> keyIt = selector.selectedKeys().iterator();
        while (keyIt.hasNext()) {
            SelectionKey key = keyIt.next();
            // 处理"接受连接"事件
            if (key.isAcceptable()) {
                ServerSocketChannel server = (ServerSocketChannel) key.channel();
                SocketChannel clientChannel = server.accept(); // 非阻塞,立即返回
                clientChannel.configureBlocking(false);
                // 注册"读数据"事件到 Selector
                clientChannel.register(selector, SelectionKey.OP_READ);
                System.out.println("客户端连接:" + clientChannel.getInetAddress());
            }
            // 处理"读数据"事件
            else if (key.isReadable()) {
                SocketChannel clientChannel = (SocketChannel) key.channel();
                ByteBuffer buf = ByteBuffer.allocate(1024);
                int len = clientChannel.read(buf); // 非阻塞读,立即返回
                if (len > 0) { // 有数据可读
                    buf.flip(); // 切换为读模式
                    String data = new String(buf.array(), 0, len);
                    System.out.println("收到数据:" + data);
                } else if (len == -1) { // 客户端断开
                    clientChannel.close();
                    System.out.println("客户端断开连接");
                }
            }
            keyIt.remove(); // 移除已处理事件,避免重复处理
        }
    }
} catch (IOException e) {
    e.printStackTrace();
}

上述代码的核心优势:通过 Selector 实现"一个线程管理所有客户端连接",仅在有连接建立或数据到达时唤醒线程,既避免了 BIO 的线程爆炸问题,又解决了纯非阻塞轮询的 CPU 空转问题。