JVM零拷贝实现机制解析
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
DirectBuffer和DMA协同实现零拷贝
1. 数据传输路径与 DMA 硬件演进
1.1 传统 I/O 模式的缺陷(4 次上下文切换,4 次数据拷贝)
在传统的 Java 网络编程中,从磁盘文件通过 Socket 发送数据需经历以下步骤:
[ Disk ] --(1. DMA Copy)--> [ Page Cache ] --(2. CPU Copy)--> [ JVM Heap Buffer ]
|
[ NIC ] <--(4. DMA Copy)-- [ Socket Buffer ] <--(3. CPU Copy)---------+
- 磁盘至内核页缓存(Page Cache):应用程序发起
read()系统调用,触发上下文切换(用户态 → \rightarrow → 内核态)。DMA 控制器将磁盘数据拷贝至内核空间页缓存。 - 内核页缓存至 JVM 堆:CPU 将内核页缓存中的数据拷贝至用户空间 JVM 堆缓冲区(
byte[])。系统调用返回,触发上下文切换(内核态 → \rightarrow → 用户态)。 - JVM 堆至 Socket 缓冲区:应用程序发起
write()系统调用,触发上下文切换(用户态 → \rightarrow → 内核态)。CPU 将 JVM 堆中的数据拷贝至内核 Socket 缓冲区(sk_buff)。 - Socket 缓冲区至网卡(NIC):DMA 控制器将 Socket 缓冲区数据异步拷贝至网卡物理缓冲区,传输完成后发起硬件中断(IRQ)。系统调用返回,触发上下文切换(内核态 → \rightarrow → 用户态)。
物理痛点:步骤 2 和 3 涉及跨越用户/内核边界的 CPU 移动数据,会锁住 CPU 核心周期、占用 L1/L2 Cache,引发 TLB(Translation Lookaside Buffer)刷屏与 Cache 失效。
1.2 DirectBuffer 与 DMA 的协同机制
为了消除步骤 2 的 CPU 拷贝,引入了 DirectByteBuffer。直接缓冲区在 JVM 堆外分配(Native C-Heap),其物理内存位于用户态可寻址空间,但其地址不受 JVM GC 管理(内存地址固定,无垃圾回收重定位风险)。
[ Disk ] --(1. DMA Copy)--> [ Page Cache ] --(2. CPU Copy)--> [ DirectByteBuffer (Native Heap) ]
|
[ NIC ] <--(4. DMA Copy)-- [ Socket Buffer ] <--(3. CPU Copy)---------------+
DMA(Direct Memory Access,直接内存访问)硬件必须要求传输目标物理内存地址在传输期间是固定且连续的(或者通过 IOMMU 提供 Scatter-Gather 页表支持)。
- HeapBuffer 的局限:Java 堆内数组(
byte[])的内存地址会因为 GC 的标记-整理(Mark-Compact)或 Copying 算法发生变更。若直接将堆内地址丢给 DMA,DMA 传输过程中如果触发 GC 导致对象被移动,DMA 就会将数据写入错误的物理内存,造成严重的内存损坏。 - DirectBuffer 的突破:内存直接通过
malloc()/mmap()从 C-Heap 分配,逻辑地址在生命周期内固定不变。JVM 仅保存一个 64 位指针address指向这块内存。当发起系统调用时,直接传递address给内核,系统调用可以直接利用 DMA 在该地址和硬件设备之间传送数据。
2. DirectBuffer 源码剖析:避免内核到用户态拷贝
在 JDK 源码层面,HeapByteBuffer 试图与 Native 接口交互时,底层强制触发了一次隐式拷贝;而 DirectByteBuffer 则直通 Native 指针。
2.1 JVM 底层对 HeapBuffer 的强制临时拷贝机制 (IOUtil.java)
// jdk/src/share/classes/sun/nio/ch/IOUtil.java
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
throws IOException
{
// 1. 如果传入的是 DirectBuffer,直接调用本地 Native 接口传输
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// 2. 核心避坑点:如果是 HeapByteBuffer,必须在 Native 堆分配一块临时的 DirectBuffer
// 原因是:必须确保传递给 POSIX read/write 系统调用的物理地址在系统调用期间不会被 GC 移动
ByteBuffer bb = Util.getTemporaryDirectBuffer(src.remaining());
try {
// 将 Heap 缓冲区中的 byte[] 重新 CPU 拷贝一次到 Temporary DirectBuffer
bb.put(src);
bb.flip();
src.position(pos);
// 真正将 Temporary DirectBuffer 的 Native 地址传给底层 OS 系统调用
int n = writeFromNativeBuffer(fd, bb, position, nd);
if (n > 0) {
src.position(pos + n);
}
return n;
} finally {
// 回收临时的 DirectBuffer 到 ThreadLocal 缓存池
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
2.2 DirectByteBuffer 的物理内存分配与销毁 (DirectByteBuffer.java)
// jdk/src/share/classes/java/nio/DirectByteBuffer.java
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
// 追踪堆外内存配额(配合 -XX:MaxDirectMemorySize 参数)
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 调用 Unsafe 分配 C-Heap 原生内存,底层即 POSIX malloc() / unsafe.cpp
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 初始化内存区域
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// 内存页对齐优化(为了更好地适配 DMA 按 Block / Page 粒度传输)
address = base + ps - (base % ps);
} else {
// 保存原生内存的首地址指针!此 long 值直接对应 POSIX 虚拟内存地址
address = base;
}
// 注册 Cleaner,用于 GC 发现该 Java 对象不可达时通过 PhantomReference 回收 Native 内存
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
2.3 C-Native 层的直接寻址与 POSIX 写入 (IOUtil.c / FileDispatcherImpl.c)
// jdk/src/solaris/native/sun/nio/ch/IOUtil.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_IOUtil_write1(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
// 传入的 address 即为 DirectByteBuffer 内部保存的 C-Heap 指针 long 值
// 转型为 C 语言底层的 void* 指针,无任何内存偏移与重新拷贝
void *buf = (void *)jlong_to_ptr(address);
// 文件描述符提取
int fd = fdval(env, fdo);
// 直接发起 POSIX write 系统调用
// 此时 OS 接收到用户态地址 buf,内核可以直接从该地址将数据写回文件/网络
// 避免了传统 read/write 中“系统调用内部将内核 Buffer 复制到用户态内存”的二次复制
return writeInternal(fd, buf, len);
}
static inline jint writeInternal(int fd, const void *buf, jint len)
{>
int result;
// 阻塞/非阻塞系统调用
RESTARTABLE(write(fd, buf, len), result);
return result;
}
3. FileChannel.transferTo() 与 Kernel sendfile 零拷贝
DirectBuffer 避免了用户空间与内核空间的数据拷贝,但在传输本地磁盘文件到网络时,仍需经过:PageCache
→
\rightarrow
→ DirectBuffer
→
\rightarrow
→ SocketBuffer 的 CPU 拷贝。
为了彻底消除用户态/内核态间所有不必要的 CPU 拷贝,Linux 引入了 sendfile 系统调用。
3.1 sendfile + Gather DMA 演进流程
[ Disk ] --(1. DMA Copy)--> [ Page Cache ]
|
| (2. 仅仅拷贝文件描述符/长度到 Socket Buffer)
v
[ NIC ] <--(3. Scatter-Gather DMA Copy)---
在支持 Scatter-Gather(SG-DMA)技术的网卡下:
- 零 CPU 拷贝:数据完全无需经过用户态内存(包括 DirectBuffer),直接在内核态完成。
- 描述符传输:内核仅将 Page Cache 的物理页帧描述符(Buffer Descriptors: 内存地址 + 偏移量)写入 Socket 缓冲区
sk_buff,无需复制物理字节。 - SG-DMA 聚合:网卡 DMA 控制器直接根据
sk_buff中的描述符列表,从内核 Page Cache 中抓取物理页数据,合并后封装成以太网帧发送。
3.2 JVM 源代码追踪:FileChannelImpl.java 到 C Native 层
Java NIO 通过 FileChannel.transferTo() 包装了 sendfile:
// jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java
public long transferTo(long position, long count, WritableByteChannel target)
throws IOException
{
// 1. 优先尝试 Linux sendfile 系统调用
long n = transferToDirectly(position, icount, target);
if (n >= 0)
return n;
// 2. 次选:如果系统不支持 sendfile,回退到 DirectBuffer 映射 (mmap)
n = transferToTrustedChannel(position, icount, target);
if (n >= 0)
return n;
// 3. 最劣兜底:传统 loop 拷贝
return transferToArbitraryChannel(position, icount, target);
}
private long transferToDirectly(long position, int count, WritableByteChannel target)
throws IOException
{
if (!nd.canTransferToDirectly(target))
return -1;
FileDescriptor srcFD = this.fd;
FileDescriptor targetFD = ((SelChImpl)target).getFD();
// 调用 Native 接口进行底层的 sendfile 系统调用
long n = transferTo0(srcFD, position, count, targetFD);
if (n == IOStatus.UNSUPPORTED_CASE) {
return -1;
}
return n;
}
// Native 声明
private native long transferTo0(FileDescriptor src, long position, long count, FileDescriptor target);
3.3 Linux C Native 层源码映射 (FileChannelImpl.c)
// jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFdo, jlong position,
jlong count, jobject dstFdo)
{
int srcFD = fdval(env, srcFdo);
int dstFD = fdval(env, dstFdo);
off64_t offset = position;
// 映射到 Linux 系统的 sendfile64() 系统调用
// 参数说明:
// dstFD: 目标网络 Socket 描述符
// srcFD: 源磁盘文件描述符
// &offset: 读取文件的起始偏移量
// count: 传输的总字节数
jlong nbytes = sendfile64(dstFD, srcFD, &offset, count);
if (nbytes < 0) {
if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
// 非阻塞 I/O 情况下, Socket 缓冲区满了,重试
return IOS_UNSUPPORTED_CASE;
}
if (errno == EINVAL || errno == ENOSYS) {
// 系统内核版本不支持或目标 FD 不支持 sendfile
return IOS_UNSUPPORTED;
}
JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
return 0;
}
return nbytes;
}
4. 超高性能传输:Linux MSG_ZEROCOPY 底层探秘
sendfile 解决了“磁盘文件
→
\rightarrow
→ Socket”的零拷贝,但其限制是源端必须是支持 mmap/page 的文件描述符。如果数据是在内存中动态生成的(例如 RPC 序列化协议包、内存数据库响应),sendfile 无法适用。
Linux 4.14 引入了 MSG_ZEROCOPY 特性,允许用户态内存(包括 DirectBuffer)直接进行网卡 TX 零拷贝。
[ DirectBuffer / User Page ] --(Page Pinning Locking)------------------+
| (1. 物理页直接钉住)
v
[ NIC Hardware ] <-----------(2. DMA Reads User Page directly)-------+
4.1 MSG_ZEROCOPY 的内核原理与生命周期
- 页面钉住(Page Pinning):内核通过
get_user_pages_fast()锁住用户态DirectBuffer对应的虚拟内存物理页,递增其 Page Reference Count,防止其被 SWAP 换出或重分配。 - 零拷贝发送:内核
sk_buff结构的skb_frag_t结构体直接指向用户态DirectBuffer的物理页帧地址。DMA 引擎直接从用户态内存拉取数据发送,不进行任何拷贝。 - 异步完成通知(Completion Notification):网卡发送完成后,内核释放物理页引用,并将发送完成信号推进 Socket 的错误队列(Error Queue,
MSG_ERRQUEUE)。用户进程需要定期读取该队列以复用/释放内存 buffer。
4.2 JVM/Netty Native 结合 MSG_ZEROCOPY 源码解析
下面演示如何在基于 JNI/C++ 的 JVM 高性能扩展库(如 Netty Native Epoll 架构)中集成 MSG_ZEROCOPY:
// Native 层 JNI 交互伪代码与底层 POSIX 系统调用流程
#include <sys/socket.h>
#include <linux/errqueue.h>
#include <netinet/in.h>
// 1. 在 Socket 创建阶段开启 SO_ZEROCOPY 选项
JNIEXPORT void JNICALL
Java_com_example_native_Socket_enableZeroCopy(JNIEnv *env, jclass clazz, jint fd) {
int val = 1;
// 启用内核 Socket 级别的 Zero-Copy 支持
if (setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &val, sizeof(val)) < 0) {
JNU_ThrowIOExceptionWithLastError(env, "Failed to set SO_ZEROCOPY");
}
}
// 2. 执行网络零拷贝发送
JNIEXPORT jint JNICALL
Java_com_example_native_Socket_sendZeroCopy(JNIEnv *env, jclass clazz,
jint fd, jlong directBufAddress,
jint len) {
void* buf = (void*) jlong_to_ptr(directBufAddress);
// 传入 MSG_ZEROCOPY Flag
// 注意:系统将不会拷贝 buf 中的数据到 Socket Buffer,而是锁住内存页!
ssize_t res = send(fd, buf, len, MSG_ZEROCOPY);
if (res < 0) {
if (errno == ENOBUFS) {
// 如果内核锁住的内存页达到上限 (RLIMIT_MEMLOCK),需要回退到常规 send
return fallbackStandardSend(fd, buf, len);
}
JNU_ThrowIOExceptionWithLastError(env, "send MSG_ZEROCOPY failed");
}
return (jint)res;
}
// 3. 轮询内核 Completion Queue (MSG_ERRQUEUE),清理与确认已完成传输的 Buffer
JNIEXPORT jboolean JNICALL
Java_com_example_native_Socket_reapZeroCopyCompletions(JNIEnv *env, jclass clazz, jint fd) {
struct msghdr msg = {0};
char control[100];
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
// 从错误队列 (MSG_ERRQUEUE) 中拉取 Linux 发送完成通知
int res = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (res < 0) return JNI_FALSE;
struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
if (cm->cmsg_level == SOL_SOCKET && cm->cmsg_type == SO_EE_TYPE_ZEROCOPY) {
struct sock_extended_err *serr = (struct sock_extended_err *)CMSG_DATA(cm);
// serr->ee_info 到 serr->ee_data 包含发送成功的 Frame Sequence ID 范围
uint32_t lo = serr->ee_info;
uint32_t hi = serr->ee_data;
// 通知 Java 层:这批 DirectBuffer 对应的 Frame 已由网卡 DMA 发送完毕,可安全复用/释放!
notifyBufferCanBeReused(env, lo, hi);
return JNI_TRUE;
}
return JNI_FALSE;
}
5. 系统架构与决策对比矩阵
在系统架构演进中,各种 I/O 传输模式的技术指标对比汇总如下:
| 技术指标 / 优化维度 | 传统 Standard I/O (HeapBuffer) | 用户态零拷贝 (DirectBuffer) | 内核零拷贝 (sendfile) | 内存级零拷贝 (MSG_ZEROCOPY) |
|---|---|---|---|---|
| CPU 拷贝次数 | 2 次 (Kernel ↔ \leftrightarrow ↔ Heap) | 1 次 (Direct → \rightarrow → Socket) | 0 次 | 0 次 |
| DMA 拷贝次数 | 2 次 | 2 次 | 2 次 (SG-DMA 优化) | 2 次 |
| 上下文切换次数 | 4 次 | 4 次 | 2 次 | 2 次 (加上异步 Ack 轮询) |
| 数据源限制 | 无限制 | 无限制 | 必须是 File / Mmap | 任意内存 (含 DirectBuffer) |
| 内存页锁住开销 | 无 | 无 | 无 | 高 (get_user_pages) |
| GC 垃圾回收影响 | 高 (内存频繁分配/移动) | 低 (不占用 Heap) | 极低 (无 JVM 介入) | 极低 (无 JVM 介入) |
| 推荐适用场景 | 小包处理、业务逻辑解包 | 高频 RPC 网络 Socket API | 静态大文件下载/HTTP 静态资源 | 极高吞吐的内存数据块/大包发送 |
系统级总结调优路径
- 进程内数据处理与协议解析:优先采用
DirectByteBuffer+ Pooled Allocator(如 NettyByteBufAllocator),避免 JVM GC 压力的同时,降低IOUtil.java内部隐式的临性堆外内存申请与memcpy开销。 - 磁盘文件网络分发(如 Kafka, Nginx, RocketMQ 级应用):严格使用
FileChannel.transferTo(),完全由操作系统内核sendfile接管,配合网卡的 Scatter-Gather DMA 能力实现全链路 Zero-Copy。 - 超大规模内存块传输(> 10GB/s 吞吐需求):结合 Linux Kernel 4.14+ 开启 Socket
MSG_ZEROCOPY支持,直接将DirectBuffer绑定的物理页 Lock 住并推送至硬件网卡 TX 队列,完全解除 CPU 在主存到 Socket 缓冲区的介入。

168

被折叠的 条评论
为什么被折叠?



