Java NIO 比传统 IO 强在哪里?

我花了几天时间去了解NIO 的核心知识,期间看了《Java 编程思想》和《疯狂 Java 讲义》中的 NIO 模块。但是,看完之后还是很,不知道 NIO 是干嘛用的,网上的资料和书上的知识点没有很好地对应上。看不太懂,功力不够。

我这里先给大家展示一副传统 IO 和 NIO 的对比图,感受一下。

img

传统 IO 基于字节流或字符流(如 FileInputStream、BufferedReader 等)进行文件读写,以及使用 Socket 和 ServerSocket 进行网络传输。

NIO 使用通道(Channel)和缓冲区(Buffer)进行文件操作,以及使用 SocketChannel 和 ServerSocketChannel 进行网络传输。

传统 IO 采用阻塞式模型,对于每个连接,都需要创建一个独立的线程来处理读写操作。当一个线程在等待 I/O 操作时,无法执行其他任务。这会导致大量线程的创建和销毁,以及上下文切换,降低了系统性能。

NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务。这种模式通过使用选择器(Selector)来监控多个通道(Channel)上的 I/O 事件,实现了更高的性能和可伸缩性。

01、NIO 和传统 IO 在操作文件时的差异

JDK 1.4 中,java.nio.*包引入新的 Java I/O 库,其目的是提高速度。实际上,“旧”的 I/O 包已经使用 NIO重新实现过,即使我们不显式的使用 NIO 编程,也能从中受益

  • nio 翻译成 no-blocking io 或者 new io 都无所谓啦,都说得通~

在《Java 编程思想》读到“即使我们不显式的使用 NIO 编程,也能从中受益”的时候,我是挺在意的,所以:我们测试一下使用 NIO 复制文件和传统 IO 复制文件 的性能:

首先我用Python生成一个1GB和一个512MB的测试文件,测试代码我也一并奉上:

import os


def create_test_file(file_path, file_size_mb):
    """
    创建一个指定大小的测试文件。

    参数:
        file_path (str): 将要创建的文件的路径。
        file_size_mb (int): 文件大小,单位为兆字节。
    """
    # 1MB = 1024 * 1024 字节
    chunk_size = 1024 * 1024  # 1 MB
    num_chunks = file_size_mb

    print(f"开始生成文件: {file_path}")
    print(f"文件大小: {file_size_mb} MB")

    try:
        with open(file_path, 'wb') as f:
            # 创建一个填充了零的1MB字节块
            data_chunk = b'\x00' * chunk_size
            for i in range(num_chunks):
                f.write(data_chunk)
                # 每写入100MB,打印一次进度
                if (i + 1) % 100 == 0:
                    print(f"已写入 {i + 1} MB...")

        # 验证文件大小
        actual_size = os.path.getsize(file_path)
        expected_size = file_size_mb * chunk_size
        if actual_size == expected_size:
            print(f"文件生成成功!路径: {file_path}, 大小: {file_size_mb} MB")
        else:
            print(f"文件大小不匹配!预期: {expected_size} 字节, 实际: {actual_size} 字节")

    except IOError as e:
        print(f"生成文件时出错: {e}")


def main():
    # 提示用户输入文件路径
    file_path = input("请输入要生成的文件路径和名称 (例如: test.bin): ")

    # 提示用户输入文件大小,并处理输入
    while True:
        try:
            file_size_mb = int(input("请输入文件大小,单位为MB (例如: 1024): "))
            if file_size_mb > 0:
                break
            else:
                print("文件大小必须是正数。请重新输入。")
        except ValueError:
            print("输入无效,请输入一个整数。")

    create_test_file(file_path, file_size_mb)


if __name__ == "__main__":
    main()

然后在Java里编写测试代码:

