- 一、 传统 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 之间的内存流转模型:
整个过程涉及到四个核心内存区域:
JVM 堆内存(Java Heap):Java 对象(如 byte[])分配的地方,受 GC(垃圾回收器)管理。本地堆内存(Native Heap / C-Heap):JNI 依赖的 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.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:Java 线程发起 IO_Read,JVM 触发read()系统调用,用户态(User Mode)切换为内核态(Kernel Mode)。内存复制 1(DMA 复制):内核检查 Page Cache,若未命中,则由 DMA(Direct Memory Access)控制器将数据从物理磁盘异步复制到内核空间的 Page Cache 中。 内存复制 2(CPU 复制):CPU 将数据从内核空间的 Page Cache 复制到用户空间的 Native Buffer(即源码中的 stackBuf或malloc分配的指针)。上下文切换 2: read()系统调用返回,从内核态切换回用户态。内存复制 3(CPU 复制):JNI 层执行 SetByteArrayRegion,CPU 将数据从 Native Buffer 再次复制到 JVM 堆内存中的 Java byte[] 对象中。
2. BIO 写入文件的全流程
当执行 FileOutputStream.write(jbyteArray) 时,逻辑正好相反,同样涉及 4 次内存复制 和 2 次上下文切换:
内存复制 1(CPU 复制):JNI 层执行 GetByteArrayRegion,CPU 将数据从 JVM 堆的 Java byte[] 复制到用户空间的 Native Buffer。上下文切换 1:Java 线程发起 IO_Write,JVM 触发write()系统调用,用户态切换为内核态。内存复制 2(CPU 复制):CPU 将数据从用户空间的 Native Buffer 复制到内核空间的 Page Cache(如果是网络 Socket,则是复制到 Socket Buffer)。 上下文切换 2: write()系统调用返回,从内核态切换回用户态。内存复制 3(DMA 复制):操作系统的刷新线程(如 Linux 的 flusher线程)异步或同步通过 DMA 控制器将数据从 Page Cache 刷新到物理磁盘(或网络显卡 NIC)。
3. 内存复制与上下文切换时序分解
基于上述 OpenJDK 8u 源码,以单次标准文件读取为例,整个 BIO 的数据流水线可以精确拆解为 3 次内存复制 与 2 次上下文切换:
| 1 | 上下文切换 1 | IO_Readread() 系统调用 | |||
| 2 | 内存复制 1 | DMA Copy | |||
| 3 | 内存复制 2 | CPU Copy | stackBuf 或 malloc 空间)。 | ||
| 4 | 上下文切换 2 | read() | |||
| 5 | 内存复制 3 | byte[]) | CPU Copy | 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 规避用户空间全量复制)的底层根本原因。
夜雨聆风