Skip to content

Java NIO - 04 IO模型

本文介绍IO模型。

1. 从操作系统说起:内核态与用户态

操作系统为了保证系统的稳定性和安全性,将运行状态划分为内核态(Kernel Mode)和用户态(User Mode),也称为系统态(System Mode)和用户态。 这两种状态在权限级别和可以执行的操作上有着根本的区别。

1.1 内核态 (Kernel Mode)

内核态,也称为系统态或特权态,是操作系统运行的最高权限级别。当处理器处于内核态时,代码可以执行任何指令集中的任何指令,并可以直接访问计算机的所有硬件资源,包括内存、I/O 设备等等。

核心特点

  • 最高权限: 在内核态下运行的代码拥有不受限制的访问权限。
  • 直接访问硬件: 可以直接控制和操作硬件设备。
  • 运行关键系统代码: 操作系统的内核、设备驱动程序以及一些关键的系统服务通常运行在内核态。
  • 稳定性至关重要: 由于内核态代码的错误可能导致整个系统崩溃,因此内核代码的稳定性和可靠性至关重要。

运行在内核态的典型程序

  • 操作系统内核 (Operating System Kernel): 这是操作系统的核心部分,负责进程管理、内存管理、文件系统、设备驱动等核心功能。
  • 设备驱动程序 (Device Drivers): 用于控制和管理硬件设备的程序,必须直接与硬件交互,因此通常运行在内核态。
  • 某些系统服务 (System Services): 一些需要直接访问硬件或执行特权操作的系统服务,例如进程调度、内存管理服务等。

可以形象地比喻为: 内核态就像一个国家的中央政府,拥有最高的权力,可以管理和控制国家的任何资源(硬件),负责国家(系统)的正常运转。

1.2 什么是用户态 (User Mode)

用户态,也称为普通态或非特权态,是操作系统为普通应用程序提供的运行状态。当处理器处于用户态时,代码的执行权限受到限制,不能直接访问某些硬件资源,也不能执行某些特权指令。用户态程序必须通过系统调用(System Call)来请求操作系统内核提供服务,才能间接地访问硬件资源或执行特权操作。

核心特点

  • 权限受限: 在用户态下运行的代码受到操作系统设定的权限限制。
  • 间接访问硬件: 不能直接访问硬件,必须通过系统调用请求内核服务才能访问。
  • 运行用户应用程序: 我们平时使用的各种应用程序,例如浏览器、文字处理器、游戏等,通常运行在用户态。
  • 安全性更高: 用户态程序的错误通常不会导致整个系统崩溃,因为其权限受限,不会直接影响到系统核心。

运行在用户态的典型程序

  • 用户应用程序 (User Applications): 例如,网页浏览器、办公软件、游戏、音视频播放器等等,我们日常使用的绝大多数应用程序都运行在用户态。

可以形象地比喻为: 用户态就像国家的普通公民,受到法律(操作系统)的约束,不能随意访问和使用国家的资源(硬件),需要通过合法的途径(系统调用)向政府(内核)申请才能获得需要的服务。

1.3 内核态和用户态的切换

操作系统在内核态和用户态之间切换通常发生在以下几种情况:

  • 系统调用 (System Call): 用户态程序需要请求操作系统内核提供的服务(例如,读取文件、创建进程、网络通信等)时,会发起系统调用。系统调用是一种特殊的中断,会触发处理器从用户态切换到内核态,由内核代码处理系统调用请求,完成后再切换回用户态。

    例如: 当用户程序需要打开一个文件时,它会调用 open() 函数,这个函数最终会触发一个系统调用,将控制权交给内核,内核在内核态下执行文件打开操作,然后将结果返回给用户程序,控制权再回到用户态。

  • 中断 (Interrupt) 和异常 (Exception): 硬件设备发出的中断信号(例如,键盘输入、网卡接收到数据)或者程序执行过程中发生的异常(例如,除零错误、访问非法内存)都可能导致处理器从用户态切换到内核态,由内核中的中断处理程序或异常处理程序进行处理。处理完成后,通常会切换回用户态继续执行用户程序。

    例如: 当用户按下键盘上的一个键时,键盘控制器会向 CPU 发送一个中断信号,CPU 接收到中断信号后,会从用户态切换到内核态,执行键盘中断处理程序,读取键盘输入,并将输入信息传递给相应的应用程序。

  • 进程切换 (Process Switching): 当操作系统进行进程切换时,也可能涉及到内核态和用户态的切换。进程切换通常由操作系统内核中的调度器完成,调度器运行在内核态。

