ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JVM垃圾回收从入门到实战:内存模型、调优与排障全解析

JVM垃圾回收从入门到实战:内存模型、调优与排障全解析 做Java开发这些年最让新手头皮发麻的估计就是JVM垃圾回收了。明明代码能跑一压测就卡死明明对象不用了内存还是蹭蹭往上涨面试一问可达性分析、三色标记、G1的RSet嘴上答得溜真到排障现场就只能盯着GC日志干瞪眼。垃圾回收其实没那么玄乎它本质上就是一套“自动管理内存”的机制解决了手动释放内存带来的各种麻烦但同时也把复杂度转移到了对GC行为的理解和调优上。这篇文章我会从内存模型讲起把对象判活、回收算法、回收器选型、调优排障一路串下来既照顾刚入门的同学也会给有经验的开发者一些能在生产环境直接用的思路配着jvm内存模型、jvm调优、jvm面试题这些高频关注点帮你把这条线彻底捋顺。1. 垃圾回收到底在回收什么内存模型与对象生死判定1.1 堆空间怎么划分GC在这里干什么活JVM的内存模型是整个垃圾回收的地基。很多人以为“垃圾回收就是清理堆内存”这话对但不完整。准确说JVM管理的内存分为线程共享区和线程私有区其中堆Heap和方法区MetaspaceJDK8之后是线程共享的虚拟机栈、本地方法栈、程序计数器是线程私有的。垃圾回收主要发生在堆上方法区也有部分回收比如卸载类、清理常量池但主力战场永远是堆。堆在逻辑上被分成了新生代Young Generation和老年代Old Generation。新生代又进一步拆成Eden区和两个Survivor区S0、S1也叫From、To默认大小比例是8:1:1。新创建的对象一般先进入Eden区如果对象太大比如超过-XX:PretenureSizeThreshold设定的阈值默认3MB具体看JVM实现会直接进入老年代避免在新生代反复复制浪费性能。新生代满了就触发Minor GC也叫Young GC老年代满了就触发Major GC或者说Full GCFull GC通常会顺带回收新生代所以每次Full GC都是一次重量级的全局停顿这也是我们调优时最关注的指标之一。理解了这层结构你就明白GC的一个核心矛盾对象朝生夕死大部分对象活不过第一轮Minor GC所以新生代的回收频率很高、单次耗时很短少数活下来的对象会逐步晋升到老年代老年代空间大、回收频率低但一旦触发Full GC停顿时间往往很长。从工程角度讲调优的本质就是尽量让对象在新生代就被回收掉减少老年代的压力从而压缩Full GC的发生频率和耗时。1.2 引用计数法为什么被抛弃可达性分析才是主角判断一个对象能不能被回收最早期的方案是引用计数法给每个对象加一个计数器被引用一次就加1引用失效就减1计数器归零就回收。这个思路很直观但有个致命缺陷——解决不了循环引用。比如A引用了BB也引用了A外部没有任何地方指向它们但两者的计数器永远不小于1于是这两个对象就成了“死而不僵”的内存垃圾。所以主流JVM都没有采用引用计数法而是选了可达性分析Reachability Analysis。可达性分析的思路类似在一个社交网络里找“活跃用户”从一组根节点GC Roots出发沿着引用关系向下遍历能到达的对象就是“活的”不能到达的就是“可回收的垃圾”。根节点包括这么几类虚拟机栈栈帧中的局部变量表中引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象以及Java虚拟机内部的引用如基本数据类型对应的Class对象、常驻的异常对象等。用生活类比就是你把房间里的几个固定锚点门、窗、大梁当作根用绳子把所有家具连起来绳子能连到的家具都先留着连不到的统统搬走。GC Roots的定义直接决定了哪些对象不会被误杀。有经验的开发者设计缓存、对象池时心里都会绷一根弦一个对象被全局静态变量拽着就永远不可能被回收相反如果一个对象只是被某个局部变量暂时引用局部变量出栈后它随时可能被回收。这个判断逻辑是最底层的内存管理思维比死记硬背回收器参数重要得多。1.3 四种引用类型的实际价值从强引用到虚引用要说清“判活”绕不开Java的四种引用类型。强引用Strong Reference是我们平时最常用的Object obj new Object()只要强引用还在GC就永远不会回收被引用对象哪怕OOM也不收。软引用SoftReference用来描述“有用但非必需”的对象在内存即将溢出时GC会把软引用指向的对象列入回收范围第二次Full GC时如果内存还是不够才真正回收非常适合做内存敏感的缓存比如图片缓存、大对象缓存。弱引用WeakReference比软引用更短命下一次GC一发生就立刻被回收经典应用是WeakHashMap和ThreadLocal的ThreadLocalMap中的key用来防止内存泄漏。虚引用PhantomReference最特殊它不能通过get()获取对象唯一的作用是在对象被回收时收到一个系统通知常用来做对象销毁后的资源清理、堆外内存的回收跟踪。四种引用的关系可以这样记强引用是“不到黄河心不死”软引用是“保命时才放手”弱引用是“风一吹就走”虚引用是“死后通知你”。实际开发中软引用缓存要小心GC频繁触发导致的性能抖动弱引用则要配合ReferenceQueue使用避免堆积大量失去引用的Reference对象本身造成内存增长。我见过不止一次因为滥用软引用结果缓存命中率极低、GC压力反而变大的案例。引用类型不是越“弱”越好它是个精细的取舍选错了方向调优就越调越偏。2. 三大基础算法从标记到回收的底层逻辑2.1 标记-清除最朴素但留下隐患的方案标记-清除Mark-Sweep是最基础的算法分两个阶段先根据可达性分析标记出所有存活对象再统一回收未被标记的对象。听起来简单缺点也明显一是效率不稳定堆越大标记和清扫都要遍历更多对象二是会产生大量内存碎片就像硬盘文件删多了会产生碎片一样明明剩余空间还有几百MB但都是东一块西一块的小孔洞想分配一个连续的大数组时直接触发Full GC还是分配失败。碎片问题在新生代尤甚。新生代里频繁创建和销毁对象如果用标记-清除内存很快变成“蜂窝煤”。所以它实际并不会用在新生代更多是作为一种思路基础。CMS回收器在老年代用的是改良版“标记-清除”它允许碎片化但会在服务长时间运行后因为碎片过多触发“并发模式失败”然后退化到Serial Old做Full GC这也就是为什么CMS性能会随着运行时间衰减的深层原因。理解了碎片你就能理解为什么后来的算法都在跟“空间连续性”较劲。2.2 标记-复制新生代的效率之王标记-复制Mark-Copy把可用内存按容量分成大小相等的两块每次只使用其中一块。回收时把存活对象复制到另一块空闲区然后一次性清空当前这半区。实现简单、运行高效而且因为每次都是整半区回收不存在碎片。代价是空间浪费——始终有一半空闲着。这个代价在新生代是可以接受的因为新生代对象存活率极低通常低于10%不需要按1:1平分而是把Eden区和两块Survivor区搭配着用比例8:1:1实际浪费的只有10%的空间远比一分为二划算。对象在新生代的流转过程是这样的新对象进入Eden区发生Minor GC时把Eden区和S0From中仍然存活的对象复制到S1To同时把这些对象的年龄加1。复制完成后Eden和S0被清空S0和S1的角色互换S1变成新的FromS0变成空闲的To。如此反复当对象年龄达到-XX:MaxTenuringThreshold默认15CMS默认6时就晋升到老年代。这里有个隐藏的坑如果某个Survivor区装不下存活对象多出来的对象也不会傻等而是直接“提前晋升”到老年代这些“过早晋升”的对象多了老年代的压力就上来了。观察GC日志里老年代增长曲线时这个细节往往是判断问题源头的关键。2.3 标记-整理老年代的折中选择老年代里的对象存活率高如果还用复制算法复制成本会高到没法看直接用标记-清除又会留下碎片。于是有了标记-整理Mark-Compact先标记存活对象然后让所有存活对象向堆的一端移动按内存地址顺序重新排列最后清理掉边界之外的所有空间。整理过程既避免了碎片问题又控制住了复制成本像收拾一个塞满旧家具的仓库——先把有用的东西贴墙码整齐再把空出来的区域一次扫干净。标记-整理是Serial Old、Parallel Old、G1的Full GC阶段采用的策略。它的主要开销在于移动对象需要更新所有引用关系移动过程中必须暂停应用线程但为了彻底解决碎片问题这个停顿是值得的。这里说一个很多新手容易混淆的点标记-整理和标记-清除不是“二选一”而是不同回收器在不同场景下的组合策略。比如G1在Young GC阶段走复制流程在Mixed GC阶段对老年代的Region也做了复制式回收只有在退化成Full GC时才会做Serial Old式的整堆整理。理解回收器对算法的具体应用方式比背算法定义有用得多。3. 生产环境垃圾回收器选型从Serial到ZGC怎么选3.1 七款回收器横向对比JVM历史上比较经典的回收器有Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1以及JDK11引入的ZGC和JDK12引入的Shenandoah。每个回收器背后都是一段对“停顿时间”和“吞吐量”的取舍史。Serial是新生成单线程回收器新生代复制算法、老年代标记-整理单核CPU、Client模式下的默认选择简单但停顿时长难接受。ParNew本质上是Serial的多线程版本在新生代并行回收配合CMS一起工作是JDK8及以前服务端常见的搭配之一。Parallel Scavenge和Parallel Old是“吞吐量优先”的组合重点在于能控制吞吐量适合后台任务、批处理这类对延迟不敏感的场景。CMS主打“低停顿”老年代使用标记-清除通过并发标记、并发清除减少停顿但存在碎片、并发模式失败等问题JDK14已经正式移除。G1是JDK9之后的默认回收器用Region化内存布局把堆分成多个大小相等的Region通过优先回收垃圾最多区域的策略来预测停顿时间。ZGC和Shenandoah则是追求极致低延迟的“扛把子”停顿时间能控制在几毫秒甚至更低靠染色指针、读屏障等黑科技实现适合超大堆、超低延迟场景目前ZGC在JDK15之后开始逐渐趋于成熟。回收器GC方式适用场景核心局限Serial / Serial Old单线程客户端、小堆停顿长ParNew新生代并行配合CMS老年代仍需CMS兜底Parallel Scavenge / Parallel Old并行、吞吐量优先批处理、后台任务延迟不可控CMS并发标记清除互联网低延迟服务碎片、并发失败概率G1Region化、可预测停顿多核大堆、JDK9默认大对象分配仍是短板ZGC / Shenandoah超低延迟超大堆、毫秒级停顿JDK版本要求高、调优经验少选型没有“最好”只有“最合适”。如果你维护的是一个基于JDK8的老项目不想折腾就直接用默认的Parallel组合先把问题定位清楚再考虑切换如果是新项目且能上JDK17及以上无脑用G1大概率不会错真到了几十GB堆还要低延迟再评估ZGC。3.2 关键参数怎么配选型落地靠参数。最常用的一组-Xms和-Xmx设置初始堆和最大堆生产环境务必设成一样避免堆大小动态伸缩带来的性能抖动-Xmn设置新生代大小-XX:NewRatio设置老年代与新生代比例-XX:SurvivorRatio设置Eden与Survivor比例-XX:MaxTenuringThreshold设置晋升阈值。GC日志方面JDK9之前的写法是-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK9及之后的统一日志风格写法是-Xlog:gc*:file/path/to/gc.log:time,uptime,level。我还习惯在日志里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/heap.hprof这样OOM时能自动dump堆快照不配置这个宕机后连现场都保不住。以G1为例常见配套参数是-XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize8m。重点说MaxGCPauseMillis它只是“目标值”而非“硬性指标”G1会根据历史数据动态调整新生代大小来尽量满足这个停顿目标。如果你把目标设成50ms但硬件跟不上G1反而会频繁调整Region分配、增加GC频率吞吐量雪崩。所以这个参数要结合实际监控结果反复校准而不是拍脑袋设一个激进值。3.3 延迟和吞吐量先想清楚要哪个垃圾回收器选型的本质是一道“延迟和吞吐量”的选择题。吞吐量指单位时间内处理业务请求的数量-XX:GCTimeRatio19表示把GC时间控制在总时间的5%默认99%的时间用于业务具体比值为1/(119)5%。延迟则指单次请求的响应时间核心指标是GC停顿STWStop The World的时长。两者天然矛盾想吞吐量高就尽量少做GC堆设置大一点GC频率低但单次停顿就长想延迟低就要频繁用小堆触发GC单次停顿短但GC次数上去了总耗时反而增加。我给出一组实操建议如果业务是用户直接交互的如API网关、在线交易优先保证延迟选G1并设置合理停顿目标如果是离线计算、批处理任务用Parallel组合追求最大吞吐即可。线上容量规划一般留30%左右内存余量给JVM的“隐性问题”比如元空间增长、堆外内存、线程栈等。不要以为-Xmx8g就意味着JVM最多占8GB内存JVM总占用还包括Metaspace、Code Cache、Direct Memory、线程栈贪便宜把机器内存全部塞进-Xmx结果机器Swap狂飙GC停顿变成秒级这是最常见的调优翻车现场。4. JVM调优实操从GC日志到问题根治4.1 排查工具与GC日志解读先说工具。排查GC问题我平时用得最多的是jstat、jmap、jstack和VisualVM。jstat用来实时监控GC和类加载情况排查Full GC频率时我会敲jstat -gcutil pid 1000每秒输出一次堆各区域使用率和GC时间统计看几轮数据就能初步判断瓶颈。jmap用于生成堆dump比如jmap -dump:formatb,fileheap.hprof pid配合MAT分析内存泄漏是定位问题最“实锤”的手段。jstack用来抓线程快照排查死锁、线程阻塞等问题。VisualVM则适合开发环境做可视化取样线上一般用不了原因你懂的。GC日志怎么读看两处关键信息一是Young GC和Full GC的频率二是每次GC前后的堆占用。正常的Minor GC是“高频小波动”每秒或几分钟一次每次回收后Eden区几乎清空老年代使用率平滑增长。如果看到Full GC频繁出现且每次回收后堆占用仍然居高不下基本就是“内存边回收边涨”的节奏。日志里还有一个容易忽略的指标“Allocation Failure”——晋升失败或分配失败说明堆里已经很难找到连续空间容纳新对象这是老年代碎片化或堆太小的信号。4.2 一个Full GC频繁的实战案例有一次帮客户排查一个在线服务现象是CPU飙升、接口响应偶尔出现几秒钟的尖峰。我先用jstat观察发现Full GC每两分钟一次但单次耗时并不长老年代使用率倒是一直在90%以上回收效果明显不理想。再翻GC日志看到大量对象从新生代晋升到老年代而且年龄很小就晋升了。第一反应是堆设置偏小但看了-Xmx已经接近机器内存上限没法无脑加堆。于是抓了堆dump用MAT分析出占用老年代最多的对象是一个业务缓存类数量达到几十万每个对象都挂在全局静态缓存Map里缓存未设置过期和淘汰策略。表面看是“对象太多”本质是这个缓存Map的设计没有和垃圾回收配合好——缓存写多读少且对象个头偏大直接晋升老年代把老年代撑爆了。处理方案分两步第一步给缓存加容量上限和LRU淘汰策略控制对象总量第二步用软引用包裹缓存value让JVM在内存紧张时自动回收一部分缓存条目。加完参数上线观察Full GC频率从每2分钟降到每30分钟一次CPU尖峰消失。这个案例说明调优的起点不是调参数而是搞清楚谁在占用内存。参数只是“通道闸门”你连流量源头都找不到盲目把-Xmx调大只会掩盖问题而不会消除问题。你可以把JVM内存想象成一个大池子池子满了要么扩大池子要么减少进水大多数时候减少进水才是正道。4.3 我踩过的坑与调优原则调优踩过的坑数一数能给你排出一串。第一个是并行GC线程数的设置-XX:ParallelGCThreads别乱设默认值跟着CPU核数走如果你手动设成一个很小的值在多核机器上会导致GC吞吐严重下降。第二个是-XX:MaxMetaspaceSize不设置元空间无限增长偶尔运气不好类加载过多直接Metaspace OOM所以生产环境务必给它设个上限同时怀疑反射生成类时要结合jstat -gcmetacapacity观察。第三个是堆大小设置成动态伸缩模式即-Xms不等于-Xmx导致GC触发时堆还在扩容统计口径混乱。调优原则我总结成三句话第一先定位问题再动参数一切以监控数据和GC日志为准第二每次只改一个参数观察至少一个业务周期再决定下一步第三任何调优动作都要伴随完整的指标记录包括GC频率、停顿时间、吞吐量、CPU和内存改动前后有对比才算数。实践多了你会发现真正好用的调优往往是“简配”——不是参数堆得越多越好而是让JVM在最接近“舒适区”的配置下运行用最少的参数变动解决最大的问题。5. JVM GC高频面试题与避坑清单5.1 常见运行错误与排查日常开发和线上运维最常撞见的GC相关错误有这么几类java.lang.OutOfMemoryError: Java heap space典型的堆空间不足要么堆太小要么有内存泄漏java.lang.OutOfMemoryError: GC overhead limit exceededJVM把98%的时间花在GC上、但回收不到2%的堆空间为了保护进程主动抛错这种情况基本是内存泄漏已到晚期java.lang.OutOfMemoryError: Metaspace元空间耗尽常见于反复动态生成类如反射代理、热部署场景java.lang.OutOfMemoryError: Unable to create new native thread线程创建过多或受Linux系统线程数限制配合jstack看看线程数量即可判断。排查前别急着怪JVM先确认是不是代码原因。我自己排障的顺序是先看GC日志的Full GC频率再配合jstat看各区域使用率然后jmap dump抓堆快照最后MAT里看“Leak Suspects”报告基本能锁定可疑对象。有时候问题出于第三方库的静态缓存、线程池队列堆积、数据库连接池泄漏这些“暗雷”单靠堆参数是排不掉的。掌握这套“日志—监控—快照”三板斧比背一堆命令参数更能救命。5.2 面试时怎么答GC问题面试问到垃圾回收考察点通常不是背诵而是逻辑。常见高频题有GC Roots有哪些新生代为什么用复制算法CMS为什么有碎片问题G1和CMS的区别ZGC为什么能做到毫秒级停顿三色标记算法是什么并发标记阶段怎么解决漏标问题我给你的建议是按“回答公式”来组织先给结论再给原因最后给场景。比如问“新生代为什么用复制算法”先答“因为新生代对象存活率低复制成本低且不产生碎片”再展开“Eden和两个Survivor分配比例8:1:1一次Minor GC能回收绝大部分对象剩余存活对象复制到另一块Survivor区”最后补一句“如果Survivor区不够还会提前晋升老年代这也是老年代压力来源之一”。这样既体现了深度又展示了工程思维而不是像背八股文一样平铺直叙。三色标记尤其值得花时间搞懂。它把对象分成白、灰、黑三类白色表示尚未扫描到灰色表示自身被扫描了但内部的引用还没处理完黑色表示自身和内部引用都处理完了。并发标记阶段通过写屏障和增量更新或原始快照SATB来避免黑色对象误引用白色对象导致的漏标。理解它你就能看懂G1和CMS在最棘手的并发一致性问题上各自的选择面试官问得再深你也能接得住。5.3 个人实操心得最后分享点我自己的实操体会。垃圾回收调优没有银弹别信网上那种“一天搞定JVM调优”的爽文真正赚钱的活儿都是枯燥的数据对比今天改一个参数观察两小时GC曲线明天再看业务高峰的表现如此反复一周才敢动线上的开关。我还习惯把GC日志统一采集到日志中心配上告警规则比如Full GC频率超过每小时5次、单次停顿超过500ms就触发告警。这样比起事故发生后才发现要省心太多。另外工具是死的人是活的亲手把一次线上Full GC事件从头到尾排一遍比看一百篇调优文章都有用。你亲自做过的第一个OOM排查会深深记在脑子里因为它逼着你把内存模型、引用类型、回收器行为全部串成一条线。希望这篇内容能帮你少走点弯路也欢迎把这篇文章转给团队里新来的同学一起把JVM这块硬骨头啃下来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进