欢迎光临
我们一直在努力

JVM堆外与Native内存分配原理

JVM堆外与Native内存分配原理

  • 前言
  • JVM堆外与Native内存分配原理
    • 1. 堆外与Native内存分配机制源码解析
      • 1.1 DirectByteBuffer 分配与回收机制
        • 内存分配流程
        • 内存回收流程
      • 1.2 JNI 内存分配与 Native 交互
        • 1. Native 库内部自由分配(无法被 JVM 约束)
        • 2. JNI 临界区内存(`GetPrimitiveArrayCritical`)
        • 3. 通过 JNI 包装已有的 Native 地址为 DirectByteBuffer
      • 1.3 Project Panama (FFM API) 内存管理机制
        • FFM API 核心对象架构
        • 源码级生命周期与分配解析
        • Panama 的分类与生命周期对比
    • 2. 系统级排查:/proc/$PID/smaps 深度解构
      • 2.1 `/proc/$PID/smaps` 关键字段解析
      • 2.2 通过 smaps 区分 JVM 堆、堆外与 Native 内存泄漏
        • 典型特征分析:
        • 快速定位 smaps 泄露的 Linux Shell 脚本
    • 3. eBPF 动态追踪 GC 观测不到的 Native 内存泄漏
      • 3.1 基于 eBPF 的 Native 内存泄漏检测 C 源码
      • 3.2 使用 BCC 工具链现场诊断 JVM Native 泄漏
        • 诊断步骤
        • 1. 启动目标 JVM 并获取进程 ID
        • 2. 使用 BCC `memleak` 跟踪目标 JVM 进程的 Native 内存泄漏
        • 3. 分析打印出的堆栈迹象
    • 4. 三类内存机制与排查策略汇总

前言

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

JVM堆外与Native内存分配原理

1. 堆外与Native内存分配机制源码解析

JVM 观测的“堆内存”(Heap Memory)由 GC 机制统一管理,而“堆外内存”(Off-heap Memory)和“本地内存”(Native Memory)则绕过了 Java 堆的回收机制。三类典型的分配方式在 JVM 及 Native 层面的实现路径存在根本性差异。

+———————————————————————————–+
| JVM Process Space |
| |
| +———————–+ +————————————————–+ |
| | Java Heap | | Native Memory | |
| | (Managed by HotSpot) | | | |
| | [ Young / Old Gen ] | | +——————–+ +——————–+ | |
| +———————–+ | | DirectByteBuffer | | Foreign Function | | |
| | | Off-Heap (Unsafe) | | & Memory API (FFM) | | |
| | +——————–+ +——————–+ | |
| | +——————–+ +——————–+ | |
| | | JNI Allocations | | C/C++ Libraries | | |
| | | (malloc/mmap) | | (glibc / jemalloc) | | |
| | +——————–+ +——————–+ | |
| +————————————————–+ |
+———————————————————————————–+


1.1 DirectByteBuffer 分配与回收机制

DirectByteBuffer 属于 Java NIO 的核心组件,其本质是在 JVM 进程的 Native 堆中通过 C 运行库的 malloc 分配一段连续内存,并由 Java 层的 DirectByteBuffer 对象持有该内存段的基地址指针(address)。

内存分配流程

通过 ByteBuffer.allocateDirect(bytes) 分配内存时,底层调用链路如下:

// 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));

// 1. 核心额度预留机制:校验并记录 DirectMemory 额度,超限则尝试触发 GC 归还
Bits.reserveMemory(size, cap);

long base = 0;
try {
// 2. 调用 Unsafe 接口在 Native 堆分配物理/虚拟内存
base = UNSAFE.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
UNSAFE.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
address = base + ps (base & (ps 1));
} else {
address = base;
}

// 3. 注册 Cleaner 对象(基于 PhantomReference),绑定释放回调 Deallocator
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

