jvm对外内存-direct buffer

现象

image

分析

步骤一:jvm堆内存健康

image

步骤二 

通过了解这种一般是代码里面触发,一般自己写业务代码不会有手动调用,那么就只有框架,发现直接内存处于高水位,直接内存高水位,但是没有达到限制

image

image

步骤三

了解到框架代码使用直接内存,如果申请不足会尝试调用system.GC,可能是框架导致

try {
    buffer = ByteBuffer.allocateDirect(size);
} catch (OutOfMemoryError e) {
    // JVM 会尝试一次 Full GC 来回收被 Cleaner 管理的 DirectByteBuffer
    System.gc();  // 由 Java 内部触发,不是您的代码!
    try {
        Thread.sleep(100);
    } catch (InterruptedException ignored) {}
    buffer = ByteBuffer.allocateDirect(size);
}

以下内容引入自:https://learn.lianglianglee.com/专栏/Netty%20核心原理剖析与%20RPC%20实践-完/10%20%20双刃剑:合理管理%20Netty%20堆外内存.md

为什么需要对堆外内存

在 Java 中对象都是在堆内分配的,通常我们说的JVM 内存也就指的堆内内存,堆内内存完全被JVM 虚拟机所管理,JVM 有自己的垃圾回收算法,对于使用者来说不必关心对象的内存如何回收。

堆外内存与堆内内存相对应,对于整个机器内存而言,除堆内内存以外部分即为堆外内存,堆外内存不受 JVM 虚拟机管理,直接由操作系统管理。

堆外内存优缺点

堆外内存和堆内内存各有利弊,这里我针对其中重要的几点进行说明。

  1. 堆内内存由 JVM GC 自动回收内存,降低了 Java 用户的使用心智,但是 GC 是需要时间开销成本的,堆外内存由于不受 JVM 管理,所以在一定程度上可以降低 GC 对应用运行时带来的影响。
  2. 堆外内存需要手动释放,这一点跟 C/C++ 很像,稍有不慎就会造成应用程序内存泄漏,当出现内存泄漏问题时排查起来会相对困难。
  3. 当进行网络 I/O 操作、文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互,直接使用堆外内存可以减少一次内存拷贝。
  4. 堆外内存可以实现进程之间、JVM 多实例之间的数据共享。

由此可以看出,如果你想实现高效的 I/O 操作、缓存常用的对象、降低 JVM GC 压力,堆外内存是一个非常不错的选择。

堆外内存的分配方式

Java 中堆外内存的分配方式有两种:ByteBuffer#allocateDirect和Unsafe#allocateMemory。

首先我们介绍下 Java NIO 包中的 ByteBuffer 类的分配方式,使用方式如下:

// 分配 10M 堆外内存

ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); 

跟进 ByteBuffer.allocateDirect 源码,发现其中直接调用的 DirectByteBuffer 构造函数:

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

    Bits.reserveMemory(size, cap);

    long base = 0;

    try {

        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;

    }

    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

    att = null;

}

image

从 DirectByteBuffer 的构造函数中可以看出,真正分配堆外内存的逻辑还是通过 unsafe.allocateMemory(size),接下来我们一起认识下 Unsafe 这个神秘的工具类。

Unsafe 是一个非常不安全的类,它用于执行内存访问、分配、修改等敏感操作,可以越过 JVM 限制的枷锁。Unsafe 最初并不是为开发者设计的,使用它时虽然可以获取对底层资源的控制权,但也失去了安全性的保证,所以使用 Unsafe 一定要慎重。Netty 中依赖了 Unsafe 工具类,是因为 Netty 需要与底层 Socket 进行交互,Unsafe 在提升 Netty 的性能方面起到了一定的帮助。

在 Java 中是不能直接使用 Unsafe 的,但是我们可以通过反射获取 Unsafe 实例,使用方式如下所示

private static Unsafe unsafe = null;

static {

    try {

        Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

        getUnsafe.setAccessible(true);

        unsafe = (Unsafe) getUnsafe.get(null);

    } catch (NoSuchFieldException | IllegalAccessException e) {

        e.printStackTrace();

    }

}

