JVM垃圾回收从原理到实践:可达性分析、GC算法与G1/ZGC
2026/9/15 12:27:56 网站建设 项目流程

你搜索“GC算法”“垃圾回收器”相关问题时,大概率会看到一堆定义:引用计数、可达性分析、标记-清除、复制算法、CMS、G1……背下来很容易,可一旦线上服务出现Full GC频繁、接口偶发长时间停顿,手上的八股文却一句都用不上。这篇文章就围绕Java虚拟机里的GC这条主线,把“对象如何判定为垃圾”“三种基础回收算法”“分代模型下的各种垃圾回收器”以及“GC日志排查思路”串成一个完整的体系,把我这些年调GC问题、准备面试的经验也一并放进去。无论你是正在啃Java面试题的新人,还是想搞明白线上GC日志的老开发,这篇文章应该都能给你一些比“背概念”更实在的东西。

1. 为什么Java偏要“自动回收”:GC要解决的核心矛盾

1.1 手动内存管理的苦日子

要理解GC的价值,得先回头看C/C++的时代。那时候内存由程序员手动管理,用malloc分配,用free释放,听起来简单,但实际写代码时非常折磨。最难的地方在于:你很难判断一块内存“最后被使用”的时刻。假如你写一个函数,它把一个对象的指针传给好几个模块,这些模块里可能有人缓存了引用,也可能有人提前释放了它。你没办法在编译期准确知道,某个引用什么时候会变成没人再用。

于是经典的三个噩梦出现了:悬垂指针(释放之后还有人用)、重复释放(两个模块都觉得该自己释放)、内存泄漏(一直没人释放)。这些都是运行时才能暴露的错误,排查起来极其痛苦。Java干脆把这个责任从程序员手里收走,改由JVM统一管理内存。GC存在的意义,首先是消除这一整类生命周期错误,让开发者集中精力写业务逻辑。

不过自动回收也不是“系统帮你记录引用次数,计数归零就清掉”这么简单。它需要同时满足三个目标:

  • 正确性:正在使用的对象绝对不能回收,否则程序直接崩;
  • 效率:回收过程不能太频繁、太慢,否则业务执行时间全耗在GC上;
  • 吞吐与延迟:GC造成的停顿要尽量短,不能让服务每隔几秒就卡一下。

这三个目标在工程上经常打架,后面聊到的所有算法和垃圾回收器,本质都是这三者之间的妥协。

1.2 判断对象是否可回收:引用计数与可达性分析之争

GC要做的第一件事,是从一堆对象里把“垃圾”挑出来。怎么判断一个对象是否无用?业界有过两条路线。

引用计数法的思路很直观:每个对象内部维护一个整数计数器,每次被其他变量引用就+1,引用失效就-1,计数器到0就说明没有地方再用它了,可以回收。优点是实现简单、判定及时。但它有一个致命缺陷:无法处理循环引用。举一个最简单的情况,A对象里引用了B对象,B对象里又引用了A对象,两个对象外部已经没有任何变量指向它们,但这两个对象的计数器仍然各持1,永远到不了0,内存就泄漏了。Python的GC其实也用了引用计数,但正因为这个缺陷,Python不得不再加一套gc模块来处理循环引用的残局。

可达性分析换了一个角度:从一组称为“GC Roots”的根对象出发,沿着引用链不断往下走,能被走到的对象都算“活着的”,走不到的对象统一标记为可回收。这种方案天然不受循环引用影响——你A引用B、B引用A,但没有任何一个GC Roots能到达你们,你们就会被判为垃圾。

Java最终选的就是可达性分析。GC Roots主要包括这么几类:

  • 当前正在执行的方法栈帧里的局部变量和参数;
  • 静态变量(类的Class对象里引用的对象);
  • JNI(Java Native Interface)引用的对象;
  • 活跃线程对象、系统类加载器、被锁对象等。

这里想多提一句,很多人会忽略四种引用类型对GC的影响。强引用(new出来的普通引用)只要存在就绝对不会被回收;软引用(SoftReference)在内存充足时保留,内存紧张时优先回收,适合做内存敏感的缓存;弱引用(WeakReference)只能活到下一次GC,适合做缓存映射;虚引用(PhantomReference)几乎不能通过它拿到对象,主要用于跟踪对象被回收的通知。实际开发中,我见过有人用WeakHashMap做缓存,以为万事大吉,但WeakHashMap里被弱引用的是key不是value,value可能被强引用链牵着一直存在,最后缓存没清干净,老年代压力反而上去了,这个坑在面试里也经常能聊出深度。