2. IO模型

IO模型有两组关键词:同步与异步、阻塞与非阻塞。我们需要理解这几组关键词来理解IO模型。

2.1 同步与异步、阻塞与非阻塞

在上面的内核态与用户态的介绍中,如果用户态程序请求操作系统内核提供的服务,我们把用户态程序称为调用方,把操作系统内核称为被调用方。

同步与异步:调用方是否主动获取调用结果,同步是主动获取结果,异步是被动接受结果(被调用者通知调用者)。

阻塞与非阻塞:是指调用方在等待结果的过程中,是否会暂停活动,阻塞是暂停活动,非阻塞是不暂停活动。

根据同步与异步、阻塞与非阻塞的组合,可以分为以下四种IO模型:

同步 (Synchronous)异步 (Asynchronous)
阻塞(Blocking)同步阻塞异步阻塞
非阻塞 (Non-blocking)同步非阻塞异步非阻塞

2.3 IO模型详解

以餐厅点餐为例子,小明去烧鹅坊餐厅吃饭,服务员小红接待。

2.3.1 同步阻塞

同步的意思是服务员小红态度很不好,烧鹅好了也不通知小明。小明点完餐后就眼巴巴地望着出餐口,一直等待不干其他事。当烧鹅做好后,小明就拿过烧鹅开始吃饭。

程序发起 I/O 请求后,线程会被阻塞,等待 I/O 操作完成并返回结果,才能继续执行。 程序 主动等待结果,并且等待期间 线程被挂起

例如,在Java中我们可以通过java.io.FileInputStream同步阻塞读取文件内容:

java
public static void main(String[] args) {
    try (
            FileInputStream fileInputStream = new FileInputStream("jwtdemo/src/main/resources/a.txt");
    ){
        byte[] buffer = new byte[1024];
        // 同步阻塞读取文件内容
        int len = fileInputStream.read(buffer);

        System.out.println(new String(buffer, 0, len));

    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

2.3.2 同步非阻塞

同步的意思是服务员小红态度很不好,烧鹅好了也不通知小明。小明点完餐后就去找位置坐下,玩会儿手机就去出餐口看看烧鹅好了吗,没好又去窗边看看风景,再去出餐口看看烧鹅好了吗,没好又去便利店买瓶水,再回来出餐口看看烧鹅好了吗。当小明去出餐口发现烧鹅做好后,就拿过烧鹅开始吃饭了。

程序发起 I/O 请求后,系统调用不会阻塞线程,会立即返回 (可能返回错误码表示操作未完成)。 程序需要 不断地轮询 (polling) 检查 I/O 操作是否完成。 程序 主动等待结果 (轮询),但是等待期间 线程不会被挂起,可以执行其他任务 (例如轮询检查其他文件描述符)。 selectpollepoll 等多路复用 I/O 技术就属于同步非阻塞 I/O 模型

例如,在Java中,我们可以通过线程池模拟同步非阻塞读取文件内容:

java
public static void main(String[] args) throws IOException, InterruptedException {
    // 创建共享缓冲区
    ByteBuffer sharedBuffer = ByteBuffer.allocate(1024);
    // 标志文件读取是否完成
    AtomicBoolean fileReadCompleted = new AtomicBoolean(false);

    // 创建线程池,用于执行文件读取任务
    ExecutorService executor = Executors.newSingleThreadExecutor();
    // 提交文件读取任务到线程池
    executor.submit(() -> {
        try (FileChannel fileChannel = FileChannel.open(
                Paths.get("jwtdemo/src/main/resources/a.txt"),
                StandardOpenOption.READ)) {
            fileChannel.read(sharedBuffer);
            fileReadCompleted.compareAndSet(false, true);
        } catch (IOException e) {
            e.printStackTrace();
        }
    });

    // 同步:不断轮询判断读取文件是否完成
    while (true) {
        if(!fileReadCompleted.get())
            continue;

        sharedBuffer.flip();
        while (sharedBuffer.hasRemaining()){
            System.out.print((char) sharedBuffer.get());
        }
        sharedBuffer.clear();

        break;
    }
}

与真正的异步非阻塞 I/O 的区别:

  • 实现方式不同:
    • 真正的异步非阻塞 I/O (例如 AIO, AsynchronousFileChannel): 依赖于操作系统内核的异步 I/O 支持,程序发起异步操作后,I/O 操作完全在内核态异步执行,内核在操作完成后主动通知程序。 程序无需自己创建线程,也无需进行轮询检查。
    • 线程池 + 原子变量模拟的 "非阻塞": 通过 手动创建线程池原子变量 来实现 "非阻塞" 效果。 文件 I/O 操作仍然是 阻塞的,只是阻塞发生在独立的线程中,主线程通过轮询检查原子变量来同步等待结果。 本质上仍然是同步阻塞模型,只是将阻塞操作转移到了后台线程,在主线程层面模拟了非阻塞的效果。
  • 性能和效率:
    • 真正的异步非阻塞 I/O: 通常具有 更高的性能和效率,因为完全依赖于操作系统内核的异步机制,可以更充分地利用操作系统资源,减少线程切换开销。
    • 线程池 + 原子变量模拟的 "非阻塞": 性能和效率相对较低,仍然存在线程创建、线程切换、线程同步的开销,并且轮询检查原子变量也会占用一定的 CPU 资源。

2.3.3 异步阻塞

异步的意思是服务员小红很称职,烧鹅好了后会通知小明。但是小明不放心,点完餐后还是望着出餐口,一直等待不干其他事。当烧鹅做好后,小红告诉小明,这是你的烧鹅,小明就拿过烧鹅去吃饭了。你说小明是不是很傻!

程序发起异步 I/O 请求后,线程会被阻塞,等待 操作系统内核完成 I/O 操作后,再唤醒线程并返回结果。 程序 被动等待结果 (等待内核通知),但是等待期间 线程被挂起

"异步" 的目的是为了不阻塞线程,但 "阻塞" 又导致线程被挂起,因此这种模型比较矛盾,实际应用价值不高。

2.3.4 异步非阻塞

异步的意思是服务员小红很称职,烧鹅好了后会通知小明。小明就去做其他事了,比如找位置,玩手机,看风景,买水,当烧鹅好了后,小红通知小明,小明起身去拿烧鹅吃饭。 有两种通知方式:

  • 在点餐时,小明告诉小红:“烧鹅好了之后打电话给我。”
  • 在点餐时,小明给了小红一包辣椒粉,并说:“烧鹅好了之后,帮我把辣椒粉撒上去,然后帮我送到桌上。”

程序发起异步 I/O 请求后,线程不会被阻塞,可以立即返回,继续执行其他任务操作系统内核会在后台完成 I/O 操作,并将结果 (例如通过信号、回调函数等) 异步通知给程序。 程序 被动等待结果 (等待内核通知),并且等待期间 线程不会被挂起Linux AIO (Asynchronous I/O) 就属于异步非阻塞 I/O 模型

这是 最理想的 I/O 模型

操作系统内核通过信号通知程序:

java
public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {

    AsynchronousFileChannel asynchronousFileChannel = AsynchronousFileChannel.open(
            Paths.get("src/main/resources/a.txt"),
            StandardOpenOption.READ
    );

    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 异步非阻塞读取,返回的 Future<Integer> 表示读取完成信号量
    Future<Integer> future = asynchronousFileChannel.read(byteBuffer, 0);

    // 线程可以做其他事
    doSomething();

    while (true){
        // future.isDone()返回读取事件是否完成
        if(!future.isDone())
            continue;

        // future.get() 表示读取的数据量
        System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 读取文件完成,总共读取" + future.get() + "字节数据,结果如下:");

        byteBuffer.flip();
        while (byteBuffer.hasRemaining()){
            System.out.print((char)byteBuffer.get());
        }
        byteBuffer.clear();

        break;
    }

}

private static void doSomething() throws InterruptedException {
    System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 开始做其他事...");
    Thread.sleep(1000);
    System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 结束做其他事...");
}

结果如下:

txt
main 15:17:19.754 开始做其他事...
main 15:17:20.761 结束做其他事...
main 15:17:20.761 读取文件完成,总共读取3字节数据,结果如下:
xyz

操作系统通过回调函数通知程序:

java
public static void main(String[] args) throws IOException, InterruptedException {
    AsynchronousFileChannel asynchronousFileChannel = AsynchronousFileChannel.open(
            Paths.get("src/main/resources/a.txt"),
            StandardOpenOption.READ
    );

    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 通过回调的方式异步非阻塞IO
    asynchronousFileChannel.read(byteBuffer, 0, byteBuffer, new CompletionHandler<Integer, ByteBuffer>(){
        @Override
        public void completed(Integer result, ByteBuffer attachment) {
            System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 读取文件完成,总共读取" + result + "字节数据,结果如下:");

            attachment.flip();
            while (attachment.hasRemaining()){
                System.out.print((char)attachment.get());
            }
            System.out.println();  // 换行
            attachment.clear();
        }

        @Override
        public void failed(Throwable exc, ByteBuffer attachment) {
            System.out.println(LocalTime.now() + " 读取文件失败,异常信息:" + exc.getMessage());
        }
    });

    // 做其他事
    doSomething();
}

private static void doSomething() throws InterruptedException {
    System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 开始做其他事...");
    Thread.sleep(1000);
    System.out.println(Thread.currentThread().getName() + " " +LocalTime.now() + " 结束做其他事...");
}

结果如下:

txt
Thread-8 15:19:10.163 读取文件完成,总共读取3字节数据,结果如下:
main 15:19:10.163 开始做其他事...
xyz
main 15:19:11.178 结束做其他事...

可以看到回调函数是在另一个线程调用的。注意,由于回调函数是在守护线程中调用的,如果主线程结束了,则回调线程也会结束,有可能造成未打印文件内容,我们可以阻塞主线程解决该问题。

2.3.5 多路复用

多路复用是基于同步非阻塞的。

我们仍然以小明点餐为例子,加入小明到烧鹅坊点餐,由于是非阻塞的,所以小明可以继续做其他事,此时他可以去奶茶店点杯奶茶,仍然是同步非阻塞的,然后去甜品店点份甜品,仍然是同步非阻塞的。之后,他就在手机上关注三家店的状态,当某一家店出餐后,会更新手机上的状态,小明看到后就去该店取餐。

在Java中,可以使用NIO中的Selector实现多路复用:

java
public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();

    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(8080));
    serverSocketChannel.configureBlocking(false);

    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    while (true){
        // 阻塞等待事件就绪
        selector.select();

        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
        while (iterator.hasNext()){
            SelectionKey selectionKey = iterator.next();

            if(selectionKey.isAcceptable()){
                ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();

                SocketChannel socketChannel = channel.accept();
                socketChannel.configureBlocking(false);

                socketChannel.register(selector, SelectionKey.OP_READ);
            }
            if(selectionKey.isReadable()){
                SocketChannel channel = (SocketChannel) selectionKey.channel();

                ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                int len = channel.read(byteBuffer);
                if(len == -1){
                    channel.close();
                    selectionKey.cancel();
                }else if(len > 0){
                    byteBuffer.flip();
                    while (byteBuffer.hasRemaining()){
                        System.out.print((char) byteBuffer.get());
                    }
                    byteBuffer.clear();
                }
            }

            iterator.remove();
        }
    }
}