在 Bits.reserveMemory() 中,JVM 通过原子变量监控直接内存的使用量。若超出 -XX:MaxDirectMemorySize,将主动调用 System.gc() 清理被垃圾回收的 DirectByteBuffer:

// java.nio.Bits.java
static void reserveMemory(long size, int cap) {
if (!MEMORY_LIMIT_SET && VM.isModuleSystemInited()) {
MAX_MEMORY = VM.maxDirectMemory();
MEMORY_LIMIT_SET = true;
}

// 校验限额,若通过直接返回
if (tryReserveMemory(size, cap)) {
return;
}

final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

// 尝试显式触发 Reference 链处理与 DirectByteBuffer 对应的 Cleaner
boolean interrupted = false;
try {
while (jlra.waitForReferenceProcessing()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
} catch (InterruptedException e) {
interrupted = true;
}

// 若触发 Reference 挂起逻辑后仍分配失败,强制执行 System.gc() 促使 Cleaner 运行
System.gc();

// 阻塞式循环等待 Cleaner 线程将堆外内存释放
long sleepTime = 1;
int sleeps = 0;
while (!tryReserveMemory(size, cap)) {
if (sleeps >= MAX_SLEEPS) {
// 最终仍无法获取空间,抛出 Native 层面的 OOM 异常
throw new OutOfMemoryError("Direct buffer memory");
}
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
interrupted = true;
}
sleeps++;
sleepTime <<= 1;
}
}

HotSpot 内部 Unsafe_AllocateMemory 的实际 Native 实现如下:

// hotspot/share/prims/unsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong bytes)) {
if (bytes < 0) {
THROW_0(vmSymbols::java_lang_IllegalArgumentException());
}
size_t sz = (size_t)bytes;
if (sz != bytes) {
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}

// NMT (Native Memory Tracking) 校验与追踪计数
u_char* ptr = (u_char*)::os::malloc(sz, mtAllocator);
if (ptr == NULL) {
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}
return addr_to_java(ptr);
} UNSAFE_END

// hotspot/share/runtime/os.cpp
void* os::malloc(size_t size, MEMFLAGS flags, const NativeCallStack& stack) {
// HotSpot 内部封装的内存分配,实际调用 libc 的 malloc
void* ptr = ::malloc(alloc_size);
// NMT 记录该内存块由哪个 Subsystem 分配 (如 mtAllocator, mtInternal)
MemTracker::record_malloc((address)ptr, size, flags, stack, AllocFailStrategy::RETURN_NULL);
return ptr;
}

内存回收流程

DirectByteBuffer 本身位于 Java 堆,受 GC 管辖。当它没有强引用时,GC 会将对应的 Cleaner (继承自 PhantomReference) 放入 ReferenceQueue。

ReferenceHandler 线程处理该队列,并触发 Cleaner.clean() 逻辑:

// java.nio.DirectByteBuffer.java
private static class Deallocator implements Runnable {
private static Unsafe 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;
}
// 调用底层的 free 接口释放 Native 内存
unsafe.freeMemory(address);
address = 0;
// 扣减 Bits 内部的记录额度
Bits.unreserveMemory(size, capacity);
}
}

风险点:DirectByteBuffer 的生命周期强依赖 GC 垃圾回收。如果 Java 堆内存充足,GC 不触发,即使 Native 内存不足,也不会触发 Cleaner 的回收动作。如果通过参数 -XX:+DisableExplicitGC 禁用了 System.gc(),Bits.reserveMemory 的救急机制失效,将极大增加 OOM 风险。


1.2 JNI 内存分配与 Native 交互

JNI (Java Native Interface) 允许 C/C++ Native 代码直接分配和操作 Native 内存,也可以借助 JNI Env API 跨界访问 Java 堆。

1. Native 库内部自由分配(无法被 JVM 约束)

在 Native 共享库(.so / .dll)中,调用 C 标准库分配内存:

// Native C Library
JNIEXPORT void JNICALL Java_com_example_NativeLib_processData(JNIEnv *env, jobject obj, jint size) {
// 采用标准 C 堆分配,不受 MaxDirectMemorySize 或 -Xmx 任何 JVM 参数约束
void* native_buffer = malloc(size);

// 若开发人员忘记在此处调用 free(native_buffer),将引发 GC 无法感知的 Native 内存泄漏
// free(native_buffer);
}

2. JNI 临界区内存(GetPrimitiveArrayCritical)

当 Native 代码需要高效率读取 Java 堆中的数组(如 byte[])时,可以使用临界区 API:

JNIEXPORT void JNICALL Java_com_example_NativeLib_directAccess(JNIEnv *env, jobject obj, jbyteArray array) {
jboolean isCopy;
// 获取指向 Java 堆内部数组的指针;若 JVM 不支持 Pinning,则可能会分配一段临时 Native 复制缓冲区
void* data = (*env)->GetPrimitiveArrayCritical(env, array, &isCopy);

/* 在 GetPrimitiveArrayCritical 与 ReleasePrimitiveArrayCritical 之间,
JVM 可能会挂起 GC(阻止垃圾收集器移动该对象),形成临界区。*/

(*env)->ReleasePrimitiveArrayCritical(env, array, data, 0);
}

在 HotSpot 源码中,GetPrimitiveArrayCritical 的内部行为如下:

// hotspot/share/prims/jni.cpp
JNI_ENTRY_NO_PRESERVE(void*, jni_GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy))
GC_locker::lock_critical(thread); // 递增 GC lock 计数,阻塞 GC 动作
oop a = JNIHandles::resolve_non_null(array);
if (isCopy != NULL) {
*isCopy = JNI_FALSE;
}
// 直接返回 Java 堆内对象的真实地址(Pinning 操作)
return arrayOop(a)->base(type);
JNI_END

如果 Native 代码长期未释放 ReleasePrimitiveArrayCritical,GC 将被无限期阻塞,进而导致全盘 Stop-The-World 或线程无法到达 SafePoint。

3. 通过 JNI 包装已有的 Native 地址为 DirectByteBuffer

JNIEXPORT jobject JNICALL Java_com_example_NativeLib_wrapNativeMemory(JNIEnv *env, jobject obj, jlong ptr, jlong capacity) {
// 使用 Native 已分配的内存地址直接构建 DirectByteBuffer 对象
// JVM 不负责管理该 ptr 指针的生命周期,开发者需自行控制释放时机
return (*env)->NewDirectByteBuffer(env, (void*)ptr, capacity);
}


1.3 Project Panama (FFM API) 内存管理机制

Project Panama (JEP 454: Foreign Function & Memory API) 重新设计了 Java 与 Native 交互的方式,摒弃了 JNI 和 Unsafe 的安全漏洞,引入了显示的内存作用域生命周期控制(Arena)。

FFM API 核心对象架构
  • MemorySegment:代表一段连续的内存区域(可以是堆内,也可以是堆外 Native 内存)。
  • Arena:控制 MemorySegment 的生命周期,定义其分配和销毁的时机。
  • SegmentAllocator:负责分配内存段的策略模式。
源码级生命周期与分配解析

通过 Arena.ofConfined() 或 Arena.ofShared() 进行堆外内存分配:

// 使用 Panama FFM API 分配 Native 内存
try (Arena arena = Arena.ofConfined()) {
// 分配 1KB 的堆外 Native 内存
MemorySegment segment = arena.allocate(1024);

// 对 Native 内存写入数据,进行安全越界检查 (Bound Check)
segment.set(ValueLayout.JAVA_INT, 0, 42);

// 在此 Scope 内部,内存绝对安全
} // 离开 try-with-resources 块,自动触发 arena.close(),立即释放底层 Native 内存!

Arena.ofConfined() 对应的源码实现位于 jdk.internal.foreign 包中:

