Java NIO内存管理机制剖析
Java NIO内存管理机制剖析
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
NIO内存管理机制剖析
作为软件开发工程师,深入理解 Java NIO(New I/O)的内存管理机制对于构建高性能、低延迟的分布式系统至关重要。Java NIO 的核心优势之一在于支持直接内存(Direct Memory),它允许 Java 程序直接分配 OS 本地堆内存,从而绕过 JVM 堆,减少数据在内核空间与用户空间之间的拷贝次数。
以下结合 OpenJDK 8u(具体对应 jdk8u/jdk/src/share/classes/java/nio/ 及 sun/misc/)的源码,对 Java NIO 内存管理机制进行全景式的深度剖析。
1. Java NIO 内存架构概述:Heap vs Direct
传统的 HeapByteBuffer(堆字节缓冲区)将数据存储在 JVM 堆内(通过 byte[] 数组)。在进行 I/O 操作(如 Socket 读写、File 读写)时,JVM 必须先将堆内数据拷贝到操作系统内核态的临时缓冲区(Intermediate Buffer)中,然后再交给操作系统内核处理。这是因为 JVM 的垃圾回收器(GC)可能会随时移动堆内对象的位置,导致其物理地址发生改变。
DirectByteBuffer 则直接通过操作系统的 malloc 族函数在堆外分配内存,并在 Java 堆内保留一个极小的引用对象。
在 NIO 中,Buffer 分为两大类:HeapByteBuffer(堆缓冲区)和 DirectByteBuffer(直接缓冲区)。
| 特性 | HeapByteBuffer | DirectByteBuffer |
|---|---|---|
| 内存分配位置 | JVM 堆内(Java Heap) | 操作系统本地内存(Native Memory / Off-heap) |
| 分配与销毁开销 | 极低(仅底层 byte 数组分配) | 较高(涉及 OS 系统调用 malloc/free) |
| GC 影响 | 受 GC 管辖,频繁并发可能导致存活对象晋升 | 不受 JVM 堆 GC 直接管辖,减轻 GC 暂停压力 |
| I/O 性能 | 慢(需要先拷贝到临时的 Native Buffer 再调用内核) | 快(通过零拷贝或单次拷贝直接与内核交互) |
为什么 HeapByteBuffer 无法直接用于内核 I/O?
JVM 在执行垃圾回收(GC)时,通常会移动对象以压缩堆空间(例如 Mark-Compact 算法)。如果直接将 JVM 堆内数组的内存地址传给操作系统的 read 或 write 系统调用,在 I/O 读写过程中一旦发生 GC,数组的物理地址就会发生改变,从而导致内存损坏。因此,JVM 在处理 HeapByteBuffer 的 I/O 时,必须先在堆外复制一份临时的 Direct Buffer,再将堆外地址传给操作系统。
2. DirectByteBuffer 分配流程源码解析
DirectByteBuffer 的创建始于 ByteBuffer.allocateDirect(int capacity)。
2.1 构造函数入口
在线索追踪到 java.nio.DirectByteBuffer 的构造函数中:
// java/nio/DirectByteBuffer.java
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
// 1. 是否需要页面对齐,由 -XX:+PageAlignDirectMemory 参数控制
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
// 2. 核心:预留内存控制与配额检查
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 3. 调用 Unsafe 分配物理内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 4. 初始化内存空间(清零)
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;
}
2.2 内存准入网关:Bits.reserveMemory
Bits.reserveMemory 是控制直接内存大小时的核心闸门。它负责检查当前已分配的直接内存是否超过了 -XX:MaxDirectMemorySize 指定的上限。如果不足,它会尝试触发 GC 来回收直接内存。
// java/nio/Bits.java
static void reserveMemory(long size, int cap) {
// 获取允许的最大直接内存,默认约等于 Runtime.getRuntime().maxMemory()
if (!maxMemoryInitialized) {
if (!VM.isBooted()) return;
maxMemory = VM.maxDirectMemory();
maxMemoryInitialized = true;
}
// 乐观锁:尝试直接原子增加预留计数
if (tryReserveMemory(size, cap)) {
return; // 分配成功
}
// 如果空间不够,进入悲观控制链:尝试回收
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
// 循环尝试执行挂起的 Reference 的 clean 操作(释放由于 DirectByteBuffer 死亡带来的堆外内存)
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
// 显式触发 System.gc(),寄希望于 Full GC 来回收不可达的 DirectByteBuffer
System.gc();
// 阶梯式休眠等待 GC 线程异步执行 Cleaner
long sleepTime = 1;
int maxSleeps = 9; // 最大重试 9 次,总休眠时间 1+2+4+8+...+256 ms
for (int i = 0; i < maxSleeps; i++) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (tryReserveMemory(size, cap)) {
return;
}
sleepTime <<= 1; // 倍增休眠时间
}
// 彻底没有空间,抛出 OOM
throw new OutOfMemoryError("Direct buffer memory");
}
系统工程师笔记: > 很多高并发系统会设置
-XX:+DisableExplicitGC来防止应用显式调用System.gc()引发长时间 STW。但在 OpenJDK 8中,如果同时设置了该参数且直接内存用尽,Bits.reserveMemory内部的System.gc()将会变成空操作(No-op),导致上面代码中的tryReserveMemory大概率持续失败,最终频繁引发java.lang.OutOfMemoryError: Direct buffer memory。
3. Direct Memory 的自动回收机制
由于 DirectByteBuffer 本身是在 Java 堆内创建的对象(占用几十个字节),而它指向的堆外纯净内存在 OS 侧。当 DirectByteBuffer 对象在 Java 堆中不可达并被 GC 回收时,必须有一种机制同步释放对应的堆外内存,否则会导致严重的本地内存泄漏。
OpenJDK 8使用 sun.misc.Cleaner(基于虚引用 PhantomReference)来实现这一自动释放过程。
3.1 Cleaner 与 Deallocator 的绑定
看回构造函数中的这一行:
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
Deallocator 是一个实现了 Runnable 接口的内部静态类,保存了本地内存的基地址 base 和大小 size:
// java/nio/DirectByteBuffer.java -> Deallocator
private static class Deallocator implements Runnable {
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;
}
// 调用 Unsafe 释放底层 OS 内存
unsafe.freeMemory(address);
address = 0;
// 扣减 Bits 中的内存计数
Bits.unreserveMemory(size, capacity);
}
}
回收的完整闭环
- 对象死亡:
DirectByteBuffer在 Java 堆内变为垃圾。 - 触发 GC:JVM 进行 Scavenge/Full GC。发现该对象仅有虚引用指向。
- 入队挂起:JVM 将该
Cleaner放入Reference.pending队列。 - 异步唤醒:Java 高优先级守护线程
ReferenceHandler循环取出队列中的元素,如果发现是Cleaner,则调用cleaner.clean()。 - 执行释放:
cleaner.clean()内部会调用Deallocator.run(),最终执行unsafe.freeMemory(address),内存归还给操作系统。
3.2 sun.misc.Cleaner 的执行原理
Cleaner 继承自 java.lang.ref.PhantomReference。当 JVM 检测到 DirectByteBuffer 对象仅被虚引用指向(即 Java 堆中已无强引用)时,GC 会将该 Cleaner 对象加入到 java.lang.ref.Reference 的 pending 链表中。
JVM 的全局守护线程 Reference Handler 会不断轮询该链表,并最终调用 Cleaner.clean() 方法:
// sun/misc/Cleaner.java
public class Cleaner extends PhantomReference<Object> {
// 双向链表,用于防止 Cleaner 本身被 GC 回收
private static Cleaner first = null;
private final Runnable thunk; // 保存传入的 Deallocator
public void clean() {
if (!remove(this))
return;
try {
// 调用 Deallocator.run() 执行真正的内存释放
thunk.run();
} catch (final Throwable x) {
// 保护机制:如果清理失败,打印错误,防止阻塞 Reference Handler 线程
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x).printStackTrace();
return null;
}});
}
}
}
4. I/O 交互中的临时 Direct Buffer 机制
在日常开发中,即使我们显式使用了 HeapByteBuffer(例如 ByteBuffer.allocate(1024))来进行 Channel 读写,NIO 框架底层仍然会与直接内存产生隐式交互。
以 FileChannelImpl.write(ByteBuffer src) 为例,追踪至 sun.nio.ch.IOUtil:
// sun/nio/ch/IOUtil.java
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
throws IOException
{
// 如果本来就是 DirectBuffer,直接进 Native 层写
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// 程序走到这里,说明传入的是 HeapByteBuffer
int pos = src.position();
int limit = src.limit();
int rem = (pos <= limit ? limit - pos : 0);
// 1. 从线程局部缓存(ThreadLocal)中获取一个复用的临时 DirectByteBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
// 2. 将 HeapByteBuffer 中的数据拷贝到临时的 DirectByteBuffer 中
bb.put(src);
bb.flip();
src.position(pos); // 恢复原 buffer 指针
// 3. 调用 Native 方法,传入直接内存的物理地址进行 I/O
int n = writeFromNativeBuffer(fd, bb, position, nd);
if (n > 0) {
src.position(pos + n);
}
return n;
} finally {
// 4. 将临时的 DirectBuffer 放回 ThreadLocal 缓存池,供下次复用
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
为什么 NIO 要引入 ThreadLocal 临时缓冲区?
- 规避 GC 带来的物理地址漂移:前面提到,内核必须拿到固定的内存物理地址。
- 极高地提升性能:如果不使用
Util.getTemporaryDirectBuffer做 ThreadLocal 级别的复用,每一次对HeapByteBuffer的读写都会导致动态地allocateDirect(引发本地系统调用malloc和Cleaner的注册),高并发下这将造成严重的内核态自旋和 OOM。
5. 系统视角的性能调优与监控建议
理解了 OpenJDK 8的源码后,在生产环境下进行 Java NIO 内存管理和调优时,有以下几个核心方针:
5.1 监控直接内存
由于直接内存不属于 JVM 堆,常规的 jstat 或 visualvm 默认图表无法直观看到它的精确用量。
- 原生工具:使用
jcmd <pid> VM.native_memory detail(需要配置-XX:NativeMemoryTracking=detail)来追踪Internal和GC分类的堆外内存分配。 - JMX 暴露:NIO 已经通过 MBean 将信息注册到了平台。可以通过代码或 JConsole 读取
java.nio:type=BufferPool,name=direct属性下的Count、TotalCapacity和MemoryUsed。
5.2 参数调优规避隐患
- 明确设置边界:务必显式设置
-XX:MaxDirectMemorySize=2G(结合机器物理内存与堆内存合理规划),防止堆外内存无限膨胀击穿 OS 限制被系统 OOM Killer 杀掉。 - 谨慎使用 ExplicitGC 禁用:如果配置了
-XX:+DisableExplicitGC,请务必在 Netty 或高性能框架中依赖其自带的内存池(如 Netty 的PooledByteBufAllocator),手动通过ReferenceCountUtil.release()显式释放内存,不要指望Bits.reserveMemory里的System.gc()能帮应用兜底。 - 主动释放:在不需要利用虚引用等待 GC 回收的特殊场景(例如大文件单次传输映射),可以通过反射手动调用
DirectByteBuffer的cleaner().clean(),实现即时、确定性的堆外内存释放。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)