前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

文件系统高效读写原理剖析

作为系统工程师,深入理解 Java 文件系统的高效读写机制,必须将视角从 Java 虚拟机(JVM)层下探到操作系统内核层(OS Kernel)。Java 在 1.4 版本引入 NIO(New I/O),并在 7u 和8中不断演进,其核心目标就是减少用户态与内核态之间的上下文切换,以及消减不必要的数据拷贝(Zero-Copy)

以下基于 OpenJDK8源码,全面剖析 Java 文件高性能读写的底层核心机制。


一、 传统 I/O 与 NIO 的核心分水岭:Buffer 转换机制

在传统的 java.io(如 FileInputStream)中,每一次 readwrite 操作都会陷入系统调用。而在 java.nio 中,一切读写皆围绕 Channel(通道)和 Buffer(缓冲区)展开。

1. HeapByteBuffer 的“二次拷贝”隐患

当我们使用 ByteBuffer.allocate(bytes) 分配堆内存缓冲区时,其底层是一个 Java 字节数组(byte[])。当通过 FileChannel 写入磁盘时,JVM 无法直接将堆内存地址传给操作系统的 write 系统调用。

原因在于:JVM 垃圾回收器(GC)可能会在执行系统调用时移动该内核数组的内存地址(Copying/Compact GC)

为了解决这个问题,OpenJDK 在底层做了妥协。分析 sun.nio.ch.IOUtil.java 的源码:

// sun.nio.ch.IOUtil.java 源码片段
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
    throws IOException
{
    // 如果是 DirectBuffer,直接写入
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, position, nd);

    // Substitute a native buffer 
    // 如果是 HeapByteBuffer,必须在堆外(Native)申请一个临时的 DirectBuffer
    int pos = src.position();
    int lim = src.limit();
    int rem = (pos <= lim ? lim - pos : 0);
    
    // 从线程本地缓存(ThreadLocal)或新分配获取一块堆外内存
    ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
    try {
        bb.put(src); // 第一次拷贝:把堆内内存复制到堆外内存
        bb.flip();
        src.position(pos);

        // 第二次拷贝:调用系统调用,将堆外内存写入内核 Page Cache
        int n = writeFromNativeBuffer(fd, bb, position, nd);
        if (n > 0) {
            src.position(pos + n);
        }
        return n;
    } finally {
        Util.offerFirstTemporaryDirectBuffer(bb); // 回收临时堆外缓冲区
    }
}

系统级痛点: > 使用 HeapByteBuffer 读写文件会导致严重的双重拷贝现象(Java Heap → \rightarrow Native Heap → \rightarrow Kernel Page Cache)。频繁的临时堆外内存申请与释放,也是导致 YGC 压力增大的隐形杀手。


二、 核心机制一:DirectByteBuffer 与堆外零拷贝

为了消除上述的第一次拷贝,Java 提供了 ByteBuffer.allocateDirect(bytes)。它直接调用操作系统底层分配堆外内存,Java 线程和内核驱动可以同时访问这一块物理内存。

1. 堆外内存的分配(java.nio.Bits

当我们实例化一个 DirectByteBuffer 时,底层通过 sun.misc.Unsafe 请求操作系统分配内存:

// java.nio.DirectByteBuffer 构造函数
DirectByteBuffer(int cap) {
    // ... 省略权限及安全检查
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    
    // 1. 核心步骤:向操作系统申请预留内存额度
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 2. 核心步骤:调用 Unsafe 裸指针分配物理内存(底层通常是 malloc)
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // ... 
    address = base; // 记录原生内存首地址
}

2. 完美的 Native 读写通道

DirectByteBuffer 被传入 FileChannelImpl 时,通过 IOUtil.writeFromNativeBuffer 直接下发地址:

// sun.nio.ch.IOUtil.java
private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
                                         long position, NativeDispatcher nd)
    throws IOException
{
    int pos = bb.position();
    int lim = bb.limit();
    int rem = (pos <= lim ? lim - pos : 0);

    int written = 0;
    if (rem == 0)
        return 0;
    
    // 直接获取 DirectByteBuffer 的底层 Native 内存绝对地址
    long address = ((DirectBuffer)bb).address();
    
    // 依位置调用本地 native 方法
    if (position != -1) {
        written = nd.pwrite(fd, address + pos, rem, position);
    } else {
        written = nd.write(fd, address + pos, rem);
    }
    if (written > 0)
        bb.position(pos + written);
    return written;
}