package demo01;

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class SimpleFileTransferTest {

    // 使用传统的 I/O 方法传输文件
    private long transferFile(File source, File des) throws IOException {
        long startTime = System.currentTimeMillis();

        if (!des.exists())
            des.createNewFile();

        // 创建输入输出流
        BufferedInputStream bis = new BufferedInputStream(new FileInputStream(source));
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(des));

        // 使用数组传输数据
        byte[] bytes = new byte[1024 * 1024];
        int len;
        while ((len = bis.read(bytes)) != -1) {
            bos.write(bytes, 0, len);
        }

        long endTime = System.currentTimeMillis();
        return endTime - startTime;
    }

    // 使用 NIO 方法传输文件
    private long transferFileWithNIO(File source, File des) throws IOException {
        long startTime = System.currentTimeMillis();

        if (!des.exists())
            des.createNewFile();

        // 创建随机存取文件对象
        RandomAccessFile read = new RandomAccessFile(source, "rw");
        RandomAccessFile write = new RandomAccessFile(des, "rw");

        // 获取文件通道
        FileChannel readChannel = read.getChannel();
        FileChannel writeChannel = write.getChannel();

        // 创建并使用 ByteBuffer 传输数据
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 1024);
        while (readChannel.read(byteBuffer) > 0) {
            byteBuffer.flip();
            writeChannel.write(byteBuffer);
            byteBuffer.clear();
        }

        // 关闭文件通道
        writeChannel.close();
        readChannel.close();
        long endTime = System.currentTimeMillis();
        return endTime - startTime;
    }

    public static void main(String[] args) throws IOException {
        SimpleFileTransferTest simpleFileTransferTest = new SimpleFileTransferTest();
        File source = new File("test_500MB.bin");
        File des = new File("io.avi");
        File nio = new File("nio.avi");

        // 比较传统的 I/O 和 NIO 传输文件的时间
        long time = simpleFileTransferTest.transferFile(source, des);
        System.out.println(time + ":普通字节流时间");

        long timeNio = simpleFileTransferTest.transferFileWithNIO(source, nio);
        System.out.println(timeNio + ":NIO时间");
    }
}

先测试500MB的文件,看一下输出:

356:普通字节流时间
189:NIO时间

然后再试一下1GB的文件,直接看结果:

532:普通字节流时间
339:NIO时间

可以看到还是NIO比较快,那么如果把大小提高到10GB呢?试试看

5690:普通字节流时间
5670:NIO时间

可以看到文件越大,NIO的速度和传统IO的差距越小,但是事实真的是这样吗?我在代码中加一个测试方法:

// 使用 NIO 的 transferTo() 方法传输文件
private long transferFileWithNIO_ZeroCopy(File source, File des) throws IOException {
    long startTime = System.currentTimeMillis();

    if (!des.exists()) {
        des.createNewFile();
    }

    try (
        FileInputStream fis = new FileInputStream(source);
        FileOutputStream fos = new FileOutputStream(des);
        FileChannel readChannel = fis.getChannel();
        FileChannel writeChannel = fos.getChannel()
    ) {
        // 使用 transferTo() 方法进行零拷贝传输
        readChannel.transferTo(0, readChannel.size(), writeChannel);
    }

    long endTime = System.currentTimeMillis();
    return endTime - startTime;
}

public static void main(String[] args) throws IOException {
    SimpleFileTransferTest simpleFileTransferTest = new SimpleFileTransferTest();
    File source = new File("test_10Gb.bin");
    File des = new File("io.avi");
    File nio = new File("nio.avi");
    File nioZeroCopy = new File("nio_zero_copy.avi");

    // 比较传统的 I/O 和 NIO 传输文件的时间
    long time = simpleFileTransferTest.transferFile(source, des);
    System.out.println(time + ":普通字节流时间");

    long timeNio = simpleFileTransferTest.transferFileWithNIO(source, nio);
    System.out.println(timeNio + ":NIO时间 (使用 ByteBuffer)");

    long timeNioZeroCopy = simpleFileTransferTest.transferFileWithNIO_ZeroCopy(source, nioZeroCopy);
    System.out.println(timeNioZeroCopy + ":NIO时间 (使用 transferTo 零拷贝)");
}

来看结果:

5640:普通字节流时间
5627:NIO时间 (使用 ByteBuffer)
23:NIO时间 (使用 transferTo 零拷贝)

看到了吗,速度完全碾压。零拷贝我在这简单提一下,我不是科班,基础比不上大伙,如果有误可在评论区指出:

零拷贝(Zero-Copy)是一种计算机操作技术,顾名思义,就是在数据传输过程中,避免不必要的 CPU 拷贝操作


简单理解:零拷贝是啥?

