你搜索“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对象里引用的对象JNIJava Native Interface引用的对象活跃线程对象、系统类加载器、被锁对象等。这里想多提一句很多人会忽略四种引用类型对GC的影响。强引用new出来的普通引用只要存在就绝对不会被回收软引用SoftReference在内存充足时保留内存紧张时优先回收适合做内存敏感的缓存弱引用WeakReference只能活到下一次GC适合做缓存映射虚引用PhantomReference几乎不能通过它拿到对象主要用于跟踪对象被回收的通知。实际开发中我见过有人用WeakHashMap做缓存以为万事大吉但WeakHashMap里被弱引用的是key不是valuevalue可能被强引用链牵着一直存在最后缓存没清干净老年代压力反而上去了这个坑在面试里也经常能聊出深度。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到ZGC3.1 弱分代假设GC效率的基石JVM的分代设计背后有一个统计规律叫弱分代假设绝大部分对象通常是90%以上创建之后很快就变成垃圾活过第一次GC的对象数量很少而这些少数对象往往还能活很长时间。这就给GC优化指明了方向把堆分成几个区域对不同区域采取不同的回收策略。新生代使用复制算法因为大部分对象活不过第一轮复制代价很低老年代存活率高用标记-整理或标记-清除来降低对象移动成本。3.2 对象的一生Eden、Survivor、老年代在HotSpot虚拟机里大多数对象诞生在新生代的Eden区。新生代里还有一个Eden和两个Survivor区通常叫from和toHotSpot默认配置是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到G1JDK的发展史上出现过一批垃圾回收器它们不是替代关系更像在不同场景下的“分工选择”。我需要明确一点它们是基于不同目标设计的独立实现有些甚至能组合使用。Serial / Serial Old是最早期的回收器。新生代用Serial单线程复制算法老年代用Serial Old单线程标记-整理。它的问题在于GC时必须暂停所有用户线程Stop The WorldSTW停顿时间长。但正因为单线程没有线程交互开销在小堆和客户端模式下反而很有效率。比如早期的IDE启动时、JShell等工具里Serial反而是默认选择。Parallel Scavenge / Parallel Old是吞吐量优先的回收器。多线程并行执行GC适合计算密集型任务尤其在服务启动后不需要太多交互的场景。JDK 8时代的默认组合就是Parallel Scavenge Parallel Old这也是目前存量系统里最常见的一种组合。它们的关注点是“在尽可能短的时间内完成GC”代价是单次停顿时间可能比CMS长一些。**CMSConcurrent Mark Sweep**的目标变成低停顿。它用并发标记和并发清除的方式尽量让GC线程和业务线程同时运行。它的优点是停顿时间明显缩短但代价很大标记-清除造成碎片化、无法处理“浮动垃圾”、并发失败时会退化成Serial Old式的暂停把停顿全赔进去。CMS曾是互联网服务的主流选择后来在JDK 14被正式移除大批老系统还在用面试里它的出场率反而很高。**G1Garbage First**是JDK 9之后的默认回收器。它的核心是把整个堆划分为多个Region默认大概2048个新生代和老年代不再是物理上连续的一大块而是逻辑上的Region集合。G1每次回收时并不一定要回收全部堆而是先维护一个优先级列表优先处理“垃圾最多、回收收益最大”的Region集合这就是“Garbage First”名字的由来。它还支持-XX:MaxGCPauseMillis来指定目标停顿时间默认200毫秒让停顿变得可预测。G1在实际线上环境里体现了非常好的平衡性兼顾吞吐量和延迟又能处理较大的堆所以成为现代Java服务的默认选择。不过它也不是银弹——如果业务线程对内存的分配压力太大G1在Mixed GC阶段仍然可能STWRegion之间还有巨型对象分配不下的情况。3.4 ZGC为什么能接近零停顿ZGC是Oracle在JDK 11引入的实验性GCJDK 15转正定位是超大堆从几百GB到TB级下的低延迟场景。它最核心的差异在于两项技术染色指针Colored PointerJVM把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:PrintTenuringDistributionJDK 9之后的统一日志格式是-Xlog:gc*:file/opt/logs/gc.log:time,uptime,level,tags一段典型的GC日志长这样2025-01-01T10:00:01.1230800: 345.678: [GC (Allocation Failure) [PSYoungGen: 6144K-512K(7168K)] 6144K-6124K(10240K), 0.0034567 secs] [Times: user0.01 sys0.00, real0.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:formatb,fileheap.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:SurvivorRatioEden与单个Survivor的比例默认8。如果晋升到老年代的对象多且survivor频繁溢出需要关注这个值。-XX:MaxGCPauseMillisG1的目标停顿时间。设得过小会导致GC频繁尝试调整Region回收计划反而增加CPU消耗。-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPathOOM时自动导出堆快照这个强烈建议生产环境开启。这些参数不是堆得越多越好。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*:fileserial_gc.log MyStressTest java -XX:UseG1GC -Xms64m -Xmx64m -Xlog:gc*:fileg1_gc.log MyStressTest写一个不断分配临时对象的小程序分别用不同回收器跑一遍对比日志差异。半小时就能直观感受到Serial、Parallel、G1、ZGC在停顿时间、内存占用、GC频率上的不同性格。面试时聊到“你调过GC参数吗”你能把这种真实观察讲出来比背下来的任何答案都有说服力。5.3 一些容易被追问深入的边界知识除了主流问题面试官很容易沿着“GC线程与业务线程的关系”继续追问。这里有几个细节也很值得展开安全点SafepointGC需要所有线程暂停时业务线程必须运行到安全点才能挂起。安全点一般位于方法调用、循环跳转、异常抛出的边界太密集会拖慢原代码太稀疏会让线程迟迟进不了GC。这也是为什么某些死循环代码会导致GC停顿时间异常因为循环里没有安全点。三色标记与写屏障CMS和G1的并发标记阶段用的都是三色标记法白、灰、黑为了避免并发修改引用产生漏标或错标CMS用增量更新G1用SATB。这个题目偏进阶但能答出来会明显拉开印象分。它的核心困难在于业务线程一边改对象引用GC线程一边在标记两者同时工作如何保证不漏掉仍被引用的对象。逃逸分析与栈上分配HotSpot的JIT编译可以做逃逸分析如果对象没有逃逸出方法它可以直接在栈上分配甚至通过标量替换拆散为普通变量。这样根本不会进入堆也就不会给GC增加负担。现代JVM的实际GC压力比直观想象小很多原因之一就是这里面已经被捞出去一批对象。讲到这里GC的知识框架基本闭环先判断对象是否可回收再用某种算法回收再在不同代际的场景里做回收器取舍最后落到GC日志和真实问题定位。我自己在早期学这些内容时也是背了又忘、忘了又背直到某次线上接口频繁卡顿我打开GC日志一点点推导出是缓存泄漏才真正把这些概念连成一张网。如果你现在正对着这些名词发愁建议不要只看文章找台机器实际跑一轮不同回收器的对比实验那个过程中踩到的细节会让你记住得比文档深刻得多。