这里有个难以理解的点:是阻塞等待事件发生的,即是内核通知应用程序某个事件发生了,就是异步的,那为什么说Selector是同步非阻塞的?

  • 为什么是同步的?虽然是内核通知应用程序某个事件发生了,但是只是数据就绪标志,而不是数据本身。同步是指应用程序主动获取结果,这里的结果是数据,后续应用程序需要通过管道Channel主动获取数据本身,所以说是同步的。
  • 为什么是非阻塞的?虽然Selector.select()是阻塞的,但这是为了高效地等待多个事件就绪,而不是阻塞在某一个IO事件上,即非阻塞体现在Selector可以监控多个事件是否就绪(对应这多个管道),并且每个管道(ServerSocketChannel和SocketChannel)是非阻塞的。

3. 零拷贝

3.1 传统方法

首先我们先看一个典型的数据传输案例,首先从文件中读取内容,并将文件内容通过TCP传输出去:

java
File file = new File("src/main/resources/data.txt");
FileInputStream fileInputStream = new FileInputStream(file);

// 读取文件内容
byte[] buffer = new byte[1024];
int len = fileInputStream.read(buffer);

// 发送文件内容
Socket socket = new Socket("127.0.0.1",8080);
socket.getOutputStream().write(buffer,0,len);

在上面的例子中,整个过程如下:

image-20250212212616946

  1. 首先因为调用了read()方法,所以要从用户态切换到内核态,调用操作系统的读能力,将数据从磁盘读入内核缓冲区。这期间用户线程(Java程序线程阻塞),操作系统使用DMA(Direct Memory Access)来实现文件读,期间不会使用CPU。
  2. 然后在内核态下,操作系统将内核缓冲区中的数据复制到用户缓冲区(即byte[] buffer数组),这期间CPU会参与拷贝,无法利用DMA。
  3. 然后从内核态切换到用户态,继续执行Java代码;
  4. Java代码调用了write()写方法,所以又从用户态切换到内核态,调用操作系统的写能力,首先将数据从用户缓冲区复制到socket缓冲区,这期间CPU会参与拷贝,无法使用DMA。
  5. 然后使用DMA将socket缓冲区中的数据写入网卡,通过网络发送出去。
  6. 最后从内核态切换到用户态,继续执行Java代码。

可以看到,在整个过程中,存在四次数据复制,四次内核态与用户态切换。

3.2 transferTo()或transferFrom()

我们可以使用NIO中Channel提供的transferTo()transferFrom()方法传输数据:

java
try(
        FileChannel fileChannel = FileChannel.open(Path.of("src/main/resources/b.txt"), StandardOpenOption.READ);
        SocketChannel socketChannel = SocketChannel.open();
    ){

    socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
    fileChannel.transferTo(0, fileChannel.size(), socketChannel);

}catch (Exception e){
    e.printStackTrace();
}

在上面的例子中,过程如下:

image-20250212213052158

  1. Java程序调用transferTo(),从用户态切换到内核态;
  2. 在内核态下,首先利用DMA将数据从磁盘拷贝到内核缓冲区中,此过程不需要CPU参与;
  3. 然后将数据从内核缓冲区拷贝到socket缓冲区,此过程需要CPU参与;
  4. 然后将数据从socket缓冲区写入网卡,并最终发送出去;
  5. 最后从内核态切换到用户态;

