jvm对外内存-direct buffer
现象

分析
步骤一:jvm堆内存健康

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


步骤三
了解到框架代码使用直接内存,如果申请不足会尝试调用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 虚拟机管理,直接由操作系统管理。
堆外内存优缺点
堆外内存和堆内内存各有利弊,这里我针对其中重要的几点进行说明。
- 堆内内存由 JVM GC 自动回收内存,降低了 Java 用户的使用心智,但是 GC 是需要时间开销成本的,堆外内存由于不受 JVM 管理,所以在一定程度上可以降低 GC 对应用运行时带来的影响。
- 堆外内存需要手动释放,这一点跟 C/C++ 很像,稍有不慎就会造成应用程序内存泄漏,当出现内存泄漏问题时排查起来会相对困难。
- 当进行网络 I/O 操作、文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互,直接使用堆外内存可以减少一次内存拷贝。
- 堆外内存可以实现进程之间、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; }

从 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 对象。

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

此时 Cleaner 对象不再有任何引用关系,在下一次 GC 时,该 Cleaner 对象将被添加到 ReferenceQueue 中,并执行 clean() 方法。clean() 方法主要做两件事情:
- 将 Cleaner 对象从 Cleaner 链表中移除;
- 调用 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堆内存查看引用关系

定位问题

浙公网安备 33010602011771号