乐于分享
好东西不私藏

Java BIO数据复制源码剖析

Java BIO数据复制源码剖析
  • 一、 传统 BIO 内存复制宏观架构
  • 二、 OpenJDK8核心源码追踪与解析
    • 1. Java 层面的入口
    • 2. JNI 层的桥接
    • 3. 核心实现:`io_util.c`(关键内存复制发生地)
  • 三、 操作系统维度的内存复制与上下文切换全历程
    • 1. BIO 读取文件的全流程(以 Linux 为例)
    • 2. BIO 写入文件的全流程
    • 3. 内存复制与上下文切换时序分解
  • 四、 核心系统级思考:为什么必须引入 Native Buffer 这一层中间复制
    • 1. 垃圾回收(GC)导致的对象移动问题
    • 2. JNI 临界区控制的代价过高
  • 五、 总结与对比

前言

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

BIO内存复制源码剖析

在 Java 的传统 BIO(Blocking I/O,同步阻塞 I/O)模型中,进行一次简单的文件或网络读写,其背后的内存复制与系统调用过程远比表面上的 stream.read() 或 stream.write() 复杂。从应用层的 JVM 堆内存,到操作系统的内核空间,再到物理硬件,数据需要经过多次上下文切换(Context Switch)内存复制(Memory Copy)

以下作为系统工程师,结合 OpenJDK8 源码,对传统 BIO 的内存复制完整生命周期进行深度剖析。


一、 传统 BIO 内存复制宏观架构

在深入源码之前,首先需要明确一次完整的传统 BIO 读/写操作在操作系统与 JVM 之间的内存流转模型:

整个过程涉及到四个核心内存区域:

  1. JVM 堆内存(Java Heap):Java 对象(如 byte[])分配的地方,受 GC(垃圾回收器)管理。
  2. 本地堆内存(Native Heap / C-Heap):JNI 依赖的 C/C++ 运行时内存,不受 Java GC 直接控制。
  3. 内核页缓存(Page Cache / Socket Buffer):操作系统内核空间的缓冲区,用于加速 I/O 性能。
  4. 物理设备(Disk / NIC):实际的硬件存储或网络设备。

二、 OpenJDK8核心源码追踪与解析

传统 BIO 的底层实现位于 JDK 源码的 java.io 包中。我们以 FileInputStream 和 FileOutputStream 为例,追踪其字节数组读写的核心链路。

1. Java 层面的入口

在 java.io.FileInputStream 中,读取数据的核心方法是 read(byte b[], int off, int len),它直接调用了 native 方法 readBytes

// jdk/src/share/classes/java/io/FileInputStream.java
public int read(byte b[], int off, int len) throws IOException {
return readBytes(b, off, len);
}

private native int readBytes(byte b[], int off, int len) throws IOException;

2. JNI 层的桥接

对应的 C 语言 native 实现位于 FileInputStream.c 中,它将请求转发给 io_util.c 的通用读写函数:

// jdk/src/share/native/java/io/FileInputStream.c
JNIEXPORT jint JNICALL
Java_java_io_FileInputStream_readBytes(JNIEnv *env, jobject this,
                                       jbyteArray bytes, jint off, jint len)
 {
// 调用 io_util.c 中的 readBytes 核心实现
return readBytes(env, this, bytes, off, len, fis_fd);
}

3. 核心实现:io_util.c(关键内存复制发生地)

io_util.c 中的 readBytes 和 writeBytes 函数是理解 BIO 内存复制的核心。真正的内存分配与复制逻辑位于 jdk/src/share/native/java/io/io_util.c。请注意其对栈缓冲区(Stack Buffer)堆外内存(C Heap)的选择。以下是 OpenJDK8 的精简源码及系统级注释:

深度解析:readBytes(输入流复制)

#define BUF_SIZE 8192

