本文开始我们正式开启java.nio模块的讲解。

这个模块是Java1.4引入了,就是为了解决BIO的各种限制。

NIO是所有现代Java高性能框架(Netty, Kafka, Dubbo底层)的基础。

他的模式跟BIO完全不同,主要依赖三个核心组件:Buffer (缓冲区)、Channel (通道)、Selector (选择器)。

本文先把NIO的基础Buffer和Channel搞清楚,Selector和非阻塞网络编程我们放到下一篇中讲。

一、背景

JDK1.0到1.3提供的java.io让阻塞流、线程/连接模型写起来很直观。

但到了2000年左右,互联网进入高速发展期,这套模型在高并发Web服务器面前就不够用了。

线程数爆炸、上下文切换严重、二进制处理和字符集转换也不够高效。

与此同时,操作系统层面早就有了select/poll这类多路复用接口,C/C++程序可以玩各种高性能网络架构,在加上微软也发布了.NET战略,Java没有动作,就没办法立足于后端服务器开发了。

在这样的背景下,Sun发起了JSR 51,目标就是把操作系统已有的高性能I/O能力搬到Java层。

提供缓冲区模型、非阻塞的Channel、多路复用的Selector、可插拔的字符集转换器、正则和printf式格式化、以及更强大的文件系统接口,让Java在服务器和IO密集型场景不输在起跑线上。

所以说技术演进总是有他的目的的。

二、缓冲区

2.1 BIO于NIO缓冲区区别

之前我们在讲BufferedInputStream的时候,就知道有缓冲区的概念。

那为什么BIO已经有了缓冲区的设计,还要在NIO里面再搞一套复杂的Buffer体系。

在BufferedInputStream里,我们只需要new BufferedInputStream(fis),然后调用read()。

他的内部其实有一个byte[] buf,默认是8KB。

image-20251119160134648

对于我们开发者来说,我们最多只能通过构造指定缓冲区的大小。

而对于什么时候从磁盘读数据填充缓冲区?什么时候清空缓冲区?全都是BufferedInputStream内部自动管理。

所以BufferedInputStream中的缓冲区对于我们来说基本上就是个黑盒,我们控制不了他。

而NIO中的缓冲区,ByteBuffer就是把所有的控制权都暴露给了我们。

我们要通过allocate自己申请空间。

通过channel.read(buffer)决定什么时候往里面写。

通过flip()决定什么时候切换模式。

通过channel.write(buffer)决定什么时候读出来。

这些操作其实就是把传统IO在JVM内部做的事情暴露给了我们,让我们有了更多的控制权。

这样我们就能实现比如零拷贝、堆外内存等性能调优的操作。

对于我们来说,NIO中的缓冲区就是一个白盒,我们可以根据需要操作。

2.2 NIO缓冲区核心指针操作

ByteBuffer中有四个核心的指针来手动管理数据。

image-20251119160152722

其中capacity就是容量,也就是缓冲区能够容纳的数据元素的最大数量。

limit是界限,缓冲区中无法读写的第一个元素的索引。是有效数据的终点。

在写模式下,limit通常等于capacity,表示我们可以一直写满整个缓冲区。

在读模式下,limit等于之前写入了多少数据,表示我们最多只能读到这里,后面是无效数据。

position是位置,有点像当前的游标,表示下一个要读取或写入的元素的索引。每读/写一个数据,position自动 +1。

mark是一个标记,看一当成一个备忘录,我们调用mark()方法的时候,会记录当前position。当调用reset()方法 时,position回滚到mark的位置。默认是-1。

下面我们通过一个案例和流程图具体的看下这几个指针是怎么运作的。

初始化

当我们调用ByteBuffer.allocate(8)或者clear()之后,Buffer处于写模式。

总长度是8,也就是capacity。

在写模式下,limit表示最多能写到哪里,所以一开始就等于capacity,值为8。

position是下一个要写入数据的位置,从起点开始,也就是0。

mark初始化时是-1,表示未标记状态。

image-20251119160202775

写入三个字节后

我们连续写入3个字节(比如A,B,C)。

position变成了3,每写入一个字节,position就向后移动一位。他现在指向下一个要写入的位置。

limit、capacity、mark都保持不变。

在这里插入图片描述

切换到读模式

当我们想读取刚刚写入的数据时,就必须调用flip()方法。这是一个切换操作。

flip()方法会做三件事。

image-20251119160351852

第一,把可读的终点设置成之前写入的数据末尾。也就是limit=position。

第二,把读位置重置到起点,position=0。

第三、mark标记变成-1(我们初始化时其实就是-1)

image-20251119160315698

读取两个字节后

我们连续的读取两个字节。

