资讯动态

JVM垃圾回收核心指南:从对象判定到收集器选型与调优

发布时间:2026/10/16 4:29:34 来源:尧图企业网站定制
1. 垃圾回收到底在解决什么问题从堆内存划分和对象存活判定说起面试里聊到JVM十个面试官有九个会从垃圾回收GC切进来剩下的那个也会在聊完类加载之后补一刀“GC讲讲”。很多同学把GC当纯八股来背张口就是“分代收集、可达性分析、CMS和G1的区别”背得很顺但一被追问“为什么要分成新生代和老年代”“可达性分析里的GC Roots到底有哪些”就开始含糊。其实GC这个话题之所以被反复拷问是因为它直接关系到一个Java进程能不能稳定、低延迟地跑下去。你要是连“GC在收拾哪块内存、什么时候判定对象该死、用什么手段打扫干净”都说不清就算背了再多收集器名字也只是空中楼阁。先回到最基础的问题Java的自动内存管理管的到底是什么C/C里开发者自己malloc、自己free漏了free就内存泄漏多free就悬空指针崩溃。Java把这块活抢过来干了new出来的对象由JVM统一分配和回收开发者不需要手动释放。但“自动”不等于“没有成本”JVM必须知道哪些对象还能被用到、哪些已经没人引用然后把没人要的对象占用的空间腾出来重新分配给新对象。这个过程就是垃圾回收。它可不是后台默默地扫一扫那么简单真实场景下一次停顿可能让线上接口的延迟从几毫秒飙到几百毫秒所以才会有一代又一代的收集器去逼近“低停顿”这个目标。再说堆内存的划分。HotSpot里的堆不是一块铁板而是按“对象存活概率”分了区域新生代Eden 两个Survivor和老年代。为什么这么分因为绝大部分对象的生命周期极短。你写个for循环里面new出来的临时对象大概率在下次GC时就没人用了。把这些短命对象集中放在新生代用复制算法快速清理成本极低把长期存活的对象晋升到老年代用标记整理或并发收集慢慢处理避免一遍遍复制大对象。这就是“分代收集”的由来。面试时如果把这一层逻辑讲透面试官基本就会觉得你不是在背题。那对象什么时候算“死”了主流方案不是“引用计数”而是“可达性分析”。引用计数的思路是给对象加计数器被引用一次就加一引用失效就减一减到零就能回收。听起来简单但它解决不了循环引用A引着BB引着A外部没人用这两个对象计数器却永远不为零内存就泄漏了。Java用的可达性分析是从一组被称为GC Roots的根对象出发沿着引用链往下走能走到的对象就是“活的”走不到的哪怕互相引用成一团乱麻也照样判定为可回收。这里有一个面试高频追问GC Roots包括什么至少要能说出这么几类虚拟机栈栈帧中的本地变量表中引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象比如字符串常量池里的引用本地方法栈中JNI即Java Native Interface引用的对象所有被同步锁synchronized持有的对象Java虚拟机内部的引用如基本数据类型对应的Class对象、常驻的异常对象等很多初级选手只知道前三条后两条说不出来。其实“被锁持有的对象”这个点在排查“对象为什么回收不掉”时特别有用别小看它。2. 分代假说与垃圾回收算法为什么复制算法是年轻代的主流解法聊完判定就该聊“怎么回收”。回收不是把对象简单地“删掉”而是要把内存空间重新整理好才能继续分配新对象。这里牵扯出三种基础算法标记-清除、标记-复制、标记-整理。别急着背优缺点先把它们背后的逻辑捋一遍。2.1 弱分代假说与真实的对象存活比例“分代收集”理论基于两条经验法则弱分代假说绝大多数对象朝生夕灭和强分代假说熬过多次GC的对象越来越难回收。所以设计上让新生代承受高频的小规模GC把绝大多数“短命鬼”一次性清掉代价极低老年代则承受低频但完整的大规模GC处理那些真正“长寿”的对象。这里有个数据可以参考在大多数业务系统里新生代GC一次能回收掉90%以上的对象。正因为存活率这么低复制算法才划算。复制的成本是“存活对象数 × 复制开销”存活对象越少复制成本越低如果反过来把复制算法用在老年代存活对象一大片每次GC都得把整个区搬一遍代价直接起飞。2.2 三种基础GC算法的优缺点对照三种算法在面试题里几乎是固定搭配我一般建议用表格去记忆它们的核心差异算法核心思路优点缺点典型使用场景标记-清除先标记可回收对象再统一清除实现简单产生内存碎片标记和清除两个阶段都慢理论地基几乎不单独用标记-复制把存活对象复制到另一块空区域原区域整体清空无碎片分配只需移动指针浪费一半空间存活率高时复制开销大新生代存活率低空间换时间标记-整理标记存活对象后向一端移动直接清理边界外的空间无碎片空间利用率高移动对象和更新引用的开销大老年代存活率高避免碎片这里有个容易踩的误区很多人以为“标记-清除”会真的把内存“清干净”其实它只是把未标记对象占用的空间标记为“可用”物理上对象还在那里下次分配时被覆盖。所以标记-清除留下的是一堆不连续的“空洞”如果下次要分配一个大数组明明总剩余空间够却可能因为找不到连续区域而触发一次Full GC。这正是老年代不敢用“标记-清除”单打独斗的原因。2.3 从标记清理到内存整理碎片问题的应对既然碎片这么讨厌那干脆把存活对象都往一边挪挪完直接一刀切边界之外全部释放这就是“标记-整理”。老年代用这种思路能保证内存连续分配大对象时不会因为碎片频繁触发Full GC。不过“整理”不是没成本的。对象一旦移动所有指向它的引用都要同步更新这个过程涉及遍历整个堆的引用关系开销不小。为了平衡有些实现会允许老年代GC时“先标记-清除几次等到碎片严重了再整理一次”比如CMS就是这么干的它把“并发”和“碎片化”的矛盾全暴露出来了后面聊收集器时会细说。我自己的经验是学这三种算法不要孤立地记结论要抓住一个核心矛盾内存利用率、GC停顿、吞吐量三者不可兼得。复制算法用空间换时间整理算法用时间换空间清除算法图省事但留后患。所有后来出现的收集器本质都是在给这三个目标找平衡点。3. 主流收集器横向对比与演进逻辑Serial、Parallel、CMS、G1、ZGC算法是“招式”收集器是“拿着招式上战场的人”。JVM里的收集器一代代演进背后是硬件环境和业务诉求的变化从单核到多核从追求吞吐量到追求低延迟。搞懂这条演进线比背十个收集器名字有用得多。3.1 收集器与算法的对应关系先理清一个容易乱的点不是每个收集器都用“一种”算法。新生代收集器Serial、ParNew、Parallel Scavenge都用复制算法老年代收集器里Serial Old用标记-整理CMS用标记-清除G1和ZGC则是把堆切成Region后在不同Region上混合使用复制和整理思路。我建议按“组合”来记Serial Serial Old单线程收集Client模式下的默认组合适合小内存、单核环境。ParNew CMS曾经的互联网标配“低延迟组合”多线程新生代 并发老年代。Parallel Scavenge Parallel Old“吞吐量优先”组合适合批处理、离线计算这类对停顿不敏感的任务。G1全堆分Region可同时回收新生代和老年代JDK 9之后的默认收集器。ZGC/Shenandoah目标把停顿控制在10毫秒以内用读屏障和染色指针做并发标记、并发整理。记住一个细节ParNew是Serial的多线程版本它俩的GC行为和参数几乎一致所以面试官问“ParNew和Serial的区别”你答“ParNew是多线程版用于配合CMS使用”基本就过关了。3.2 STW、吞吐量、低延迟三条路线的取舍收集器分代演进的核心矛盾是Stop The WorldSTW也就是GC执行时要把业务线程暂停。新生代GC因为快STW还能忍老年代GC动不动几百毫秒甚至几秒在在线业务里就是灾难。于是工程师们开始分路线要吞吐量就多停一会儿把活干完要低延迟就想办法和业务线程并发干。这三条路线对应三类典型场景吞吐量优先后台定时任务、离线报表、科学计算。停顿个一两秒无所谓单位时间内干更多活才是硬道理。Parallel系列是代表。响应时间优先电商下单、支付链路、实时推荐。接口P99延迟一旦飙升业务KPI直接崩CMS和G1是主力。超低停顿大规模堆内存下的广告检索、高频交易系统。堆越大传统GC的停顿越长ZGC和Shenandoah就是为了这个场景。面试官常问“CMS为什么被G1取代”其实答案不止一个CMS用标记-清除必然产生碎片碎片积累到一定程度就得用Serial Old兜底做Full GC那一次停顿直接拉满CMS的并发标记阶段需要和业务线程抢CPUCPU核数少时吞吐下降明显CMS做不到全堆回收只能管老年代新生代还要靠ParNew配合两个收集器的协调也有额外开销。G1用Region概念把整个堆切成小块每块独立决定是回收还是保留既避免了全堆扫描的停顿又从设计上消灭了“只能回收老年代”的割裂感。3.3 G1中的Region与RSet机制G1的两大杀手锏是Region和RSetRemembered Set。Region把堆分成若干个大小相同的小块默认2048个每个小块可以扮演Eden、Survivor、Old甚至Humongous大对象区。GC时不再全堆扫描而是挑“垃圾最多的Region”优先回收这就是G1名字的由来Garbage First优先处理垃圾占比高的区域。那RSet是干嘛的跨Region引用怎么记录如果Region A里的对象引用了Region B里的对象GC要判断B里某个对象是否存活总不能把所有Region都扫一遍。RSet就是一张“反向指针表”记录“有哪些Region引用了我”。回收某个Region时只需要扫描它的RSet就能知道哪些外部对象指向自己不用全堆遍历。这个设计极大降低了大堆下的扫描成本但代价是写屏障Write Barrier的开销每次引用赋值都要顺带更新RSet。很多人问“G1是低延迟收集器吗”严格说G1是“可预测停顿”收集器它通过-XX:MaxGCPauseMillis来调停目标但停顿时间和堆大小、存活对象数量强相关。G1在超大堆上的表现不够极致所以才有ZGC接棒。3.4 ZGC的染色指针与读屏障ZGC的设计思路完全跳出了传统分代框架。它不区分新生代老年代而是把整个堆做成可动态调整的Region配合染色指针Colored Pointer实现并发标记、并发转移。染色指针把指针本身的某几位变成“标记位”对象还没移动指针上已经写好了状态信息。GC线程移动对象时普通线程如果访问了被移动的对象读屏障会捕获到并重定向到新地址业务线程几乎无感知。ZGC最亮眼的数字是可以做到TB级堆下STW不超过10毫秒但它是牺牲了CPU换来的。读屏障在每次引用读取时都要做额外判断CPU开销比G1高不少。所以选型不能拿ZGC当作万能答案堆不大、停顿容忍度又没那么极端的场景G1反而更均衡。4. 一次Full GC的排查过程GC日志怎么看、参数怎么调理论聊完必须落地。很多同学原理背得溜一到线上看GC日志就懵满屏的[GC和[Full GC到底该怎么读哪些参数能调、哪些参数调了反而更糟这一节我用一次真实的排查过程来讲。4.1 从GC日志拆解一次完整的Minor GC先开启GC日志。JDK 8和JDK 11的开关写法不一样特别容易踩坑JDK 8-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.logJDK 11-Xlog:gc*:file/path/gc.log假设线上跑了个服务日志里出现这样一行[GC (Allocation Failure) [PSYoungGen: 873321K-10240K(874496K)] 1245210K-371234K(1572864K), 0.0112130 secs]拆开看PSYoungGen是Parallel Scavenge新生代回收前占873MB回收后剩10MB新生代总容量874MB。整个堆回收前1.2GB回收后371MB总堆1.5GB。耗时11毫秒。这是一次年轻代GC原因是“Allocation Failure”也就是Eden区不够分配新对象了。看到这种日志不用紧张属于正常现象。真正需要注意的是连续出现Full GC[Full GC (Metadata GC Threshold) [PSYoungGen: 0K-0K(34304K)] [ParOldGen: 31362K-31362K(69376K)] 31362K-31362K(103680K), [Metaspace: 16384K-16384K(16384K)], 0.4150930 secs]这个案例里新生代根本回收不出东西老年代也没变化MetaSpace触发了GC阈值。如果Full GC之后内存占用纹丝不动优先怀疑是不是元空间膨胀或者类加载器泄漏而不是一上来就调大堆内存。4.2 常见GC参数与典型调优场景调优的第一步是明确目标是降P99延迟还是提整体吞吐还是解决某个频繁Full GC的线上故障目标不同下手点完全不同。我列几个最常用的参数参数作用适用场景-Xms/-Xmx堆初始/最大大小建议两者相等避免运行时堆扩容扩容是要STW的-XX:NewRatio新生代:老年代比例默认1:2消息量大的短生命周期对象多时把新生代调大-XX:SurvivorRatioEden:Survivor比例默认8:1S区太小会导致对象提前晋升老年代-XX:MaxTenuringThreshold对象晋升老年代前经历的最大GC次数默认15对象生命周期短可调小减少复制成本-XX:MaxGCPauseMillisG1的停顿目标只对G1生效设太激进反而会导致GC频率飙升-XX:ConcGCThreads并发GC线程数并发阶段和业务线程抢CPU别拍脑袋调大有个特别常见的坑很多团队一看到GC频繁就把-Xmx加大堆确实大了但GC每次扫描的范围也大了单次停顿反而变长。而且堆越大晋升到老年代的对象越多Full GC的频率不一定降。堆大小的设置应该先量出系统的“活跃数据大小”存活撑过多次GC的对象总量堆保持在活跃数据的3到6倍左右最合理1倍是临界、10倍就是浪费。4.3 Full GC的常见根因和排查链路真正让我印象深刻的是一次模拟项目X的线上事故。某服务每两个小时就来一次Full GCGC日志显示老年代回收后内存下降很少但过一会儿又慢慢涨上去了涨到一个值就触发下一次Full GC。刚开始怀疑是内存泄漏用jmap -histo导出堆对象前几名都是业务Bean看着不像泄漏后来用jmap -dump抓了堆快照配合MAT分析发现了一个非常大的HashMapKey是一个外部系统的订单号Value是上下文对象这个Map被一个单例一直持有但里面的Entry只增不减。这其实不是经典的“泄漏”而是“未清理的缓存”本来应该定期清理过期Key代码里漏了。解决办法要么引入带有过期策略的本地缓存组件要么至少用定时任务把超过一段时间的Entry挪出去。从这次排查里我总结出Full GC根因的几个高发方向大对象直接进入老年代一个几MB的byte[]如果新生代放不下会直接进老年代频繁创建大对象会快速塞满老年代。日志里能看到老年代空间被大块连续占用。对象过早晋升Survivor空间不足或者晋升阈值设太低短命对象提前进入老年代导致老年代空间被“假长寿”对象占满。内存泄漏缓存、线程池TaskQueue、静态集合类这三个是最常见的泄漏窝点。Metaspace膨胀反射、代理、类加载器未释放导致元空间不断增长MetaSpace GC频繁又触发Full GC。系统GC被显式调用System.gc()虽然默认是建议性调用但某些框架、监控探活会把它变成强制Full GCGC日志里会看到完整路径。排查链路的顺序我一般建议先开GC日志看频率和耗时再用jstat -gcutil看各区占比再用jmap -dump抓堆快照分析对象结构最后用jstack看看是不是有线程在反复触发大对象分配。抓到根因后能改代码的改代码不能改代码的才考虑调参数顺序反了很容易南辕北辙。5. 八股背后的真实考点几个容易翻车的细节与我的复盘最后这一节我基于自己反复被问、也反复追问别人的经验把GC八股里最容易翻车、最值得深挖的几个点集中聊一聊。这些都不是冷门偏题而是“自认为会了、一问就露馅”的角落。5.1 这些细节你必须会区分第一个高频翻车点是“stop-the-world”和“safe point”的关系。很多人以为GC想停就停其实JVM要让线程停下来必须等到线程运行到安全点Safe Point。循环里没有方法调用、没有内存分配的代码线程可能长时间不进入安全点GC只能干等。编译优化里有一种“非计数循环”可能被JIT优化成不带安全点的死循环这就是为什么线上偶然会出现GC前线程迟迟挂不住的情况。面试如果被问到“为什么会出现GC停顿比预期长”除了堆大安全点这个原因是很加分的。第二个是“System.gc()到底会不会立即Full GC”。严格说它只是触发一次“建议性”GCHotSpot里默认会转换成一次Full GC但可以用-XX:DisableExplicitGC关掉。很多Java应用踩过的坑是框架里用了System.gc()来“主动回收堆外内存的堆内引用”结果导致线上频繁Full GC。调优时第一件事就该确认GC日志里有没有“Explicit GC”字样有就查谁调出来的。第三个是“老年代GC一定会做可达性分析吗”。G1的并发标记阶段只标记活跃对象没有传统意义上的“全堆清扫”ZGC的并发整理阶段甚至不知道哪些Region是垃圾它靠染色指针在访问时兜底修正。所以别把“老年代GC 标记-整理”当成铁律不同收集器的实现差异很大。5.2 关于强软弱虚引用的作用别只背定义面试题里的“四种引用”几乎是必问但大多数人只会背定义强引用不回收软引用内存不足时才回收弱引用每次GC就回收虚引用只用于对象回收后的通知。我在项目里真正用过的场景才是加分项。软引用最经典的实践是图片缓存。某个图像处理Demo里要加载一批大图直接放内存一会儿就OOM用SoftReference包装一下内存紧张时JVM会自动把最久未用的软引用对象清掉缓存命中率和高内存风险之间就有了缓冲。注意软引用回收不等于“按LRU顺序”它受Survivor晋升状态和对象引用强度的影响不能拿它当精确的内存淘汰器。弱引用常用于“缓存存在但不需要阻止回收”的场景比如框架里的WeakHashMap键是弱引用键对象没有被外部强引用时Entry自动移除。ThreadLocal的内存泄漏问题本质也在这里ThreadLocalMap的Entry继承WeakReferenceKey被弱引用持有如果线程不结束、Value一直有强引用就会出现“Entry还在但Key已经回收”的脏数据所以用完记得remove()。虚引用最容易被忽略它不能通过get()拿到对象唯一作用是在对象被回收时收到一个通知。堆外内存的回收就是典型场景DirectByteBuffer的堆内对象被回收时通过虚引用触发Cleaner去释放堆外内存。5.3 我在反复面试和实战后的心得清单回头看GC这个话题之所以被当成核心八股是因为它特别能区分“背过答案”和“真见过问题”的候选人。几个心得供大家参考第一一定亲手排查一次线上GC问题。不需要多复杂把一个Demo跑起来人为制造一个缓存泄漏然后用jstat -gcutil观察各区变化抓一次堆快照分析一次存活对象。这个流程走完你对GC日志和参数的理解会远超那些只刷题的选手。第二理解“调优是寻找合适不是追求最优”。-Xmx不是越大越好-XX:MaxGCPauseMillis不是设得越低越好调一个参数之前必须想清楚它会挤压什么、牺牲什么。比如把新生代调大Minor GC频率降低但单次暂停时间拉长同时晋升的老年代对象可能更“大龄”老年代GC压力反而上升。第三GC是“结果”不是“原因”。线上GC频繁先问为什么对象这么多再问怎么收。很多时候把代码库里一个滞留的连接池、一个不清理的本地缓存修掉GC频率直接腰斩比任何参数调整都管用。第四面试回答的节奏比答案本身更重要。先讲这个机制要解决的问题再讲它的方案设计最后讲一个真实场景里的取舍。面试官听到你能把“CMS为什么碎片化”“G1为什么引入RSet”“ZGC为什么上染色指针”串成一条演进逻辑线基本就不会再揪着冷门参数不放了。说起来“八股”这个词常被当成贬义但GC这套东西不一样背下来的框架配合真实踩坑后的体会才是真正能迁移的能力。哪怕你未来不面试、不调优只是写业务代码理解JVM怎么收拾你随手new出来的那些对象也会在选择数据结构和缓存策略时多一分底气。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