Java BIO数据复制源码剖析前言BIO内存复制源码剖析一、 传统 BIO 内存复制宏观架构二、 OpenJDK8核心源码追踪与解析1. Java 层面的入口2. JNI 层的桥接3. 核心实现io_util.c关键内存复制发生地深度解析readBytes输入流复制深度解析writeBytes输出流复制三、 操作系统维度的内存复制与上下文切换全历程1. BIO 读取文件的全流程以 Linux 为例2. BIO 写入文件的全流程3. 内存复制与上下文切换时序分解四、 核心系统级思考为什么必须引入 Native Buffer 这一层中间复制1. 垃圾回收GC导致的对象移动问题2. JNI 临界区控制的代价过高五、 总结与对比前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正。BIO内存复制源码剖析在 Java 的传统 BIOBlocking I/O同步阻塞 I/O模型中进行一次简单的文件或网络读写其背后的内存复制与系统调用过程远比表面上的stream.read()或stream.write()复杂。从应用层的 JVM 堆内存到操作系统的内核空间再到物理硬件数据需要经过多次上下文切换Context Switch与内存复制Memory Copy。以下作为系统工程师结合OpenJDK8源码对传统 BIO 的内存复制完整生命周期进行深度剖析。一、 传统 BIO 内存复制宏观架构在深入源码之前首先需要明确一次完整的传统 BIO 读/写操作在操作系统与 JVM 之间的内存流转模型整个过程涉及到四个核心内存区域JVM 堆内存Java HeapJava 对象如byte[]分配的地方受 GC垃圾回收器管理。本地堆内存Native Heap / C-HeapJNI 依赖的 C/C 运行时内存不受 Java GC 直接控制。内核页缓存Page Cache / Socket Buffer操作系统内核空间的缓冲区用于加速 I/O 性能。物理设备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.javapublicintread(byteb[],intoff,intlen)throwsIOException{returnreadBytes(b,off,len);}privatenativeintreadBytes(byteb[],intoff,intlen)throwsIOException;2. JNI 层的桥接对应的 C 语言 native 实现位于FileInputStream.c中它将请求转发给io_util.c的通用读写函数// jdk/src/share/native/java/io/FileInputStream.cJNIEXPORT jint JNICALLJava_java_io_FileInputStream_readBytes(JNIEnv*env,jobject this,jbyteArray bytes,jint off,jint len){// 调用 io_util.c 中的 readBytes 核心实现returnreadBytes(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输入流复制#defineBUF_SIZE8192jintreadBytes(JNIEnv*env,jobject this,jbyteArray bytes,jint off,jint len,jfieldID fid){jint nread;charstackBuf[BUF_SIZE];// 预分配的线程栈缓冲区大小为 8KBchar*bufNULL;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(len0){return0;}/* * 关键点 1根据读取长度判断是否需要分配 Native 堆内存 * 如果请求长度大于 8KB为了防止栈溢出改用 malloc 在 C 堆分配内存 */if(lenBUF_SIZE){bufmalloc(len);if(bufNULL){JNU_ThrowOutOfMemoryError(env,NULL);return0;}}else{bufstackBuf;// 小于或等于 8KB直接使用线程栈避免 malloc 开销}fdGET_FD(this,fid);if(fd-1){JNU_ThrowIOException(env,Stream Closed);nread-1;gotocleanup;}/* * 关键点 2发起系统调用发起第一次数据复制 (Kernel - User Native Buffer) * IO_Read 在类 Unix 系统下被宏定义为 read(fd, buf, len) */nreadIO_Read(fd,buf,len);if(nread0){/* * 关键点 3发起第二次数据复制 (User Native Buffer - JVM Heap) * 将 Native 缓冲区buf中的数据安全地复制到 Java 的 jbyteArray 中 */(*env)-SetByteArrayRegion(env,bytes,off,nread,(jbyte*)buf);}elseif(nread-1){/* 错误处理 */JNU_ThrowIOExceptionWithLastError(env,Read failed);}cleanup:/* 如果当时是用 malloc 分配的堆内存必须在此处显式释放否则会导致内存泄漏 */if(buf!stackBuf){free(buf);}returnnread;}深度解析writeBytes输出流复制// jdk/src/share/native/java/io/io_util.cvoidwriteBytes(JNIEnv*env,jobject this,jbyteArray bytes,jint off,jint len,jfieldID fid){jint nwritten;charstackBuf[BUF_SIZE];char*bufNULL;FD fdGET_FD(this,fid);// ... 省略校验代码 ...// 1. 分配本地内存Native Bufferif(lenBUF_SIZE){bufstackBuf;}else{bufmalloc(len);if(bufNULL){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)){intoff_origoff;while(len0){// 3. 第二次核心复制Native Buffer - 内核空间// IO_Write 在 Unix/Linux 下等价于 write(fd, buf, len) 系统调用nwrittenIO_Write(fd,buf(off-off_orig),len);if(nwrittenJVM_IO_ERR){JNU_ThrowIOExceptionWithLastError(env,Write failed);break;}elseif(nwrittenJVM_IO_INTR){JNU_ThrowByName(env,java/io/InterruptedIOException,NULL);break;}len-nwritten;offnwritten;}}// 4. 释放本地堆内存if(buf!stackBuf){free(buf);}}三、 操作系统维度的内存复制与上下文切换全历程基于上述 OpenJDK 源码我们可以精确梳理出在操作系统视角下一次传统 BIO读取Read和写入Write的完整性能损耗模型。1. BIO 读取文件的全流程以 Linux 为例当执行FileInputStream.read(jbyteArray)时系统执行了4 次内存复制和2 次上下文切换上下文切换 1Java 线程发起IO_ReadJVM 触发read()系统调用用户态User Mode切换为内核态Kernel Mode。内存复制 1DMA 复制内核检查 Page Cache若未命中则由 DMADirect Memory Access控制器将数据从物理磁盘异步复制到内核空间的Page Cache中。内存复制 2CPU 复制CPU 将数据从内核空间的Page Cache复制到用户空间的Native Buffer即源码中的stackBuf或malloc分配的指针。上下文切换 2read()系统调用返回从内核态切换回用户态。内存复制 3CPU 复制JNI 层执行SetByteArrayRegionCPU 将数据从Native Buffer再次复制到 JVM 堆内存中的Java byte[]对象中。2. BIO 写入文件的全流程当执行FileOutputStream.write(jbyteArray)时逻辑正好相反同样涉及4 次内存复制和2 次上下文切换内存复制 1CPU 复制JNI 层执行GetByteArrayRegionCPU 将数据从 JVM 堆的Java byte[]复制到用户空间的Native Buffer。上下文切换 1Java 线程发起IO_WriteJVM 触发write()系统调用用户态切换为内核态。内存复制 2CPU 复制CPU 将数据从用户空间的Native Buffer复制到内核空间的Page Cache如果是网络 Socket则是复制到 Socket Buffer。上下文切换 2write()系统调用返回从内核态切换回用户态。内存复制 3DMA 复制操作系统的刷新线程如 Linux 的flusher线程异步或同步通过 DMA 控制器将数据从Page Cache刷新到物理磁盘或网络显卡 NIC。3. 内存复制与上下文切换时序分解基于上述 OpenJDK 8u 源码以单次标准文件读取为例整个 BIO 的数据流水线可以精确拆解为3 次内存复制与2 次上下文切换步骤阶段类型源内存区域目标内存区域复制类型 / 涉及源码详细说明1上下文切换 1用户态User Mode内核态Kernel ModeIO_Read/read()系统调用Java 线程通过 JNI 进入底层 C 库向内核发起 read 系统调用线程挂起。2内存复制 1磁盘/硬件设备内核空间Page CacheDMA Copy无需 CPU 参与操作系统的 DMA 控制器将磁盘数据读入内核的页缓存。3内存复制 2内核空间Page Cache用户空间Native BufferCPU Copy内核完成内核将 Page Cache 中的数据拷贝到用户态指定的 Native 缓冲区源码中的stackBuf或malloc空间。4上下文切换 2内核态Kernel Mode用户态User Moderead()系统调用返回系统调用结束从内核态切回用户态Java 线程被唤醒继续执行。5内存复制 3用户空间Native Buffer用户空间JVM Heap(byte[])CPU CopyJNI 实现源码中的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引入 NIONew I/O并支持DirectByteBuffer直接利用本地内存规避 JVM Heap 到 Native Heap 的复制以及FileChannel.transferTo零拷贝利用sendfile规避用户空间全量复制的底层根本原因。