2. 三种回收算法拆解:标记-清除、标记-复制、标记-整理

找到了垃圾对象,接下来要考虑怎么回收。三种基础算法是理解后面所有垃圾回收器的地基,各自有非常直观的代价和收益。

2.1 标记-清除:最朴素的想法为什么有碎片问题

标记-清除(Mark-Sweep)的执行过程分两步:先按可达性分析把需要存活的对象标出来,然后再把标记为垃圾的对象内存释放掉。逻辑上很简单,但它有两个绕不开的毛病。

第一是效率问题。标记和清除两个阶段都需要遍历堆里的对象,对象越多,耗时越长。第二是内存碎片问题。清除之后,内存里会留下大量不连续的小空隙。这就像从一整个书架上随机抽走若干本不同的书,剩下的空位都是零散的,想再在架子上塞进一整套大部头就会很困难。

后果就是:JVM在后续分配大对象时,明明空闲内存总量够,却因为找不到连续区域而分配失败,被迫提前触发一次Full GC。碎片化严重时,GC频率会高得离谱,形成恶性循环。很多老系统Full GC频繁但堆占用率看着不高,一部分原因就是碎片化。

2.2 标记-复制:用空间换时间去碎片

标记-复制(Copying)算法做了这样一个设计:把内存分成大小相等的两块,每次只使用其中一块。分配对象时只在这一块里顺序分配,用完算数。回收时,把这块里还存活的对象全部复制到另一块,然后把原块一次性整体清空。

这套设计的优点非常直接:存活对象集中放到一起,剩余空间是完整连续的,彻底消灭碎片问题;同时因为每次只操作半区,分配对象只移动堆顶指针,效率极高。缺点也明显:空间利用率只有50%,有一半内存始终空着。另外,如果存活对象很多,复制开销会非常大。

但仔细想想,这算法天生适合“大部分对象活不久”的场景。如果100个对象里99个是垃圾,只有1个需要复制,整体成本很低。这正是新生代想要的效果。

2.3 标记-整理:移动对象的代价与收益

标记-整理(Mark-Compact)把标记和压缩合在一起:先标记存活对象,然后让所有存活对象向内存一端移动,最后清理掉边界之外的内存。这样做既保留标记-除的速度优势,又解决了碎片问题,代价是对象移动本身有开销——被移动的对象的所有引用都得更新。这个更新引用的过程听起来轻巧,实现起来很麻烦,尤其是并发场景下需要配合很多额外机制。

既然复制算法已经能去碎片,为什么不直接拿复制算法去回收整个堆?原因是老年代的存活率太高。复制算法把存活的都搬到另一块,成本随存活率线性增长;而老年代每天可能只有少量对象熬过来,但累积下来的对象数量巨大,复制一整块显然不划算。所以标记-整理更合适老年代。

2.4 三种算法对比

为了看清楚差异,我把它们放在一张表里:

算法思路最大优点最大缺点典型场景
标记-清除标记垃圾后统一回收实现简单、不需要移动对象碎片化、标记清除两次遍历耗时老年代(CMS早期思路)
标记-复制存活对象复制到另一半,整块清空无碎片、分配快空间利用率低、存活率高时成本大新生代
标记-整理存活对象移向一端再清空无碎片、空间利用率高移动对象和更新引用成本高老年代

没有一种算法能包打天下。也正因为如此,JVM才选择了分代模型:新生代适合复制,老年代适合标记-整理或标记-清楚,各取所长。

3. 分代模型与回收器演进:从Serial到ZGC

3.1 弱分代假设:GC效率的基石

JVM的分代设计背后有一个统计规律,叫弱分代假设:绝大部分对象(通常是90%以上)创建之后很快就变成垃圾,活过第一次GC的对象数量很少,而这些少数对象往往还能活很长时间。

这就给GC优化指明了方向:把堆分成几个区域,对不同区域采取不同的回收策略。新生代使用复制算法,因为大部分对象活不过第一轮,复制代价很低;老年代存活率高,用标记-整理或标记-清除来降低对象移动成本。

3.2 对象的一生:Eden、Survivor、老年代

在HotSpot虚拟机里,大多数对象诞生在新生代的Eden区。新生代里还有一个Eden和两个Survivor区(通常叫from和to),HotSpot默认配置是Eden:两个Survivor = 8:1:1,也就是说只有10%的空间会被“浪费”。

当一个对象在Eden区“出生”,经历第一次Minor GC时如果它还活着,就会被复制到其中一个Survivor区,并且对象年龄加1。此后每次Minor GC,对象都会在from和to两个Survivor之间来回复制,年龄继续增长。默认情况下,年龄达到15的对象会晋升到老年代(可以通过-XX:MaxTenuringThreshold调整),大对象也可以直接进老年代(通过-XX:PretenureSizeThreshold设置),避免在新生代里反复复制。

