☰
JVM直接内存深度剖析:原理、回收机制与泄漏排查实战
2026/9/30 4:37:37 网站建设 项目流程

先说个真实的感受:在JVM相关的问题里,直接内存(Direct Memory)是被误解最多的一个概念。不少人知道它存在,但说不清它到底归谁管;知道Netty在用,但不知道为什么非要用它;面试时被问到“直接内存什么时候回收”,答到一半就开始含糊。更麻烦的是生产环境里它一出问题就非常隐蔽——堆的内存看着正常,GC也一切平稳,但进程的常驻内存一直在涨,最后直接OOM。这篇文章我把直接内存从原理到排障完整梳理一遍,包括默认参数到底怎么算、NIO与Netty为什么绕不开它、以及我用NMT工具排查内存泄漏的实操记录,希望能帮大家一次性把这个区域搞透。

1. 直接内存到底是什么——一张图看清它在JVM里的位置

1.1 先理清JVM内存模型里根本没有它

很多人第一次听说直接内存是在学习《Java虚拟机规范》的时候,然后就会发现一个奇怪的情况:规范里定义的运行时数据区——程序计数器、虚拟机栈、本地方法栈、堆、方法区——里面没有一个区域叫“直接内存”。那它到底算JVM的哪部分?

准确的说法是:直接内存是JVM进程向操作系统申请的一块堆外内存,它不受Java堆大小的约束,也不由垃圾回收器直接管理,但它又确确实实由JVM进程使用,并且占用了进程的地址空间。你可以把它理解为JVM在“自己家”之外租的一块仓库,仓库不归JVM的管家(GC)管,但进出仓库的货物需要JVM来协调。

这里顺便澄清一个高频概念混淆:很多人把“JVM内存模型”直接等同于内存区域划分,但JMM(Java Memory Model)讲的是多线程间的可见性和有序性,是一套抽象的并发规则;而这次讨论的JVM运行时数据区划分才是物理内存的布局。直接内存不属于后者,它是JVM规范之外的“编外成员”。

1.2 直接内存和堆内存的一页纸对比

为了直观理解,我把日常使用中最关键的差异整理成了表格:

对比维度堆内存(Heap Memory)直接内存(Direct Memory)
归属管理JVM堆内,受GC管理JVM堆外,不受GC直接管理
分配方式new对象,由分配器管理ByteBuffer.allocateDirect()或Unsafe.allocateMemory()
默认上限受-Xmx控制受-XX:MaxDirectMemorySize控制,默认等于-Xmx
回收机制GC自动回收依赖Cleaner机制,与GC配合但不完全同步
IO效率需要中间拷贝零拷贝,减少一次堆内外复制
分配/释放成本相对低相对高,建议池化复用
内存可见性jstat、jmap可直接观测常规工具看不到,需NMT或OS级别工具

这张表里最值得琢磨的是“零拷贝”和“回收机制”这两行。零拷贝不是指不用拷贝,而是指相对于堆内存方案减少了一次从堆内到堆外的复制。至于回收机制,这是很多线上事故的源头——直接内存在Java堆里只有一个很小的DirectByteBuffer对象作为“门面”,真正的数据存在堆外。GC能回收的是那个门面对象,而门面对象被回收时会触发堆外内存的释放。问题就出在“触发”这两个字的时机上,后面我会详细展开。

为什么直接内存的分配成本高?因为每次分配都可能触发系统调用,而且为了对齐和操作系统的内存页打交道,底层还会做一些额外处理。所以高频分配场景下不池化的话,性能损耗比堆内分配大得多。

2. 为什么说NIO和Netty离不开直接内存

2.1 一句话讲透NIO的DirectByteBuffer

Java NIO从JDK 1.4开始引入了ByteBuffer,这个缓冲区的核心能力是读写通道(Channel)的数据。你有两种方式获得ByteBuffer:HeapByteBuffer和DirectByteBuffer。前者分配在堆内,后者就是直接内存的实现。

调用方式很直观:

// 分配直接内存,容量为1MB ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 普通堆内缓冲区 ByteBuffer heapBuffer = ByteBuffer.allocate(1024 * 1024);

