ARTICLE DETAIL

资讯详情

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

JVM直接内存深度解析:从分配机制到泄漏排查实战

JVM直接内存深度解析:从分配机制到泄漏排查实战 做JVM调优这些年我发现一个很有意思的现象面试里聊JVM内存模型大部分人能熟练背出堆、栈、方法区但一提到直接内存Direct Memory十个人里有八个会卡壳。日常排查线上OOM时很多人习惯性盯着堆内存看半天却忘了还有一块“堆外”空间可能正在悄悄膨胀直到进程被莫名杀掉或者容器被反复重启才意识到问题的源头根本不在堆内。今天这篇文章我就把直接内存这个“冷门大户”彻底聊透——它到底是什么、为什么要存在、内存怎么分配和回收、参数怎么设、泄露怎么查以及面试官在这个考点上最喜欢埋哪些坑。这不是一篇蜻蜓点水的科普而是把我自己排查过的真实案例、看过的源码细节、以及踩过几次坑之后总结出的操作手法都放进来希望能帮你在遇到直接内存问题时少走弯路。1. 直接内存初识它站在JVM内存模型的哪个角落1.1 从一次“找不到原因的OOM”说起先讲一个我印象很深的案例。那时我们有个网关服务运行一段时间后进程突然消失没有打印任何堆内存溢出的堆栈日志里只留下一条淡淡的“An unexpected error occurred while trying to open file”之类的内容。当时第一反应是查JVM堆、查GC日志结果一切正常堆内存使用率不高GC也很平稳Young区没有频繁晋升老年代也没膨胀。后来用系统层面的工具查看进程实际占用的物理内存才发现一个关键数字Java进程的RSS已经远远超过-Xmx设置的堆最大值。也就是说JVM确实只向操作系统申请了那么多堆内存但整个进程的实际物理驻留远超这个数。这多出来的部分就是堆外内存。再往下查发现是网关里某个模块用了Netty的池化内存分配器通过DirectBuffer方式申请了堆外内存因为使用完后没有正确释放慢慢把进程拖垮了。这个案例很好地说明了直接内存的一个核心特点它不在JVM堆管理范围内却真实地占用进程的物理地址空间。如果只盯着堆内数据永远找不到问题根源。1.2 直接内存到底在哪堆外的那张地图想理解直接内存先得把它放回JVM内存模型的完整图谱里。通常我们用一张简化图来记堆内Heap分为新生代和老年代线程私有的是虚拟机栈、本地方法栈然后是方法区/元空间再有就是程序计数器。直接内存严格来说不属于JVM运行时数据区的法定组成部分它是通过JNIJava Native Interface调用native代码向操作系统申请的一块“外部”地址空间。在JDK 8及以后**元空间Metaspace**本身也属于堆外内存但直接内存通常不包含它我们讨论的直接内存特指通过ByteBuffer.allocateDirect()、sun.misc.Unsafe.allocateMemory()或者基于这些底层能力实现的框架如Netty、Kafka、RocketMQ申请的native memory。理解直接内存时我喜欢做一个类比把JVM堆想象成一个大型仓库所有“普通货物”都按规矩码放在仓库里仓库管理员GC会定期清扫、整理而直接内存就像仓库旁边的一块露天场地货物直接从场地上装车对应Native IO但没人专门负责定期扫地必须由使用者自己清理或者依靠一些特殊的“清洁机制”来触发。这块露天场地的好处是离装卸平台近坏处是脏了容易堆积。从内存布局角度看直接内存位于JVM进程的堆外由操作系统管理地址映射JVM堆内的对象通过DirectByteBuffer这样一个很小的Java对象来“指向”堆外的那块地址真正的大块数据在堆外。这个设计本身就埋着一个重要的性能逻辑数据不经过JVM堆省掉了Java堆和native堆之间的复制。1.3 为什么需要直接内存IO场景的刚需直接内存存在的根本原因是解决传统IO操作中数据复制次数过多的问题。在JDK 1.4引入NIO之前传统FileInputStream、FileOutputStream从磁盘读数据到内存时数据要先进内核缓冲区再复制到JVM堆内的字节数组这个过程涉及用户态和内核态的切换同时存在堆内到堆外的拷贝。如果使用FileChannel配合ByteBuffer.allocateDirect()分配的直接缓冲区在Linux这类操作系统上就能借助sendfile、writev等系统调用实现零拷贝Zero-Copy。操作系统可以把磁盘文件的数据直接通过DMA搬运到内核缓冲区再通过write系统调用直接发送到Socket缓冲区整个路径都不经过JVM堆更不需要把数据先复制到用户空间再写回内核。这个过程用一句话概括就是直接内存打破了Java堆和操作系统IO之间的墙让数据尽可能在靠近内核的路径上流动。对于网络框架、消息中间件、高吞吐日志采集等场景来说这一层优化是实打实的性能收益。尤其是Netty这类框架把直接内存池化以后进一步减少了反复申请和释放系统调用的开销。2. 底层机制拆解DirectByteBuffer与Cleaner的回收暗战2.1 DirectByteBuffer一个“薄壳”背后的重量在Java层面我们使用ByteBuffer.allocateDirect(capacity)来申请一块直接内存。很多人以为这就像new byte[capacity]一样简单实际上这会创建一个DirectByteBuffer实例而这个Java对象的内部结构可以用“麻雀虽小五脏俱全”来形容。我们先看一段简化版的源码逻辑DirectByteBuffer(int cap) { super(-1, 0, cap, cap, false); try { long ps Bits.pageSize(); long size Math.max(1L, (long)cap (ps - 1)); address unsafe.allocateMemory(size); cleaner Cleaner.create(this, new Deallocator(address, size)); } catch (OutOfMemoryError e) { Bits.unreserveMemory(size, cap); throw e; } }这段代码里最核心的两行unsafe.allocateMemory(size)在native层申请了真实的内存块Cleaner.create(this, new Deallocator(address, size))则为这块内存注册了一个“善后清洁工”。这个设计的关键在于真正的大块数据在堆外Java堆里只有一个很小的DirectByteBuffer对象。这个对象可以被GC正常管理而当它被判定为垃圾时Cleaner的机制会触发Deallocator去调用unsafe.freeMemory(address)真正释放堆外空间。2.2 分配与回收的完整链路分配链路本身不复杂一句话描述allocateDirect-new DirectByteBuffer-Unsafe.allocateMemory- 得到堆外地址 - 同时注册Cleaner。回收链路则需要分两条路径理解路径一GC触发的自动回收。Cleaner继承自PhantomReference虚引用。虚引用有一个特点当被引用对象只被虚引用指向时GC在回收该对象后会将其放入引用队列。Cleaner内部自己维护了一个双向链表并在Reference类静态块中启动了一个处理线程Reference$ReferenceHandler这个线程会不断消费引用队列中的Cleaner实例调用其clean()方法进而触发Deallocator.run()完成freeMemory。路径二显式回收。如果业务代码能拿到DirectByteBuffer可以调用((DirectByteBuffer)buffer).cleaner().clean()去手动清理。Netty等框架内部就是这么做的通过引用计数和显式回收来精确控制堆外内存的生命周期。这里我想特别强调一个容易被误会的点很多人以为DirectBuffer被GC回收后堆外内存就立刻释放了。实际上堆外内存的释放必须等到Cleaner的clean()方法被调用而调用时机取决于ReferenceHandler线程的处理速度。如果JVM堆内都没什么GC压力DirectByteBuffer对象迟迟不被回收堆外空间就一直占着。2.3 经常看到的“幽灵内存”是怎么来的在实际生产环境中最常遇到的一类现象是进程的物理内存占用节节攀升但JVM堆内存走势平稳堆内GC也没有明显异常。这种问题几乎都是“使用完的DirectByteBuffer没有被垃圾回收”造成的。结合上面的机制再深挖一步DirectByteBuffer对象本身很小在Minor GC时很快就能被识别为垃圾。问题的关键在于如果这个对象是在老年代被分配比如经历了多次晋升或者Young区一直有存活对象导致它被连带标记为存活那么它进入GC回收的时机就会大幅延后。这就像仓库管理员很久才巡逻一次露天场地而露天场地上的货物又是一直存在的那这块区域自然会越来越乱。Netty的堆外内存池之所以能精准控制内存核心就是绕过了对GC回收时机的依赖通过引用计数器主动追踪每块堆外内存的借用和归还。如果业务代码直接使用ByteBuffer.allocateDirect()又没有显式释放那占用的堆外内存就全看GC“何时想起来打扫”而这恰恰是最大的不可控因素。3. 参数调优与容量规划MaxDirectMemorySize的正确玩法3.1 默认值陷阱你以为没设其实它等于-Xmx很多公司排查直接内存问题时第一反应是“我们没配置直接内存参数”。但实际上JVM对直接内存的容量限制有默认值-XX:MaxDirectMemorySize默认等于-Xmx的值JDK 8中是这样不同版本和不同JVM实现略有差异。这意味着什么如果你设置了-Xmx2g但没设置-XX:MaxDirectMemorySize那么JVM理论上允许直接内存使用到2GB。再加上堆本身的2GB进程的常驻内存最高可能达到4GB甚至更多还要叠加元空间、线程栈、JIT编译产物等。很多“明明设置了堆上限进程却被系统杀掉的案例”问题恰恰出在这里——系统按CGroup配额或云主机物理内存来判断进程是否触顶而这个进程不光有堆还有可能占用等量的堆外内存。我在实践中喜欢用一个经验来检查任何在容器里运行的服务都要明确设置MaxDirectMemorySize即使是设置为和Xmx相同值也要写明。这种显式声明有几个好处一是让后来排查的人一眼知道你的意图二是避免JDK版本升级后默认行为变化导致意外。3.2 容量估算不能拍脑袋要从业务回路倒推估算直接内存最好从业务数据路径倒推而不是凭直觉写一个值。这里分享一个简单的推算思路确定使用直接内存的模块和场景。比如Netty接收缓冲区、发送缓冲区、业务自定义的DirectByteBuffer池。估算“在途”数据量。例如网关中如果有100个并发连接每个连接接收缓冲区默认8MB发送缓冲区16MB那理想情况下大约需要2-3GB但这是极端峰值通常还要考虑缓冲区分页、半包、粘包等情况。考虑突发堆外分配。比如偶尔一次大对象的FileChannel.read操作一次性要分配比较大的DirectBuffer。留出余量。建议比估算峰值多出20%-30%作为系统抖动、压缩指针等开销的缓冲。一个常见的误区是把MaxDirectMemorySize设得比-Xmx还大。这会带来一个问题堆内对象和堆外内存同时处于高水位时进程物理内存飙升操作系统可能直接触发OOM Killer把整个进程杀掉而不是给JVM机会优雅处理。更合理的做法是堆和直接内存共享进程的物理内存预算两者分别设置上限且总和不超过容器内存上限减去元空间和线程栈等固定开销。3.3 和堆内存的关系不是此消彼长是共享物理资源有朋友会问直接内存和堆内存是不是像后台数据库连接池和线程池那样有某种直接竞争关系严格来说它们之间没有直接的内部联动但在物理内存层面必然共享同一个地址空间。JVM在启动时把堆的虚拟空间预留好但真正的物理页是按需分配堆外内存是在运行时动态映射物理页。如果堆内存高水位时堆外分配也频繁发生物理内存就会快速耗尽。我们需要从GC角度理解一个问题堆内的DirectByteBuffer对象是被GC管理的但它指向的堆外地址却不被GC直接管理。也就是说GC能准确知道堆内有多少对象却无法直观感知堆外已经使用了多少字节。这就会导致一种“失真”JVM认为自己的堆负载很健康实际上整个进程的物理内存已经濒临崩溃边缘。因此监控直接内存不能只看JVM的Heap Used必须把进程级RSS纳入核心监控指标。当RSS持续超过Xmx MaxDirectMemorySize的总和时就需要立刻排查堆外内存是否发生了泄漏。4. 实操诊断直接内存泄露的排查思路与工具实录4.1 先读懂异常信息直接内存OOM长什么样子直接内存不足时的异常信息和堆内存OOM有肉眼可见的差别。堆内存OOM会打印java.lang.OutOfMemoryError: Java heap space而直接内存溢出常常出现java.lang.OutOfMemoryError: Direct buffer memory有时候还会伴随java.lang.OutOfMemoryError: Map failedMap failed一般出现在FileChannel.map创建MappedByteBuffer时本质上是操作系统无法再映射新的虚拟内存空间或物理内存不足。这里有个重要经验如果线上频繁出现Direct buffer memory但又刚好卡在MaxDirectMemorySize附近说明容量规划偏小业务峰值时并发分配直接内存的需求超过了设定上限如果进程RSS远超Xmx MaxDirectMemorySize还不提示Direct buffer memory那基本可以判定有某处内存没有走JVM设置的限制路径典型的例子就是Unsafe.allocateMemory或者JNI层代码这类内存JVM完全无法控制。我在实践中见过最典型的一个场景RocketMQ在异步发送时如果处理不当setCallback未释放消息或者消费者拉取消息后快照未释放Broker端堆外内存使用量会线性上涨。异常日志可能是Direct buffer memory也可能干脆不报Java层异常直接进程被OOM Killer干掉。所以真正专业的一线排查不能只把目光放在日志里的OutOfMemoryError上。4.2 排查工具组合拳从JVM层到OS层逐级下钻直接内存排查最忌讳只用一个工具就下结论。我习惯按下面这个顺序逐级下钻每层确认后再进入下一层。第一层JVM层排查。使用jcmd命令查看JVM导出的内存信息这是我认为最直接的方式之一。比如可以运行jcmd PID VM.native_memory sumearize执行后会输出JVM进程的整体native memory使用情况包括Java Heap、Class、Thread、Code Cache、GC、Compiler、Internal、Symbol等分类其中能清晰看到Internal、Other等项的数值。如果发现Internal数值异常很可能就是某块堆外内存未被纳入直接内存统计范围但又被JVM感知到了。还可以用jcmd PID VM.native_memory detail输出更细的分类配合--enable-native-summary启动参数来使用。启动时如果不加-XX:NativeMemoryTrackingsummary这个命令会提示无法使用。经验做法是需要排查的时候下一次发布再带上NMT参数因为生产环境长期开着NMT有一定性能开销尤其是detail级别。第二层GC与堆外引用对象排查。直接内存的Java层引用链很短常规的堆dump里能看到DirectByteBuffer对象但直接内存本身的数据不在堆dump里。所以我们更多通过jmap -dump导出的堆对象去统计DirectByteBuffer的数量和总容量。可以先在dump文件上用MAT或Eclipse Memory Analyzer查看DirectByteBuffer对象再看其capacity字段。如果发现有大量存活且容量很大的DirectByteBuffer那么最可能就是上下游接口传递数据时直接使用ByteBuffer.allocateDirect()却没有释放或者框架内部的池化Buffer未被归还。第三层OS层确认物理内存占用。JVM层能看到对象引用但要确认进程真正吃了多少物理内存必须看OS层。我经常用top -p PID看RES或者用pmap -x PID | sort -k3 -nr | head -20找出占用物理内存最多的匿名映射段。pmap会列出进程地址空间的每一段映射堆外内存通常表现为anon段也就是没有对应文件路径的映射。如果pmap里发现一段段几百兆甚至几个GB的anon内存且数量和大小持续攀升基本可以锁定堆外内存的方向。另一个很有用的数据源是/proc/PID/smaps和/proc/PID/statuscat /proc/PID/status | grep Vm cat /proc/PID/smaps | grep Rss | awk {sum$2} END {print sum}在容器环境里还要额外注意CGroup限制。很多业务跑在K8s里Pod的memory limit写的是4GB进程RSS一旦逼近4GB就可能被无征兆杀掉。如果用top看宿主机的空闲内存还有很多但容器就是不停重启一定要先检查/sys/fs/cgroup/memory/memory.limit_in_bytes和memory.usage_in_bytes这能快速判断是不是容器的CGroup限制把进程杀掉了而不是Java层面报了什么错。4.3 实战案例复盘一个小小的“未释放”如何吃掉整台机器下面复盘一个比较典型的排查过程。某内部系统升级后运维反馈机器内存持续增长两三天后容器被重启堆内存、GC日志、MAT分析看起来都正常。当时服务里有一个定时任务会从远端拉取一批文件用FileChannel.read读到堆外缓冲区再交给后续代码处理。定位过程走一遍上面的排查链先看基础监控Heap Used一直稳定在30%左右GC也很正常但容器RSS一路从1.2GB涨到3.5GB到顶就重启。用jcmd PID VM.native_memory summary看到Other项持续增长但JVM内部的Java Heap、Class等都没有明显异常。使用jmap -dump导出堆在MAT里筛选所有DirectByteBuffer发现数量超过了几千个且总capacity达到2.8GB。正常情况下这个任务只有80个并发每个文件的平均大小也就几MB怎么想都不该有这么多DirectByteBuffer。再看引用关系发现这些DirectByteBuffer挂在某条业务线程的CallBack链条上任务处理完文件后对应的ByteBuffer应该被close但代码里某个分支在异常发生时直接continue跳过了释放逻辑。问题本质不在JVM参数而是业务代码对DirectBuffer的生命周期管理存在漏洞。修复方式有两种一种是在finally里显式释放另一种是确保用try-with-resources或框架的引用计数机制等可靠手段。这给我们的教训很直白使用堆外内存的代码必须把释放逻辑放在finally块中或者交给具备确定性回收能力的框架来管理。5. 面试视角直接内存的高频考点与底层原理追问5.1 高频面试题从定义到机制的全覆盖直接内存在面试里出现的频率不低它通常会混在JVM内存模型、NIO、零拷贝这些问题里。我把常见的面试题按难度排了个序从基础到深入大家可以对照自测。第一类概念题。“直接内存属于JVM内存模型吗”“直接内存是什么”“它和堆内存的主要区别是什么”这类题目看起来基础却最能暴露是否真正理解。标准回答要往这三点上靠位置在堆外、由操作系统管理、适合IO频繁场景GC不直接管理回收时机。第二类机制题。“为什么直接内存能提升IO性能”“Zero-Copy是怎么实现的”“DirectByteBuffer的回收依赖什么机制”这类题需要画图解释数据在内核缓冲区、用户空间、Socket缓冲区之间的流转路径突出“避免中间复制”这个核心。第三类实战题。“你遇到过直接内存泄露吗怎么排查”“MaxDirectMemorySize参数不设置会怎样”这类题是面试官用来区分有没有真实经验的试金石。如果能在回答里补充一个具体案例比如“当时进程RSS达到目标值后直接被K8s杀掉最终通过pmap定位到xxx”会比单纯背概念有说服力得多。5.2 底层原理追问这是区分资深与初级的分水岭有几个追问方向很常见这里展开说一下。追问一Unsafe.allocateMemory和ByteBuffer.allocateDirect有什么区别ByteBuffer.allocateDirect内部也是调用Unsafe.allocateMemory但它多了容量管理、字节序处理、Cleaner回收这些Java层的封装。直接使用Unsafe.allocateMemory是无视所有JVM参数限制的。如果业务代码直接用Unsafe申请堆外内存那么MaxDirectMemorySize管不住它。这也是很多内存泄漏问题为什么设置了MaxDirectMemorySize却依然失控的原因之一。追问二DirectByteBuffer对象在堆内被回收时堆外内存一定能同步释放吗不一定。Cleaner是一个虚引用GC把DirectByteBuffer对象标记为可回收后会通过ReferenceHandler处理回收事件但这里有一个执行时序问题。如果ReferenceHandler线程因为某些原因被阻塞或者JVM堆外压力太大释放操作就可能滞后。当然在绝大多数情况下这个机制能正常工作但理解“GC回收Java对象”和“释放堆外内存”是两个动作是回答这个问题的关键。追问三高速网络框架为什么普遍使用堆外内存Netty、Kafka这类框架之所以大量使用直接内存池根本原因有两个。一是IO性能路径考虑堆外内存能减少复制二是池化管理需要直接内存池可以实现精确的分配和回收控制。Nginx、Redis等C系组件天然就是“堆外”模型Java系中间件为了拉近与native性能的差距也会倾向于把高频使用的缓冲区放到堆外。5.3 建议记忆的最小知识集如果是为了面试系统性准备我建议把下面这个知识集合记住基本可以应对80%以上的直接内存问题直接内存不属于JVM运行时数据区的标准组成部分但逻辑上可以把它视作一块独立的堆外空间。主要来源是ByteBuffer.allocateDirect()、FileChannel.map()、Unsafe.allocateMemory()以及基于它们的NIO框架。优势是减少IO堆内复制、降低GC压力对象变小、适合高性能场景。劣势是分配和回收的开销高于堆内存需要显式管理且JVM无法精确感知其物理使用量。默认限额等于-Xmx可以通过-XX:MaxDirectMemorySize调整。排查工具链jcmdpmapMAT 容器CGroup文件必要时借助Arthas的memory命令快速查看内存分布。6. 个人实践中的几点体会文章最后分享几个我在长期排查直接内存问题过程中沉淀下来的个人习惯不一定对所有人都适用但至少值得尝试。第一用直接内存之前先问自己三个问题这个场景真的需要零拷贝吗这个数据缓冲区能否复用分配的缓冲区大小是否有上界如果三个问题里有任何一个回答不出明确理由宁可先用堆内ByteBuffer。堆外内存不是免费的午餐它把内存管理责任从JVM转移到了开发者身上代价是显式释放成本和更高的出错概率。第二线上服务最好开NMT的summary级别监控至少保留一台核心机器长期开启。-XX:NativeMemoryTrackingsummary的开销远小于detail但能在排查时提供JVM视角的分类数据。把internal、other等指标接入监控大盘当发现可疑增长时能第一时间缩小范围。第三容器环境下必须把RSS作为一等监控指标。不管JVM的堆内指标多正常只要进程RSS持续往容器内存Limit逼近就要开始重视。经验数值是当RSS超过Xmx MaxDirectMemorySize的80%时就需要排查堆外内存的使用趋势。这个比值可以根据具体服务做调整但绝对不能等到被CGroup杀掉才去追查。第四业务代码中凡是使用直接内存的场景必须建立两个硬性约定缓冲区必须有明确的释放出口释放逻辑必须放在finally块或者框架生命周期管理机制中。我在极少数需要手写DirectBuffer的场景中会额外封装一个工具类内部用引用计数保证每次分配都会有对应释放。这套约定虽然简单却实实在在避免过几次线上事故。直接内存这个主题说深很深说浅也很浅。它不像GC算法那样有大量源码级讨论但它决定了Java应用在IO密集型场景下的内存水位表现。希望这篇围绕JVM直接内存的经验整理能让你在下次面对“进程内存怎么涨个不停”这类问题时比之前更有方向感。
返回列表