jint readBytes(JNIEnv *env, jobject this, jbyteArray bytes, jint off, jint len, jfieldID fid) {
    jint nread;
char stackBuf[BUF_SIZE]; // 预分配的线程栈缓冲区,大小为 8KB
char *buf = NULL;
    FD fd;

if (IS_NULL(bytes)) {
        JNU_ThrowNullPointerException(env, NULL);
return -1;
    }
if (outOfBounds(env, off, len, bytes)) {
        JNU_ThrowOutOfBoundsException(env, NULL);
return -1;
    }

if (len == 0) {
return 0;
    }

/* * 关键点 1:根据读取长度判断是否需要分配 Native 堆内存
     * 如果请求长度大于 8KB,为了防止栈溢出,改用 malloc 在 C 堆分配内存
     */

if (len > BUF_SIZE) {
        buf = malloc(len);
if (buf == NULL) {
            JNU_ThrowOutOfMemoryError(env, NULL);
return 0;
        }
    } else {
        buf = stackBuf; // 小于或等于 8KB,直接使用线程栈,避免 malloc 开销
    }

    fd = GET_FD(this, fid);
if (fd == -1) {
        JNU_ThrowIOException(env, "Stream Closed");
        nread = -1;
goto cleanup;
    }

/* * 关键点 2:发起系统调用,发起第一次数据复制 (Kernel -> User Native Buffer)
     * IO_Read 在类 Unix 系统下被宏定义为 read(fd, buf, len)
     */

    nread = IO_Read(fd, buf, len);

if (nread > 0) {
/* * 关键点 3:发起第二次数据复制 (User Native Buffer -> JVM Heap)
         * 将 Native 缓冲区(buf)中的数据安全地复制到 Java 的 jbyteArray 中
         */

        (*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);
    } else if (nread == -1) {
/* 错误处理 */
        JNU_ThrowIOExceptionWithLastError(env, "Read failed");
    }

cleanup:
/* 如果当时是用 malloc 分配的堆内存,必须在此处显式释放,否则会导致内存泄漏 */
if (buf != stackBuf) {
free(buf);
    }
return nread;
}

深度解析:writeBytes(输出流复制)

// jdk/src/share/native/java/io/io_util.c
void writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,
                jint off, jint len, jfieldID fid)

{
    jint nwritten;
char stackBuf[BUF_SIZE];
char *buf = NULL;
    FD fd = GET_FD(this, fid);

// ... 省略校验代码 ...

// 1. 分配本地内存(Native Buffer)
if (len <= BUF_SIZE) {
        buf = stackBuf;
    } else {
        buf = malloc(len);
if (buf == NULL) { JNU_ThrowOutOfMemoryError(env, NULL); return; }
    }

// 2. 第一次核心复制(JVM Heap -> Native Buffer)
// 通过 JNI 的 GetByteArrayRegion 将 Java byte[] 数组的数据复制到本地内存
    (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);

if (!(*env)->ExceptionCheck(env)) {
int off_orig = off;
while (len > 0) {
// 3. 第二次核心复制(Native Buffer -> 内核空间)
// IO_Write 在 Unix/Linux 下等价于 write(fd, buf, len) 系统调用
            nwritten = IO_Write(fd, buf + (off - off_orig), len);
if (nwritten == JVM_IO_ERR) {
                JNU_ThrowIOExceptionWithLastError(env, "Write failed");
break;
            } else if (nwritten == JVM_IO_INTR) {
                JNU_ThrowByName(env, "java/io/InterruptedIOException"NULL);
break;
            }
            len -= nwritten;
            off += nwritten;
        }
    }

// 4. 释放本地堆内存
if (buf != stackBuf) {
free(buf);
    }
}


三、 操作系统维度的内存复制与上下文切换全历程

基于上述 OpenJDK 源码,我们可以精确梳理出在操作系统视角下,一次传统 BIO 读取(Read) 和 写入(Write) 的完整性能损耗模型。

1. BIO 读取文件的全流程(以 Linux 为例)

当执行 FileInputStream.read(jbyteArray) 时,系统执行了 4 次内存复制 和 2 次上下文切换

  1. 上下文切换 1:Java 线程发起 IO_Read,JVM 触发 read() 系统调用,用户态(User Mode)切换为内核态(Kernel Mode)。
  2. 内存复制 1(DMA 复制):内核检查 Page Cache,若未命中,则由 DMA(Direct Memory Access)控制器将数据从物理磁盘异步复制到内核空间的 Page Cache 中。
  3. 内存复制 2(CPU 复制):CPU 将数据从内核空间的 Page Cache 复制到用户空间的 Native Buffer(即源码中的 stackBuf 或 malloc 分配的指针)。
  4. 上下文切换 2read() 系统调用返回,从内核态切换回用户态。
  5. 内存复制 3(CPU 复制):JNI 层执行 SetByteArrayRegion,CPU 将数据从 Native Buffer 再次复制到 JVM 堆内存中的 Java byte[] 对象中。

2. BIO 写入文件的全流程

当执行 FileOutputStream.write(jbyteArray) 时,逻辑正好相反,同样涉及 4 次内存复制 和 2 次上下文切换

  1. 内存复制 1(CPU 复制):JNI 层执行 GetByteArrayRegion,CPU 将数据从 JVM 堆的 Java byte[] 复制到用户空间的 Native Buffer
  2. 上下文切换 1:Java 线程发起 IO_Write,JVM 触发 write() 系统调用,用户态切换为内核态。
  3. 内存复制 2(CPU 复制):CPU 将数据从用户空间的 Native Buffer 复制到内核空间的 Page Cache(如果是网络 Socket,则是复制到 Socket Buffer)。
  4. 上下文切换 2write() 系统调用返回,从内核态切换回用户态。
  5. 内存复制 3(DMA 复制):操作系统的刷新线程(如 Linux 的 flusher 线程)异步或同步通过 DMA 控制器将数据从 Page Cache 刷新到物理磁盘(或网络显卡 NIC)。