补充一个容易忽略的细节:HotSpot还有“动态对象年龄判定”机制,并不是非得等到MaxTenuringThreshold。如果在某个Survivor区里,相同年龄对象大小的总和已经超过Survivor空间的一半,年龄大于等于这些对象的对象就会直接晋升老年代。这个机制的初衷是防止Survivor区被长期存活的“钉子户”占满,实际调优时如果发现老年代增长异常,也可以从这里找线索。

3.3 回收器逐个拆解:Serial到G1

JDK的发展史上出现过一批垃圾回收器,它们不是替代关系,更像在不同场景下的“分工选择”。我需要明确一点:它们是基于不同目标设计的独立实现,有些甚至能组合使用。

Serial / Serial Old是最早期的回收器。新生代用Serial,单线程复制算法;老年代用Serial Old,单线程标记-整理。它的问题在于GC时必须暂停所有用户线程(Stop The World,STW),停顿时间长。但正因为单线程,没有线程交互开销,在小堆和客户端模式下反而很有效率。比如早期的IDE启动时、JShell等工具里,Serial反而是默认选择。

Parallel Scavenge / Parallel Old是吞吐量优先的回收器。多线程并行执行GC,适合计算密集型任务,尤其在服务启动后不需要太多交互的场景。JDK 8时代的默认组合就是Parallel Scavenge + Parallel Old,这也是目前存量系统里最常见的一种组合。它们的关注点是“在尽可能短的时间内完成GC”,代价是单次停顿时间可能比CMS长一些。

**CMS(Concurrent Mark Sweep)**的目标变成低停顿。它用并发标记和并发清除的方式,尽量让GC线程和业务线程同时运行。它的优点是停顿时间明显缩短,但代价很大:标记-清除造成碎片化、无法处理“浮动垃圾”、并发失败时会退化成Serial Old式的暂停,把停顿全赔进去。CMS曾是互联网服务的主流选择,后来在JDK 14被正式移除,大批老系统还在用,面试里它的出场率反而很高。

**G1(Garbage First)**是JDK 9之后的默认回收器。它的核心是把整个堆划分为多个Region(默认大概2048个),新生代和老年代不再是物理上连续的一大块,而是逻辑上的Region集合。G1每次回收时并不一定要回收全部堆,而是先维护一个优先级列表,优先处理“垃圾最多、回收收益最大”的Region集合,这就是“Garbage First”名字的由来。它还支持-XX:MaxGCPauseMillis来指定目标停顿时间(默认200毫秒),让停顿变得可预测。

G1在实际线上环境里体现了非常好的平衡性:兼顾吞吐量和延迟,又能处理较大的堆,所以成为现代Java服务的默认选择。不过它也不是银弹——如果业务线程对内存的分配压力太大,G1在Mixed GC阶段仍然可能STW,Region之间还有巨型对象分配不下的情况。

3.4 ZGC为什么能接近零停顿

ZGC是Oracle在JDK 11引入的实验性GC,JDK 15转正,定位是超大堆(从几百GB到TB级)下的低延迟场景。它最核心的差异在于两项技术:

  • 染色指针(Colored Pointer):JVM把GC状态信息直接编码进对象引用的指针里。指针里用几个bit来表示对象当前是“标记中”“重定位中”还是“可访问”等状态,这样GC线程和业务线程访问同一个对象时,不需要通过额外堆外结构查询GC状态,读对象的开销大大降低。
  • 读屏障(Load Barrier):当业务线程读取一个引用时,读屏障会检查这个引用的染色状态。如果发现对象正在被移动或者需要修正引用,就立刻在当前线程内完成修正,而不会让业务线程停下来等GC。

更关键的是,ZGC将几乎所有的GC阶段都设计成可以和业务线程并发执行,最终把单次GC停顿时间压到稳定的几毫秒。我之前在一台配置较高的测试机器上,把堆开到64GB,通过承受高强度分配压力的压测程序观察,停顿时间基本能维持在一个很低的水平。

但ZGC并非适合所有场景。它需要足够多的CPU核心来运行并发GC线程,也要消耗更多内存来做指针染色和对象重映射。在堆只有几个GB的小服务上,ZGC的并发开销可能比GC本身的收益还大。选择垃圾回收器,非常讲究“看菜下饭”。

3.5 选型思路与参考表