这个时候,position变成了2。每读取一个字节,position就向后移动一位。他指向下一个要读取的位置。

limit还是3,没有变,表示最多只能读到索引为2的位置。

mark和capacity也保持不变。

在这里插入图片描述

标记和重置

假设在position为1的时候,我们调用mark()。然后我们又读取了一个字节,position变成了2。

mark变成了1,他记录下了当时position的值。

这个时候调用reset()方法,会把position恢复到最后一次mark()的位置。

也就是position被重置回了标记点。

在这里插入图片描述

重读

rewind()操作有点类似于flip(),但是他不改变limit,他只做两件事。

v2-1f31309fd6a353e31433e7d98e435eec~resize_1440_q75

第一、position置0,也就是回到起点。

第二、mark变成-1,丢掉标记。

经常用来重新读取Buffer里的数据。

压缩并继续写

假设我们已经读取了2个字节(A, B),现在想把剩余的未读数据©移动到Buffer开头,然后接着写入新数据。

compact()会做三件事。

第一、移动数据,把未读数据(从position到limit-1) 复制到Buffer的起始位置。

第二、position=未读数据长度,设置下一个写位置在未读数据之后。

第三、limit=capacity,恢复为写模式,limit回到最大容量。

image-20251119160605984

2.3 NIO缓冲区状态机转换

Mermaid (26)

三、通道

3.1 BIO和NIO的数据传输

上一章我们深入了解了NIO的缓冲区,他给我们提供了对内存数据的精细控制。

但是,数据从哪里来?到哪里去?这就引出了我们今天要讲的另一个核心概念,通道。

如果说Buffer是数据的容器,那么Channel就是数据的管道。

前者负责存储数据,后者负责传输数据。

在传统的BIO中,我们使用InputStream和OutputStream进行数据传输:

在这里插入图片描述

而在NIO中,Channel是双向的。

在这里插入图片描述

BIO可以直接把字节写入流中。

NIO不能直接向Channel写入字节,也不能直接从Channel读取字节。

NIO必须通过Buffer读写数据。

3.2 核心Channel类型

文件通道

FileChannel,用于文件读写,从FileInputStream/FileOutputStream中获取。

FileChannel channel = FileChannel.open(path, options);
FileChannel channel = new FileInputStream("file.txt").getChannel();
FileChannel channel = new RandomAccessFile("file.txt", "rw").getChannel();

channel.transferTo(position, count, targetChannel);  // 零拷贝传输
channel.map(MapMode.READ_WRITE, 0, capacity);        // 内存映射

网络通道

SocketChannel用于TCP客户端,对应的是BIO中的Socket。

ServerSocketChannel用于TCP服务端,对应BIO中的ServerSocket。

DatagramChannel用于UDP通信。

// 客户端模式
SocketChannel client = SocketChannel.open();
client.connect(new InetSocketAddress("localhost", 8080));
client.configureBlocking(false);  // 非阻塞模式

// 服务端模式
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
SocketChannel clientChannel = server.accept();

注意:FileChannel永远是阻塞的。 操作系统对于普通文件的读写,很难做到真正的非阻塞。所以,我们不能把FileChannel注册到Selector(选择器)上。只有网络Channel(SocketChannel等)才能配置为非阻塞模式。

3.3 通道和缓冲区的协作

我们看看Channel和Buffer是怎么相互配合工作的。

下面是一个标准的读写流程:

在这里插入图片描述

四、案例

4.1 基础场景

下面我们用FileChannel和ByteBuffer来实现一个文件复制工具。感受一下NIO中的文件操作。

package com.lazy.snail.day62;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * @ClassName NIODemo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/19 13:53
 * @Version 1.0
 */
public class NIODemo2 {
    public static void main(String[] args) {
        String source = "C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛.txt";
        String dest = "C:\\Users\\lazysnail\\Desktop\\lazysnail.txt";

        copyFile(source, dest);
    }

