JVM垃圾回收这个话题,我在不同场合讲了不下几十遍。面试要考,线上调优要懂,可每次一提什么可达性分析、什么CMS/G1/ZGC,总有人第一反应是“背不下来”。其实GC的知识点不是靠背的,是靠理清一条主线串起来的:先知道回收什么东西,再想怎么回收,最后看有哪些回收器能用、怎么排查问题。这篇速记版,就是按照这条主线帮你把整块知识摊开,配合我实际排查中总结的记忆锚点和踩坑笔记,争取让你花半小时就建立一张完整的知识地图。不论你是准备面试、复习基础,还是被线上频繁Full GC折腾过,照着这个框架去理解和查漏,都会比死记参数高效得多。
1. 先搞清一件事:GC到底在回收什么
1.1 对象存活的判定:引用计数法与可达性分析
垃圾收集要动手之前,虚拟机必须先回答一个问题:哪些对象还能用,哪些已经“死”了。
第一种思路叫引用计数法。给每个对象配一个计数器,每被引用一次就加1,引用失效就减1,计数器归零就是垃圾。这个设计非常直观,实现代价也小,但它有一个致命伤:处理不了循环引用。举个例子,对象A持有一个指向B的引用,对象B也持有指向A的引用,除此之外再没有别人引用它们。按引用计数的逻辑,A和B的计数器永远不为0,它们就永远占着内存不释放。现实中这类互相引用的结构太常见了,所以主流JVM都没把引用计数法当作唯一判定手段。
第二种思路,也是HotSpot虚拟机的选择,叫可达性分析。思路是从一组称为GC Roots的根对象出发,沿着引用链往下走,只要从某个根出发能到达的对象,就判定为存活;走不到的对象,哪怕还自引用着,也是垃圾。这个模型天然绕开了循环引用问题:你从一个根出发走不到A和B,不管它们内部怎么互相指,都会被回收。你可以类比成“从港口出发有没有航线能到达某个岛”,而不是“这个岛有没有给自己挂救生圈”。
GC Roots到底包含哪些东西,是高频考点,我用四个字记:“栈静JNI”。拆开就是:
- 虚拟机栈中的局部变量表所引用的对象,也就是正在执行的方法里引用的对象。
- 方法区里的静态属性引用的对象,还有常量池中的引用。
- 本地方法栈中JNI引用的对象。
- Java虚拟机内部的一些特殊引用,比如基本类型对应的Class对象、常驻的异常对象、系统类加载器等。
再补充一个现在容易被问的细节:synchronized持有的对象、被JMX等管理组件注册的对象,也属于roots的范畴。背不用全背,但要理解“凡是根能直接或间接摸到的对象,JVM就认为它还活着”,这个思想贯穿了下面所有算法的讲解。
1.2 四种引用类型:面试问答的送分题
理解了判定逻辑,紧接着就是引用类型的区分。Java把引用从强到弱分成四种:强引用、软引用、弱引用、虚引用。
- 强引用:代码里最常见的
new出来的对象。只要强引用还在,GC宁可让堆溢出也不会回收它。 - 软引用:用
SoftReference包裹。系统将要发生内存溢出异常之前,会先把软引用关联的对象回收掉。适合做内存敏感的缓存。 - 弱引用:用
WeakReference包裹。只要GC发生,无论内存够不够,弱引用对象都会被回收。ThreadLocal的ThreadLocalMap为什么用弱引用做key,就是为了避免key强引用导致Entry永远无法清理。 - 虚引用:最弱的引用,拿不到对象实例,唯一用途是在对象被回收时收到一个系统通知,比如
Cleaner机制里就用它做资源清理追踪。
面试题里经常问“软引用和弱引用有什么区别”,最稳的回答模板是:一个在内存溢出前才回收,一个在下一次GC就回收;前者适合缓存,后者适合防止内存泄漏的兜底。
这里提醒一个常见的漏考点:finalize()方法。早期JVM曾提供finalize作为对象自救的手段,对象可以在被判定死亡后,在finalize里重新建立引用从而逃脱回收。但这套机制设计复杂、执行不稳定,JDK 9明确标记了废弃,现在完全不推荐依赖它。聊到“对象自救”时提一嘴“那是finalize的遗留设计,现在别用”,反而显得你有实战认知。
2. 三大基础回收算法,一张表讲透
判定出哪些对象是垃圾之后,第二步是怎么清。JVM里的主流算法归根结底就三种:标记-清除、标记-复制、标记-整理。它们的前半段都一样,先标记存活对象,区别在后半段怎么处理未存活的内存。
2.1 标记-清除:最朴素的思路
流程分两阶段:先标记出所有不需要回收的存活对象;再统一回收掉未被标记的对象。空出来的内存是零散的,所以这个算法最大的问题是内存碎片化。就像停车场里东空一格西空两格,车多了就停不下,明明总空位数够,却找不到连续的大车位。
碎片化带来的连锁反应是:后续分配大对象时,JVM发现没有连续空间,又得提前触发一次GC来整理空间。所以单纯用标记-清除的场景很少,它更多是作为其他算法的基础思想存在。CMS收集器的老年代回收,底层就是标记-清除的变体,这也是它碎片化严重的原因。
2.2 标记-复制:拿空间换时间
标记-复制的思路是:把内存按容量分成大小相等的两块,每次只使用其中一块。回收时把存活对象整体复制到另一块上,然后一次性清掉已使用的那半块。
优点很明确:没有内存碎片,分配快,只要移动堆顶指针;缺点也明显,内存利用率只有50%,而且如果存活对象多,复制成本就大。你说这算法好像浪费了一半空间?别急,实际使用中JVM不会这么死板,它做了精妙优化——新生代里并不用对半分的方案,而是把内存分成一块较大的Eden空间和两块较小的Survivor空间,默认比例8:1:1,每次只使用Eden和其中一块Survivor,回收时把存活对象一次性复制到另一块Survivor上。这样可用内存达到了90%,而不是50%。
如果Survivor空间放不下那么多存活对象怎么办?这时候就轮到“分配担保机制”登场了——放不下的对象会直接晋升到老年代,相当于把Survivor装不下的压力转交老年代兜底,防止复制算法直接崩溃。
2.3 标记-整理:避免碎片化的折中
复制算法在对象存活率高的场景下效率极低,而老年代恰恰就是大量长生命周期对象的聚集地,所以老年代不能简单用复制。标记-整理的做法是:标记完存活对象之后,让所有存活对象向内存的一端移动,然后直接清理掉边界以外的内存。
好处是没有碎片,坏处是移动对象涉及更新所有引用地址,这个更新过程通常要求暂停用户线程(Stop The World,简称STW)。所以标记-整理本质上是用停顿时间换空间连续性。到这里你可以把三种算法串起来记:清除是基础但散,复制是快且整但费空间,整理是整且省空间但费停顿。
2.4 分代收集理论:算法组合拳的由来
面试常问“为什么新生代用复制,老年代用整理”。原理其实就两条客观规律:
第一,绝大多数对象生命周期极短。项目里new出来的临时对象,往往几十毫秒内就不再用了。我经常用一个比喻:机场的到达通道,大部分人出来就走,只有少数人转乘、停留。新生代里活下来的对象通常不到10%,这种低存活率正是复制算法的理想场景。
第二,熬过多次GC的对象,大概率还会继续存活。这些对象堆积在老年代,数量多且寿命长,复制算法在它们身上就是灾难。老年代更适合标记-清除或标记-整理。
于是现代JVM都采用分代收集:新生代用复制,老年代用清除或整理。这并非理论上的最优,而是实践里性价比最高的选择。记住这句话,以后任何收集器讨论,底层都逃不开这套思想。
| 算法 | 是否产生碎片 | 是否需要大块连续空间 | 适用存活率场景 | 典型使用者 |
|---|---|---|---|---|
| 标记-清除 | 有 | 否 | 低或高都可,但碎片麻烦 | CMS |
| 标记-复制 | 无 | 是 | 低存活率 | ParNew、Parallel Scavenge |
| 标记-整理 | 无 | 是 | 高存活率 | Serial Old、Parallel Old |
3. 堆布局与回收时机:Minor、Major、Full GC
3.1 新生代与老年代的结构
Java堆按分代理念划分为新生代和老年代,新生代内部又划分成Eden区、Survivor from区、Survivor to区,默认大小比例是8:1:1,可以通过-XX:SurvivorRatio调整。
很多接触GC不久的人第一次看到“from”“to”会被绕晕。记住一个规则:任意时刻总有一块Survivor是空的,GC发生时它作为“to区”接收存活对象。下一次GC,两块survivor交换身份,上一轮的to变成from。这就像左右手倒腾东西:两手交替,总有一只是空着的接收方。
Eden区则是新对象的主要分配地。绝大多数new出来的对象先落到Eden,Eden快满时触发一次Minor GC,把Eden和当前非空的Survivor里的存活对象,复制到另一块空的Survivor中,同时清空Eden和原Survivor。这个过程里大部分对象直接被清掉,剩下的带着年龄加1进入Survivor。
除了正常分配,还有几种特殊对象不走这条路:
- 大对象:超过
-XX:PretenureSizeThreshold设置的大对象,直接分配到老年代。这么做是避免大对象在Eden和Survivor之间复制时,产生昂贵的复制开销和下标的巨大压力。默认情况下这个阈值是0,表示不启用,可以显式设置。 - 动态年龄判定:JVM并不死等对象熬到阈值15才晋升,如果Survivor里同龄对象加起来超过Survivor空间的一半,年龄大于等于这些对象的那批就会提前晋升老年代。
顺带提一句JVM参数里的-XX:MaxTenuringThreshold,它控制对象晋升老年代的年龄阈值,默认是15。这只在Serial和ParNew收集器下按字面生效,Parallel系列里可能被动态调节覆盖,理解成“阈值可被JVM自动调整”更准确。
3.2 GC触发机制速查
Minor GC触发条件很直接:Eden区空间不足就会触发,因为新对象基本都是在Eden分配的。Eden没满,一般不会自动触发Minor GC,所以它是高频但低成本的。
Major GC和Full GC的触发条件就要小心了,很多面试题坑都挖在这里。触发Full GC常见场景有:
- 老年代空间不足。比如晋升上去的对象太多,老年代放不下。
- 分配担保失败。Minor GC前,JVM检查老年代最大连续空间是否大于新生代所有对象总大小,如果小于且
HandlePromotionFailure设置不允许冒险,就会转成一次Full GC。 - 元空间(方法区)空间不足。JDK 8之后字符串常量池、类的元数据都存在元空间,元空间满了也会触发Full GC。
- 调用
System.gc()。这个方法只是建议JVM执行GC,并非强制,但很多框架和中间件代码里确实会调用它,建议线上环境用-XX:+DisableExplicitGC禁用显式GC,防止莫名的Full GC爆发。 - CMS运行时出现Concurrent Mode Failure:CMS并发清理阶段,还没有足够的空间供新晋升的对象使用,就会暂定并发收集,退化到Serial Old做Full GC。
这里还有一个容易混淆的认知:Major GC和Full GC是不是一回事?严格来说,Major GC指老年代GC,Full GC是指清理整个堆和元空间。但现实里很多收集器触发老年代GC时,往往连着新生代一起处理,日志里显示的也是“Full GC”,所以实践中很多人把两者混着叫。面试时你可以说清概念区别再强调“实际日志和工具中通常合并观察”,显得你是真懂而不是背概念。
3.3 一句话记住GC链路
我给我的记忆口诀是:新对象进Eden,Eden满Minor GC,熬过N轮进Survivor再进老年代,老年代满Major/Full GC,小年轻用复制,老顽固用整理,大个子直接去老年代。把这五句串下来,你脑子里已经有了一幅堆空间运作图。
4. 主流垃圾收集器盘点:从Serial到ZGC
有了算法和分代模型,第三步就是看具体产品。不同收集器的区别,不在于是不是用了某个新算法,而在于怎么组合算法、怎么控制停顿时间。
4.1 经典收集器逐个拆解
Serial收集器是最古老的新生代收集器,单线程工作,GC时必须暂停所有用户线程。单线程听上去落后,但在单核CPU、小内存场景下,它反而因为没有线程切换开销而更高效,Client模式下默认就是它。配合它的老年代版本Serial Old,使用标记-整理算法。
ParNew是Serial的多线程版本,新生代并行收集,主要价值在于能和老年代的CMS搭配使用。CMS只认ParNew作为新生代搭档,所以当年CMS时代,ParNew几乎是“绑定款”。
Parallel Scavenge系列是很多JDK 8默认组合(PS+PO)的主角。它的目标不是最低停顿,而是最大化吞吐量,也就是让GC时间占总运行时间的比例最小。为此它有一套自适应调节策略,可以自动调整新生代大小、晋升阈值,达到吞吐量目标。适合后台批处理这类对停顿不敏感、对整体吞吐看重的任务。
CMS全称Concurrent Mark Sweep,是老年代收集器,核心卖点是并发收集、低停顿。它把GC过程拆成多个阶段:初始标记、并发标记、重新标记、并发清除、并发重置。只有初始标记和重新标记需要短暂STW,最耗时的并发标记和并发清除阶段可以和用户线程一起跑。但也别把它想成神仙:
- 并发阶段占用CPU,吞吐量会下降。
- 经典标记-清除必然带来碎片化。
- 会产生浮动垃圾,并发清理时用户线程还在产生新垃圾。
- 如果并发清理期间晋升对象太多,触发Concurrent Mode Failure,会退化到Serial Old做Full GC,停顿反而更长。 即便如此,CMS在JDK 8时代还是低延迟场景的事实标准,很多老系统的GC日志里你都会看到它的身影。
G1从JDK 9开始成为默认收集器。它的最大革新是把整个堆划分成很多个大小相等的Region,逻辑上仍有新生代老年代之分,但物理上不再要求连续,新生代和老年代各自由一拨Region组成,可以动态调整。G1还引入了可预测停顿模型:用-XX:MaxGCPauseMillis设置目标停顿时间,它通过调整新生代Region数量、选择回收性价比最高的Region集合来尽量达到目标。过程分为初始标记、并发标记、最终标记、筛选回收。它适合内存大、多核机器上的服务端应用,既想吞吐不错又想停顿可控。
ZGC到JDK 11引入、JDK 15正式支持,设计目标是把GC停顿时间控制到毫秒级以内,而且停顿时间不随堆大小增长。核心技术包括染色指针与读屏障,在指针里编码状态信息,实现并发标记、并发转移、并发重定位,把几乎整个GC过程都并发化。适合超大堆、超低延迟场景。需要注意它和G1一样是物理不分代的,官方也没按老分代模型设计。ZGC刚上手时要把一堆新概念和G1区分开,但业务价值很直接:堆再大,停顿也稳。
4.2 场景选型对照表
| 收集器 | 适用代 | 算法 | 特点 | 适合场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程、简单高效 | 单核小内存、客户端 |
| ParNew | 新生代 | 复制 | 多线程、能配合CMS | 多核服务端配CMS |
| Parallel Scavenge | 新生代 | 复制 | 吞吐量优先 | 批处理、数据计算 |
| Serial Old | 老年代 | 整理 | 单线程 | CMS的后备方案 |
| Parallel Old | 老年代 | 整理 | 多线程、吞吐优先 | PS搭档 |
| CMS | 老年代 | 并发清除 | 低停顿、有碎片 | 对延迟敏感的中等堆 |
| G1 | 不分代 | 混合回收 | 可预测停顿、Region化 | JDK 9+默认,大内存服务端 |
| ZGC | 不分代 | 并发整理 | 停顿毫秒级、大堆友好 | 超大堆、极低延迟 |
实际选型我的建议很简单:JDK 8以前的老系统,沿用默认或已有配置,别为了追新随便换;新系统用JDK 8+,直接享受G1;如果你的应用堆在几十GB以上、延迟指标非常严苛,再评估ZGC或Shenandoah。调优的首要原则是不折腾,稳定优先。
4.3 关键参数速记
最核心的参数其实就几个:-Xms、-Xmx设置堆大小,两者设成相同值,避免运行时扩容抖动;-XX:NewRatio控制新生代与老年代比例;-XX:MaxGCPauseMillis设置G1的停顿目标;-XX:GCTimeRatio设置Parallel系列吞吐目标;-XX:MaxTenuringThreshold控制晋升年龄;-XX:+DisableExplicitGC禁掉显式GC。收集器选择参数-XX:+UseG1GC、-XX:+UseConcMarkSweepGC、-XX:+UseZGC等。
这里要压碎一个常见的误区:堆越大,GC越少,性能越好。现实中堆过大会导致单次GC时间变长,尤其是老年代Full GC,一次动辄十几秒的停顿比频繁几次短GC更伤。所以调堆大小是找平衡点,不是单调递增。真正的调优路径是:先看GC日志确认问题类型,再决定动收集器、动堆大小,还是改代码里的分配模式。
5. 常见问题与排查技巧实录
5.1 一张图看懂GC日志先说怎么看GC日志。JDK 8常用参数组合是这样:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.logJDK 9开始日志系统统一了,改用:
-Xlog:gc*:file=gc.log:time,uptime,level,tags拿到一个gc.log,先分三块看:看GC类型(是Minor还是Full)、看前后内存变化、看耗时。举个例子:
[GC (Allocation Failure) [PSYoungGen: 51200K->5120K(59392K)] 71680K->12800K(194560K), 0.0132430 secs]这段的含义是:发生了Young GC,新生代从51200K降到5120K,整堆从71680K降到12800K,耗时13毫秒。一眼扫过去,主要看Minor GC之后堆剩余容量是否在稳步上升。如果在接近Full的区域前一直攀升,就说明有大对象或泄漏趋势。
实际排查时我一般配两个工具:-XX:+PrintGCDetails输出详细日志,再用GCViewer或在线日志分析工具画曲线。曲线能很直观地暴露两个信号:内存曲线持续上涨,说明存在不释放的对象积累;Full GC次数逐渐密集,说明已经到了危险区。
5.2 频繁Full GC的定位套路
线上遇到频繁Full GC,最忌讳一上来就放大堆。我踩过这个坑:曾经某服务堆给到很大,Full GC反而更稀疏但单次更久,整体延迟更难看。正确顺序是这样的:
第一步,先用jstat -gcutil <pid>看实时GC状况。jstat的YGCT、FGCT两个字段能直接看到新生代和老年代累计GC时间与频率,如果FGCT增长很快,确定问题在Full GC。
第二步,打印或转储堆快照。jmap -dump:live,format=b,file=heap.bin <pid>可以导出快照,再用MAT或VisualVM分析。这里一定要提醒:生产环境jmap -dump会让服务短暂停顿,建议在维护窗口做,或者配合jmap -histo:live先粗略看一下对象直方图,确认有没有占内存极大的异常对象。
第三步,对照业务代码找目标。最容易出问题的几类:往静态Map里无脑塞数据、用ThreadLocal但不主动remove、使用缓存框架却设了超大上限、大数组不停new却不释放。MAT里看“支配树”可以快速定位谁引用着大头对象。
还有个隐蔽原因:元空间存放了大量动态生成的类,比如频繁反射、大量代理类生成、热部署场景,也会撑满元空间触发Full GC。遇到这种情况,别只调堆,要检查类加载器的使用是否规范。
再列一份高频原因对照表:
| 症状 | 可能原因 | 优先处理方向 |
|---|---|---|
| Full GC频繁但堆占用不大 | 显式调用System.gc() | 加-XX:+DisableExplicitGC |
| Full GC频繁且老年代涨满 | 内存泄漏或大对象过多 | jmap转储后分析支配树 |
| 新晋升对象多、老年代增长快 | 对象生命周期设计不合理 | 检查缓存、池化、作用域 |
| 元空间持续增长触发FGCT | 动态类生成过多 | 检查反射/代理/类加载器 |
| 单次Full GC耗时很长 | 堆太大、或老年代碎片 | 评估G1/ZGC或调优老年代 |
5.3 内存泄漏不等于内存溢出
这两者经常被混用,但它们是因果关系。内存泄漏是持续占用内存且无法释放,积累到一定程度才会导致内存溢出,也就是OutOfMemoryError。线上排查时,看到一个OOM就以为是堆不够,于是在加大堆后继续泄漏,再OOM,再加大,这是最典型的死循环。
正确做法是拿到OOM的堆快照做分析,应用层加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<path>,让JVM在OOM时自动dump,再去MAT里找谁占着内存不放手。很多时候问题不在JVM参数,而在代码里的引用生命周期没有管好。
最后放一个我自己常用的排查口诀:先看生成曲线(是锯齿状还是阶梯递增),再数GC频率,三查老年代占用,四转快照分析,五对业务代码改引用。这套流程配合上面的速查表,基本能覆盖九成以上的GC问题。
根据我的经验,调优和排查真正考验的不是记住多少参数名,而是能不能快速从GC日志里读出系统健康状态,并准确把问题定位到堆配置上、还是代码引用上、还是收集器选型上。把这篇文章里的主线和排查流程过一遍之后,再去看那些零散的技术名词,你会发现它们都各自归位了。等你在真实系统里处理完一次GC故障,回头再读这些内容,共鸣会比现在还深。