直接内存管理机制剖析
- 前言
- 直接内存管理机制剖析
-
- [一、 JVM 直接内存(Direct Memory)架构概述](#一、 JVM 直接内存(Direct Memory)架构概述)
- [二、 Java 层面的分配与配额监控](#二、 Java 层面的分配与配额监控)
-
- [1. 入口:`DirectByteBuffer` 构造器源码深度解析](#1. 入口:
DirectByteBuffer构造器源码深度解析) - [2. 闸门守卫:`Bits.reserveMemory` 的两套演进机制](#2. 闸门守卫:
Bits.reserveMemory的两套演进机制) -
- [机制 A:早期 OpenJDK 8的经典同步锁实现(单线程瓶颈)](#机制 A:早期 OpenJDK 8的经典同步锁实现(单线程瓶颈))
- [机制 B:后期OpenJDK 8优化版(使用 `AtomicLong` 与 `JavaLangRefAccess` 进行精细控制)](#机制 B:后期OpenJDK 8优化版(使用
AtomicLong与JavaLangRefAccess进行精细控制))
- [1. 入口:`DirectByteBuffer` 构造器源码深度解析](#1. 入口:
- [三、 C++ 虚拟机本地层面的分配桥梁](#三、 C++ 虚拟机本地层面的分配桥梁)
-
- [1. `unsafe.cpp` 层的内存映射](#1.
unsafe.cpp层的内存映射) - [2. `os::malloc` 层对标准 C 库的调用](#2.
os::malloc层对标准 C 库的调用)
- [1. `unsafe.cpp` 层的内存映射](#1.
- [四、 堆外内存的回收机制(生命周期终点)](#四、 堆外内存的回收机制(生命周期终点))
-
- [1. 回收器的核心实现 `Deallocator`](#1. 回收器的核心实现
Deallocator) - [2. 两种回收路径](#2. 两种回收路径)
-
- [路径 A:GC 触发的主动/被动回收](#路径 A:GC 触发的主动/被动回收)
- [路径 B:应用层手动干预回收](#路径 B:应用层手动干预回收)
- [1. 回收器的核心实现 `Deallocator`](#1. 回收器的核心实现
- [五、 排查与踩坑指南](#五、 排查与踩坑指南)
-
- [1. 致命参数:`-XX:+DisableExplicitGC`](#1. 致命参数:
-XX:+DisableExplicitGC) - [2. 内存监控的利器:NMT (Native Memory Tracking)](#2. 内存监控的利器:NMT (Native Memory Tracking))
- [1. 致命参数:`-XX:+DisableExplicitGC`](#1. 致命参数:
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
直接内存管理机制剖析
一、 JVM 直接内存(Direct Memory)架构概述
在 Java 应用程序中,直接内存(Direct Memory)也就是常说的堆外内存 (Off-Heap Memory)。它不属于 JVM 堆(Heap Memory)的一部分,而是通过 java.nio.ByteBuffer.allocateDirect(int) 调用的、直接向操作系统申请的本地内存。
为什么引入直接内存?
- 实现零拷贝(Zero-Copy):如果数据存在 JVM 堆内,在执行 I/O 操作(如网卡发送、磁盘写入)时,JVM 虚拟机必须先将堆内数据复制到操作系统内核的临时缓冲区(临时的堆外内存空间),然后由内核发起 I/O。而直接内存允许 Java 代码和操作系统直接共享这一块内存区域,规避了中间的二次内存拷贝。
- 减轻垃圾回收(GC)压力 :大型的缓冲区如果驻留在堆中,会产生大量生命周期长的老年代对象,增加 Full GC 的停顿时间(STW)。堆外内存由底层 C/C++ 的
malloc分配,其回收不直接依仗复杂的 GC 标记复制算法。
二、 Java 层面的分配与配额监控
直接内存的管理在 Java 层面主要通过 java.nio.DirectByteBuffer、java.nio.Bits 以及 sun.misc.Unsafe 来协作完成。
1. 入口:DirectByteBuffer 构造器源码深度解析
当调用 ByteBuffer.allocateDirect(capacity) 时,JVM 内部实际上是创建了一个 DirectByteBuffer 实例。以下是 OpenJDK 8中该构造器的关键源码,带有详细系统级注释:
java
// 位于 openjdk8u/jdk/src/share/classes/java/nio/DirectByteBuffer.java
DirectByteBuffer(int cap) { // 包权限构造函数
super(-1, 0, cap, cap);
// 检查是否需要内存页对齐,可以通过参数 -XX:+PageAlignDirectMemory 控制
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize(); // 获取当前操作系统的一页内存大小(通常为 4KB)
// 如果需要按页对齐,实际申请的物理内存大小会增加一个页的大小,以确保能切出对齐的起始地址
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
// 【核心步骤一】:向 Bits 模块申请额度。
// 这一步会检查当前已分配的堆外内存是否超过了 -XX:MaxDirectMemorySize 的限制。
// 如果超限,会尝试在内部触发垃圾回收并进行阶段性等待,若最终仍无额度则抛出 OOM。
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 【核心步骤二】:通过 sun.misc.Unsafe 本地方法,跨越 JNI 边界向操作系统申请分配 C 堆内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
// 如果操作系统真的无可用内存分配,则回滚之前在 Bits 中记录的配额计数
Bits.unreserveMemory(size, cap);
throw x;
}
// 将分配出来的本地物理内存块中的数据全部批量初始化清零
unsafe.setMemory(base, size, (byte) 0);
// 根据是否需要页对齐,计算并保存最终对外暴露的有效内存起始虚拟地址 (address)
if (pa && (base % ps != 0)) {
// 向上取整对齐到操作系统的页边界
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 【核心步骤三】:创建虚引用追踪器(Cleaner)。
// 将当前 DirectByteBuffer 实例(this)与一个 Deallocator(释放器Runnable)绑定。
// 当 JVM 垃圾回收器检测到该 DirectByteBuffer 对象变得不可达时,
// ReferenceHandler 线程会执行这个 Cleaner,从而安全地在后台释放掉分配的底层 base 地址对应的 native 内存。
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
2. 闸门守卫:Bits.reserveMemory 的两套演进机制
Bits.reserveMemory 是 JVM 堆外内存防线的核心守卫,用来确保进程分配的直接内存不会无限制膨胀导致物理机崩溃。
架构师视角注:在 OpenJDK 8的演进过程中(早期版本与中后期 Update 版本),这块代码经历了一次重大的无锁化重构。
机制 A:早期 OpenJDK 8的经典同步锁实现(单线程瓶颈)
java
// 经典早期 openjdk8实现
static void reserveMemory(long size, int cap) {
// 第一次调用时,初始化获取允许分配的最大堆外内存上限(默认通常等同于 -Xmx 的最大堆内存)
synchronized (Bits.class) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory(); // 对应 -XX:MaxDirectMemorySize
memoryLimitSet = true;
}
// 检查:如果当前"已分配总量 + 本次申请量"小于等于最大限制,说明额度充足
if (cap <= maxMemory - totalCapacity) {
reservedMemory += size;
totalCapacity += cap;
count++;
return; // 成功拿到额度,直接返回
}
}
// 执行到这里,说明堆外内存配额不足了!
// 最后的挣扎:显式调用 System.gc(),期望通过 Full GC 来强迫回收那些已经死亡、但还没被清理的 DirectByteBuffer 对象
System.gc();
try {
// 强行休眠 100ms,给 GC 线程和 ReferenceHandler 线程预留时间去执行 Cleaner.clean() 以释放部分内存
Thread.sleep(100);
} catch (InterruptedException x) {
Thread.currentThread().interrupt();
}
// 再次进入同步块检查额度
synchronized (Bits.class) {
if (totalCapacity + cap > maxMemory) {
// 如果 GC 后依然凑不出足够的堆外额度,抛出臭名昭著的直接内存 OOM
throw new OutOfMemoryError("Direct buffer memory");
}
reservedMemory += size;
totalCapacity += cap;
count++;
}
}
机制 B:后期OpenJDK 8优化版(使用 AtomicLong 与 JavaLangRefAccess 进行精细控制)
早期版本通过 synchronized (Bits.class) 进行全局加锁,在高并发分配堆外内存时会导致严重的线程阻塞。后期优化的 OpenJDK 8引入了 SharedSecrets 跨包调用机制,主动去推进虚引用的回收流程:
java
// 优化后的后期 openjdk8u/jdk/src/share/classes/java/nio/Bits.java
static void reserveMemory(long size, int cap) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// 乐观尝试:内部通过 AtomicLong.compareAndSet (CAS) 尝试扣减配额,无锁高效率
if (tryReserveMemory(size, cap)) {
return;
}
// 走到这里说明配额不足,获取操作底层引用队列的后门接口
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
// 循环推进那些已经进入 pending 状态(已被 GC 发现不可达)但尚未被 Cleaner 处理的虚引用,
// 在当前分配线程内直接同步触发它们的释放,自救式地抢救直接内存额度。
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
// 如果依然不够,主动触发全局轻量/重量 GC
System.gc();
// 采用指数回退休眠策略,最多重试 9 次,总休眠时间大约为 0.5 秒左右
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
// 如果无法处理更多 pending 引用,则休眠当前线程,等待 GC 异步处理
if (!jlra.tryHandlePendingReference()) {
try {
Thread.sleep(sleepTime);
sleepTime <<= 1; // 1ms, 2ms, 4ms, 8ms... 指数递增
sleeps++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// 依然无济于事,无情抛出 OOM
throw new OutOfMemoryError("Direct buffer memory");
}
三、 C++ 虚拟机本地层面的分配桥梁
在 Java 层调用 unsafe.allocateMemory(size) 后,控制权移交给了 HotSpot 虚拟机的 C++ 本地代码。
1. unsafe.cpp 层的内存映射
cpp
// 位于 openjdk8u/hotspot/src/share/vm/prims/unsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
UnsafeWrapper("Unsafe_AllocateMemory");
// 参数边界合法性校验
if (size < 0) {
THROW_0(vmSymbols::java_lang_IllegalArgumentException());
}
size_t sz = (size_t)size;
if (sz != size) { // 防止 64 位整型截断导致的内存溢出漏洞
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}
// 调用本地操作系统的分发器进行 malloc 分配。
// mtInternal 标签将其归类为 JVM 内部组件内存,用于 NMT (Native Memory Tracking) 统计。
void* x = os::malloc(sz, mtInternal);
if (x == NULL) {
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}
// 将 C 堆分配的虚拟内存地址转换为 64 位 long 型指针,返回给 Java 层
return addr_to_java(x);
UNSAFE_END
2. os::malloc 层对标准 C 库的调用
HotSpot 没有自己造轮子去构建一整套堆外虚拟内存分配器,它底层依然是封装了操作系统的标准 C 库(如 Linux 下的 glibc、ptmalloc)。
cpp
// 位于 openjdk8u/hotspot/src/share/vm/runtime/os.cpp
void* os::malloc(size_t size, MEMFLAGS memflags, const NativeCallStack& stack) {
if (size == 0) {
size = 1;
}
// 如果开启了 JVM 原生内存追踪 (-XX:NativeMemoryTracking=detail),
// 实际分配的大小会额外加上一小块 MallocHeader 的字节大小,用来存储该内存块的元数据。
size_t alloc_size = size + MemTracker::malloc_header_size(memflags);
// 【真正底层的系统调用】:发起标准 C 库的 malloc 动作。
// 这会引发操作系统内核的虚拟内存分配(如 sbrk 或 mmap 分配)
void* ptr = ::malloc(alloc_size);
if (ptr == NULL) {
return NULL;
}
// 将分配信息注册到 NMT 监控树中,供 jcmd 命令查询,最后返回给上层
return MemTracker::record_free_and_malloc(ptr, size, memflags, stack);
}
四、 堆外内存的回收机制(生命周期终点)
直接内存的自动回收是依托于 sun.misc.Cleaner 这一特殊的虚引用(PhantomReference)实现的。
1. 回收器的核心实现 Deallocator
在 DirectByteBuffer 构造时注册的 Deallocator 扮演了最终斩断内存占用的刽子手角色:
java
// 位于 openjdk8u/jdk/src/share/classes/java/nio/DirectByteBuffer.java 内部类
private static class Deallocator implements Runnable {
private static long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// 防御性编程:避免重复释放导致操作系统 Segment Fault 崩溃
return;
}
// 【核心】:反向调用 Unsafe 释放掉这块本地 C 堆内存,底层对应的是系统的 ::free(address)
unsafe.freeMemory(address);
address = 0;
// 归还额度:内存成功释放后,扣减 Bits 中记录的已用直接内存总量
Bits.unreserveMemory(size, capacity);
}
}
2. 两种回收路径
路径 A:GC 触发的主动/被动回收
- 当 Java 堆内的
DirectByteBuffer对象生命周期结束,变得不可达时。 - 在下一次垃圾回收(可以是 Young GC 或 Full GC)中,JVM 发现该对象只被虚引用(
Cleaner)所指向。 - GC 机制会将该
Cleaner对象放入java.lang.Reference的pending队列中。 - JVM 唯一的守护线程
Reference Handler会不断读取该队列,并调用cleaner.clean()方法。 clean()方法最终调用Deallocator.run(),执行unsafe.freeMemory归还给系统。
路径 B:应用层手动干预回收
由于 GC 触发回收具有明显的延迟性(如果堆内存充足,长时间不触发 GC,即使直接内存满了也不会主动回收),业界大名鼎鼎的框架(如 Netty)普遍采用手动释放方案:
java
// 手动释放 DirectByteBuffer 堆外内存的标准工业写法
if (buffer.isDirect()) {
Cleaner cleaner = ((DirectBuffer) buffer).cleaner();
if (cleaner != null) {
cleaner.clean(); // 绕过 GC,立即引发底层 ::free 动作,高并发下极大地提升了稳定性
}
}
五、 排查与踩坑指南
作为软件开发工程师,在基于 OpenJDK 8架构构建高并发应用时,必须特别注意以下两个直接内存相关的底层行为缺陷:
1. 致命参数:-XX:+DisableExplicitGC
很多线上调优指南会推荐配置 -XX:+DisableExplicitGC 以禁止代码中误调 System.gc() 引发频繁 Full GC 停顿。
- 后果 :由于上面源码分析所示,
Bits.reserveMemory在额度不足时的"最后挣扎"全盘依赖System.gc()。一旦禁用了显式 GC,该方法内部的System.gc()将变为空指针式失效,线程休眠 100ms 后依然无法等来引用释放。 - 现象 :线上会毫无征兆地频繁爆出
java.lang.OutOfMemoryError: Direct buffer memory,但此时机器的物理内存和 Java 堆内存都极度空闲。 - 对策 :如果必须优化显式 GC,建议改用
-XX:+ExplicitGCInvokesConcurrent(允许并发执行显式 GC),绝不能粗暴禁用。
2. 内存监控的利器:NMT (Native Memory Tracking)
直接内存在操作系统的命令(如 top)中表现为进程的 RES(常驻内存)持续升高,常规的 Java 监控工具(如 jstat、jmap)完全无法洞察这部分数据。
- 解决方案:
- 启动 JVM 时配置
-XX:NativeMemoryTracking=detail。 - 在线上通过 JDK 自带的
jcmd动态分析:
bash
# 建立内存快照基线
jcmd <PID> VM.native_memory baseline
# 运行一段时间后对比,排查是否存在堆外内存泄露
jcmd <PID> VM.native_memory detail.diff
- 查看输出结果中
Internal区域(对应mtInternal标签)的变更,即可精准定位是不是DirectByteBuffer的分配导致了操作系统内存的耗尽。