从使用者的角度看,两者都是ByteBuffer,API完全一样,但底层差异巨大。DirectByteBuffer内部通过Unsafe.allocateMemory分配堆外内存,同时使用了一个非常关键的对象——Cleaner,用于在DirectByteBuffer本身被垃圾回收后,释放关联的堆外内存。

为什么NIO要用它?看一个最常见的场景:从磁盘读文件然后通过Socket发送出去。如果用堆内存,数据会经历这样的路径:磁盘 -> 内核缓冲区 -> JVM堆内缓冲区 -> 堆外临时缓冲区 -> Socket缓冲区。中间多了一次堆内到堆外的拷贝。而使用直接内存,路径简化为:磁盘 -> 内核缓冲区 -> 堆外缓冲区 -> Socket缓冲区。少了一次CPU拷贝,这就是所谓的“零拷贝”在NIO层面的意义。

2.2 从Kafka到Netty,这些框架为什么都选直接内存

生产环境里最典型的使用者就是Netty和Kafka这些高性能框架。

Netty默认的分配器是PooledByteBufAllocator,它在创建ByteBuf时,默认优先使用DirectBuffer(池化的直接内存版本)。Netty选择直接内存的核心原因,一是减少IO路径上的拷贝开销,二是通过池化解决直接内存分配/释放成本高的问题——分配好的内存复用,避免了反复进行系统调用。

Kafka同样重度依赖直接内存。它的日志段读写、网络发送环节都尽量使用FileChannel和底层零拷贝能力(如sendfile)来避免多余的数据复制。在Kafka的性能调优中,socket缓冲区直接使用堆外内存可以显著减少GC压力,因为大块缓冲区如果放在堆内,会变成大对象频繁进入老年代,引发频繁的Full GC。

但这里必须提醒一句:直接内存并非银弹。如果你只是在一个普通的CRUD应用里读几个小文件,用直接内存反而得不偿失。它的优势场景是大块数据、长期复用、IO密集;劣势场景是小对象、短生命周期、频繁分配。判断标准很简单:问问自己,这段数据需不需要跨过JVM堆边界进行IO传输?不需要的话,老老实实待在堆里就好。

3. 直接内存为什么“看不见却会爆”——内存泄漏排查实录

3.1 一个典型的Direct buffer memory报错现场

先还原一下现场。某天晚上线上服务突然告警,错误日志里出现了这样一段:

java.lang.OutOfMemoryError: Direct buffer memory at java.nio.Bits.reserveMemory(Bits.java:658) at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:121) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)

这个报错非常典型:堆内存还很充足,GC也一切正常,但进程就是分配不了新的直接内存了。原因一目了然——直接内存的累计使用量已经达到了上限阈值(MaxDirectMemorySize)。

为什么明明有回收机制还会OOM?这里必须把DirectByteBuffer的回收机制理清楚。每个DirectByteBuffer在创建时都绑定了一个Cleaner,Cleaner继承自PhantomReference。JDK的ReferenceHandler守护线程会不断处理来自GC的引用通知,当GC判定DirectByteBuffer对象不可达时,就会把对应的Cleaner放入Pending队列,ReferenceHandler线程随后调用Cleaner.clean()方法,真正释放堆外内存。

问题在于:如果持有DirectByteBuffer引用的业务代码不释放它,GC永远无法判定它为不可达,堆外内存就永远不会被回收。哪怕你手动调用System.gc(),也只是向JVM“建议”执行Full GC,这个建议完全可能被-XX:+DisableExplicitGC参数拒绝。更麻烦的是,即使触发Full GC,如果JVM觉得没有必要(比如堆内还有很多空间),它也会跳过执行。这就是直接内存报警时,堆看起来一切正常的原因——GC的调度逻辑根本不关心堆外内存的使用量。

3.2 从收到告警到定位泄漏的完整路径

如果你在运维中遇到了“进程RSS持续上涨但堆内存平稳”的情况,先用下面这条链路排查。

第一步,确认进程整体内存分布。JDK自带了一个非常重要但很多人没用过的工具——Native Memory Tracking(NMT)。启动JVM时需要加上参数:

-XX:NativeMemoryTracking=summary

然后使用jcmd查看:

jcmd <pid> VM.native_memory summary