获得 Unsafe 实例后,我们可以通过 allocateMemory 方法分配堆外内存,allocateMemory 方法返回的是内存地址,使用方法如下所示:

 

// 分配 10M 堆外内存

long address = unsafe.allocateMemory(10 * 1024 * 1024);

与 DirectByteBuffer 不同的是,Unsafe#allocateMemory 所分配的内存必须自己手动释放,否则会造成内存泄漏,这也是 Unsafe 不安全的体现。Unsafe 同样提供了内存释放的操作:

unsafe.freeMemory(address);

到目前为止,我们了解了堆外内存分配的两种方式,对于 Java 开发者而言,常用的是 ByteBuffer.allocateDirect 分配方式,我们平时常说的堆外内存泄漏都与该分配方式有关,接下来我们一起看看使用 ByteBuffer 分配的堆外内存如何被 JVM 回收,这对我们排查堆外内存泄漏问题有较大的帮助。

堆外内存的回收

我们试想这么一种场景,因为 DirectByteBuffer 对象有可能长时间存在于堆内内存,所以它很可能晋升到 JVM 的老年代,所以这时候 DirectByteBuffer 对象的回收需要依赖 Old GC 或者 Full GC 才能触发清理。如果长时间没有 Old GC 或者 Full GC 执行,那么堆外内存即使不再使用,也会一直在占用内存不释放,很容易将机器的物理内存耗尽,这是相当危险的。

那么在使用 DirectByteBuffer 时我们如何避免物理内存被耗尽呢?因为 JVM 并不知道堆外内存是不是已经不足了,所以我们最好通过 JVM 参数 -XX:MaxDirectMemorySize 指定堆外内存的上限大小,当堆外内存的大小超过该阈值时,就会触发一次 Full GC 进行清理回收,如果在 Full GC 之后还是无法满足堆外内存的分配,那么程序将会抛出 OOM 异常。

此外在 ByteBuffer.allocateDirect 分配的过程中,如果没有足够的空间分配堆外内存,在 Bits.reserveMemory 方法中也会主动调用 System.gc() 强制执行 Full GC,但是在生产环境一般都是设置了 -XX:+DisableExplicitGC,System.gc() 是不起作用的,所以依赖 System.gc() 并不是一个好办法。

java.nio.ByteBuffer#allocateDirect

->

java.nio.DirectByteBuffer#DirectByteBuffer(int)

/**
 * 直接字节缓冲区的主构造函数
 * 这是Java NIO中堆外内存(直接内存)分配的核心方法
 * 
 * @param cap 请求的缓冲区容量(单位:字节)
 */
DirectByteBuffer(int cap) {                   // package-private
    // 步骤1: 调用父类MappedByteBuffer的构造函数
    // 参数说明:
    //   -1: mark标记位置(未设置)
    //    0: 当前位置
    //   cap: 限制位置(limit)
    //   cap: 容量
    // null: 底层数组(直接缓冲区没有后备数组)
    super(-1, 0, cap, cap, null);
    
    // 步骤2: 检查是否需要页面对齐
    // VM.isDirectMemoryPageAligned() 返回是否要求直接内存页面对齐
    // 通常由 -XX:+PageAlignDirectMemory 参数控制
    boolean pa = VM.isDirectMemoryPageAligned();
    
    // 获取系统页大小(通常为4KB)
    int ps = Bits.pageSize();
    
    // 步骤3: 计算实际需要分配的内存大小
    // 如果需要页面对齐,额外分配一页的空间用于对齐调整
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    
    // 步骤4: 预留内存空间(这是内存管理的核心!)
    // 1. 检查当前已分配的直接内存是否超过最大限制
    // 2. 如果超过,会触发GC并等待
    // 3. 更新已分配内存的计数器
    Bits.reserveMemory(size, cap);
    
    // 步骤5: 实际分配堆外内存
    long base = 0;  // 分配的内存的起始地址
    try {
        // 调用Unsafe分配本地内存
        // 这是通过malloc或系统调用分配的系统内存
        base = UNSAFE.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        // 如果分配失败,释放之前预留的内存配额
        Bits.unreserveMemory(size, cap);
        throw x;  // 重新抛出异常
    }
    
    // 步骤6: 初始化内存为0(清零)
    // 这是为了防止内存中可能包含之前进程的敏感数据
    UNSAFE.setMemory(base, size, (byte) 0);
    
    // 步骤7: 处理内存地址对齐
    if (pa && (base % ps != 0)) {
        // 如果需要页面对齐但分配的内存不是页对齐的
        // 向上取整到最近的页面边界
        // 计算原理:base + ps - (base & (ps - 1))
        // ps是2的幂,ps-1是低位掩码
        address = base + ps - (base & (ps - 1));
    } else {
        // 如果不需要对齐,或者已经是页对齐的
        address = base;  // 使用原始地址
    }
    
    // 步骤8: 创建Cleaner用于自动内存回收
    // 这是防止内存泄漏的关键机制
    try {
        // Cleaner.create() 创建一个PhantomReference
        // 当DirectByteBuffer对象被GC回收时,Deallocator.run()会被调用
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    } catch (Throwable t) {
        // 双重保障:如果Cleaner创建失败,手动释放内存
        // 防止Cleaner创建失败导致的内存泄漏
        UNSAFE.freeMemory(base);           // 释放堆外内存
        Bits.unreserveMemory(size, cap);   // 释放内存配额
        throw t;                           // 抛出异常
    }
    
    // 步骤9: 附件对象(用于FileChannel等场景)
    att = null;  // 初始化为null
}

 

