Java传统NIO内存管理机制剖析
- 前言
- Java传统NIO内存管理机制剖析
-
- [一、 NIO 内存管理:DirectByteBuffer 剖析](#一、 NIO 内存管理:DirectByteBuffer 剖析)
-
- [1. 为什么需要堆外内存?](#1. 为什么需要堆外内存?)
- [2. DirectByteBuffer 对象的创建(Java 层源码)](#2. DirectByteBuffer 对象的创建(Java 层源码))
- [3. 底层 C++ 内存分配(JVM 源码)](#3. 底层 C++ 内存分配(JVM 源码))
- [4. 堆外内存的回收机制(Cleaner 与虚引用)](#4. 堆外内存的回收机制(Cleaner 与虚引用))
- [二、 NIO 零拷贝原理剖析](#二、 NIO 零拷贝原理剖析)
-
- [1. 机制一:MappedByteBuffer 与 mmap](#1. 机制一:MappedByteBuffer 与 mmap)
-
- [Java 源码入口:](#Java 源码入口:)
- [Native 底层实现:](#Native 底层实现:)
- 系统级零拷贝原理解析(mmap):
- [2. 机制二:FileChannel.transferTo() 与 sendfile](#2. 机制二:FileChannel.transferTo() 与 sendfile)
-
- [Java 源码入口:](#Java 源码入口:)
- [Native 底层实现:](#Native 底层实现:)
- 系统级零拷贝原理解析(sendfile):
- [三、 架构总结](#三、 架构总结)
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
Java传统NIO内存管理机制剖析
作为软件开发程师,深入理解 Java NIO(New I/O)的内存管理与零拷贝(Zero-Copy)原理,需要将目光从 Java 堆内延伸到 JVM 源码(OpenJDK 8u)、C++ 运行时库以及 Linux 内核的系统调用。
以下结合 OpenJDK 8u 源码,逐层剖析 DirectByteBuffer 内存管理 与 MappedByteBuffer / FileChannel.transferTo 零拷贝 的底层实现。
一、 NIO 内存管理:DirectByteBuffer 剖析
NIO 引入了 DirectByteBuffer(直接缓冲区),允许 Java 程序直接访问堆外内存(Off-Heap Memory)。
1. 为什么需要堆外内存?
- 避免二次拷贝 :如果是传统的堆内内存(HeapByteBuffer),当进行 I/O 操作(如
write系统调用)时,JVM 必须先将堆内数据拷贝到堆外的一块临时缓冲区(C 堆),然后再传给操作系统。因为 JVM 存在 GC(垃圾回收),如果直接把堆内内存地址传给内核,一旦发生 GC 导致内存地址移动(Compact),I/O 数据就会损坏。 - 减少 GC 压力:堆外内存不受 JVM 堆大小限制,也不直接参与垃圾回收器的常规扫描。
2. DirectByteBuffer 对象的创建(Java 层源码)
当我们调用 ByteBuffer.allocateDirect(size) 时,实际上初始化的是 DirectByteBuffer。
java
// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.java
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
// 1. 是否需要按页对齐,默认不需要
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
// 2. 预分配内存计数,如果超出了 -XX:MaxDirectMemorySize 则触发 GC 或抛出 OOM
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 3. 调用底层 Unsafe 分配堆外内存,返回 64 位内存绝对地址
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 4. 初始化内存空间为 0
unsafe.setMemory(base, size, (byte)0);
// 5. 计算实际使用的地址
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 6. 注册 Cleaner,用于堆外内存的虚引用异步回收
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
3. 底层 C++ 内存分配(JVM 源码)
unsafe.allocateMemory(size) 映射到 JVM 源码中的 unsafe.cpp:
cpp
// openjdk/hotspot/src/share/vm/prims/unsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
UnsafeWrapper("Unsafe_AllocateMemory");
size_t sz = (size_t)size;
if (sz != size) return 0;
// 调用 standard C 的 os::malloc 分配堆外内存
void* x = os::malloc(sz, mtInternal);
if (x == NULL) {
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}
// 返回内存指针的 64 位长整型值给 Java 层
return addr_to_java(x);
UNSAFE_END
系统级原理 :
os::malloc在 Linux 环境下,底层调用的是 glibc 的malloc()。根据申请空间的大小,内核会选择使用brk()(小内存,移动堆顶指针)或mmap()(大内存,匿名私有映射)系统调用来分配虚拟内存。
4. 堆外内存的回收机制(Cleaner 与虚引用)
DirectByteBuffer 本身是一个普通的 Java 对象,占用堆内极小的空间(仅保存 address、capacity 等属性)。如果长寿代对象持有堆外内存,极易导致堆内空间充足,但堆外内存被耗尽(OOM)。
OpenJDK 使用 sun.misc.Cleaner(一种基于虚引用 PhantomReference 的机制)来追踪 DirectByteBuffer 的生命周期:
java
// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.java
private static class Deallocator implements Runnable {
private static unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
return;
}
// 1. 释放堆外内存
unsafe.freeMemory(address);
address = 0;
// 2. 更新 Bits 中记录的直接内存使用量
Bits.unreserveMemory(size, capacity);
}
}
- 触发时机 :当
DirectByteBuffer对象在 Java 堆中不再被强引用,并被垃圾回收器(GC)标记为可回收时,JVM 会将其加入到ReferenceQueue。Java 的ReferenceHandler线程会周期性地执行Cleaner.clean(),最终调用Deallocator.run()内的unsafe.freeMemory(address),释放底层的 C 堆内存。
二、 NIO 零拷贝原理剖析
传统 I/O(如 InputStream.read + OutputStream.write)涉及 4 次上下文切换 和 4 次数据拷贝:
- 读:网卡/磁盘 → \rightarrow → 内核读缓冲区(DMA 拷贝) → \rightarrow → 用户空间缓冲区(CPU 拷贝)。
- 写:用户空间缓冲区 → \rightarrow → 内核写(Socket)缓冲区(CPU 拷贝) → \rightarrow → 网卡/磁盘(DMA 拷贝)。
NIO 主要通过两种方式实现"零拷贝"(通常指减少或消除 CPU 拷贝):
FileChannel.map()(基于mmap系统调用)FileChannel.transferTo()(基于sendfile系统调用)
1. 机制一:MappedByteBuffer 与 mmap
FileChannel.map() 将文件的一部分直接映射到进程的虚拟内存空间中。
Java 源码入口:
java
// openjdk/jdk/src/share/classes/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 {
// 调用 native 方法进行内存映射
addr = map0(imode, mapPosition, mapSize);
} catch (IOException x) {
// ... 异常处理 ...
}
// 返回 DirectByteBuffer 的子类 MappedByteBuffer
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, fd);
return Util.newMappedByteBuffer(isize, addr + pagePosition, fd, um);
}
Native 底层实现:
map0 对应 FileChannelImpl.c:
c
// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this, jint imode,
jlong jpos, jlong size)
{
// ... 获取文件描述符 fd ...
void *mapAddress = 0;
// 调用 Linux 标准的 mmap64 系统调用
mapAddress = mmap64(
0, // 由内核决定映射区的起始虚拟地址
size, // 映射空间的长度
protections, // 映射区的读写权限 (PROT_READ / PROT_WRITE)
flags, // MAP_SHARED (共享映射,修改会同步到磁盘)
fd, // 映射文件的描述符
jpos // 文件偏移量
);
if (mapAddress == MAP_FAILED) {
JNU_ThrowIOExceptionWithLastError(env, "Map failed");
return 0;
}
return ((jlong)mapAddress);
}
系统级零拷贝原理解析(mmap):
mmap建立了用户虚拟空间到内核文件页缓存(Page Cache)的直接映射。- 拷贝次数减半 :当用户进程读写这块内存时,通过缺页中断(Page Fault)将磁盘数据通过 DMA 拷贝到 Page Cache。由于用户空间和内核空间共享这段物理内存,不需要进行从内核态到用户态的 CPU 拷贝。
- 代价与限制:数据仍然需要经过 Page Cache,写出时需要 DMA 拷贝到网卡。适合大文件的随机读写。
2. 机制二:FileChannel.transferTo() 与 sendfile
当需要将文件发送到 Socket 网卡时,transferTo 可以实现真正意义上的零(CPU)拷贝。
Java 源码入口:
java
// openjdk/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;
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
// 2. 如果 target 不是普通 Socket 通道,退化为使用 MappedByteBuffer
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
// 3. 最坏情况:普通循环读写拷贝
return transferToArbitraryChannel(position, icount, target);
}
我们重点看 transferToDirectly 如何触发系统调用:
java
// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java
private long transferToDirectly(long position, int icount, WritableByteChannel target)
throws IOException
{
// 确保 target 是一个来自 SocketChannel 的文件描述符
FileDescriptor targetFD = ((SelChImpl)target).getFD();
int thisFDVal = IOUtil.fdVal(fd);
int targetFDVal = IOUtil.fdVal(targetFD);
long n = 0;
try {
// 调用 Native 的 transferTo0
n = transferTo0(fd, position, icount, targetFDVal);
} finally {
// ... 跟踪统计 ...
}
return n;
}
Native 底层实现:
transferTo0 映射到 Linux 平台下的 FileChannelImpl.c:
c
// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFDOBuffer, jlong position,
jlong count, jint dstFD)
{
// ... 获取源文件描述符 srcFD ...
off64_t offset = (off64_t)position;
// 直接调用 Linux 内核的 sendfile64 系统调用
ssize_t result = sendfile64(
dstFD, // 目标描述符:通常是 Socket fd
srcFD, // 源描述符:文件 fd
&offset, // 文件读取的偏移量
(size_t)count // 传输的字节数
);
if (result < 0) {
// 处理 EAGAIN 或 EINTR 信号
if (errno == EAGAIN) return IOS_UNAVAILABLE;
if (errno == EINTR) return IOS_INTERRUPTED;
// 如果系统不支持 sendfile(例如不支持该文件类型),返回 UNSUPPORTED 让 Java 层退化处理
if (errno == ENOSYS || errno == EINVAL) return IOS_UNSUPPORTED_CASE;
JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
return 0;
}
return result;
}
系统级零拷贝原理解析(sendfile):
sendfile 系统调用将上下文切换从 4 次减少到了 2 次。数据流转过程如下:
- Linux 内核 2.1 版本(标准 sendfile):
- 磁盘 → DMA \xrightarrow{\text{DMA}} DMA 内核 Page Cache。
- CPU 拷贝 :内核 Page Cache → CPU \xrightarrow{\text{CPU}} CPU Socket 缓冲区。
- Socket 缓冲区 → DMA \xrightarrow{\text{DMA}} DMA 网卡。
- 总计:2 次上下文切换,3 次拷贝(2次DMA,1次CPU)。
- Linux 内核 2.4+ 版本(带 SG-DMA 技术的 sendfile):
- 磁盘 → DMA \xrightarrow{\text{DMA}} DMA 内核 Page Cache。
- 消除 CPU 拷贝 :不再拷贝整个数据到 Socket 缓冲区,而是仅仅把 内存地址和偏移量的描述符(Descriptor) 复制到 Socket 缓冲区。
- 网卡的 DMA 引擎直接通过 Scatter-Gather (分散-聚集) 功能,根据描述符的指引直接去 Page Cache 读取数据并发送。
- 总计 :2 次上下文切换,2 次拷贝(均为 DMA 拷贝,0 次 CPU 拷贝)。
三、 架构总结
| 机制 | OpenJDK 8u 底层实现 | Linux 核心系统调用 | CPU 拷贝次数 | 主要解决痛点 |
|---|---|---|---|---|
| DirectByteBuffer | unsafe.allocateMemory |
malloc / brk / mmap |
I/O 时 0 次 (不需复制到C堆) | 消除 JVM 堆到 C 堆的二次拷贝,减轻 GC 压力 |
| MappedByteBuffer | FileChannelImpl.map0 |
mmap64 |
0 次(进程与内核共享内存) | 大文件高频随机读写,减少内核态到用户态的拷贝 |
| FileChannel.transferTo | FileChannelImpl.transferTo0 |
sendfile64 (带 SG-DMA) |
完全 0 次 | 消除网络文件传输(如 Kafka、Tomcat)中的所有 CPU 拷贝 |