输出结果会列出Java Heap、Class、Thread、Code Cache、GC、Compiler、Internal等各类内存的使用情况,当然也包括Direct Buffer。看到Direct Buffer那一栏数值不断上涨,恭喜你,基本锁定问题区域了。

第二步,用detail模式定位更细的调用来源。将参数改为-XX:NativeMemoryTracking=detail,再次执行:

jcmd <pid> VM.native_memory detail

detail模式会按调用点展示分配记录,常见输出包括DirectByteBuffer的分配栈。这里要注意,NMT开启后会有5%~10%的性能开销,生产环境建议只开summary模式,排查时临时升级为detail,定位完再改回。

第三步,结合代码审计找出谁在分配DirectByteBuffer。NMT能告诉你内存涨了,但不会直接告诉你是哪行代码造成的。用两种方式辅助定位:一是用Arthas的trace命令查看ByteBuffer.allocateDirect的调用频率和调用栈;二是直接Code Review搜代码库里的allocateDirect、FileChannel.map、Netty的directBuffer这些关键字。

第四步,处理方案。如果是业务代码持有ByteBuffer忘了释放,补上释放逻辑;如果是框架使用池化DirectBuffer后容量配置不合理,调整池大小;如果确实是业务需要大量堆外内存但没配上限,那就直接调大MaxDirectMemorySize并做好监控。

3.3 写代码时的三个避坑习惯

我在实际项目里踩过几次坑之后,养成了三个习惯,分享给大家。

第一个习惯:用完ByteBuffer后不要等待GC,及时调用回收接口。JDK 8及以前可以反射调用Cleanner:

if (buffer instanceof sun.nio.ch.DirectBuffer) { ((sun.nio.ch.DirectBuffer) buffer).cleaner().clean(); }

但在JDK 9之后模块化限制了内部API的访问,需要添加--add-exports参数。更通用的做法是使用Netty的ByteBuf并依赖它的引用计数机制,通过ReferenceCountUtil.release()显式释放。至少要做到try-with-resources或finally块中释放。

第二个习惯:不要依赖System.gc()来清理直接内存。有些团队在内存紧张时手动调用System.gc(),这在默认参数下确实可能触发Full GC并间接清理DirectByteBuffer,但一旦有人加上-XX:+DisableExplicitGC(很多应用为了减少Full GC会加),这招就完全失效了。把内存回收寄托在“可能执行也可能不执行”的GC上,是在玩火。

第三个习惯:给直接内存配上独立的监控指标。不要只盯着堆内存的监控面板,加上MaxDirectMemorySize使用率的监控。Netty等框架都暴露了相关Metrics,没有的话可以定期用jcmd获取NMT数据再交给监控系统做阈值告警。

4. 参数详解:MaxDirectMemorySize到底在管什么

4.1 默认值藏在哪里

关于MaxDirectMemorySize,面试官最爱问的一个问题是:如果你不配置它,默认值是多少?

答案有点绕:默认等于当前JVM的最大堆内存,也就是-Xmx的值。这个“默认值”并不是一个静态常量,而是JVM在启动时通过sun.misc.VM类计算出来的。VM.maxDirectMemory()方法内部逻辑是:如果用户通过-XX:MaxDirectMemorySize显式指定了大小,就用用户配置;否则,默认返回Runtime.getRuntime().maxMemory(),即堆的最大可用内存。

顺带一提,这里其实有个容易踩坑的小知识:JRE和JVM的关系。JRE是Java运行环境,包含了JVM、核心类库以及启动命令等;JVM本身是JRE的核心组成部分。我们平时说的-Xmx、-XX:MaxDirectMemorySize这些参数,其实都是JVM启动时读取的启动参数,它们在java命令或应用容器的启动脚本中配置,也正因如此,这些参数对运行在同一JRE之上的所有JVM进程都会生效。

知道了默认值之后,你就能理解一个常见现象:一个JVM配置了-Xmx2g,即使不设置MaxDirectMemorySize,它也允许使用最多2GB的直接内存。如果应用本身堆就用了1.5GB,直接内存又用了1.5GB,再加上Metaspace、线程栈等,进程实际内存占用可能轻松超过4GB,远远超出你的“2GB堆”预期。这在容器化部署中尤其危险,因为容器cgroup限制的内存可能直接被击穿。