->

java.nio.Bits#reserveMemory

/**
 * 预留直接内存空间。当直接内存不足时,会尝试触发GC并重试。
 * 此方法在每次直接内存分配时被调用,用于确保不超过直接内存限制。
 * 
 * @param size 本次请求分配的内存大小(单位:字节)
 * @param cap 本次分配的缓冲区容量(单位:字节),通常与size相同
 */
static void reserveMemory(long size, long cap) {
    // 步骤1: 初始化最大直接内存限制(仅初始化一次)
    if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {
        // 从JVM参数 -XX:MaxDirectMemorySize 获取最大直接内存限制
        // 如果未设置,默认值为 -Xmx(堆的最大值)
        MAX_MEMORY = VM.maxDirectMemory();
        MEMORY_LIMIT_SET = true;  // 标记已初始化,避免重复获取
    }

    // 步骤2: 乐观尝试 - 首先尝试无锁快速分配
    // 如果当前已用内存 + 请求内存 <= 最大内存限制,则分配成功
    if (tryReserveMemory(size, cap)) {
        return;  // 快速路径:内存充足,直接返回
    }

    // 步骤3: 慢速路径 - 内存不足,需要协调GC和等待
    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    boolean interrupted = false;  // 标记当前线程是否被中断
    
    try {
        // 步骤4: 等待引用处理器处理待回收的引用
        // 包括Cleaner,它负责释放直接内存
        boolean refprocActive;
        do {
            try {
                // 等待引用处理完成,返回是否还有待处理的引用
                refprocActive = jlra.waitForReferenceProcessing();
            } catch (InterruptedException e) {
                // 线程被中断,但内存分配更重要,暂时忽略中断
                interrupted = true;
                refprocActive = true;  // 假设还有引用待处理,继续循环
            }
            
            // 每次等待后都尝试分配,可能已有内存被释放
            if (tryReserveMemory(size, cap)) {
                return;  // 分配成功,返回
            }
        } while (refprocActive);  // 循环直到没有更多引用需要处理

        // 步骤5: 主动触发Full GC
        // 此时引用处理已完成,但内存仍然不足,尝试触发GC释放更多内存
        // 注意:这是导致频繁Full GC的根源!在高并发场景下,多个线程同时调用会产生GC风暴
        System.gc();

        // 步骤6: 指数退避重试循环
        // 在触发GC后,采用指数退避策略等待和重试
        // 设计思想:给GC和内存回收足够的时间,同时避免线程间的活锁竞争
        long sleepTime = 1;  // 初始等待1毫秒
        int sleeps = 0;      // 已睡眠次数计数器
        while (true) {
            // 尝试分配
            if (tryReserveMemory(size, cap)) {
                return;  // 分配成功
            }
            
            // 检查是否超过最大重试次数
            // MAX_SLEEPS 通常为9,最大等待时间总和约 1+2+4+...+256=511ms
            if (sleeps >= MAX_SLEEPS) {
                break;  // 超过重试限制,跳出循环
            }
            
            try {
                // 如果没有引用需要处理,则进行指数退避等待
                if (!jlra.waitForReferenceProcessing()) {
                    // 关键:线程睡眠,让出CPU
                    // 目的1: 给其他线程释放内存的机会
                    // 目的2: 给GC执行的时间
                    // 目的3: 减少线程竞争导致的活锁
                    Thread.sleep(sleepTime);
                    
                    // 指数退避:每次等待时间翻倍
                    sleepTime <<= 1;  // 等价于 sleepTime = sleepTime * 2
                    sleeps++;         // 睡眠次数加1
                }
                // 如果有引用在处理,则继续等待而不增加sleepTime
                // 因为引用处理可能会释放内存
            } catch (InterruptedException e) {
                interrupted = true;  // 记录中断状态
            }
        }

        // 步骤7: 所有尝试都失败,抛出OutOfMemoryError
        // 错误信息包含详细的内存使用情况,便于问题诊断:
        // 1. 本次请求的大小
        // 2. 已分配的内存总量
        // 3. 最大内存限制
        throw new OutOfMemoryError(
            "Cannot reserve "
            + size + " bytes of direct buffer memory (allocated: "
            + RESERVED_MEMORY.get() + ", limit: " + MAX_MEMORY + ")"
        );

    } finally {
        // 步骤8: 清理工作 - 恢复线程中断状态
        // Java最佳实践:不吞没中断,但只在最后恢复
        if (interrupted) {
            Thread.currentThread().interrupt();
        }
    }
}

 

 

 