碰到具体项目时,选择哪款回收器,最应该关注三个问题:堆内存规模、延迟敏感度、CPU核心数。我汇总了一张表,便于直观对比:

回收器回收范围核心特点适配场景
Serial / Serial Old新生代/老年代单线程、简单高效单核小堆、客户端工具
Parallel Scavenge / Parallel Old新生代/老年代吞吐量优先、并行GC后端计算服务,JDK8默认
CMS老年代并发标记清除、低停顿低延迟老系统,正在被G1替代
G1整个堆(分Region)可预测停顿、平衡吞吐与延迟JDK9+默认,大多数大型服务
ZGC整个堆染色指针、读屏障、近零停顿超大堆、苛刻延迟场景

4. GC日志解读与调优实操:如何定位和解决实际问题

4.1 不要一上来就调参

我必须先泼一盆冷水:很多团队一遇到GC问题,第一反应就是改JVM参数——堆调大、换成G1、加GC线程数。实际上绝大多数线上GC问题,根因都在代码层,参数调整只能暂时掩盖症状。

我经历过一次典型的例子。一个订单服务的Full GC频率从每天一次慢慢变成十分钟一次,堆内存跟着飙升。团队成员先调了堆大小,效果只维持一天,问题又回来。最后用jmap导出堆转储,用MAT分析才发现是一个static Map缓存了每一次请求的完整明细对象,根本没设上限,也没做清理。修掉代码后,Full GC彻底消失。所以正确的顺序永远是:先看GC日志,再抓堆转储,定位对象来源,最后才谈调整参数。

4.2 GC日志怎么开、怎么看

生产环境里,建议在JDK 8及以下版本加这些JVM参数:

-Xloggc:/opt/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution

JDK 9之后的统一日志格式是:

-Xlog:gc*:file=/opt/logs/gc.log:time,uptime,level,tags

一段典型的GC日志长这样:

2025-01-01T10:00:01.123+0800: 345.678: [GC (Allocation Failure) [PSYoungGen: 6144K->512K(7168K)] 6144K->6124K(10240K), 0.0034567 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]

它透露的信息是:在JVM启动第345.678秒,新生代发生了一次Minor GC,原因是Eden分配失败;新生代占用从6144K降到512K(这里包括了survivor区),整个堆从6144K微降到6124K,但堆总占用量依然很高。这说明GC本身回收了新生代里那些短命对象,但大量对象其实都被提升到老年代或仍被引用,堆总占用没有明显下降。

再看到Full GC日志时,它的特征更明显,通常包含Full GC关键字,且停顿时间显著长于Young GC:

[Full GC (Allocation Failure) 300M->298M(512M), 0.4567890 secs]

老年代回收后只下降了一点点,这是很糟糕的信号,意味着老年代里堆积了大量“假死”对象,典型的内存泄漏或者对象晋升过度。

4.3 一次Full GC频繁的案例排查全过程

前几年我排查过一个比较有代表性的案例,步骤可以复现给大家参考。

第一步,用jstat -gcutil 进程ID 1000 10每秒钟打一次GC统计,10个采样点。结果很快看到老年代O区占比持续接近100%,每次Full GC后只能从100%降到95%左右,几分钟后再回满。

第二步,用jmap -dump:format=b,file=heap.hprof 进程ID导出堆快照,然后用MAT分析Dominator Tree。结果发现一个ConcurrentHashMap对象占用了老年代约70%的空间,map的value是一个个带时间戳的大对象。

第三步,在代码里排查这个map的写入路径,发现是某个定时任务把每次执行结果的中间快照,包括大量明细集合都放了进去,key还是时间戳,永远不会重复,而且没有任何过期清理。虽然是ConcurrentHashMap,但代码逻辑等于用了一个“永不失效的全局缓存”。

第四步,修代码,把那些明细从map里移除或者改用带过期时间的缓存框架;同时微调了堆大小和-XX:MaxTenuringThreshold,让年轻对象不要过快晋升。上线后观察一周,Full GC恢复到每天两三次,且老年代占用曲线平稳。

这个案例说明一个道理:调GC参数前,先问自己——这些对象为什么会在老年代堆积?答案大多要从业务代码里找。

4.4 常用调优参数与我的几点提醒

给出几个常见但很有用的参数:

  • -Xms-Xmx:初始堆大小和最大堆大小。生产环境建议设成相同值,避免运行时堆扩容引发性能震荡。
  • -XX:NewRatio:新生代与老年代比例,默认1:2。如果Young GC很频繁但每次回收量很小,可以适当提高新生代比例。
  • -XX:SurvivorRatio:Eden与单个Survivor的比例,默认8。如果晋升到老年代的对象多且survivor频繁溢出,需要关注这个值。
  • -XX:MaxGCPauseMillis:G1的目标停顿时间。设得过小会导致GC频繁尝试调整Region回收计划,反而增加CPU消耗。
  • -XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath:OOM时自动导出堆快照,这个强烈建议生产环境开启。