在 Linux 环境下,nd.write 映射到 C 源码(FileChannelImpl.c),最终直接调用 Linux 的标准系统调用:

// 底层 C 语言系统调用伪代码
written = write(fd, (void *)(address + pos), rem);

此时,数据直接从堆外 Native 内存拷贝到了操作系统内核的 Page Cache 中,减少了一次 JVM 内部的内存复制。


三、 核心机制二:MappedByteBuffer 与 MMAP 纵向飞跃

尽管 DirectByteBuffer 减少了内存拷贝,但每次读写依然需要历经 write()read() 系统调用。这意味着频繁的用户态(User Mode)与内核态(Kernel Mode)的上下文切换(Context Switch)

为了实现极致性能,OpenJDK 引入了 FileChannel.map() 机制,其底层正是 Linux 的 mmap(内存映射)。

1. 源码解析:FileChannelImpl.map()

通过 FileChannelImpl.java,我们可以清晰看到它是如何将文件描述符直接映射为虚拟内存地址的:

// sun.nio.ch.FileChannelImpl.java
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
    // ... 省略前置安全与范围检查
    int pagePosition = (int)(position % allocationGranularity);
    long mapPosition = position - pagePosition;
    long mapSize = size + pagePosition;

    try {
        // 调用本地方法 map0 
        addr = map0(imode, mapPosition, mapSize);
    } catch (IOException x) {
        // ...
    }

    // 根据返回的虚拟内存原生地址,直接封装成 MappedByteBuffer
    int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, fd);
    if (writable) {
        return Util.newMappedByteBuffer(isize, addr + pagePosition, fd, um);
    } else {
        return Util.newMappedByteBufferR(isize, addr + pagePosition, fd, um);
    }
}

2. 深入 OpenJDK 固有的 C 源码(FileChannelImpl.c

map0 在 Native 层的实现逻辑如下(针对 Unix/Linux 平台):

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint jmode, jlong position, jlong size)
{
    // ... 获取操作系统文件句柄 fd
    int fd = jintToFD(env, fdo);
    void *vma;
    
    // Linux 系统调用:mmap
    // 将文件 fd 的指定内容,直接映射到进程的虚拟内存空间
    vma = mmap64(
        0,                    // 系统自动选择映射区的起始地址
        size,                 // 映射区域大小
        protections,          // 期望的内存保护标志(PROT_READ / PROT_WRITE)
        flags,                // 指定映射对象的类型(MAP_SHARED / MAP_PRIVATE)
        fd,                   // 文件描述符
        position              // 被映射资源的起点 offset
    );

    if (vma == MAP_FAILED) {
        JNU_ThrowIOExceptionWithLastError(env, "Map failed");
        return 0;
    }

    return ((jlong)vma); // 返回该段虚拟内存的首地址给 Java 层
}

3. MMAP 的高效本质

当 Java 层拿到该虚拟地址(addr)后,对 MappedByteBufferputget 操作,本质上就是直接读写高地址的进程虚拟内存空间

  • 免去系统调用:后续读写文件不再需要 read/write 系统调用,等同于操作内存。
  • 缺页中断(Page Fault)处理:当进程访问该虚拟地址且发现物理内存中无对应 Page Cache 时,CPU 会触发缺页中断,由内核直接将磁盘数据加载进 Page Cache。用户态与内核态在同一块物理内存上“共享数据”。

四、 核心机制三:FileChannel.transferTo 与大文件零拷贝

在进行文件分发、网盘传输或消息队列(如 Kafka)搭建时,“把文件从磁盘读出来,再通过网络 Socket 发送出去”是最核心的场景。

传统方式需要 4 次拷贝和 4 次上下文切换。而 OpenJDK8提供的 FileChannel.transferTo() 则是该场景下的终极利器。