3. 内存复制与上下文切换时序分解

基于上述 OpenJDK 8u 源码,以单次标准文件读取为例,整个 BIO 的数据流水线可以精确拆解为 3 次内存复制 与 2 次上下文切换

步骤
阶段类型
源内存区域
目标内存区域
复制类型 / 涉及源码
详细说明
1上下文切换 1
用户态(User Mode)
内核态(Kernel Mode)
IO_Read
 / read() 系统调用
Java 线程通过 JNI 进入底层 C 库,向内核发起 read 系统调用,线程挂起。
2内存复制 1
磁盘/硬件设备
内核空间 Page Cache
DMA Copy
(无需 CPU 参与)
操作系统的 DMA 控制器将磁盘数据读入内核的页缓存。
3内存复制 2
内核空间 Page Cache
用户空间 Native Buffer
CPU Copy
(内核完成)
内核将 Page Cache 中的数据拷贝到用户态指定的 Native 缓冲区(源码中的 stackBuf 或 malloc 空间)。
4上下文切换 2
内核态(Kernel Mode)
用户态(User Mode)
read()
 系统调用返回
系统调用结束,从内核态切回用户态,Java 线程被唤醒继续执行。
5内存复制 3
用户空间 Native Buffer
用户空间 JVM Heap (byte[])
CPU Copy
(JNI 实现)
源码中的 SetByteArrayRegion 执行,通过 CPU 将 Native 内存中的字节硬拷贝到 JVM 堆里的 Java 数组。

注: 如果是“读取文件并将其通过 Socket 发送出去”的经典网络传输场景(Read + Write 组合),由于涉及两个独立的系统调用,流程会膨胀为 4 次内存复制 和 4 次上下文切换


四、 核心系统级思考:为什么必须引入 Native Buffer 这一层中间复制

很多开发者会产生疑问:为什么 OpenJDK 源码不直接把 Java 的 byte[] 内存地址传递给 read() 或 write() 系统调用,从而减少一次用户空间的内部复制?

这背后是 JVM 内存管理机制(GC)与操作系统系统调用之间的核心冲突,原因有二:

1. 垃圾回收(GC)导致的对象移动问题

Java 堆内存是由垃圾回收器管理的。在传统的 BIO 阻塞期间(例如等待网络数据或磁盘旋转寻道),当前线程会被挂起。在这个挂起期间,JVM 可能会触发 GC。 诸如 G1、Parallel Scavenge 等回收器会进行内存整理(Compaction),从而移动对象的物理内存地址。如果直接将 byte[] 的物理地址传给内核:

  • 在内核进行 I/O 写入时,byte[] 被 GC 挪走了,内核就会读取到错误的垃圾数据。
  • 在内核进行 I/O 读取时,内核会将数据写到原有的旧地址,引发内存损坏(Memory Corruption),甚至导致 JVM 崩溃。

而 Native Buffer(无论是栈上的 stackBuf 还是 C-Heap 的 malloc 空间)是不受 Java GC 管辖的,其物理地址在分配后绝对固定,因此内核可以安全地读写。

2. JNI 临界区控制的代价过高

JNI 确实提供了一种直接获取 Java 数组底层原生指针的方法:GetPrimitiveArrayCritical。它会通知 GC 锁定(Pin)该数组,使其在执行期间不被移动。 然而,传统 BIO 是同步阻塞的。如果一个网络连接因为对端网络延迟导致阻塞几分钟,那么这个 Java 数组就会被锁定几分钟。如果大量线程同时阻塞在 I/O 上,GC 将因为大量内存被锁定而无法正常工作,导致严重的 JVM 停顿甚至 OOM。因此,OpenJDK 选择“空间与 CPU 换安全”,采用了 Native Buffer 中转的方案。


五、 总结与对比

传统 BIO 的设计初衷是提供简单易用的流式 API,但在高并发、大数据量的场景下,多次内存复制和上下文切换会带来极高的 CPU 与内存带宽开销。这也是后来 JDK引入 NIO(New I/O)并支持 DirectByteBuffer(直接利用本地内存,规避 JVM Heap 到 Native Heap 的复制)以及 FileChannel.transferTo(零拷贝,利用 sendfile 规避用户空间全量复制)的底层根本原因。