通过前面堆外内存分配方式的介绍,我们知道 DirectByteBuffer 在初始化时会创建一个 Cleaner 对象,它会负责堆外内存的回收工作,那么 Cleaner 是如何与 GC 关联起来的呢?

Java 对象有四种引用方式:强引用 StrongReference、软引用 SoftReference、弱引用 WeakReference 和虚引用 PhantomReference。其中 PhantomReference 是最不常用的一种引用方式,Cleaner 就属于 PhantomReference 的子类,如以下源码所示,PhantomReference 不能被单独使用,需要与引用队列 ReferenceQueue 联合使用。

public class Cleaner extends java.lang.ref.PhantomReference<java.lang.Object> {

    private static final java.lang.ref.ReferenceQueue<java.lang.Object> dummyQueue;

    private static sun.misc.Cleaner first;

    private sun.misc.Cleaner next;

    private sun.misc.Cleaner prev;

    private final java.lang.Runnable thunk;

    public void clean() {}

}

首先我们看下,当初始化堆外内存时,内存中的对象引用情况如下图所示,first 是 Cleaner 类中的静态变量,Cleaner 对象在初始化时会加入 Cleaner 链表中。DirectByteBuffer 对象包含堆外内存的地址、大小以及 Cleaner 对象的引用,ReferenceQueue 用于保存需要回收的 Cleaner 对象。

image

 当发生 GC 时,DirectByteBuffer 对象被回收,内存中的对象引用情况发生了如下变化:

 

image

 

此时 Cleaner 对象不再有任何引用关系,在下一次 GC 时,该 Cleaner 对象将被添加到 ReferenceQueue 中,并执行 clean() 方法。clean() 方法主要做两件事情:

  1. 将 Cleaner 对象从 Cleaner 链表中移除;
  2. 调用 unsafe.freeMemory 方法清理堆外内存。

至此,堆外内存的回收已经介绍完了,下次再排查内存泄漏问题的时候先回顾下这些最基本的知识,做到心中有数。

总结

堆外内存是一把双刃剑,在网络 I/O、文件读写、分布式缓存等领域使用堆外内存都更加简单、高效,此外使用堆外内存不受 JVM 约束,可以避免 JVM GC 的压力,降低对业务应用的影响。当然天下没有免费的午餐,堆外内存也不能滥用,使用堆外内存你就需要关注内存回收问题,虽然 JVM 在一定程度上帮助我们实现了堆外内存的自动回收,但我们仍然需要培养类似 C/C++ 的分配/回收的意识,出现内存泄漏问题能够知道如何分析和处理。

