资讯动态

深度解构:Java 垃圾回收机制

发布时间:2026/9/29 14:24:52 来源:尧图企业网站定制
写在前面线上服务最让人不安的时刻往往不是接口直接报错而是平时都好好的某个时间点整批请求一起变慢。监控看起来又不算失控机器没挂线程池没满数据库也没炸。直到翻到GC日志才发现那一小段空白时间里JVM正在回收内存。问题也就跟着冒出来了程序明明还在跑为什么回收内存会让业务停下来JVM到底怎么判断哪些对象该回收哪些对象还得留下为什么有时只是一次很短的年轻代回收有时却会冒出让人头疼的Full GC顺着这些问题往下走GC这件事其实可以拆成两步看第一步不是“删对象”而是先判断谁还活着。第二步才是针对不同区域、不同寿命分布决定用什么方式回收。GC的主线不要从“怎么删”开始看而要从“谁还活着”开始看。先判断对象是不是还活着为什么引用计数不够很多人第一次接触垃圾回收时直觉都会落到一个很简单的办法上谁还被引用着谁就活谁的引用数清零谁就回收这个办法听起来顺手真正落到JVM上却不够稳。最典型的问题就是循环引用。两个对象哪怕已经和业务逻辑彻底没关系了只要它们还互相指着对方的引用数就不可能归零。只靠计数JVM很容易把本该回收的一组对象一直留在堆里。对应到代码里循环引用最容易长成这样class Obj { Obj ref; } Obj a new Obj(); Obj b new Obj(); a.ref b; b.ref a; // 业务侧不再持有这两个对象 a null; b null;如果只按“对象之间彼此还被引用着”来判断原来那组对象似乎还没断开但从程序真正还握着的入口看这两个对象其实已经整组断根了。如果是引用计数a和b互相持有会让这组对象的计数很难降到零如果是可达性分析只要它们已经和GC Roots断开即使彼此还互相引用也仍然可以被回收。关键不在这两个对象互相引用了几次而在于它们虽然彼此还连着却已经不再被业务侧真正持有。如果判定规则只是“引用计数没归零就不回收”这组对象就会被错误留下。这也是Java没把引用计数当作主判定口径的原因。GC真正关心的不是一个对象“手里还有几条线”而是这些线能不能一路追到真正还在使用它的那一端。GC Roots 决定对象生死JVM的主流做法是可达性分析。它不会从每个对象自己开始数引用而是先拿出一组确定还在被程序直接依赖的起点再从这些起点沿着引用关系往下走。能走到的对象说明当前执行过程里还有意义走不到的对象才会进入回收视野。这组起点就是GC Roots。常见来源并不复杂栈帧里的局部变量类的静态字段运行时常量池里仍被持有的引用JNI持有的对象引用判断逻辑只有一句话不是看对象还被多少个对象指着而是看能不能从GC Roots出发走到它。能从GC Roots走到就暂时活着走不到才进入回收视野。这里图里的英文其实只要先记住一个核心词就够了GC Roots可以直接理解成根对象 / 根引用起点。也就是程序此刻明确还在使用、可以拿来往下追引用关系的那一组起点。图里左边那组对象虽然层层相连但关键不在“连了几层”而在于最上面能从GC Roots一路走下来所以它们仍然属于存活对象。右边那组对象也还互相引用看上去并不“孤单”但整组已经和GC Roots断开。对JVM来说这类对象就算彼此之间还在指来指去也已经没有保留的必要了。后面所有回收动作都建立在先把存活对象和无效对象区分开这个前提之上。GC的难点从来不只是删除而是先别删错。为什么堆要分年轻代和老年代多数对象其实活不久如果堆里的对象都活得差不多长最直接的做法就是每次都把整堆扫一遍。但真实程序不是这样跑的。一次普通请求里临时字符串、集合节点、中间结果对象、序列化缓冲区往往很快就会失去意义。它们创建得快消失得也快。真正会长期留下来的通常只是一小部分缓存对象、会话状态、共享结构或者生命周期更长的数据。G1Lf5w2P这就给了JVM一个很重要的工程假设多数对象朝生夕灭少数对象才会长期存活。既然寿命分布这么不均匀就没必要每次都用同样的方式处理整片堆。更划算的办法是把经常死得很快的对象和活得更久的对象分开看。一次 GC 周期里对象怎么流动分代回收先从年轻代看起新对象通常先进入Eden年轻代空间紧张时会触发一次Minor GC大多数已经没用的对象会直接在年轻代被清掉只有少量还活着的对象才会被复制到Survivor区继续观察如果这些对象后面几轮回收都还活着就说明它们不再是“短命临时工”了而是更可能长期留下来的成员。这时它们会逐步晋升到老年代。老年代里的对象存活率更高回收代价也更重所以后续策略自然不会和年轻代完全一样。图里的英文名词先按这组中文去读Eden新对象最先进入的分配区Survivor From / To幸存者区的来源区 / 目标区也就是“这一轮从哪来、往哪去”Minor GC主要处理年轻代的一次回收图里有两条关键流向。第一条是左上到右侧的“短命对象流”。新对象进入Eden后大多数对象会在一次Minor GC后直接结束生命周期。这就是为什么年轻代适合高频回收因为真正需要搬运和保留的对象并不多。第二条是中部往下的“晋升流”。只有那些连续几轮都没死掉的对象才会在Survivor区之间继续流转最后进入老年代。老年代里对象更多、活得更久回收时更关心停顿控制和空间整理成本。有了这个视角几个常见术语就不容易混了Minor GC主要处理年轻代Mixed GC在G1的语境下除了年轻代还会顺带回收一部分老年代分区Full GC代价最高通常会把整个 Java 堆都纳入处理范围也就是年轻代和老年代一起看在很多HotSpot实现里往往还会顺带处理类元数据等相关区域所以它通常意味着堆压力已经明显上来了或者前面的并发回收没有及时跟上这三个词先放到一张表里术语主要处理区域常见触发语境体感特点Minor GC年轻代Eden 分配压力上来新对象大量产生频率高但单次通常更轻Mixed GC年轻代 一部分老年代分区G1 已完成并发标记开始按回收价值分批处理老年代 Region不是整堆一起扫而是分批回收Full GC通常是整个 Java 堆很多实现里还会连类元数据相关区域一起处理堆压力明显上来或前面的并发回收没及时跟上最重、最该警惕的一类回收分代回收真正解决的不是“把堆切成两半”这么简单而是承认对象寿命本来就不平均然后顺着这个事实去设计更省停顿、更省成本的回收路径。分代回收不是教条而是顺着对象寿命分布做出来的工程优化。三种回收算法在交换什么把对象分出“该回收”和“不该回收”之后下一步才轮到真正麻烦的部分这些垃圾到底怎么清。这里最容易产生的误解是把回收理解成一次简单的“删除动作”。但堆内存不是表格也不是文件夹。对象在堆里是连续分布、互相引用、还可能被移动的。只要回收开始发生JVM就得同时面对三件事哪些对象要保留垃圾腾出来的空间还能不能顺手继续用这次回收会不会带来太重的停顿和搬运成本也正因为这三个目标经常互相打架垃圾回收没有一种“处处都最优”的算法。后面这几类策略本质上都是在不同代价之间做交换。标记-清除为什么简单但会留碎片标记-清除的思路最直接。先把还活着的对象标出来再把剩下那些没被标记的对象清掉。它的好处也很明显逻辑简单不需要额外准备一整块备用内存也不必一开始就把所有存活对象搬来搬去。只要能分清存活对象和垃圾对象就可以开始清理。但问题也恰好出在“只清理不整理”。垃圾对象虽然被删掉了空出来的位置却可能零零散散地留在堆里。时间一长堆里就会出现很多碎片。总空闲内存看起来不少真正要分配一个更大的连续对象时却可能找不到合适位置。这就像一排书架里抽走了很多旧书空位确实有了但东一个、西一个。再来一本又厚又大的新书时哪怕总空位长度加起来够也未必能一次塞进去。所以标记-清除的问题从来不是“能不能删垃圾”而是删完以后堆空间是否还足够规整。如果空间越来越碎后面分配对象会更难甚至可能逼出代价更高的整理动作。复制算法为什么适合年轻代复制算法换了一个思路。它不急着在原地收拾残局而是把还活着的对象直接复制到另一块干净区域然后整块回收原来的空间。这个办法在年轻代里尤其顺手因为年轻代最典型的特征就是死掉的对象很多真正活下来的对象很少。既然多数对象都会在一次回收后直接消失那最划算的做法往往不是在原地一点点清垃圾而是只盯住那少量幸存对象把它们搬走剩下整片区域一次性清空。它的收益有两个空间会非常规整不容易留下碎片回收成本更接近“复制少量存活对象”而不是“处理整片年轻代里的所有对象”代价也很明确。复制算法需要预留可接收对象的新区域而且对象每次幸存都要搬迁。如果一个区域里的对象存活率很高复制就会变得越来越不划算。这也是它更适合年轻代而不适合直接拿去处理整片老年代的重要原因。复制算法其实是在赌一件事活下来的对象不会太多。这个赌注放在年轻代通常成立放在老年代就未必了。标记-整理为什么更适合老年代老年代面临的问题和年轻代不一样。这里的对象活得更久存活率也更高。如果还沿用年轻代那种“把少量幸存对象复制到另一块区域”的思路搬运成本很快就会上来。标记-整理做的事可以理解成“先认出谁要留再把这些对象往一边挪整齐”。它不会像标记-清除那样留下大量碎片也不会像简单复制那样假设“幸存对象很少”。它更看重的是既要把垃圾清掉又要把剩下的有效空间重新整理成连续区域。这套思路更适合老年代。老年代里对象多、活得久、后续还可能继续驻留很长时间。如果这里长期堆满碎片后果会比年轻代更麻烦。因为一旦老年代分配和整理失控后面更容易出现更重的停顿甚至逼近Full GC这类代价更高的路径。当然整理也不是白来的。对象一旦移动引用关系就要跟着调整暂停时间和处理成本都可能上升。所以标记-整理的核心代价不在“能不能整理”而在为了换到连续空间要付出多少移动对象和更新引用的成本。三种算法到底在交换什么三类思路并排放在一起算法核心动作优势代价更适合的区域标记-清除标记存活对象直接清掉垃圾实现直接不需要额外搬迁整批对象容易留下碎片后续分配可能越来越难适合理解基础思路但单独长期使用容易被碎片拖累复制只复制幸存对象把原区域整块回收空间容易保持连续回收后分配更顺手需要额外接收区域而且要求幸存对象不能太多年轻代尤其适合“大量对象很快就死掉”的场景标记-整理标记存活对象再把它们向一侧压紧能同时回收垃圾并减少碎片对象移动和引用更新成本更高停顿压力也更重老年代更适合存活率更高、又更怕碎片的区域这也是为什么前面讲完“对象判活”和“分代回收”之后马上就得讲这些算法。因为后面的分代设计、收集器演进归根到底都离不开同一个问题在当前这片内存里碎片、搬运、停顿到底哪一种代价更值得承受。三种回收算法没有绝对优劣只有当前场景更愿意承受哪一种代价。三色标记法并发标记为什么必须有写屏障前面讲了“判活”“分代”“三种算法”。接下来先别急着看具体收集器名字先抽象出一种更难的执行方式标记线程在追对象引用图业务线程同时还在继续运行并且还会继续改引用。这种“标记和业务线程同时进行”的做法就是后面经常会看到的并发标记。它不属于某一个收集器专有名词而是一类收集器为了缩短停顿会反复用到的核心思路。这里顺手区分一下并发标记说的是“边追引用图边判活”并发清扫说的是“边释放垃圾边让业务继续跑”并发迁移说的是“边搬对象边让业务继续跑”。三色标记法直接解决的是并发标记这一步里的正确性问题。问题也正出在这里当标记线程在追对象引用图时业务线程也在不停改引用业务随手改掉的一条边会不会把“应该标到的对象”从标记视野里藏起来三色标记法就是用来解释这件事的。后面讲到CMS / G1 / ZGC / Shenandoah时你再回头看初始标记、并发标记、重新标记/最终标记、更新引用这些阶段名就会更容易对上它们分别在补哪一类问题。先把三种颜色当成三种“状态”颜色含义你可以把它理解成黑色已确认存活且已扫描完引用“这个对象我已经扫过一遍了”灰色已发现存活但引用未扫描完“待处理队列 / 工作列表”白色尚未确认可达本轮候选回收“当前还没被触达”如果整个标记过程都在STW里跑那么这套推进很直观不断从灰色里取对象扫描把新发现的对象放进灰色集合把扫描完的对象变成黑色直到灰色清空。并发标记的关键不是“标记更快”而是标记线程在追引用图的同时业务线程也在不停写引用新增、删除、改指向都可能发生。注意这里还有一个隐含规则对象一旦变成黑色就默认“本轮不会再回头扫描它的字段”否则并发标记就会退化成反复回扫成本很难控。错误示例 1对象被“误回收”漏标这是并发标记里最危险的一类问题对象其实已经可达了但由于并发期间的引用变化它没有被标出来最后还留在白色集合里。如果后续清扫阶段按“白色垃圾”直接回收就会出现误回收风险。这里我不用“集合模拟器”来写代码而是用更贴近业务写法的“对象引用变化”来讲你只要关注A.ref在并发标记期间是怎么被改掉的。class Obj { Obj ref; } Obj root new Obj(); Obj A new Obj(); Obj B new Obj(); // 初始对象图root - A B 暂时不可达 root.ref A; A.ref null; // 并发标记开始GC 线程 // 初始标记后A 被发现灰 // 随后 GC 扫描完 A 的字段A 变黑 // 此时概念上A 黑B 仍可能是 白还没被扫到 // 并发标记进行中业务线程 // 情况 1在 A 已经变黑之后业务新增了一条引用 A - B // 如果没有写屏障GC 不会再回头扫 A 的字段于是 B 可能一直保持白色漏标 - 误回收风险 A.ref B; // 这就是“黑色对象直接指向白色对象”可能带来漏标的来源。从图里的S3开始会分叉无写屏障B仍保持白色漏标如果后续清扫阶段按“白色垃圾”直接回收就会出现误回收风险有写屏障B会被补进标记队列先灰后黑漏标被补上上面这段示意代码对应的逻辑很简单只要 “A 已经黑了” 这个事实成立而 GC 又不会回扫黑对象字段那么“黑对象新增指向白对象”就有机会把 B 藏起来。这就是三色标记经常被引用的那句“不变量”不要让黑色对象直接指向白色对象长期不被发现。解决思路写屏障把“新引用”补回标记队列并发标记里问题不是“标记线程不努力”而是它不知道业务线程刚刚写入了哪条新边所以必须在“写引用”这一下做记录这类在写入点触发的机制就叫写屏障write barrier。一个最直观的修复思路叫“增量更新”当应用线程把一个引用写进某个对象时如果发现“黑 - 白”就立刻把白色对象涂成灰色也就是补进工作队列。在上面那段演示代码里对应的写屏障就像这样概念演示isBlack/isWhite/markGray都代表 GC 内部状态与动作// 如果 from 已经被扫描过黑而 to 还没被标记白就把 to 补进待处理队列涂灰 void writeBarrier(Object from, Object to) { if (gc.isBlack(from) gc.isWhite(to)) { gc.markGray(to); } }有了这类写屏障“黑对象写入新引用”这类并发写入就不会漏标白色对象会被补进灰色集合最终被标成黑色。错误示例 2对象“没有被回收”浮动垃圾到这里你已经看到了一类写屏障的目标修补“新增引用导致的漏标”。但并发期间还有另一类变化引用被删除/覆盖。很多收集器会用另一套思路也就是SATBSnapshot At The Beginning起始快照 / 标记开始时快照去保证“不误回收”代价就是本轮可能多留下一些浮动垃圾。并发标记的另一种常见现象不是“误回收”而是“该死的对象本轮没死”。例如标记开始时对象是可达的但并发标记过程中引用被断开了。为了保证不漏标收集器可能选择把它先保守地标活让它变成“浮动垃圾”留到下一轮再回收。// 仍然是同一张对象图root - A - B root.ref A; A.ref B; // 并发标记开始GC 线程 // A、B 先后被发现/扫描可能变灰、再变黑 // 并发标记进行中业务线程 // 情况 2业务把 A - B 这条边断开 // 从“此刻起”的业务视角B 已经不可达了 A.ref null; // 但 GC 为了“绝不误回收”很多实现会更像在维护“开始那一刻的快照” // 它可能仍把 B 当作本轮存活对象结果就是 B 本轮没回收浮动垃圾下一轮再处理。这就是你在SATB语境下经常听到的那句直觉本轮宁可多留浮动垃圾也不要误回收。这类策略通常和“快照式SATB”思路有关标记时更像在维护“开始那一刻的可达快照”所以本轮可能会多保留一些对象换来“不会误回收”的安全性。你在很多收集器里看到的重新标记/最终标记也可以理解成一个“收束点”并发阶段里攒下来的变更记录需要在这里被集中处理完确保本轮标记闭合。并发标记阶段的难点不是“算力不够”而是“正确性保障更难”三色标记和写屏障就是为了解决这件事。收集器为什么会一路演进前面讲了三种回收算法也讲了三色标记法在并发标记里怎么保证正确性。接下来才轮到收集器这一层在线上系统里回收不只是“能不能做”还要在吞吐、停顿、碎片和并发开销之间做取舍。收集器演进本质上就是把这些代价放进不同场景里重新分配。如果回收算法的差别只是书本上的三种基本思路Java也不至于一路长出这么多收集器。真正推动演进的不是“想把算法名词变多”而是应用场景一直在变。早期程序堆不大、机器核数不多能先把垃圾回收这件事稳定做对就已经很重要。后来服务端程序越跑越久、堆越给越大、延迟要求也越来越严格新的矛盾就冒出来了吞吐、停顿、碎片、并发开销不可能永远靠同一套收集器平衡。所以看收集器演进最好别把它当成一条版本时间线而是把它看成一串连续的问题修复。每一代都不是凭空出现而是在试图解决前一代最难受的那个短板。如果把这条演进线整体摊开主脉络大致就是这样一条主线把重活从 STW 挪到并发以及代价怎么转移把收集器当成一串名字最后很容易只剩“背结论”。更稳的读法是把它们当成在不同约束下做的工程交换想要更短的停顿就得把更多工作搬到并发阶段但并发不是白来的需要更多协调、更复杂的正确性保障也通常会吃掉更多 CPU老年代越大、对象存活率越高“碎片”和“搬迁成本”就越绕不过去所以这条演进线看起来在变“收集器”实际上在变的是我们愿意把代价从哪里挪到哪里。你想压的痛点常见做法换来的代价吞吐不够停顿内并行更多 GC 线程停顿仍在未必更短停顿太长把标记/清扫并发化并发协调成本 退化风险碎片太难受需要整理 / 需要搬迁搬迁与引用更新成本停顿要更稳把回收单元切细并调度实现复杂度更高先解决“能回收”和“吞吐不够”先抓住Serial的定位先把回收稳定做完代价是停顿最直接。它实现简单回收逻辑清楚单线程把事情做完。对于小堆、单核或者不太在意停顿的场景这种办法不一定差。对象不多、堆也不大时简单往往就是优势因为系统不需要额外为复杂并发回收付出太多协调成本。从这里往后图里会反复出现一些阶段名先统一一下中文口径。但先说清楚一点这些阶段名不是每个收集器都有。比如Serial / Parallel基本没有“并发阶段”也不会出现“初始标记/重新标记/最终标记”这种拆分它们更多是“停顿内把活一口气做完”。STWStop-The-World也就是暂停应用线程初始标记先把直接从GC Roots能摸到的对象快速标出来并发阶段默认指GC 线程和应用线程同时进行不是只表示“有多个 GC 线程一起做事”重新标记把并发阶段里发生变化的引用再收一遍尾最终标记把本轮标记阶段正式收束迁移 / 搬迁把对象挪到新的位置更新引用把旧地址关系修正到新地址上最终根更新把根对象握着的旧地址关系也收束到新地址上作为最后一个短暂停顿收尾下面这张表列的是“哪些收集器会出现哪些阶段”。这里把CMS也放进来主要是为了把收集器演进这条线讲完整。它更适合放在“理解历史演进”的语境里看而不是当成今天的主流默认选型入口。收集器你在图里会看到的典型阶段备注Serial / Parallel主要就是 STW停顿内完成标记/清理/整理或复制基本不出现“并发阶段”这类词CMS初始标记STW- 并发阶段 - 重新标记STW目标是把追踪/清扫搬到并发但碎片仍是代价G1初始标记常和年轻代回收合并- 并发阶段 - 重新标记 - 混合回收 多轮停顿通过 Region 粒度把停顿拆细并纳入调度ZGC初始标记STW- 并发阶段 - 最终标记STW- 迁移含短暂停顿 并发迁移只保留少数切换点重活尽量并发推进Shenandoah初始标记STW- 并发阶段 - 最终标记STW- 更新引用 / 最终根更新STW并发搬迁与并发更新引用更激进同样地“这个收集器主要作用在年轻代还是老年代”也不是一刀切的。下面这张表按常见组合/常见定位给你一个直觉坐标收集器主要作用区域常见口径一句话说明Serial年轻代 老年代小堆/简单场景常见整体偏“停顿内做完”Parallel年轻代 老年代典型吞吐路线停顿仍在但停顿内并行CMS老年代为主通常搭配一个年轻代收集器核心是老年代并发清扫G1年轻代 老年代全堆 Region通过 Region 粒度分批回收Mixed GC 会带一部分老年代ZGC覆盖整堆现代 JDK 里也可按分代实现更强调低停顿这里先突出它的低停顿节奏不展开分代细节Shenandoah覆盖整堆逻辑上全堆同样偏低停顿并发搬迁/更新引用更激进从这里开始图里除了GC自己的阶段线我还额外加了一条应用线程状态线绿色表示业务线程还在继续跑红色表示这一步已经被STW暂停。所以后面图里只要看到绿色并发阶段默认都在表达同一件事GC 线程还在做事应用线程也没有停。触发回收以后标记、清理、整理这些主要动作几乎都直接落在一次停顿里这也是它实现简单的原因不用为复杂的并发阶段付出额外协调成本代价同样很直接一旦堆变大或对象变多停顿就会更集中地落到业务线程头上记Serial就抓一句逻辑最直、实现最简单但停顿也最不遮掩。再看Parallel先抓它的定位停顿还在但停顿里的 GC 工作改成多线程并行目标是把总吞吐顶上去。机器核数上来、吞吐要求上来之后瓶颈就从“能不能回收”变成了“回收线程跟不跟得上应用本身的分配速度”。Parallel收集器做的事情也很朴素既然回收本身是重活那就让更多线程一起干优先把总处理能力顶上去。这类思路特别适合吞吐优先的场景。停顿依然存在但目标不是把停顿压到极低而是让应用整体完成更多工作。换句话说Parallel解决的是“GC 太慢、总效率不够”的问题不是“停顿必须足够短”的问题。Parallel并没有拿掉停顿它只是把停顿里的回收工作改成多线程一起做所以它优化的主要是总处理能力而不是把一次停顿压得特别短这也解释了为什么它更适合吞吐优先而不是延迟特别敏感的系统记Parallel就抓一句它解决的是“GC 太慢”不是“停顿太长”。CMS 为什么不再是终点当应用从批处理、单机任务慢慢转到更典型的在线服务后另一个问题开始变得刺眼有时候系统总吞吐并不差但一次偏长的停顿就足以把延迟打花。先抓CMS的定位开始明确转向低停顿把一部分老年代工作挪到并发阶段。它的核心诉求不是把回收做得最省 CPU而是尽量缩短停顿让更多工作和应用线程并发进行。对服务端系统来说这种方向非常有吸引力因为它直接回应了“接口别一下卡住太久”这个现实问题。但CMS也有自己的代价没有很好地解决碎片整理并发回收本身也不是免费的系统要为更复杂的协调和额外 CPU 成本买单所以CMS的意义很大但它并没有把问题彻底做完。它证明了“低停顿值得追”也把后面的路线进一步逼了出来如果既想控制停顿又不想长期背着碎片问题走那还得继续往前。如果只从STW分布看CMS的思路就会非常清楚把最重的追踪和清扫尽量搬到并发阶段只把两个关键收束点留在停顿里。核心目标先把老年代回收从“整段长停顿”改成“只有少数收束点停顿”主要做法初始标记和重新标记保留在STW中间的标记和清扫尽量并发主要代价碎片问题没有解决并发回收跟不上时还可能退化记CMS就抓一句它先把停顿压下来了但还背着碎片和退化风险。一旦回收从停顿内转向并发核心难点就不再只是速度而是并发期间如何保证标记正确性。前面单独展开的三色标记法解决的就是这里的问题。前面那章三色标记法正好可以对回这里在CMS / G1里常写作重新标记在ZGC / Shenandoah里常写作最终标记它们都是并发标记后的收束点。G1 为什么成了主流默认先抓G1的定位不是单纯继续压停顿而是开始追求“停顿更可控、更可预测”。它的关键不是先谈“默认”而是先把堆管理粒度从“按代大块处理”改成“按Region分批调度”。有了这个基础后面你看到的Mixed GC和“停顿更可预测”才真正成立。G1之所以重要不是因为它第一次提出了某个全新的回收动作而是因为它在工程上给出了一套更均衡的答案。它的核心思路有两点。第一不再把堆看成几块特别粗的区域而是拆成很多更细的分区来管理。第二不再只盯“把垃圾清掉”而是开始显式地把停顿目标也纳入调度考虑。这意味着G1处理的已经不是单个算法问题而是一个更现实的综合问题在有限停顿预算里优先回收哪些分区怎样一边回收一边尽量维持总体吞吐和空间可用性。到了G1收集器演进的主线已经从“能不能回收”变成了“能不能更可预测地回收”。对大量线上业务来说这比追求某个单点指标的极致更实际。它不追求像吞吐型收集器那样把总效率压到极致也不一味地为了低停顿把所有代价都推高。正因为这种平衡感G1很适合大多数通用服务场景。官方文档也明确把它作为现代HotSpot JVM中大多数硬件和操作系统配置下的默认选择。如果把G1拆成STW节奏来看它并不是“只有很少几次停顿”而是把停顿切成更可控、更分散的几段。G1 的堆为什么切成 Region以及 Mixed GC 为什么成立前几代收集器常见的堆直觉是“年轻代一块、老年代一块”。这种划分简单直接但它也意味着老年代一旦变大很多决策容易变成“要么不收、要么收一大块”。G1做的关键一步是把整堆切成很多大小相同的Region。分代仍然存在但它不再依赖两块连续大区而是让不同Region在当前阶段扮演Eden / Survivor / Old等角色。维度传统分代连续区Serial / Parallel / CMS 常见形态G1 Region基本管理单元连续大区大量等大小 Region分代的落点通过大区边界体现通过 Region 的“角色”体现回收决策粒度更像“按代/按大区”更像“按 Region 价值分批”Mixed GC 体感不太突出“顺带回收一部分老年代”回收一批年轻代 选中的老年代 Region大对象Humongous常见实现需要单独处理策略G1 里会有专门的 Humongous Region 口径所以G1的优势不是“完全没有停顿”而是把回收单元切细到Region粒度后更容易围绕停顿目标做调度。核心目标不是单纯继续压停顿而是让停顿更可控、更可预测主要做法用Region切细回收单元保留初始标记 / 重新标记再把后续回收拆成多轮Mixed GC主要代价实现更复杂停顿并没有消失只是被拆细并纳入调度记G1就抓一句它不追求绝对最短而是让停顿更容易控。ZGC 和 Shenandoah 还在继续压什么即便如此G1也没有让所有场景都满意。只要堆继续变大、延迟目标继续收紧就还是会有人觉得停顿不够短或者不够稳定。如果把G1看作“停顿更可预测”那ZGC和Shenandoah继续追的就是“把停顿进一步压短而且尽量别随着堆变大一起拖长”。它们的共同方向都很明确把更多本来容易造成停顿的工作继续并发化尽量让应用线程少停、短停差别则主要体现在并发搬迁、引用更新这些动作组织得有多激进。这类收集器解决的问题比CMS当年还更激进。它们不是简单追求“比以前短一点”而是在低停顿目标上进一步往前压。官方资料里ZGC的定位就是低延迟、大堆、停顿时间尽量与堆大小解耦Shenandoah也明确把“更稳定的短停顿”作为核心目标。代价也很明确。更激进的并发回收通常意味着实现更复杂额外开销更高吞吐和资源占用未必像更传统的收集器那样好看所以它们不是默认适合所有系统而是更适合那些把响应时间放在非常靠前位置的场景。先抓ZGC的定位继续压低停顿把大部分重活留在并发阶段只保留少数很短的切换点。它不是彻底消灭停顿而是努力把STW压缩成几个很短的切换点。这里为了突出它的停顿组织方式先按低停顿阶段来讲不把分代细节展开到这张图里。核心目标把停顿进一步压短并尽量别随着堆变大一起拖长主要做法只保留初始标记 / 最终标记 / 迁移开始这些短切换点把标记和迁移重活尽量放进并发阶段主要代价实现复杂并发开销更高对资源条件也更挑剔记ZGC就抓一句它追求的不是没有停顿而是让停顿短到不再随着堆一起明显变长。再看Shenandoah先抓它的定位低停顿方向更激进连搬迁和更新引用都尽量往并发阶段挪。从STW图上也能看出来它不只并发标记还尽量把对象搬迁 / 更新引用这些重活一起搬到并发阶段。核心目标继续追低停顿并把整理这件事也尽量并发化主要做法除了并发标记还把并发搬迁和并发更新引用一起往前推主要代价并发 CPU 和空间开销更高实现复杂度也更重记Shenandoah就抓一句它和ZGC一样追低停顿但在并发整理这件事上走得更深。Serial / Parallel核心目标是先把回收做稳、把吞吐顶上去主要做法是把回收主体留在STW里其中Parallel再把停顿内的工作并行化CMS核心目标是先压低老年代停顿主要做法是把标记和清扫尽量放进并发阶段只把初始标记 / 重新标记留作收束点G1核心目标是让停顿更可控主要做法是把堆切成Region再把初始标记 / 重新标记 / 混合回收拆成更可预测的多段节奏ZGC / Shenandoah核心目标是继续追低停顿主要做法是把更重的标记、迁移和整理动作继续往并发阶段搬只保留少数短切换点六种收集器的对比表如下收集器核心目标主要做法主要代价记一句Serial先把回收稳定做完基本把标记、清理、整理都放在一次 STW 里由单线程完成堆一大停顿就容易很集中逻辑最直、实现最简单但停顿也最不遮掩Parallel把总吞吐顶上去停顿仍在但把停顿里的 GC 工作改成多线程并行停顿未必短不适合强延迟诉求它解决的是“GC 太慢”不是“停顿太长”CMS先把老年代回收从整段长停顿改成少数收束点停顿初始标记 / 重新标记 留在 STW中间的标记和清扫尽量并发碎片问题没有解决并发回收跟不上时还可能退化它先把停顿压下来了但还背着碎片和退化风险G1让停顿更可控、更可预测用 Region 切细回收单元保留 初始标记 / 重新标记再把后续回收拆成多轮 Mixed GC实现更复杂停顿没有消失只是被拆细并纳入调度它不追求绝对最短而是让停顿更容易控ZGC把停顿进一步压短并尽量别随着堆变大一起拖长只保留 初始标记 / 最终标记 / 迁移开始 这些短切换点把标记和迁移重活尽量放进并发阶段实现复杂并发开销更高对资源条件也更挑剔它追求的不是没有停顿而是让停顿短到不再随着堆一起明显变长Shenandoah继续追低停顿并把整理这件事也尽量并发化除了并发标记还把并发搬迁和并发更新引用一起往前推并发 CPU 和空间开销更高实现复杂度也更重它和 ZGC 一样追低停顿但在并发整理这件事上走得更深从CMS到G1再到ZGC / Shenandoah真正变化的不是“会不会回收”而是“把多少重活留在停顿里又把多少重活搬到并发阶段”。收集器演进主线收集器演进的主线如下Serial先把回收稳定做完适合简单和小规模场景Parallel优先把吞吐顶上去CMS开始明显转向低停顿诉求G1在停顿、吞吐和空间整理之间做更均衡的工程权衡ZGC / Shenandoah继续把低停顿目标往前推进所以收集器演进真正想说明的不是“新的一定全面替代旧的”而是应用关心的指标变了收集器的最优解也会跟着变。回到怎么选到这里判活、分代、算法和收集器演进已经能连成一条线。最后一步不是再记一遍名词而是把这条主线压成几条能落地的选型判断。吞吐优先和停顿优先怎么选如果系统最看重的是总吞吐而且可以接受更长一些的停顿思路通常会偏向Parallel这类更强调整体效率的方案如果系统是更典型的在线服务希望在大多数情况下把停顿控制得更稳G1往往是更自然的起点如果系统对长尾延迟特别敏感堆又比较大关注点才会继续往ZGC、Shenandoah这类更强调低停顿的路线走这几条判断背后其实都是同一个问题当前系统最怕失去什么。有的系统怕总处理能力不够有的系统怕一次停顿太长有的系统怕堆越来越大以后停顿形态失控收集器的选择不是先背结论而是先把这个“最怕什么”想清楚。先想清系统最怕失去什么再选收集器。最后只留三件事GC的第一步永远是判活不是上来就删对象分代回收成立不是因为堆天生该分年轻代和老年代而是因为对象寿命本来就不平均收集器一路演进核心始终没变围绕吞吐、停顿、碎片和并发开销这几种代价换一个更适合当前场景的平衡点如果再回到这篇文章的理解主线顺序还是这三步先看对象怎么判活再看分代和回收节奏怎么设计最后再看当前业务到底更怕吞吐掉下来还是更怕停顿拉长。如果真到线上排查GC卡顿第一反应通常还是先看GC日志、停顿类型、回收频率和堆占用而不是先做对象可达性分析。Java GC不是在追求一个永远最强的收集器而是在不同代价之间不断找平衡。再回头看开头那个“服务突然卡一下”的现象排查路径可以直接落成三步先看对象还能不能从GC Roots走到再看它会落在哪一代、适合哪种回收策略最后再看当前系统更在意吞吐还是停顿先判活再回收先分代再选择收集器。把这条线抓住Java GC就不会再像一堆零散名词。

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

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

免费获取报价 →
↑