可以看到,在整个过程中,存在着三次数据复制,两次内核态与用户态切换。相较于传统方法有了效率上的提升。

3.3 进一步提升

从Linux 2.4及以后,有了进一步提升,流程如下:

image-20250212213625545

  1. Java程序调用transferTo(),从用户态切换到内核态;
  2. 在内核态下,首先利用DMA将数据从磁盘拷贝到内核缓冲区中,此过程不需要CPU参与;
  3. 然后将一些文件描述信息从内核缓冲区复制到socket缓冲区,由于文件描述信息很少,所以此过程基本不耗费时间;
  4. 然后从内核缓冲区中的数据写入网卡发送出去;
  5. 最后从内核态切换回用户态;

可以看到,Linux 2.4的改进是的CPU拷贝数据彻底消失了。

3.4 mmap

mmap 是一种内存映射文件的方法,允许程序将文件或设备直接映射到内存中,从而通过内存地址访问文件内容,避免了频繁的读写操作。在Java中,我们可以通过FileChannel.map()方法实现:

java
public static void main(String[] args) throws IOException {
    // 打开文件并获取 FileChannel
    RandomAccessFile file = new RandomAccessFile("src/main/resources/b.txt", "rw");
    FileChannel channel = file.getChannel();

    // 获取 MappedByteBuffer,即文件内存映射
    MappedByteBuffer byteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());

    // 此时MappedByteBuffer是可读状态,直接读取
    while (byteBuffer.hasRemaining()){
        System.out.print((char) byteBuffer.get());
    }

    // 向MappedByteBuffer随机写入字符,会反映到文件中
    byteBuffer.clear();
    for (int i = 0; i < byteBuffer.capacity(); i++)
    {
        int nextInt = new Random().nextInt('a', 'z');
        byteBuffer.put((byte) nextInt);
    }

    // 关闭资源
    channel.close();
    file.close();
}

上面的例子演示了MappedByteBuffer的使用,这是文件在Java应用程序内存空间的映射,我们直接读取MappedByteBuffer就可以获取文件内容,直接向MappedByteBuffer写入内容就可以向文件写入。

如果我们使用mmap技术将文件的内容通过网络传输出去,可以如下实现:

java
public static void main(String[] args) {
    try (
            FileChannel fileChannel = new RandomAccessFile("src/main/resources/b.txt", "rw").getChannel();
            SocketChannel socketChannel = SocketChannel.open();
            ){

        MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());

        socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));

        socketChannel.write(mappedByteBuffer);

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

相较于传统的方法,mmap省略了一次数据拷贝:从内核缓冲区拷贝到用户缓冲区。

image-20250213185241622

既然有了transferTo()transferFrom()方法,mmap还有什么优势呢?

有,mmap允许我们修改数据!现有一个需求,有一个文件,需要将里面的字母转换为大写字母,然后通过网络传输出去,此时使用mmap就可以实现:

java
public static void main(String[] args) {
    try (
            FileChannel fileChannel = new RandomAccessFile("src/main/resources/b.txt", "rw").getChannel();
            SocketChannel socketChannel = SocketChannel.open();
            ){

        MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());

     		// 小写字母转换为大写字母
        for (int i = 0; i < mappedByteBuffer.capacity(); i++) {
            if('a' <= mappedByteBuffer.get(i) && mappedByteBuffer.get(i) <= 'z'){
                mappedByteBuffer.put(i, (byte) (mappedByteBuffer.get(i) - 32));
            }
        }

        socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));

        socketChannel.write(mappedByteBuffer);

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

image-20250213185812101

参考资料

[1] https://www.bilibili.com/video/BV1py4y1E7oA

[2] Gemini、deepseek

[3] https://shawn-xu.medium.com/its-all-about-buffers-zero-copy-mmap-and-java-nio-50f2a1bfc05c

[4] https://developer.ibm.com/articles/j-zerocopy/