4.2 Tomcat启动怎么设置

以最常见的Tomcat为例,启动脚本是catalina.sh,JVM参数通过JAVA_OPTS环境变量传递。在catalina.sh中或者通过系统环境变量这样设置:

export JAVA_OPTS="-Xms2g -Xmx2g -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g -XX:NativeMemoryTracking=summary"

这里我有一个配置建议:不要因为“默认等于-Xmx”就把参数留空不配。显式配置的价值在于明确预期,让团队知道直接内存的业务预算就是1GB,而不是“可能到2GB也没问题”。同时,MaxDirectMemorySize需要与堆内存、Metaspace、线程栈统一规划。

服务器物理内存分配的一个实用公式是:总内存 = 堆内存(-Xmx)+ Metaspace + 线程栈总和 + 直接内存 + 操作系统缓存余量。比如一台16GB内存的服务器,合理分配可以是堆8GB、Metaspace 512MB、线程栈约1GB(200个线程,默认1MB栈)、直接内存1GB,剩下5GB左右留给OS页缓存和其他进程。直接把-Xmx调到12GB、直接内存还不设上限,这种配置在IO密集型服务上迟早出问题。

4.3 与直接内存相关的其他参数

除MaxDirectMemorySize外,还有几个参数在直接内存排障时会经常碰到:

  • -XX:+DisableExplicitGC:禁用System.gc()。如果开了这个参数,就不要再寄希望于System.gc()来回收直接内存。
  • -XX:+PrintGCDetails / -XX:+PrintGCDateStamps:开启GC日志,辅助确认Full GC是否发生、频率如何。
  • -XX:NativeMemoryTracking=summary / detail:开启NMT,用于观测堆外内存使用。
  • -Dio.netty.noPreferDirect=true:Netty框架中设置是否优先使用直接内存。

关于DisableExplicitGC需要多说一句:有些团队为了防止Full GC频繁触发,直接禁用了System.gc(),却没考虑某些框架(包括老版本的Netty)确实依赖System.gc()来促进直接内存回收。如果你的服务使用了大量直接内存又禁用了System.gc(),同时MaxDirectMemorySize设置得又比较小,那离Direct buffer memory的报错不远了。

5. 直接内存底层原理:从源码看DirectByteBuffer

5.1 DirectByteBuffer怎么诞生的

很多文章只讲直接内存“是什么”,但面试和排障中真正拉开差距的是“内部怎么运作”。这里以JDK 8的源码为例(JDK 9之后有模块化调整,但核心思路一致),把整条链路拆开来看。

当你调用ByteBuffer.allocateDirect(capacity)时,源码里的调用链是这样的:

// java.nio.ByteBuffer public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); }

DirectByteBuffer构造函数逻辑做了三件事:

第一,通过Bits.reserveMemory()做预算检查。它会将本次申请的内存大小与已使用的直接内存总量相加,看是否超过maxMemory(即MaxDirectMemorySize)。注意,这个方法有一个tryReserveMemory的快速路径和慢速路径,慢速路径中会执行System.gc(),就是希望在内存不足时通过Full GC回收掉一些不可达的DirectByteBuffer。

第二,调用Unsafe.allocateMemory(size)申请堆外内存。这是真正向操作系统申请内存的入口,获取到内存地址并保存在address字段。

第三,创建Cleaner。通过Cleaner.create(this, new Deallocator(address, size)),绑定当前DirectByteBuffer对象和内存地址。Deallocator是一个Runnable,它的run方法调用Unsafe.freeMemory(address)。

5.2 Cleaner、虚引用与回收链条

Cleaner继承自PhantomReference

整个回收链条是这样的:GC线程在垃圾回收时发现某个DirectByteBuffer只有虚引用指向它,判定为不可达,于是把对应的Cleaner放入pending队列。JVM的ReferenceHandler守护线程不断从pending队列取出引用,执行Cleaner.clean()。clean()方法会调用Deallocator.run(),最终通过Unsafe.freeMemory释放堆外内存。

