JVM直接内存管理机制剖析

直接内存管理机制剖析

  • 前言
  • 直接内存管理机制剖析
    • [一、 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优化版(使用 AtomicLongJavaLangRefAccess 进行精细控制))
    • [三、 C++ 虚拟机本地层面的分配桥梁](#三、 C++ 虚拟机本地层面的分配桥梁)
      • [1. `unsafe.cpp` 层的内存映射](#1. unsafe.cpp 层的内存映射)
      • [2. `os::malloc` 层对标准 C 库的调用](#2. os::malloc 层对标准 C 库的调用)
    • [四、 堆外内存的回收机制(生命周期终点)](#四、 堆外内存的回收机制(生命周期终点))
      • [1. 回收器的核心实现 `Deallocator`](#1. 回收器的核心实现 Deallocator)
      • [2. 两种回收路径](#2. 两种回收路径)
        • [路径 A:GC 触发的主动/被动回收](#路径 A:GC 触发的主动/被动回收)
        • [路径 B:应用层手动干预回收](#路径 B:应用层手动干预回收)
    • [五、 排查与踩坑指南](#五、 排查与踩坑指南)
      • [1. 致命参数:`-XX:+DisableExplicitGC`](#1. 致命参数:-XX:+DisableExplicitGC)
      • [2. 内存监控的利器:NMT (Native Memory Tracking)](#2. 内存监控的利器:NMT (Native Memory Tracking))

前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

直接内存管理机制剖析

一、 JVM 直接内存(Direct Memory)架构概述

在 Java 应用程序中,直接内存(Direct Memory)也就是常说的堆外内存 (Off-Heap Memory)。它不属于 JVM 堆(Heap Memory)的一部分,而是通过 java.nio.ByteBuffer.allocateDirect(int) 调用的、直接向操作系统申请的本地内存。

为什么引入直接内存?

  1. 实现零拷贝(Zero-Copy):如果数据存在 JVM 堆内,在执行 I/O 操作(如网卡发送、磁盘写入)时,JVM 虚拟机必须先将堆内数据复制到操作系统内核的临时缓冲区(临时的堆外内存空间),然后由内核发起 I/O。而直接内存允许 Java 代码和操作系统直接共享这一块内存区域,规避了中间的二次内存拷贝。
  2. 减轻垃圾回收(GC)压力 :大型的缓冲区如果驻留在堆中,会产生大量生命周期长的老年代对象,增加 Full GC 的停顿时间(STW)。堆外内存由底层 C/C++ 的 malloc 分配,其回收不直接依仗复杂的 GC 标记复制算法。

二、 Java 层面的分配与配额监控

直接内存的管理在 Java 层面主要通过 java.nio.DirectByteBufferjava.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优化版(使用 AtomicLongJavaLangRefAccess 进行精细控制)

早期版本通过 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 下的 glibcptmalloc)。

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 触发的主动/被动回收
  1. 当 Java 堆内的 DirectByteBuffer 对象生命周期结束,变得不可达时。
  2. 在下一次垃圾回收(可以是 Young GC 或 Full GC)中,JVM 发现该对象只被虚引用(Cleaner)所指向。
  3. GC 机制会将该 Cleaner 对象放入 java.lang.Referencepending 队列中。
  4. JVM 唯一的守护线程 Reference Handler 会不断读取该队列,并调用 cleaner.clean() 方法。
  5. 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 监控工具(如 jstatjmap)完全无法洞察这部分数据。

  • 解决方案
  1. 启动 JVM 时配置 -XX:NativeMemoryTracking=detail
  2. 在线上通过 JDK 自带的 jcmd 动态分析:
bash 复制代码
# 建立内存快照基线
jcmd <PID> VM.native_memory baseline

# 运行一段时间后对比,排查是否存在堆外内存泄露
jcmd <PID> VM.native_memory detail.diff
  1. 查看输出结果中 Internal 区域(对应 mtInternal 标签)的变更,即可精准定位是不是 DirectByteBuffer 的分配导致了操作系统内存的耗尽。