// jdk/internal/foreign/ConfinedArena.java
public final class ConfinedArena extends ArenaImpl {
public ConfinedArena(Thread owner) {
super(new MemorySessionImpl.Confined(owner));
}

@Override
public MemorySegment allocate(long byteSize, long byteAlignment) {
// 校验分配空间与边界
MemorySessionImpl.checkValidState(session);
// 底层直接调用 Unsafe 分配物理/虚拟内存段
long addr = MemorySessionImpl.allocateNative(byteSize, byteAlignment);
return NativeMemorySegmentImpl.makeNativeSegmentUnchecked(addr, byteSize, session);
}
}

Arena 销毁(close())的决定性代码逻辑:

// jdk/internal/foreign/MemorySessionImpl.java
public void close() {
// 1. CAS 修改 session 状态为 CLOSED
if (state.compareAndSet(STATE_OPEN, STATE_CLOSED)) {
// 2. 线程同步点:如果是 Shared Arena,会等待所有正在并发访问该 Segment 的线程退出 SafePoint
// 3. 遍历注册在该 Session 下的 Cleanup 资源,主动释放 Native 指针
alreadyClosed();
} else {
throw new IllegalStateException("Already closed or locked");
}
}

void alreadyClosed() {
// 真正的释放动作:强脱离,无须等待 GC!
for (Runnable cleanup : resourceList) {
cleanup.run(); // 底层触发 Unsafe.freeMemory(address) 或 mmap 的 munmap
}
}

Panama 的分类与生命周期对比
Arena 类型线程安全性生命期掌控方式内存释放时机底层实现原理
Arena.ofConfined() 单线程独占 显示生命周期 (AutoCloseable) 显式调用 close() 时立即释放 零 GC 参与,内部包含严格的线程所有权校验。
Arena.ofShared() 多线程共享 显示生命周期 (AutoCloseable) 显式调用 close() 时立即释放 采用 Global SafePoint/Handshake 确保所有线程停止读写后再 free。
Arena.ofAuto() 多线程共享 隐式生命周期 (由 GC 管理) 触发 GC 时回收(无确定性) 基于 Cleaner 机制,效果类似于 DirectByteBuffer。
Arena.global() 多线程共享 无限期(进程生命周期) 永不释放 全局单例,通常用于映射进程级别的常驻 C 结构体。

2. 系统级排查:/proc/$PID/smaps 深度解构

在 JVM GC 无法感知 Native 内存膨胀时,Linux 系统的 /proc/$PID/smaps 是分析物理内存(RSS)与虚拟内存(VMA)分布的关键路径。

2.1 /proc/$PID/smaps 关键字段解析

smaps 显示了当前进程中所有 Virtual Memory Area (VMA) 虚拟内存区域的映射状态。一个典型块示例如下:

7f9a40000000-7f9a44000000 rw-p 00000000 00:00 0 [anon]
Size: 65536 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 32768 kB
Pss: 32768 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 32768 kB
Referenced: 32768 kB
Anonymous: 32768 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd wr mr mw me nr ms

字段解析说明:

  • 7f9a40000000-7f9a44000000:虚拟内存起止地址,此处区间为 0x7f9a44000000 – 0x7f9a40000000 = 0x4000000(即 64MB)。
  • rw-p:VMA 访问权限,r=Read, w=Write, x=Execute, p=Private (Copy on Write)。
  • Size:该 VMA 区间申请的虚拟内存大小(64MB)。
  • Rss (Resident Set Size):当前实际分配并占用物理内存的大小。此处为 32768 kB (32MB),说明虚拟内存仅有半数页面完成了 Page Fault (缺页中断) 并映射至物理内存。
  • Pss (Proportional Share Size):按共享进程比例平摊后的物理内存大小。
  • Private_Dirty:当前进程独占且修改过的物理内存。Native 动态分配的内存泄漏主要反映在 Private_Dirty 和 Anonymous 上。
  • Anonymous:匿名内存,即无文件背景(非 .so / .jar 文件映射)的内存段。通过 malloc、mmap(MAP_ANONYMOUS) 或 brk 申请的内存均计入此项。

