ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM直接内存深度剖析:原理、回收机制与泄漏排查实战

JVM直接内存深度剖析:原理、回收机制与泄漏排查实战 先说个真实的感受在JVM相关的问题里直接内存Direct Memory是被误解最多的一个概念。不少人知道它存在但说不清它到底归谁管知道Netty在用但不知道为什么非要用它面试时被问到“直接内存什么时候回收”答到一半就开始含糊。更麻烦的是生产环境里它一出问题就非常隐蔽——堆的内存看着正常GC也一切平稳但进程的常驻内存一直在涨最后直接OOM。这篇文章我把直接内存从原理到排障完整梳理一遍包括默认参数到底怎么算、NIO与Netty为什么绕不开它、以及我用NMT工具排查内存泄漏的实操记录希望能帮大家一次性把这个区域搞透。1. 直接内存到底是什么——一张图看清它在JVM里的位置1.1 先理清JVM内存模型里根本没有它很多人第一次听说直接内存是在学习《Java虚拟机规范》的时候然后就会发现一个奇怪的情况规范里定义的运行时数据区——程序计数器、虚拟机栈、本地方法栈、堆、方法区——里面没有一个区域叫“直接内存”。那它到底算JVM的哪部分准确的说法是直接内存是JVM进程向操作系统申请的一块堆外内存它不受Java堆大小的约束也不由垃圾回收器直接管理但它又确确实实由JVM进程使用并且占用了进程的地址空间。你可以把它理解为JVM在“自己家”之外租的一块仓库仓库不归JVM的管家GC管但进出仓库的货物需要JVM来协调。这里顺便澄清一个高频概念混淆很多人把“JVM内存模型”直接等同于内存区域划分但JMMJava 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的DirectByteBufferJava NIO从JDK 1.4开始引入了ByteBuffer这个缓冲区的核心能力是读写通道Channel的数据。你有两种方式获得ByteBufferHeapByteBuffer和DirectByteBuffer。前者分配在堆内后者就是直接内存的实现。调用方式很直观// 分配直接内存容量为1MB ByteBuffer buffer ByteBuffer.allocateDirect(1024 * 1024); // 普通堆内缓冲区 ByteBuffer heapBuffer ByteBuffer.allocate(1024 * 1024);从使用者的角度看两者都是ByteBufferAPI完全一样但底层差异巨大。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在创建时都绑定了一个CleanerCleaner继承自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 TrackingNMT。启动JVM时需要加上参数-XX:NativeMemoryTrackingsummary然后使用jcmd查看jcmd pid VM.native_memory summary输出结果会列出Java Heap、Class、Thread、Code Cache、GC、Compiler、Internal等各类内存的使用情况当然也包括Direct Buffer。看到Direct Buffer那一栏数值不断上涨恭喜你基本锁定问题区域了。第二步用detail模式定位更细的调用来源。将参数改为-XX:NativeMemoryTrackingdetail再次执行jcmd pid VM.native_memory detaildetail模式会按调用点展示分配记录常见输出包括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及以前可以反射调用Cleannerif (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.shJVM参数通过JAVA_OPTS环境变量传递。在catalina.sh中或者通过系统环境变量这样设置export JAVA_OPTS-Xms2g -Xmx2g -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize1g -XX:NativeMemoryTrackingsummary这里我有一个配置建议不要因为“默认等于-Xmx”就把参数留空不配。显式配置的价值在于明确预期让团队知道直接内存的业务预算就是1GB而不是“可能到2GB也没问题”。同时MaxDirectMemorySize需要与堆内存、Metaspace、线程栈统一规划。服务器物理内存分配的一个实用公式是总内存 堆内存-Xmx Metaspace 线程栈总和 直接内存 操作系统缓存余量。比如一台16GB内存的服务器合理分配可以是堆8GB、Metaspace 512MB、线程栈约1GB200个线程默认1MB栈、直接内存1GB剩下5GB左右留给OS页缓存和其他进程。直接把-Xmx调到12GB、直接内存还不设上限这种配置在IO密集型服务上迟早出问题。4.3 与直接内存相关的其他参数除MaxDirectMemorySize外还有几个参数在直接内存排障时会经常碰到-XX:DisableExplicitGC禁用System.gc()。如果开了这个参数就不要再寄希望于System.gc()来回收直接内存。-XX:PrintGCDetails / -XX:PrintGCDateStamps开启GC日志辅助确认Full GC是否发生、频率如何。-XX:NativeMemoryTrackingsummary / detail开启NMT用于观测堆外内存使用。-Dio.netty.noPreferDirecttrueNetty框架中设置是否优先使用直接内存。关于DisableExplicitGC需要多说一句有些团队为了防止Full GC频繁触发直接禁用了System.gc()却没考虑某些框架包括老版本的Netty确实依赖System.gc()来促进直接内存回收。如果你的服务使用了大量直接内存又禁用了System.gc()同时MaxDirectMemorySize设置得又比较小那离Direct buffer memory的报错不远了。5. 直接内存底层原理从源码看DirectByteBuffer5.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 GCFull 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.refALL-UNNAMED。问题三未来方向是什么JDK 14起提出的Foreign-Memory Access APIJEP 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和线程栈留出足够的预算系统才真正扛得住高峰。
返回列表