这些参数不是堆得越多越好。Xmx设得太大但业务实际用不到,会让Full GC时遍历和回收范围膨胀,停顿时间更不可控。最稳的策略是小步调优:改一个参数,观察几天GC日志,确认效果后再改下一个。

5. 面试官常问的GC题:从八股文到原理追问

5.1 高频问题的考察意图

整理一些高频面试题,我会附上“面试官真正想听什么”的思路。

问题一:如何判断对象已死?
标准回答是引用计数和可达性分析,重点要展开循环引用缺陷,以及Java的四类引用。面试官考察的其实是“你是否只是背了结论,还是能讲清楚为什么引用计数不行”。

问题二:Minor GC、Major GC、Full GC的区别?
Minor GC只回收新生代,Major GC通常指老年代回收,Full GC则通常伴随老年代回收、年轻代回收、元空间回收和堆外整理。这个题容易答乱,要结合具体回收器说清楚。

问题三:CMS为什么被G1替代?
CMS是并发标记清除,低停顿,但碎片化严重,且并发失败会退化。G1用Region解决了碎片和可预测停顿问题。这类对比题考察一个核心能力:理解设计权衡,而不只是记忆特性。

问题四:G1和ZGC的区别?
最核心的回答是停顿时间的实现哲学不同。G1通过可预测的Region回收计划来缩短停顿,ZGC则通过染色指针和读屏障做到近乎零停顿,且停顿时间基本不随堆大小增长。

问题五:GC Roots有哪些?
这类题一定要落到实例:栈帧局部变量、静态变量、JNI引用、锁对象、活跃线程等。能举出一个实际例子会显得更扎实。

5.2 从“会背”到“用过”:用经验回答面试官

准备面试时,与其死记硬背几十个参数,不如亲自做一个小实验:

java -XX:+UseSerialGC -Xms64m -Xmx64m -Xlog:gc*:file=serial_gc.log MyStressTest java -XX:+UseG1GC -Xms64m -Xmx64m -Xlog:gc*:file=g1_gc.log MyStressTest

写一个不断分配临时对象的小程序,分别用不同回收器跑一遍,对比日志差异。半小时就能直观感受到Serial、Parallel、G1、ZGC在停顿时间、内存占用、GC频率上的不同性格。面试时聊到“你调过GC参数吗”,你能把这种真实观察讲出来,比背下来的任何答案都有说服力。

5.3 一些容易被追问深入的边界知识

除了主流问题,面试官很容易沿着“GC线程与业务线程的关系”继续追问。这里有几个细节也很值得展开:

  • 安全点(Safepoint):GC需要所有线程暂停时,业务线程必须运行到安全点才能挂起。安全点一般位于方法调用、循环跳转、异常抛出的边界,太密集会拖慢原代码,太稀疏会让线程迟迟进不了GC。这也是为什么某些死循环代码会导致GC停顿时间异常,因为循环里没有安全点。
  • 三色标记与写屏障:CMS和G1的并发标记阶段用的都是三色标记法(白、灰、黑),为了避免并发修改引用产生漏标或错标,CMS用增量更新,G1用SATB。这个题目偏进阶,但能答出来会明显拉开印象分。它的核心困难在于:业务线程一边改对象引用,GC线程一边在标记,两者同时工作,如何保证不漏掉仍被引用的对象。
  • 逃逸分析与栈上分配:HotSpot的JIT编译可以做逃逸分析,如果对象没有逃逸出方法,它可以直接在栈上分配,甚至通过标量替换拆散为普通变量。这样根本不会进入堆,也就不会给GC增加负担。现代JVM的实际GC压力比直观想象小很多,原因之一就是这里面已经被捞出去一批对象。

讲到这里,GC的知识框架基本闭环:先判断对象是否可回收,再用某种算法回收,再在不同代际的场景里做回收器取舍,最后落到GC日志和真实问题定位。我自己在早期学这些内容时,也是背了又忘、忘了又背,直到某次线上接口频繁卡顿,我打开GC日志一点点推导出是缓存泄漏,才真正把这些概念连成一张网。如果你现在正对着这些名词发愁,建议不要只看文章,找台机器实际跑一轮不同回收器的对比实验,那个过程中踩到的细节,会让你记住得比文档深刻得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询