    public static void copyFile(String source, String dest) {
        long start = System.currentTimeMillis();

        try (FileInputStream fis = new FileInputStream(source);
             FileChannel inChannel = fis.getChannel();
             FileOutputStream fos = new FileOutputStream(dest);
             FileChannel outChannel = fos.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(8192);
            while (inChannel.read(buffer) != -1) {
                buffer.flip();
                outChannel.write(buffer);
                buffer.clear();
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("NIO 复制耗时:" + (end - start) + "ms");
    }
}

示例代码中我们从文件输入流和文件输出流获取了NIO通道。

然后通过allocate创建了一个8KB的缓冲区。

while循环中,从输入通道读取数据到buffer。

flip方法切换buffer为读模式,write方法将buffer数据写入到输出通道。

然后通过clear方法清空buffer,准备下一次的读取。

看完这个案例,是不是觉得跟之前的BIO没有太大的区别?

确实在这种简单的文件复制场景里,看不出NIO太大的优势。但是如果我们拷贝很大的文件,从耗时上还是能感受到二者的差异。

4.2 使用直接缓冲区

如果想使用直接缓冲区,只需要在创建缓冲区的时候,将allocate修改成allocateDirect。

ByteBuffer buffer = ByteBuffer.allocateDirect(8192);

allocate会在JVM的堆上分配内存。

allocateDirect则是在操作系统本地内存上分配。

我们看下两种方式,内存的分配情况:

在这里插入图片描述

对于allocate构建的Heap Buffer,当发生I/O(比如从Channel读数据)时,操作系统先把数据读到内核缓冲区。然后,JDK底层native代码会把数据从内核缓冲区拷贝到JVM堆内的byte[]。这一步相当于有个额外拷贝的过程。

而allocateDirect则不需要经过JVM的拷贝。

4.3 零拷贝

零拷贝有两种,一种是直接缓冲区 + 内存映射,这是Direct Buffer的一种特殊形态。

通过mmap机制,把用户空间的Direct Buffer直接映射到内核空间的页缓存。

数据不需要在内核和用户空间之间来回拷贝,他们共用同一块物理内存。

还有一种是使用transferTo,这种模式下数据根本不进入用户空间。

JVM只需要发个指令,然后剩下的活儿全都在内核干。

在这里插入图片描述

package com.lazy.snail.day62;

import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

/**
 * @ClassName NIODemo3
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/19 15:00
 * @Version 1.0
 */
public class NIODemo3 {
    public static void main(String[] args) {
        copyFileZeroCopy("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛工坊.txt", "C:\\Users\\lazysnail\\Desktop\\lazysnailstudio.txt");
    }

    public static void copyFileZeroCopy(String source, String dest) {
        long start = System.currentTimeMillis();

        try (FileChannel inChannel = FileChannel.open(Paths.get(source));
             FileChannel outChannel = FileChannel.open(Paths.get(dest),
                     StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
            long size = inChannel.size();
            long transferred = inChannel.transferTo(0, size, outChannel);

            System.out.println("传输字节数: " + transferred);

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

        long end = System.currentTimeMillis();
        System.out.println("NIO Zero-Copy 复制耗时:" + (end - start) + "ms");
    }
}

测试文件最好100MB以上。transferTo在Windows和Linux实现上有差异。Linux上能够实现真正意义的零拷贝。

4.4 文件内存映射

文件内存映射在前两节,我们已经提到过,我们简单看下代码怎么写:

package com.lazy.snail.day62;

import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

/**
 * @ClassName NIODemo4
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/19 15:17
 * @Version 1.0
 */
public class NIODemo4 {
    public static void main(String[] args) {
        copyFileMapped("C:\\Users\\lazysnail\\Desktop\\懒惰蜗牛工坊.txt", "C:\\Users\\lazysnail\\Desktop\\lazysnailstudio.txt");
    }
    public static void copyFileMapped(String source, String dest) {
        long start = System.currentTimeMillis();

        try (FileChannel inChannel = FileChannel.open(Paths.get(source));
             FileChannel outChannel = FileChannel.open(Paths.get(dest),
                     StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE)) {

            long size = inChannel.size();
            // 将文件映射到内存
            MappedByteBuffer inBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, size);
            MappedByteBuffer outBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, size);
            outBuffer.put(inBuffer);

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

        long end = System.currentTimeMillis();
        System.out.println("NIO Mapped 复制耗时:" + (end - start) + "ms");
    }
}

下面是时序图:

在这里插入图片描述

4.4 小结

关于直接缓冲区、零拷贝、内存映射文件这三个概念,可能初学有点绕。

我小结一下,理一下这三者之间的关系。

直接缓冲区是基础手段,我们可以把他看成是一个工具。

而内存映射文件是一种特殊的直接缓冲区,可以把他看成是一个高级工具。

零拷贝则是我们最终的目的,以达到更高的性能。

还是来张图,方便记忆:

在这里插入图片描述

结语

本文我们深入解构了NIO的数据容器(Buffer)和传输管道(Channel)。

了解了直接缓冲区和零拷贝背后的系统原理。

不过,文中的FileChannel还是阻塞模式。

NIO真正出色的IO多路复用,我们还没体会到。

下文我们将讲一下NIO中的Selector,配合上网络编程,看一下什么才是非阻塞,什么是多路复用。

下一篇预告

Day63 | Java IO之NIO三件套–选择器(下)

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