假设你正在将一个文件从磁盘读出来,然后通过网络发送出去。在传统方式下,这个过程需要多次数据复制:

  1. 第一次拷贝:数据从磁盘复制到内核缓冲区(操作系统管理的内存)。
  2. 第二次拷贝:数据从内核缓冲区复制到用户缓冲区(Java 程序使用的内存)。
  3. 第三次拷贝:数据从用户缓冲区复制回内核缓冲区,准备发送。
  4. 第四次拷贝:数据从内核缓冲区复制到网络设备

你看,仅仅是发送一个文件,数据就需要被复制四次,其中两次是在内核空间和用户空间之间进行,这两次是最消耗 CPU 资源和时间的。

零拷贝技术就是为了消除这些不必要的复制。

原理:零拷贝怎么做到的?

零拷贝的核心原理是让数据直接在内核空间完成传输,跳过用户空间。最常见的实现方式是使用操作系统提供的特定系统调用,比如 Linux 的 sendfile() 或 Java NIO 的 FileChannel.transferTo()

transferTo() 为例,它的工作流程是这样的:

  1. 数据从磁盘读到内核缓冲区:这是不可避免的,因为数据必须从磁盘加载到内存。
  2. 直接传输到网络设备:数据直接从内核缓冲区发送到网络设备,中间不需要再复制到用户缓冲区。

通过这种方式,数据只被复制了两次(从磁盘到内核缓冲区,再从内核缓冲区到网络设备),而不是四次,从而大大减少了 CPU 的负担和数据传输的延迟。

总的来说,零拷贝就是利用操作系统底层的优化,让数据在内核空间内部进行直接传输,避免了在用户空间内核空间之间来回切换和复制数据的开销,从而大幅提升了文件或数据传输的性能。

我是Linux系统,我感觉底层可能就是用到了sendfile(),而能不能实现零拷贝,还需要看操作系统的底层是不是支持,现代化的Linux/Unix系统肯定是支持的。

而 NIO 的魅力主要体现在网络中

NIO(New I/O)的设计目标是解决传统 I/O(BIO,Blocking I/O)在处理大量并发连接时的性能瓶颈。传统 I/O 在网络通信中主要使用阻塞式 I/O,为每个连接分配一个线程。当连接数量增加时,系统性能将受到严重影响,线程资源成为关键瓶颈。而 NIO 提供了非阻塞 I/O 和 I/O 多路复用,可以在单个线程中处理多个并发连接,从而在网络传输中显著提高性能。

以下是 NIO 在网络传输中优于传统 I/O 的原因:

①、NIO 支持非阻塞 I/O,这意味着在执行 I/O 操作时,线程不会被阻塞。这使得在网络传输中可以有效地管理大量并发连接(数千甚至数百万)。而在操作文件时,这个优势没有那么明显,因为文件读写通常不涉及大量并发操作。

②、NIO 支持 I/O 多路复用,这意味着一个线程可以同时监视多个通道(如套接字),并在 I/O 事件(如可读、可写)准备好时处理它们。这大大提高了网络传输中的性能,因为单个线程可以高效地管理多个并发连接。操作文件时这个优势也无法提现出来。

③、NIO 提供了 ByteBuffer 类,可以高效地管理缓冲区。这在网络传输中很重要,因为数据通常是以字节流的形式传输。操作文件的时候,虽然也有缓冲区,但优势仍然不够明显。

02、NIO 和传统 IO 在网络传输中的差异

来看服务器端代码的差别。

IO,用的套接字,代码比较简单,之前学过,应该都能看得懂,用 while 循环监听客户端 Socket:

package demo02;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class IOServer {
    // 使用线程池来处理客户端请求,实现并发
    private static final ExecutorService THREAD_POOL = Executors.newFixedThreadPool(200);

    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("传统 IO 服务器已启动,监听端口 8080...");

            while (true) {
                // 接受新的客户端连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("接受了新的传统 IO 连接:" + clientSocket.getInetAddress());

                // 将客户端请求提交到线程池中处理
                THREAD_POOL.execute(() -> {
                    try (
                        InputStream in = clientSocket.getInputStream();
                        OutputStream out = clientSocket.getOutputStream()
                    ) {
                        byte[] buffer = new byte[1024];
                        int bytesRead = in.read(buffer);
                        if (bytesRead != -1) {
                            // 将接收到的数据回写给客户端
                            out.write(buffer, 0, bytesRead);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    } finally {
                        try {
                            clientSocket.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                });
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            THREAD_POOL.shutdown();
        }
    }
}

NIO,这部分我加上注释,主要用到的是 ServerSocketChannel 和 Selector:

package demo02;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public class NIOServer {
    public static void main(String[] args) {
        try {
            // 创建 ServerSocketChannel
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            // 绑定端口
            serverSocketChannel.bind(new InetSocketAddress(8081));
            // 设置为非阻塞模式
            serverSocketChannel.configureBlocking(false);

            // 创建 Selector
            Selector selector = Selector.open();
            // 将 ServerSocketChannel 注册到 Selector,关注 OP_ACCEPT 事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("NIO 服务器已启动,监听端口 8081...");

            // 无限循环,处理事件
            while (true) {
                // 阻塞直到有事件发生
                selector.select();
                // 获取发生事件的 SelectionKey
                Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    // 处理完后,从 selectedKeys 集合中移除
                    iterator.remove();

                    // 判断事件类型
                    if (key.isAcceptable()) {
                        ServerSocketChannel server = (ServerSocketChannel) key.channel();
                        SocketChannel client = server.accept();
                        client.configureBlocking(false);
                        client.register(selector, SelectionKey.OP_READ);
                        System.out.println("接受了新的 NIO 连接:" + client.getRemoteAddress());
                    } else if (key.isReadable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        ByteBuffer buffer = ByteBuffer.allocate(1024);
                        client.read(buffer);
                        // 翻转 ByteBuffer,准备读取
                        buffer.flip();
                        client.write(buffer);
                        // 关闭连接
                        client.close();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上面的代码创建了一个基于 Java NIO 的简单 TCP 服务器。它使用 ServerSocketChannel 和 Selector实现了非阻塞 I/O 和 I/O 多路复用。服务器循环监听事件,当有新的连接请求时,接受连接并将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件。当有数据可读时,从 SocketChannel 中读取数据并写入 ByteBuffer,然后将数据从 ByteBuffer 写回到 SocketChannel。

这里简单说一下 Socket 和 ServerSocket,以及 ServerSocketChannel 和 SocketChannel。

Socket 和 ServerSocket 是传统的阻塞式 I/O 编程方式,用于建立和管理 TCP 连接。

  • Socket:表示客户端套接字,负责与服务器端建立连接并进行数据的读写。
  • ServerSocket:表示服务器端套接字,负责监听客户端连接请求。当有新的连接请求时,ServerSocket 会创建一个新的 Socket 实例,用于与客户端进行通信。

在传统阻塞式 I/O 编程中,每个连接都需要一个单独的线程进行处理,这导致了在高并发场景下的性能问题。在接下来的客户端测试用例中会看到。

为了解决传统阻塞式 I/O 的性能问题,Java NIO 引入了 ServerSocketChannel 和 SocketChannel。它们是非阻塞 I/O,可以在单个线程中处理多个连接。

  • ServerSocketChannel:类似于 ServerSocket,表示服务器端套接字通道。它负责监听客户端连接请求,并可以设置为非阻塞模式,这意味着在等待客户端连接请求时不会阻塞线程。
  • SocketChannel:类似于 Socket,表示客户端套接字通道。它负责与服务器端建立连接并进行数据的读写。SocketChannel 也可以设置为非阻塞模式,在读写数据时不会阻塞线程。

再来简单说一下 Selector,后面会再细讲。

Selector 是 Java NIO 中的一个关键组件,用于实现 I/O 多路复用。它允许在单个线程中同时监控多个 ServerSocketChannel 和 SocketChannel,并通过 SelectionKey 标识关注的事件。当某个事件发生时,Selector 会将对应的 SelectionKey 添加到已选择的键集合中。通过使用 Selector,可以在单个线程中同时处理多个连接,从而有效地提高 I/O 操作的性能,特别是在高并发场景下。

客户端测试用例:

package demo02;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class TestClient {

    private static final String HOST = "localhost";
    private static final int IO_PORT = 8080;
    private static final int NIO_PORT = 8081;
    private static final String HELLO_MESSAGE = "Hello, Hanserwei!";

    public static void main(String[] args) throws InterruptedException, IOException {
        // 为了方便测试,将客户端数量设置为 10000,如果你需要,可以增加
        int clientCount = 10000;

        // ------------------------- 传统 IO 测试 -------------------------
        System.out.println("开始测试传统 IO...");
        // 传统 IO 客户端使用线程池模拟,每个线程处理一个客户端
        ExecutorService ioExecutorService = Executors.newFixedThreadPool(100);
        long startTimeIO = System.nanoTime();

        for (int i = 0; i < clientCount; i++) {
            ioExecutorService.execute(() -> {
                try (Socket socket = new Socket(HOST, IO_PORT)) {
                    OutputStream out = socket.getOutputStream();
                    InputStream in = socket.getInputStream();
                    out.write(HELLO_MESSAGE.getBytes());
                    byte[] buffer = new byte[1024];
                    in.read(buffer);
                } catch (IOException e) {
                    // 忽略异常,以便快速完成测试
                }
            });
        }
        ioExecutorService.shutdown();
        ioExecutorService.awaitTermination(30, TimeUnit.SECONDS);

        long endTimeIO = System.nanoTime();
        System.out.println("传统 IO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTimeIO - startTimeIO) / 1_000_000 + "ms");
        System.out.println("---------------------------------");


        // ------------------------- NIO 测试 -------------------------
        System.out.println("开始测试 NIO...");
        long startTimeNIO = System.nanoTime();

        // NIO 客户端使用 Selector,在一个线程中管理所有连接
        Selector selector = Selector.open();
        for (int i = 0; i < clientCount; i++) {
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.configureBlocking(false);
            socketChannel.connect(new InetSocketAddress(HOST, NIO_PORT));
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
        }

        int completedClients = 0;
        ByteBuffer buffer = ByteBuffer.wrap(HELLO_MESSAGE.getBytes());

        while (completedClients < clientCount) {
            selector.select();
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();

                SocketChannel client = (SocketChannel) key.channel();

                if (key.isConnectable()) {
                    if (client.isConnectionPending()) {
                        client.finishConnect();
                    }
                    client.register(selector, SelectionKey.OP_WRITE, buffer.duplicate());
                } else if (key.isWritable()) {
                    ByteBuffer writeBuffer = (ByteBuffer) key.attachment();
                    client.write(writeBuffer);
                    if (!writeBuffer.hasRemaining()) {
                        client.register(selector, SelectionKey.OP_READ);
                    }
                } else if (key.isReadable()) {
                    client.read(ByteBuffer.allocate(1024));
                    client.close();
                    completedClients++;
                }
            }
        }

        long endTimeNIO = System.nanoTime();
        System.out.println("NIO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTimeNIO - startTimeNIO) / 1_000_000 + "ms");
    }
}

输出:

开始测试传统 IO...
传统 IO 服务器处理 10000 个客户端耗时: 1071ms
---------------------------------
开始测试 NIO...
NIO 服务器处理 10000 个客户端耗时: 304ms

测试结果看起来NIO速度比传统IO快,但是我多次运行ClienTest,每次结果到不确定,我觉得这一块不能这样模拟测试。但是道理是这么个道理,后面我找到了更合适的测试方法我会修改这一部分的代码。

03、小结

本篇内容主要讲了 NIO(New IO)和传统 IO 之间的差异,包括 IO 模型、操作文件、网络传输等方面。

  • 传统 I/O 采用阻塞式模型,线程在 I/O 操作期间无法执行其他任务。NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务,通过选择器(Selector)监控多个通道(Channel)上的 I/O 事件,提高性能和可伸缩性。
  • 传统 I/O 使用基于字节流或字符流的类(如 FileInputStream、BufferedReader 等)进行文件读写。NIO 使用通道(Channel)和缓冲区(Buffer)进行文件操作,NIO 在性能上的优势并不大。
  • 传统 I/O 使用 Socket 和 ServerSocket 进行网络传输,存在阻塞问题。NIO 提供了 SocketChannel 和 ServerSocketChannel,支持非阻塞网络传输,提高了并发处理能力。