资讯中心

JVM内存模型与性能调优实战:从堆栈到Full GC排查

📅 2026/10/15 3:39:17
JVM内存模型与性能调优实战:从堆栈到Full GC排查
前阵子线上一个服务在高峰期突然出现大量Full GC接口RT从几十毫秒飙到好几秒同事第一反应是想加机器、调大堆内存我拦住他说了句“先看dump再说”。后来一查是某段业务代码把全量商品数据塞进了一个静态Map既没设上限也没有过期清理堆再大也经不住每天几百GB的写入量。这就是JVM内存模型和优化之间最典型的纠葛不懂内存模型的人只会改-Xmx懂的人会先弄清内存里到底装了什么、怎么被回收、为什么会满。这篇文章会把JVM内存模型从头到尾拆开讲堆、栈、元空间各自干什么对象是怎么被分配和回收的GC收集器怎么选参数怎么配才不算瞎调以及最让人头疼的内存泄漏和OOM到底怎么排查。如果你是刚入门的Java开发它能把内存模型和垃圾回收这件事串成一条线如果你已经写了两三年Java后面的参数推演和排查实战应该能直接用到。1. 内存模型骨架拆解堆、栈、元空间到底各管什么1.1 先把区域地图记住面试和工作都用得上JVM运行时数据区通常分成六块程序计数器、虚拟机栈、本地方法栈、堆、方法区JDK8之后由元空间承载外加运行时常量池。如果算上堆外还有直接内存。这里我不打算照抄《Java虚拟机规范》的书面定义试着用“餐厅后厨”来类比这样记忆会轻松很多。堆Heap相当于后厨的食材仓库所有对象实例、数组都放这里。它是线程共享的也是垃圾回收的第一战场。仓库不够用就会出现OutOfMemoryError: Java heap space。虚拟机栈VM Stack每位厨师都有自己的操作台线程私有。栈里存放的是栈帧一次方法调用就是一个栈帧里面装着局部变量表、操作数栈、动态链接、方法出口。方法层叠太深就报StackOverflowError栈空间不够分配新帧也可能报OOM。本地方法栈给native方法用的操作台绝大多数应用几乎不关心它。程序计数器相当于厨师手边的小抄记录当前线程执行到哪一条字节码指令它不参与GC也没有内存溢出问题。方法区/元空间放类元数据、静态变量、常量池这些“菜谱”JDK8之后迁到了本地内存。直接内存不在后厨属于外部单独租用的冷藏库NIO用得多。这张地图一定要先在脑子里搭起来否则后面看GC日志、调参数、排查内存泄漏都会觉得概念飘在空中。尤其是“堆”和“栈”的区别很多人面试挂在“局部变量存在栈里对象存在堆里”这种基础问题上其实根子就是没把区域地图记牢。1.2 永久代为什么被元空间取代JDK8以前类元数据放在永久代PermGen永久代有自己的上限跟堆的内存划分也比较别扭还要调PermSize和MaxPermSize。用过老版本Java的人都应该记得java.lang.OutOfMemoryError: PermGen space这个报错尤其是在大量使用反射、动态代理、热部署的应用里类一多、一Reload永久代经常直接炸掉。JDK8以后永久代被移除类元数据放到了元空间Metaspace默认使用本地内存。这样做最大的好处是类元数据不再受“永久代上限”这种人为设定限制操作系统内存够就能涨字符串常量池也搬进了堆由普通GC管理不再需要为“字符串会不会占满永久代”担心。但这不代表元空间可以完全放任不管。实际生产中如果某个框架不断动态生成类比如CGLIB生成代理类、Groovy动态编译、热加载ClassLoader没释放元空间占用照样会一路飙升。我曾经见过一个应用运行半个月后Metaspace占用涨到2G查了半天才定位到一个动态脚本引擎在反复创建ClassLoader类无法卸载。所以我的习惯是元空间一定要设置一个合理的初始值和上限常见做法是-XX:MetaspaceSize512m -XX:MaxMetaspaceSize512m。不要想当然地认为“本地内存是无限的”无限只是相对的业务失控时照样会捅破物理内存上限。1.3 堆外内存GC管不到但比堆内更危险堆外内存有几个常见来源NIO的ByteBuffer.allocateDirectDirectBuffer、Netty的DirectBuffer池、JNI调用以及部分框架用Unsafe直接分配的堆外内存。它们不受-Xmx控制但受本机物理内存和-XX:MaxDirectMemorySize控制。Netty这类框架偏爱堆外内存核心原因是零拷贝和降低GC压力。数据从内核态到用户态时如果不经过堆内存反复搬运性能收益非常明显而且堆外内存不受GC管理不会因为大缓冲区的分配和释放频繁触发GC。对高并发网络应用来说这个选择几乎是必然的。但堆外内存的泄漏排查比堆内麻烦得多堆内OOM可以用heap dump找到大对象堆外泄漏呢GC日志看着一切正常内存监控却在飙升jmap也看不到明显的Java对象占用。这时候往往要靠pmap、NMTNative Memory Tracking这些工具去抓可很多线上环境又不允许随便开NMT。所以我对直接内存的策略是能复用缓冲池就复用绝不在请求路径上反复new堆外Buffer一定要给MaxDirectMemorySize设上限别让它变成“不受控的黑洞”。2. 对象的一生与垃圾回收逻辑从分配到GC回收2.1 一个对象从new到回收完整路径是什么很多人背过“对象优先在Eden区分配”但不明白为什么要这么设计。我用一个不太严谨但很好记的类比新生代就像公司的新员工区老年代就是老员工区。对象刚new出来先进Eden区一次Minor GC过后没有被引用的对象直接清理掉存活下来的“新员工”挪到Survivor区每挺过一次GC年龄加一年龄到了阈值默认15可配-XX:MaxTenuringThreshold就被晋升到老年代如果对象太大新生代装不下也可能直接进老年代。这里有一个容易被忽略的细节对象不是非得熬到15岁才晋升。JVM在Minor GC时有动态年龄判定如果Survivor区中年龄相同的对象大小总和已经超过Survivor空间的一半年龄大于等于这些对象的新对象会直接晋升老年代目的是避免Survivor区装不下导致频繁复制。所以千万别机械地理解为“必须GC满15次才晋升”。另外还有个分配担保机制Minor GC之前JVM会检查老年代最大可用连续空间是否大于新生代所有对象总大小如果不够就要看是否允许分配担保失败。老版本里的HandlePromotionFailure参数就是干这个的。现在HotSpot的逻辑虽然演进了一些但理解它有助于你搞清楚为什么老年代空间不足时会触发Full GC。本质上都是“新生代想把人往老年代送老年代说自己没地方于是全体STW来一次大扫除”。2.2 三种基础GC算法为什么新生代和老年代各玩各的垃圾回收的三种基础算法是理解所有收集器的底层逻辑复制算法把内存分成两块只用一块GC时把存活对象复制到另一块。优点是碎片少、效率高缺点是浪费空间。新生代存活率低复制成本小所以非常适合。HotSpot把新生代分成Eden和两个Survivor默认8:1:1就是为了把浪费控制在10%。标记-清除先标记存活对象再统一清除没有被标记的对象。老年代存活率高直接复制成本太高用这种思路比较现实但会产生内存碎片。标记-整理标记之后把存活对象往一端移动解决碎片问题。这是老年代Full GC阶段的常见选择。顺带解释一句为什么不用引用计数法判断对象是否存活因为这方案解决不了循环引用问题——两个对象互相引用外部却已经没有入口引用计数就是不为零对象永远无法回收。HotSpot用的是可达性分析从GC Roots栈帧局部变量、静态变量、JNI引用等出发遍历对象引用图不可达的对象就是垃圾。判断“谁还活着”的本质是把引用关系当成一张图来扫而不是一个个数计数器。2.3 主流收集器怎么选从Serial到ZGCSerial是老资格的单线程收集器GC时所有业务线程都要暂停STW现在基本只有客户端小内存程序还在用。Parallel Scavenge加Parallel Old是JDK8服务端的默认组合也叫吞吐量优先收集器。它追求单位时间内处理的任务量最大会牺牲停顿时间。如果业务对RT不那么敏感、只关心整体吞吐这个组合仍然够用。我手上维护过一个离线计算服务用的就是Parallel因为它的目标是跑完任务不是单次请求快。CMS是第一代并发收集器老年代用标记-清除目标是缩短停顿时间。但它在JDK9被废弃后来被移除。原因很现实CMS会产生碎片需要预留空间给浮动垃圾并发标记阶段对CPU要求高遇到大堆还会出现Concurrent Mode Failure直接退化到Serial Old做Full GC。新项目我建议直接上G1不要抱着老教程不撒手。G1是JDK9以后的默认收集器它把堆分成若干Region新生代和老年代不再是物理连续区域而是逻辑上的Region集合。G1通过预测停顿时间模型来控制GC停顿适合堆内存较大比如4G以上且对延迟有要求的场景。JDK8升级到JDK11时G1已经相当成熟很多服务切换后GC表现比之前的Parallel加CMS要好。ZGC是JDK15之后进入生产级的超低延迟收集器目标STW时间在10ms以内支持TB级堆。它用染色指针和读屏障实现并发整理适合超大堆和极低停顿要求的核心交易链路。但ZGC的CPU和内存开销并不小低延迟不是免费的没有足够的机器资源时盲目追求它会得不偿失。我按自己的经验给一个选型倾向离线批量任务或吞吐敏感型优先ParallelWeb服务、微服务、中大型堆、需要控制RT优先G1超大堆、明确要求亚10ms停顿且有资源兜底可以评估ZGC。不要为了炫技选一个不适合业务的收集器GC的世界里从来没有“谁绝对最好”。2.4 GC日志怎么读从一行日志里看出问题JDK8的写法还是老一套-verbose:gc -Xloggc:/path/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps。JDK9之后就统一用-Xlog了例如-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags。看GC日志时重点抓几个信号Young GC频率是否过高、Full GC次数是否有突变、单次GC的STW时长、每次Minor GC后Survivor区是否装得下存活对象、晋升老年代的大小是否异常。比如日志里反复出现“Full GC (Allocation Failure)”先查老年代为什么被塞满而不是急着调大堆如果出现“Full GC (Metadata GC Threshold)”关注元空间的类加载和ClassLoader释放如果Minor GC完之后Survivor区几乎满载说明对象生命周期比预期长或者新生代设置偏小。这里有个实用心得看GC日志一定要配时间线。单看一两条日志看不出趋势把GC次数、GC耗时、堆使用率拉成图表看才能发现“每个小时GC次数都在递增”这种慢慢恶化的问题。我习惯把GC日志采集到监控平台用图表观察长期走势比出了问题再翻日志高效得多。3. 核心参数推演从默认值到一套可落地的启动配置3.1 参数速查这十来个参数是必须认识的JVM参数很多但日常工作中真正高频使用的就那么十几个。我按用途分类整理如下堆内存相关-Xms初始堆、-Xmx最大堆、-Xmn新生代大小、-XX:NewRatio新生代和老年代比例默认2、-XX:SurvivorRatioEden和Survivor比例默认8、-XX:MaxTenuringThreshold晋升年龄阈值。元空间和堆外-XX:MetaspaceSize、-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize。故障处理-XX:HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath。日志和收集器JDK8用-verbose:gc、-XX:PrintGCDetails等JDK9用-Xlog:gc*收集器选择-XX:UseParallelGC、-XX:UseG1GC、-XX:UseZGC停顿目标-XX:MaxGCPauseMillis。这里有一个很多人问过的点-Xms和-Xmx为什么要设置成一样因为JVM如果允许堆从4G逐渐涨到8G运行过程中就要反复做扩容每次扩容都涉及内存分配和锁操作偶尔还会引发不稳定的GC行为。线上为了行为可预测一般直接把-Xms和-Xmx设成同一数值让堆大小固定。3.2 以32G内存机器为例推演一套启动参数假设一台32G物理内存的机器部署单个Java服务目标接口RT低于500ms我用下面这个思路做减法而不是上来就抄别人的配置。操作系统和监控代理预留约2G元空间按512M设置线程栈默认1M如果应用长期有300个线程就需要大约300MJIT编译器、GC自身的辅助结构也要占用一小部分还要预留一块给堆外内存。这样粗略算下来堆设8G到16G都有空间余量。我的建议是先从8G堆开始压测如果GC频率和对象晋升表现正常就不必把堆撑满如果压测发现老年代频繁暴涨、对象生命周期较长再逐步上调到12G或16G对比GC停顿和吞吐后选定一个值。下面是一套JDK17加G1的启动参数示例我之前在类似场景下用过java -Xms8g -Xmx8g \ -XX:MaxGCPauseMillis200 \ -XX:MetaspaceSize512m \ -XX:MaxMetaspaceSize512m \ -XX:MaxDirectMemorySize2g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/dump.hprof \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags \ -jar application.jar注意到我这里没有写-Xmn因为G1会自己动态调整新生代Region数量强行固定新生代反而可能限制G1的停顿预测能力。如果用的是Parallel收集器希望新生代稳定那就可以显式给-Xmn比如8G堆配3G新生代让Eden足够容纳高并发周期内产生的临时对象。为什么MaxDirectMemorySize要设2G因为它默认值为0的时候其实等价于堆大小但堆外内存不占堆、不受-Xmx限制如果连接池或Netty有泄漏堆外内存可以一路涨到把物理机拖垮。我看过一次堆外内存涨到十几G的线上事故jmap看堆完全正常直到用NMT才抓到是某个框架的DirectBuffer没释放。设一个明确上限至少能让泄漏有边界触发OOM后可以靠Dump定位。3.3 调参的正确打开方式一次只动一个变量网上流传很多“最佳JVM配置”模板直接复制到线上通常不会马上出事但一旦出了问题你很难判断是哪个参数导致的。我自己的调参原则是先拿监控数据再改参数一次只改一个变量。具体流程大概是用jstat -gcutil 1000观察GC频率和分区占比或者看Prometheus上的JVM指标面板。记录当前的GC次数、GC耗时、各区域使用率作为基线。只改一个参数运行一个完整的业务周期最好覆盖一个完整的高峰和低谷对比前后指标。压测和灰度验证都做了才把参数带到生产。调优的目标不是把某一个指标调到最低而是在业务可接受的延迟和失败率之内让内存占用合理、GC频率较低、停顿可控。数据驱动的调参才能复现、能回归。如果要主动验证故障场景可以用ChaosBlade的blade create jvm命令注入Full GC或方法延迟观察监控告警和限流降级是否生效这在演练环境非常有用生产环境千万别随便试。4. 内存溢出与泄漏的排查实战4.1 OOM类型对号入座先判断是哪种问题内存溢出并不是只有“堆不够”一种情况不同错误信息对应的处理思路差别很大。我整理了一个速查表报错信息可能原因第一步动作Java heap space堆内存不足或对象堆积导出堆dump查大对象GC overhead limit exceeded对象几乎无法回收GC空转dump堆查泄漏点Metaspace元空间被动态类占满查ClassLoader查动态代理/脚本引擎unable to create new native thread线程栈无法分配看线程数、OS ulimit查线程泄漏Direct buffer memory堆外内存不足查DirectBuffer泄漏看堆外监控StackOverflowError递归过深或死循环调用用jstack看线程栈大多数人和内存泄漏初次交手遇见的都是前两种。但“GC overhead limit exceeded”特别容易误判因为它出现时GC已经几乎丧失了回收能力——回收率低得离谱JVM干脆抛异常止损。这时候重点不是调参而是用堆Dump找到到底什么东西把堆占满了。4.2 排查工具用法速记全命令实测总结平时排查JVM内存问题我出门必带这几把工具都是JDK自带的按顺序用就能拼出完整线索链。jps -l -v找Java进程PID顺便看启动参数。比ps抓Java进程方便得多。jstat -gcutil 1000每秒打印一次GC比例和次数。先看Eden、Survivor、Old区的使用率以及Young GC和Full GC频率就能判断问题大致出在哪一层。jinfo 查看当前生效的JVM参数确认线上跑的参数和启动脚本一致。jmap -dump:live,formatb,file/tmp/heap.hprof 或 jcmd GC.heap_dump /tmp/heap.hprof导出堆快照。注意线上用live模式会先触发一次Full GC生产环境操作前要评估影响。jstack 打印线程栈配合top或Arthas的thread命令查线程CPU占用定位死锁、线程阻塞。jvisualvm / VisualVMGUI监控工具适合本地开发和压测环境生产环境一般不开。MATEclipse Memory Analyzer打开堆Dump看Dominator Tree和Leak Suspects。它是我个人认为找堆内泄漏最顺手的工具。Arthas在线诊断神器。dashboard看实时概况heapdump直接导出堆thread -n 3看最忙的线程不用重启、不用和本地环境匹配版本。这里有个细节jmap在JDK8及以后虽然还能用但Oracle已经在逐步弃用有些新版本会提示用jcmd替代。所以我的习惯是导堆快照优先jcmd只有在jcmd不可用时才退回jmap。4.3 一个典型泄漏案例从RT飙升到根因定位分享一个我踩过的真实案例过程很典型。某个服务在下午高峰期接口RT突然抖动超时比例上升监控显示老年代使用率持续升高Full GC从一小时0次涨到一小时20多次。我第一件事不是改JVM参数而是登上去执行jstat -gcutil确认Full GC频繁、老年代占用接近100%。这一步排除了“参数没配好”的干扰基本锁定“有对象在往老年代堆积”。接着导出堆快照同时用jstack看了线程状态确认没有死锁。然后MAT打开DumpLeak Suspects直接指向一个HashMap对象大小占了整个堆的45%。回到代码里一看这个静态Map里存的是全量用户画像数据定时任务只做增量写入从不清理过期数据高峰期数据量一上来老年代直接爆掉。修复方案没有动JVM参数把静态Map改成Caffeine本地缓存设置最大容量、过期时间并对准入做限制。上线后Full GC降为每天几次接口RT恢复正常。这个案例给我的教训特别深内存模型的知识可以让你快速判断问题出在哪一层但根因往往在业务代码里。如果当时只调大-Xmx最多多撑几周数据量继续涨还是会炸。4.4 排查过程中的避坑心得排查内存问题最容易踩的坑是我在这类事故里摔过好几跤才总结出来的。第一现场很重要。线上能开-XX:HeapDumpOnOutOfMemoryError就先开着OOM发生时会自动留下堆Dump不用等人工去导。如果发现服务快不行了手动导Dump也可以但优先用jcmd少用jmap的-F强制模式那是在JVM已经僵死时才最后考虑的手段搞不好会把进程彻底弄挂。第二不要把工具当结果。单看一个jstat数据只能说明“GC频繁”还需要结合线程栈、CPU、内存监控一起看。四张图拼起来问题的位置才能画出来。比如明明CPU 100%导致线程阻塞你却盯着堆Dump看半天方向就完全错了。第三Arthas的heapdump命令在需要快速拿堆快照时非常好用它不像有些GUI工具那样要求本地JDK版本和目标机器完全一致还支持在线反编译、查看classloader排查动态代理和元空间问题特别顺手。5. 容易被忽视但又很关键的内存优化细节5.1 System.gc()不是不能用但要先搞清楚触发条件System.gc()在源码上只是“建议”JVM执行垃圾收集具体要不要执行、执行方式是什么取决于JVM参数和收集器实现。 JDK8的默认组合下线上如果频繁出现System.gc()相关的Full GC很多优化文章会建议直接加-XX:DisableExplicitGC。但要注意这个参数不是万能药。部分NIO框架在分配堆外内存时也会调用System.gc()目的是触发堆内对象的整理、腾出空间用于DirectByteBuffer分配。如果直接把显式GC禁掉反而可能让堆外内存分配逻辑变得不对。所以我的习惯是先通过-XX:PrintGC看看Full GC的原因确认是业务代码或框架主动调用System.gc()导致的再决定加不加DisableExplicitGC。用了ZGC之后对System.gc()的处理又不一样所以别拿着一个参数走天下。5.2 ThreadLocal、缓存、连接池这三个“暗雷”ThreadLocal结合线程池使用是相当经典的内存泄漏场景。线程池里的线程是长期复用的如果某个任务往里Set了一个大对象却不Remove下一个任务可能读到脏数据而且这个线程一直存在ThreadLocal里的Map就永远带着那个对象慢慢把堆拖垮。正确做法是在finally块里调用remove。缓存的坑更常见。很多团队图省事直接用ConcurrentHashMap当本地缓存没有容量上限、没有过期时间线上数据量一涨就出问题。这个我没少踩过。换用Caffeine或Guava Cache设置maximumSize、expireAfterWrite或expireAfterAccess明显稳得多。连接池和队列也一直是内存大户。数据库连接池、HTTP连接池、线程池如果max参数设置不合理高并发时任务堆积在阻塞队列里内存会迅速膨胀。排查这类问题时除了看堆Dump还要看线程池队列长度指标很多情况下“内存不足”的背后其实是“队列长度失控”。5.3 容器环境下的JVM内存设置要格外谨慎容器化部署之后JVM内存设置又多了一个坑。JDK8u191之前JVM默认不认识容器的内存限制会以为自己是跑在宿主机上把物理机内存当成可用内存。我就见过一个容器限制4G、宿主机32G的Java应用启动时把-Xmx设成了宿主机物理内存大小结果一压测容器直接OOM被杀。JDK10之后容器支持默认开启但老版本还是得手动处理。如果要跟着容器内存动态调整可以用-XX:MaxRAMPercentage和-XX:InitialRAMPercentage比如容器限制4G时设-XX:MaxRAMPercentage75.0比较好如果JDK版本太老还是老老实实显式写-Xmx。容器内设置的堆大小必须留出元空间、堆外内存、线程栈和系统资源不要把容器内存上限当成堆空间上限来用。最后说个我自己的习惯每次改JVM参数我都会在启动脚本里保留注释说明这个参数为什么存在。线上每次GC波动我先查最近有没有发版再看参数有没有被改动。内存模型的知识只有在一次次和Full GC、OOM、RT飙升搏斗之后才会真正长成自己的肌肉记忆。参数再华丽不如现场数据可靠工具再多不如你愿意花半小时去看一次堆Dump。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案