基于以上问题排查线上实际问题

通过以上输入我们可以发现,针对堆内,会有DirectBuffer的引用,

1、可以写个脚本定期去抓分配直接内存的堆栈

jstack 9 |grep allocate 

2、看源码如果申请失败也会有告警

java.io.IOException: canceled due to java.lang.OutOfMemoryError: Cannot reserve 8230 bytes of direct buffer memory (allocated: 268432418, limit: 268435456)
at okhttp3.internal.connection.RealCall$AsyncCall.$sw$original$run$3opc2g1(RealCall.kt:530)
at okhttp3.internal.connection.RealCall$AsyncCall.$sw$original$run$3opc2g1$accessor$$sw$gcmnir0(RealCall.kt)
at okhttp3.internal.connection.RealCall$AsyncCall$$sw$auxiliary$hec2oe3.call(Unknown Source)
at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:95)
at okhttp3.internal.connection.RealCall$AsyncCall.run(RealCall.kt)
at brave.propagation.CurrentTraceContext$1CurrentTraceContextRunnable.run(CurrentTraceContext.java:260)
at org.apache.skywalking.apm.plugin.wrapper.SwRunnableWrapper.run(SwRunnableWrapper.java:43)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.OutOfMemoryError: Cannot reserve 8230 bytes of direct buffer memory (allocated: 268432418, limit: 268435456)
at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
at java.base/java.nio.DirectByteBuffer.(DirectByteBuffer.java:111)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:360)
at java.base/sun.nio.ch.Util.getTemporaryDirectBuffer(Util.java:242)
at java.base/sun.nio.ch.NioSocketImpl.tryWrite(NioSocketImpl.java:390)
at java.base/sun.nio.ch.NioSocketImpl.implWrite(NioSocketImpl.java:410)
at java.base/sun.nio.ch.NioSocketImpl.write(NioSocketImpl.java:440)
at java.base/sun.nio.ch.NioSocketImpl$2.write(NioSocketImpl.java:819)
at java.base/java.net.Socket$SocketOutputStream.write(Socket.java:1195)
at java.base/sun.security.ssl.SSLSocketOutputRecord.deliver(SSLSocketOutputRecord.java:345)
at java.base/sun.security.ssl.SSLSocketImpl$AppOutputStream.write(SSLSocketImpl.java:1304)
at okio.OutputStreamSink.write(JvmOkio.kt:56)
at okio.AsyncTimeout$sink$1.write(AsyncTimeout.kt:102)
at okio.RealBufferedSink.emitCompleteSegments(RealBufferedSink.kt:256)
at okio.RealBufferedSink.write(RealBufferedSink.kt:147)
at okhttp3.internal.http1.Http1ExchangeCodec$KnownLengthSink.write(Http1ExchangeCodec.kt:279)
at okio.ForwardingSink.write(ForwardingSink.kt:29)
at okhttp3.internal.connection.Exchange$RequestBodySink.write(Exchange.kt:223)
at okio.RealBufferedSink.emitCompleteSegments(RealBufferedSink.kt:256)
at okio.RealBufferedSink.write(RealBufferedSink.kt:186)
at okhttp3.RequestBody$Companion$toRequestBody$2.writeTo(RequestBody.kt:152)
at okhttp3.internal.http.CallServerInterceptor.intercept(CallServerInterceptor.kt:62)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.kt:34)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.kt:95)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.kt:83)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.kt:76)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
at com.yxt.agentic.bus.extension.spring.ai.entrance.dify.DifyOkHttpObservationInterceptor.intercept(DifyOkHttpObservationInterceptor.java:50)

但是仅仅是触发的地方,并不一定代表是处问题的地方,

比如mongoDB 或者JSON.toObject底层都可有用直接内存

3、我们知道drectBuffer在JVM也是会有引用对象的,我们dump堆内存查看引用关系

image

 定位问题

 

posted @ 2026-01-19 11:30  意犹未尽  阅读(1)  评论(0)    收藏  举报