2.2 通过 smaps 区分 JVM 堆、堆外与 Native 内存泄漏

在生产环境中,排查内存泄漏需结合 smaps 布局的组织特征:

/proc/$PID/smaps Memory Layout Signature
+——————————————————————————-+
| Address Range | Size | VMA Mapping Source / Description |
+——————————————————————————-+
| 00000006c0000000… | 4096 MB | JVM Java Heap (-Xmx4g) |
| | | (Single continuous large [anon] block) |
+——————————————————————————-+
| 7f9a10000000… | 65536 kB | glibc ptmalloc thread arena #1 |
| 7f9a50000000… | 65536 kB | glibc ptmalloc thread arena #2 |
| 7f9a90000000… | 65536 kB | glibc ptmalloc thread arena #3 |
| | | (Multiple ~64MB Anonymous VMAs) |
+——————————————————————————-+
| 7f9b00000000… | 1024 kB | DirectByteBuffer / FFM Allocations |
| | | (Arbitrary sized custom Anonymous VMAs) |
+——————————————————————————-+
| 7f9c00000000… | Varies | Loaded `.so` native dynamic libraries |
| | | (File-backed VMAs) |
+——————————————————————————-+

典型特征分析:
  • JVM 堆 (Java Heap):
    • 在进程启动时由 JVM 预先保留,表现为一段极其巨大且连续的匿名内存(例如 Size 为 -Xmx 参数设定的 16GB)。
    • 随着对象创建,该段内存的 Rss 和 Private_Dirty 随着 GC 标记和分配而平缓波动。
  • glibc ptmalloc 线程内存池 (Thread Arenas):
    • glibc 在多线程并发执行 malloc 时,为了减少锁竞争,会为每个线程分配内存池。
    • 在 64 位 Linux 系统上,每个 Arena 的默认尺寸为 64MB。
    • 特征:smaps 中大量出现 Size 恰好为 64MB (65536 kB) 且 VmFlags 包含 anon 的连续内存段。由于内存碎片问题,这些 64MB 块的 Rss 可能只有几百 KB,但虚拟内存(VSIZ)却急剧膨胀。可以通过环境变量 export MALLOC_ARENA_MAX=4 抑制此现象。
  • Unsafe / DirectByteBuffer / Panama FFM / Native 泄漏:
    • 表现为不规则尺寸的 Anonymous 映射区,或者特定频繁递增的 Anonymous 块。
    • 如果发现 smaps 中大量出现小额匿名内存块,且 Rss 与 Private_Dirty 呈单调递增曲线,说明发生了 C/C++ 级别的 Native 泄漏(未执行 free 或未执行 munmap)。
    快速定位 smaps 泄露的 Linux Shell 脚本

    #!/bin/bash
    # 统计进程 PID 中所有 Anonymous 内存段的 Size 与 RSS,并降序打印前 10 大块
    PID=$1
    if [ -z "$PID" ]; then
    echo "Usage: $0 <PID>"
    exit 1
    fi

    awk '
    /^7/ || /^5/ { addr=$1 }
    /Size:/ { size=$2 }
    /Rss:/ { rss=$2 }
    /Anonymous:/ {
    anon=$2;
    if (anon > 0) {
    print anon " KB (Rss: " rss " KB) -> Address: " addr
    }
    }'
    /proc/$PID/smaps | sort -nr -k1 | head -n 20


    3. eBPF 动态追踪 GC 观测不到的 Native 内存泄漏

    HotSpot 提供的 Native Memory Tracking (NMT, -XX:NativeMemoryTracking=detail) 仅能记录 JVM 内部代码(通过 os::malloc / os::reserve_memory)发起的分配。对于通过 JNI 调用的 C 动态库(如 C++ 写的解析库、Netty 引入的 OpenSSL 动态库)、第三方 C++ 依赖或 Panama 调用暴露的 malloc,NMT 无法观测。

    借助 eBPF (Extended Berkeley Packet Filter) 的 uprobe 机制,可以在内核态实时钩住 libc.so.6 的 malloc、realloc、free、mmap 与 munmap,通过调用栈跟踪定位泄漏源头。

    +——————————————————————————-+
    | User Space |
    | |
    | +———————+ +——————–+ |
    | | JVM / JNI / Panama | | C/C++ Libraries | |
    | +———————+ +——————–+ |
    | | | |
    | +————–+—————+ |
    | | (call malloc/free) |
    | v |
    | +——————+ |
    | | glibc / libc | |
    | +——————+ |
    +—————————-|————————————————–+
    |
    | (uprobe / uretprobe Hook Points)
    v
    +——————————————————————————-+
    | Linux Kernel Space (eBPF VM) |
    | |
    | +————————————————————————-+ |
    | | BPF Program: trace_malloc() | |
    | | – Capture allocation: ptr, size, stack_id | |
    | | – Store into BPF Map: alloc_map.update(ptr, info) | |
    | +————————————————————————-+ |
    | | BPF Program: trace_free() | |
    | | – Capture deallocation: ptr | |
    | | – Remove from BPF Map: alloc_map.delete(ptr) | |
    | +————————————————————————-+ |
    | |
    | +————————————————————————-+ |
    | | BPF Maps: [ Address -> { Allocation Size, Stack Trace ID } ] | |
    | +————————————————————————-+ |
    +——————————————————————————-+


    3.1 基于 eBPF 的 Native 内存泄漏检测 C 源码

    下面给出一个基于 Linux BCC (BPF Compiler Collection) 引擎的 eBPF C 语言追踪核心逻辑:

    // native_leak_tracer.c
    #include <uapi/linux/ptrace.h>

    // 记录每一次内存分配的数据结构
    struct alloc_info_t {
    u64 size;
    u64 timestamp_ns;
    int stack_id;
    };

    // 哈希表 1:记录当前尚未被 free 的内存物理地址 -> 分配元数据
    BPF_HASH(alloc_map, u64, struct alloc_info_t, 100000);

    // 哈希表 2:存储内核/用户态调用栈追踪数据
    BPF_STACK_TRACE(stack_traces, 10000);

    // 暂存 uretprobe 返回地址与尺寸的辅助 Map
    BPF_HASH(sizes, u64, u64);

    // 1. Hook 位于 libc 的 malloc 入口点
    int trace_malloc_entry(struct pt_regs *ctx, size_t size) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    // 过滤目标 JVM 进程,假设 PID 从外部传入
    u32 pid = pid_tgid >> 32;

    // 暂存分配字节数,等待 malloc 返回地址
    sizes.update(&pid_tgid, (u64*)&size);
    return 0;
    }

    // 2. Hook 位于 libc 的 malloc 出口点 (uretprobe)
    int trace_malloc_return(struct pt_regs *ctx) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u64 *sizep = sizes.lookup(&pid_tgid);
    if (sizep == NULL) {
    return 0; // 未追踪到的分配入口
    }

    // 获取 malloc 返回的指针地址
    u64 addr = PT_REGS_RC(ctx);
    if (addr != 0) {
    struct alloc_info_t info = {};
    info.size = *sizep;
    info.timestamp_ns = bpf_ktime_get_ns();
    // 抓取用户态 Native 调用栈(包含 JNI、C++ 动态库、JVM C++ 代码)
    info.stack_id = stack_traces.get_stackid(ctx, BPF_F_USER_STACK);

    // 记录该地址申请事件
    alloc_map.update(&addr, &info);
    }

    sizes.delete(&pid_tgid);
    return 0;
    }

    // 3. Hook 位于 libc 的 free 入口点
    int trace_free_entry(struct pt_regs *ctx, void *ptr) {
    u64 addr = (u64)ptr;
    if (addr == 0) {
    return 0;
    }

    // 当检测到 free 时,从未释放映射集中移除
    alloc_map.delete(&addr);
    return 0;
    }


    3.2 使用 BCC 工具链现场诊断 JVM Native 泄漏

    在生产系统上,不需要直接手写内核 C 脚本,可以使用 BCC 预装的工具 memleak。

    诊断步骤
    1. 启动目标 JVM 并获取进程 ID

    jps -l
    # 输出: 88481 com.example.NativeApplication

    2. 使用 BCC memleak 跟踪目标 JVM 进程的 Native 内存泄漏

    # -p 88481: 追踪 JVM 进程 PID
    # -a: 打印尚未释放的内存块分布
    # –top=10: 打印泄漏量最高的前 10 个调用栈
    # -o 100000: 排除掉分配时间短于 100000 毫秒(100秒)的临时对象,精准捕获长期未释放的泄漏
    sudo /usr/share/bcc/tools/memleak -p 88481 -a –top=10 -o 100000

    3. 分析打印出的堆栈迹象

    运行一段时间后,BCC memleak 会直接输出导致 Native 内存增长的最关键调用栈(含 C++ 代码行号或 Symbol):

    [17:30:12] Top 1 outstanding allocations sorted by byte size:
    83886080 bytes allocated at allocated_size (10 allocations):
    #0 0x00007f9c2d12b321 in malloc (/usr/lib64/libc-2.28.so)
    #1 0x00007f9b88120150 in Java_com_example_NativeLib_processData+0x40 (/opt/app/libnative_lib.so)
    #2 0x00007f9b94002888 in <stub-code-for-JNI-entry>
    #3 0x00007f9b9c1042aa in [Interpreter] com.example.NativeLib.processData
    #4 0x00007f9b9c100230 in [Interpreter] com.example.Service.handleRequest

    根据追踪输出:

  • 泄漏特征:内存通过 malloc 在地址 #0 被分配。
  • 底层定位:libnative_lib.so 中的 JNI 导出函数 Java_com_example_NativeLib_processData 偏移量 0x40 处。
  • Java 关联:由 Java 层的 com.example.NativeLib.processData 触发。
  • 结合 C++ 库源码分析:发现 Java_com_example_NativeLib_processData 函数内部在处理数据报文时,针对特殊逻辑分支使用了 malloc 分配缓冲区,但退出分支时漏写了 free(),从而在系统层面引发了无限的 Native 物理内存(RSS)膨胀,而 JVM GC 和 NMT 对此完全无能为力。


    4. 三类内存机制与排查策略汇总

    维度DirectByteBufferJNI 原生分配Project Panama (FFM API)
    内存位置 C 堆 / 堆外 Native 内存 C 堆 / 操作系统虚拟内存 C 堆 / 操作系统虚拟内存
    分配接口 Unsafe.allocateMemory() malloc(), mmap(), GetPrimitiveArrayCritical() Arena.allocate(), SegmentAllocator
    回收机制 基于 GC + PhantomReference / Cleaner 手动控制 (C/C++ free()) Arena.close() 确定性显式销毁 / 配合 Auto 模式
    JVM 限制 受 -XX:MaxDirectMemorySize 约束 不受 JVM 任何参数限制 不受 JVM 限制 (除非手动集成 Arena.ofAuto())
    GC 感知力 能感知 (间接通过 Cleaner 对象) 无法感知 能感知 (Arena.ofAuto()) 或 零感知 (Arena.ofConfined())
    NMT 观测 可观测 (归类在 mtAllocator) 无法观测 (除非原生代码包含 HotSpot NMT 标明) 可观测 (部分内部路径) 或 不可观测
    排查方式 NMT, Bits 日志, JMX DirectBuffer 计数器 /proc/$PID/smaps, eBPF (memleak) /proc/$PID/smaps, eBPF (memleak), FFM API Bounds Validator
    赞(0)
    未经允许不得转载:171主机测评 » JVM堆外与Native内存分配原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址