这个机制设计得很巧妙,但也有脆弱之处:它依赖于“DirectByteBuffer对象本身被GC判定为不可达”。只要业务代码还持有DirectByteBuffer的引用,这条链就永远不会启动。所以直接内存泄漏的本质,往往是堆内的DirectByteBuffer门面对象没被释放,而不是堆外内存自身出了问题。

5.3 三个源码级问题的答案

问题一:为什么System.gc()能回收直接内存?因为System.gc()会触发Full GC,Full GC时那些不可达的DirectByteBuffer会被处理,Cleaner进入pending队列并被执行。但前面说了,Full GC本身的触发权在JVM手里,被DisableExplicitGC屏蔽、或者JVM判断堆不需要GC时,这一招就失效了。

问题二:为什么JDK 9之后反射调用Cleaner变麻烦了?JDK 9模块化之后,sun.misc.Cleaner不再作为公共API暴露给应用层,DirectBuffer也被划入jdk.internal.ref模块。反射时会出现IllegalAccessException,除非在启动参数中显式加上--add-opens java.base/jdk.internal.ref=ALL-UNNAMED。

问题三:未来方向是什么?JDK 14起提出的Foreign-Memory Access API(JEP 370/412等)目标就是提供更安全、更标准的堆外内存访问方式,用MemorySegment和MemorySession来统一管理直接内存和外部内存,既保留性能优势,又把安全问题纳入类型系统。对应用开发者来说,短期内依然要面对DirectByteBuffer的现实生态,但长期看会有更规范的使用姿势。

6. 面试官问起直接内存,答出这些才叫稳

6.1 高频面试题清单与拆解

“直接内存是什么?它属于JVM内存模型吗?”这题考察基础认知。直接内存是JVM运行时数据区之外的堆外内存,不受GC直接管理,但受MaxDirectMemorySize限制。严格说它不属于Java虚拟机规范定义的运行时数据区。

“MaxDirectMemorySize默认值是多少?”默认等于-Xmx的值,由VM.maxDirectMemory()动态计算。这道题还容易引出下一步——JVM进程为什么实际占用内存比-Xmx大?因为除了堆内存,还有Metaspace、线程栈、直接内存、GC相关内存,以及代码缓存。

“直接内存什么时候回收?”DirectByteBuffer的对象不可达后,由ReferenceHandler线程执行Cleaner.clean()来释放堆外内存,时机不确定。System.gc()能间接促进,但不保证执行。

“为什么Netty优先使用直接内存?”减少IO路径的数据拷贝,提升性能;配合池化机制缓解分配/释放开销;降低GC压力。

“如何排查直接内存泄漏?”用NMT或OS层面工具定位堆外内存使用,配合Arthas找分配点,最后审计代码。

“为什么Kafka的零拷贝能提升性能?”核心是减少内核态与用户态之间的数据拷贝次数,sendfile系统调用直接在内核态完成文件到Socket的传输,应用层只需要处理元数据。直接内存在这条链路中充当的是用户态侧的寄存器,减少一次堆内外搬移。

6.2 我踩过这些坑,才真正读懂了它

最后聊聊我个人的体会。在刚开始做服务端性能调优时,我也一度觉得直接内存是“锦上添花”的东西,直到有一次线上服务出现间歇性卡顿,排除了Full GC、锁竞争、IO瓶颈之后,才发现是NMT里的Direct Buffer在稳步攀升。那次排查花了我整整一个晚上,最后定位到是一个定时任务里创建了大批量DirectByteBuffer但没有释放。修复之后,RSS内存稳定了,卡顿也彻底消失了。

从那次以后,我把直接内存当作一个“需要明确预算”的资源来管理,而不是JVM的附属品。所有新服务上线前,我都会确认三件事:是否设置MaxDirectMemorySize、是否有池化机制、是否有NMT监控。这三件事看着简单,很多团队就是缺了其中一环,导致事故发生时连排查方向都找不到。

另外一个小技巧:如果你用的是容器化部署,千万别忘了给容器内存留出比-Xmx更大的空间。运行一个-Xmx2g的Java进程,容器内存限制至少要到3GB以上,否则直接内存在第一波业务高峰就可能把进程打进OOM Killer。给直接内存、Metaspace和线程栈留出足够的预算,系统才真正扛得住高峰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询