1. 源码解析:Linux 上的 sendfile 绑定

我们探究 sun.nio.ch.FileChannelImpl.javatransferTo 方法:

// sun.nio.ch.FileChannelImpl.java
public long transferTo(long position, long count, WritableByteChannel target)
    throws IOException 
{
    // ...
    // 尝试使用快速的直接传输路径
    long n;
    if ((n = transferToDirectly(position, icount, target)) > 0)
        return n;
    // ... 如果 target 不支持,退回到普通的 buffer 拷贝
}

private long transferToDirectly(long position, int icount, WritableByteChannel target)
    throws IOException 
{
    // 确保目标通道也是个标准的 FileChannelImpl 或 SocketChannelImpl
    if (!srcND.canTransferToDirectly(target))
        return -1;
        
    int thisFD = IOUtil.fdVal(fd);
    int targetFD = ((SelChImpl)target).getFDVal();
    
    // 调用 native 层的 transferTo0
    long n = transferTo0(thisFD, position, icount, targetFD);
    return n;
}

2. 底层 C 源码对零拷贝的兑现

进入 FileChannelImpl.c,可以看到针对 Linux 平台,它被直接编译为 sendfile64 系统调用:

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jint srcFD, jlong position,
                                            jlong count, jint dstFD)
{
    off64_t offset = (off64_t)position;
    
    // 直接在内核空间中进行数据传输!
    // 将 srcFD (文件) 的 Page Cache 数据直接复制到 dstFD (Socket) 的缓冲区中
    jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
    
    if (n < 0) {
        if (errno == EAGAIN) return IOS_UNAVAILABLE;
        if (errno == EINTR) return IOS_INTERRUPTED;
        // ... 
    }
    return n;
}

  • 零拷贝的绝对优势sendfile 让数据根本不需要穿透到用户态(Java 进程空间)。数据直接在内核的 Page Cache → \rightarrow Socket Buffer → \rightarrow 网卡驱动(DMA) 之间流转。
  • 上下文切换次数直接从 4 次骤降到 2 次。

五、 Java 各种文件读写机制综合对比

作为系统工程师,在面临不同的工程架构时,应根据底层原理的差异进行技术选型:

读写机制 核心底层系统调用 用户态 ↔ \leftrightarrow 内核态切换 数据内存拷贝路径 适用场景
传统 InputStream read 频繁(每次调用切换) 磁盘 → \rightarrow Page Cache → \rightarrow Native 堆外 → \rightarrow JVM 堆内 100KB 以下的常规小文件、传统流式配置读取
NIO HeapByteBuffer read / write 频繁 磁盘 → \rightarrow Page Cache → \rightarrow Native 堆外 → \rightarrow JVM 堆内 常规业务逻辑开发,无法预估内存风险的普通过渡
NIO DirectByteBuffer read / write 频繁 磁盘 → \rightarrow Page Cache → \rightarrow Native 堆外 频繁 I/O 的中大型文件,避免 JVM GC 压力的高频读写
MappedByteBuffer (MMAP) mmap / 缺页中断 仅在建立映射和发生缺页中断时 磁盘 → \rightarrow Page Cache (Java 进程建立虚拟内存直接映射) 高频读写的大文件、持久化消息队列(如 RocketMQ/Kafka 索引)
FileChannel.transferTo sendfile 极低(2次) 磁盘 → \rightarrow Page Cache → \rightarrow Socket 缓冲区 / 目标 Page Cache 静态资源服务器(如 Tomcat/Netty 静态文件分发)、网络大文件传输

💡 系统工程总结

Java 在 OpenJDK8的文件系统演进,并非自己创造了奇迹,而是通过 JNI 完美地解构并利用了现代 Linux 内核的高级特性

  • 当我们需要操作超大文件且伴随高频随机读写时,应当首选 MMAP(MappedByteBuffer
  • 当我们遇到纯粹的“数据搬运”(文件到网络)时,transferTo 是绝对的性能王者。
  • 在普通的高并发 I/O 模块中,维持一套高效复用的 DirectByteBuffer 线程池,是压榨磁盘吞吐量的底线。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