本文 的 原文 地址
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
听说你是高手,说说,常见的 GC 组件把,比如 PS+PO/ CMS /G1/ZGC / 分代ZGC 等等 ?
听说你是高手,说说,PS+PO/ CMS /G1/ZGC 的底层原理 ?
说说,CMS /G1 垃圾回收器的底层原理?
说说,CMS /G1 / ZGC 的 漏标问题,是怎么解决的?
说说,CMS 增量更新 和 G1 的STAB 分别是什么, 有什么作用, 有什么 区别 ?
说说,什么是卡表、记忆集、联合修改表?
说说, 什么是 读屏障、什么是写屏障? 分别是怎么使用的?
最近有小伙伴在面试 阿里,又遇到了相关的面试题。小伙伴懵了,因为没有遇到过,所以支支吾吾的说了几句,面试官不满意,面试挂了。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V171版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,回复:领电子书
另外,此文的内容, 收入尼恩的《 五大 GC 学习圣经 》PDF , 帮助大家 吊打 面试官。
尼恩团队 五大 GC 学习圣经
第一大 gc 学习圣经:cms
第二大 gc 学习圣经: G1
第3、4 大 gc 学习圣经: ZGC
《分代 ZGC 圣经:分代ZGC 底层原理和 大厂实战案例学习》
接下来,咱们言归正传,开始讲 分代ZGC
垃圾回收器分类
JVM垃圾回收器基于回收的方式,分为串行回收器、并行回收器、并发回收器(包括CMS、G1、ZGC、Shenandoah)。
- 串行执行:应用程序和垃圾回收器交替执行,垃圾回收器执行的时候应用程序暂停执行。串行执行指的是垃圾回收器有且仅有一个后台线程执行垃圾对象的识别和回收。
- 并行执行:应用程序和垃圾回收器交替执行,垃圾回收器执行的时候应用程序暂停执行。并行执行指的是垃圾回收器有多个后台线程执行垃圾对象的识别和回收,多个线程并行执行。
- 并发执行:应用程序和垃圾回收器同时运行,除了在某些必要的情况下垃圾回收器需要暂停应用程序的执行,其余的时候在应用程序运行的同时,垃圾回收器的后台线程也运行,如标识垃圾对象并回收垃圾对象所占的空间。
这里注意说以下几种:
基于分代和分区的维度,分为:分代回收器、分区回收器
下图描述了分代、分区回收器,以及配合使用关系
七种垃圾收集 常见垃圾回收器 的组合使用的方式
首先来看一下JDK 11之前全部可用的垃圾收集器。
图中列出了七种垃圾收集器,连线表示可以配合使用,所在区域表示它是属于新生代收集器或是老年代收集器。
这里还标出了垃圾收集器采用的收集算法,G1收集器比较特殊,整体采用标记-整理
算法,局部采用标记-复制
算法,后面再细讲。
串行回收器(Serial GC)
Serial收集器是最基础、历史最悠久的收集器。
如同它的名字(串行),它是一个单线程工作的收集器,使用一个处理器或一条收集线程去完成垃圾收集工作。
Serial GC 使用单线程进行垃圾回收,在回收时应用程序(mutator)都需要执行暂停(Stop The World,STW)。
Serial GC 新生代通常采用复制算法,老生代通常采用标记压缩算法。串行回收典型的执行过程。
Serial/Serial Old收集器的运行过程如图:
图例中没有mutator运行的区间都是指STW。
实际上串行回收中的老生代回收不仅仅回收老生代,还回收新生代。
图中一个箭头表示一个线程,此图中执行垃圾回收过程只有一个箭头,表示只有一个后台线程执行回收任务。深色箭头表示的是垃圾回收工作线程,空心箭头表示应用程序线程。
实现回收器:
- Serial(新生代):采用复制算法(Copying),默认在 Client 模式下启用。
- Serial Old(老年代):采用标记-整理算法(Mark-Compact),作为 CMS 后备回收器或与 Parallel Scavenge 配合使用
并且进行垃圾收集时,必须暂停其他所有工作线程,直到垃圾收集结束——这就是所谓的“Stop The World”。
Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
Serial Old收集器是Serial收集器的老年代版本,采用标记整理算法的单线程的收集器。 Serial Old收集器的工作过程如图:
并行回收器(Parallel GC)
并行回收的执行过程 使用多线程进行垃圾回收,就是 多个 GC线程。
而上面的 串行GC,就是一个 GC线程。
Parallel GC 以提升吞吐量为核心目标,在回收时应用程序需要暂停,新生代通常采用复制算法,老生代通常采用标记压缩算法。
Parallel GC 实现回收器:
- Parallel Scavenge(新生代):多线程复制算法,可调节吞吐量目标(
-XX:GCTimeRatio
) - Parallel Old(老年代):多线程标记-整理算法,与 Parallel Scavenge 组合使用
- ParNew(新生代):Parallel Scavenge 的改进版,专为与 CMS 配合设计
1、Parallel Scavenge收集器
Parallel Scavenge 收集器实质上是Serial收集器的多线程并行版本,使用多条线程进行垃圾收集。
Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法实现,也能够并行收集。
Scavenge 是清扫的意思。
Parallel Scavenge主要关注的是垃圾收集的吞吐量。 所谓吞吐量指的是运行用户代码的时间与处理器总消耗时间的比值。
这个比例越高,证明垃圾收集占整个程序运行的比例越小。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:
- -XX:MaxGCPauseMillis,最大垃圾回收停顿时间。
这个参数的原理是空间换时间,收集器会控制新生代的区域大小,从而尽可能保证回收少于这个最大停顿时间。
简单的说就是回收的区域越小,那么耗费的时间也越小。
所以这个参数并不是设置得越小越好。设太小的话,新生代空间会太小,从而更频繁的触发GC。
- -XX:GCTimeRatio,垃圾收集时间与总时间占比。
这个是吞吐量的倒数,原理和MaxGCPauseMillis相同。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。
2、Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
3、ParNew收集器
ParNew(新生代)是 Parallel Scavenge 的改进版,专为与 CMS 配合设计
Parnew收集器其实是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和Serial收集器完全一样。
ParNew收集器的工作过程如图所示:
这里值得一提的是Par是Parallel(并行)
的缩写。
但需要注意的是,这个并行(Parallel)
仅仅是描述同一时间多条GC线程协同工作,而不是GC线程和用户线程同时运行。
ParNew垃圾收集也是需要Stop The World的。
ParNew和Parallel Scavenge的区别究竟在哪里?
ParNew和Parallel Scavenge是两种不同的Java虚拟机垃圾收集器,主要用于新生代的垃圾收集。
它们的主要区别包括:
1、默认的配合的老年代收集器不同
ParNew收集器通常与CMS收集器配合使用,作为CMS的默认新生代收集器。而Parallel Scavenge收集器通常与Parallel Old收集器配合,形成整个Parallel收集策略。
2、目标和应用场景的差异
ParNew注重的是降低暂停时间,因此更适合需要低延迟的应用,如Web服务器、交互式应用等。而Parallel Scavenge注重高吞吐量,更适合后台运算为主的场景,如大型计算任务、批处理等。
3、暂停时间和吞吐量的考虑
ParNew为了保证低延迟,可能会牺牲部分吞吐量。而Parallel Scavenge则相反,它会牺牲部分延迟来保证最大的吞吐量。
4、自适应调节的能力
Parallel Scavenge具有自适应调节策略(-XX:+UseAdaptiveSizePolicy),能够根据系统的实际运行情况调整各个区域的大小及目标暂停时间。ParNew没有这种自适应机制。
5、ParNew 与CMS和G1收集器的组合
ParNew与CMS的结合相对紧密,它们共同为低延迟场景提供服务。
而Parallel Scavenge并不适合与CMS配合,Parallel Scavenge 常常与 Parallel Old收集器, 再JDK 8中,这个是默认的组合: PS + PO 。
并发回收器(Concurrent GC)
整个回收期间大致划分阶段:初始标记、并发标记、重新标记、回收阶段。
在初始标记和重新标记阶段需要暂停应用程序线程,在并发标记和回收期间工作线程可以和应用程序并发运行。
实现回收器:
- CMS:低延迟的老年代GC
- G1:平衡吞吐量和延迟的全堆,分区回收器
- ZGC:大堆,低延迟GC
CMS 使用标记清除算法以获取最短全局停顿时间为目标的收集器。CMS 工作流程 大致如下:
CMS(并发标记清除)收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,同样是老年代的收集齐,采用 标记-清除 算法。
CMS 的四个阶段
CMS收集齐的垃圾收集分为四步:
- 初始标记(CMS initial mark):单线程运行,需要Stop The World,标记GC Roots能直达的对象。
- 并发标记( CMS concurrent mark):无停顿,和用户线程同时运行,从GC Roots直达对象开始遍历整个对象图。
- 重新标记(CMS remark):多线程运行,需要Stop The World,标记并发标记阶段产生对象。
- 并发清除(CMS concurrent sweep):无停顿,和用户线程同时运行,清理掉标记阶段标记的死亡的对象。
涉及到了多次标记的过程,这里插入一点三色标记的知识。三色抽象用来描述对象在垃圾收集过程中的状态。
通常白色代表对象未被扫描到,灰色表示对象被扫描到但未被处理,黑色表示对象及其后代已被处理。
在CMS的标记和清除过程中就用到了这种抽象,详细的可以查看参考。
Concurrent Mark Sweep收集器运行示意图如下:
优点:CMS最主要的优点在名字上已经体现出来——并发收集、低停顿。
缺点:CMS同样有三个明显的缺点。
- Mark Sweep算法会导致内存碎片比较多
- CMS的并发能力比较依赖于CPU资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。
- 并发清除阶段,用户线程依然在运行,会产生所谓的理“浮动垃圾”(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。
CMS重新标记(Remarking)阶段的工作机制
初始标记、并发标记比较简单, 尼恩给大家 重点介绍一下 最难的阶段, CMS重新标记(Remarking)阶段.
Remarking 阶段,是 CMS(Concurrent Mark-Sweep)垃圾回收流程的第三个阶段,位于并发标记(Concurrent Marking)之后、并发清除(Concurrent Sweep)之前。
前一个阶段 concurrent mark 并发标记的局限性:
并发标记阶段允许用户线程与GC线程并行执行,导致对象引用关系可能被动态修改(如新增引用或删除引用)。
示例:用户线程在并发标记期间,新增了 黑色对象A→白色B的引用,此时若B未被其他引用链可达,则可能被错误回收 ,这就是 漏标 。
Remarking 阶段核心目标:
- 修正并发标记阶段因用户线程运行导致的引用变动,避免漏标(存活对象被误回收) 。
- 通过增量更新(Incremental Update)机制,重新扫描并发标记期间新增的引用关系,确保对象可达性分析的准确性 。
首先, 回头看看 concurrent mark 阶段的 增量更新与写屏障:
- 增量更新机制:通过写屏障(Write Barrier)记录并发标记期间新增的引用关系(如黑色对象A→白色B 的引用建立),并在重新标记阶段强制重新扫描这些引用的目标对象。
- 写屏障作用:用户线程修改对象引用时,触发写屏障将引用变更记录到 增量更新队列(Incremental Update Queue) ,暂存这些需要重新标记的对象。 供重新标记阶段处理。
Remarking 重新标记的 核心工作
1 STW暂停 ,冻结对象图状态;
2 结合三色标记与增量更新,处理新增引用导致的漏标问题;
3 基于卡表脏页扫描,缩小跨带修正范围,解决跨代漏标
4 处理 弱引用与 finalize() 终结器 ,确保特殊对象状态正确。
该阶段通过局部扫描和增量更新机制,以极短停顿(通常10-100毫秒)修正并发标记的遗漏,避免全堆遍历,是CMS低延迟设计的核心保障。
核心工作1: STW暂停,冻结对象图状态;
全局安全点(Safe Point):所有用户线程被挂起,确保内存引用关系不再变化。
线程状态检查 :确认所有线程已到达安全点,无正在执行的字节码指令(如对象引用赋值)。
核心工作2: 结合三色标记与增量更新,处理新增引用导致的漏标问题;
三色标记的修正,黑色对象新增引用问题:
- 若并发标记期间黑色对象(已扫描完成)被用户线程新增了指向白色对象(未标记)的引用,增量更新会将黑色对象降级为灰色,强制重新扫描其 引用链。
- 示例:黑色对象A在并发标记期间新增引用A→B(B为白色),重新标记阶段将A标记为灰色并重新扫描,确保B被标记为存活。
并发标记阶段,将被引用的白色对象暂存到 增量更新队列(每个线程可能有独立的本地队列),将所有线程的本地增量队列合并到 全局队列,避免遗漏任何新增引用。
这个步骤,需要 扫描 全局队列 的对象, 避免遗漏任何新增引用,解决 漏标问题。
核心工作3: 基于卡表脏页扫描,缩小跨带修正范围,解决跨代漏标
避免跨代漏标:卡表记录老年代对年轻代的引用变更,确保重新标记阶段能正确处理跨代引用的存活对象。
例如:若老年代对象A在并发标记期间新增对年轻代对象B的引用,卡表会标记A所在的卡页为脏,重新标记阶段扫描该卡页时,B会被正确标记。
核心工作4: 处理 弱引用与 finalize() 终结器 ,确保特殊对象状态正确。
弱引用判定:检查软引用、弱引用等特殊引用指向的对象是否存活,未存活则加入待回收队列 。
终结器执行:标记需执行finalize()
方法的对象,确保其存活状态被正确处理
Remarking 重新标记的停顿(STW)的必要性
- 一致性快照需求:
重新标记阶段需**暂停所有用户线程(STW)**,确保在修正引用关系时,对象图处于一致状态。若允许并发修改,可能导致标记结果不完整或矛盾。
- 效率优化:
重新标记仅处理并发标记期间变更的引用 ,而非全量扫描,因此其停顿时间显著短于并发标记阶段,但仍需短暂STW 。
总之,CMS的重新标记阶段通过增量更新机制和STW暂停,修正并发标记期间引用关系的变动,避免漏标问题。
其核心依赖写屏障记录引用变更,并在短暂停顿中完成精准修正,但会因强制保留中间状态的引用链而产生浮动垃圾
什么是CMS卡表(Card Table)?
现代JVM,堆空间通常被划分为新生代和老年代。
试想一下,在进行 YGC 时,如何 判断是否存在 老年代到新生代的引用? 一个简单的办法是扫描整个老年代,但是这个代价太大了,因此 JVM 引入了卡表来解决这个问题。
卡表(Card Table) 是 针对于跨代引用问题提出的 的一种特殊数据结构。
由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么,需要跟踪从老年代到新生代的所有引用,从而避免每次YGC时扫描整个老年代,减少 扫描 开销。
对于HotSpot JVM,使用了卡标记(Card Marking)技术来解决老年代到新生代的引用问题。
卡表的出现就是为了提高性能,采用空间换时间的方式完成的。
有了卡表后,在 YGC 时,只需将卡表中被标记为 dirty 的 card 也作为扫描范围,就可以保障不扫描整个老年代也不会有遗漏了。
具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记, 并加快对GC Roots的扫描。CMS在做YGC时,为了标记活的对象,除了需要从GC root查找外,还需要找老年代中引用的新生代对象。在找老年代中引用的新生代对象的过程中,如果老年大很大,就会很耗时。
在hotspot虚拟机中,卡表是一个字节数组,数组的每一项对应着内存中的某一块连续地址的区域,如果该区域中有引用指向了待回收区域的对象,卡表数组对应的元素将被置为1,没有则置为0;架构如下:
基于卡表(Card Table)的设计,通常将堆空间划分为一系列2次幂大小的卡页(Card Page),HotSpot JVM的卡页(Card Page)大小为512字节 。
卡表(Card Table),用于标记卡页的状态,每个卡表项对应一个卡页,每个卡表项为1个字节。
当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty。
OpenJDK/Oracle 1.6/1.7/1.8 JVM默认的卡标记简化逻辑如下:
CARD_TABLE [this address >> 9] = 0; // 通过卡表索引号,设置对应卡标识为dirty。
上面代码,首先 计算对象引用所在卡页的卡表索引号,然后,通过卡表索引号,设置对应卡标识为dirty。
将地址右移9位,相当于用地址除以512(2的9次方)。
可以这么理解,假设卡表卡页的起始地址为0,那么卡表项0、1、2对应的卡页起始地址分别为0、512、1024(卡表项索引号乘以卡页512字节)。
CMS中卡表(Card Table)的作用
新生代垃圾回收(YGC)时,需确保 被老年代引用的新生代对象存活 。所以,需要 跨代引用,检测到老年代old对象 对 yong 对象 的引用,把这些 对应 保留下来。
如何检测 到老年代old对象 对 yong 对象 的引用, 两个办法:
-
全 老年代 扫描。
-
精准化 的 老年代 扫描。
卡表在 YGC (ParNew GC)阶段的作用
对于HotSpot JVM,使用了卡标记(Card Marking)技术来解决老年代到新生代的引用问题。 卡表的出现就是为了提高性能,采用空间换时间的方式完成的。
具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记, 并加快对GC Roots的扫描。CMS在做YGC时,为了标记活的对象,除了需要从GC root查找外,还需要找老年代中引用的新生代对象。在找老年代中引用的新生代对象的过程中,如果老年大很大,就会很耗时。
卡表在 并发标记、和最终标记 阶段的作用
CMS在 并发标记 和 最终标记阶段,都用到了卡表。
CMS 并发标记 (Concurrent Mark) ,是从初始标记阶段被标记为存活的对象作为起点,向下遍历,找出所有存活的对象。
同时,由于 CMS 并发标记(Concurrent Mark) 是用户线程和GC线程并发执行,对象 的引用关系在不断发生变化,对于这些引用关系对象,都是需要进行重新标记的,否则就会出现错误。
为了提升 CMS 重新标记 (Final Remark) 的效率,JVM 会使用写屏障(write barrier)将发生引用关系变化的对象所在的区域对应的 card 标记为 dirty,Final Remark 只需要扫描这些 dirty card 区域即可,避免扫描整个老年代。
CMS 卡表的另一个补充结构: mod-union table (联合修改表)
通过上面对 card table 的介绍,我们知道,老年代被分成一个个 卡片(card),卡表就像个记事本,专门记录哪些 卡片的old 对象,引用过 new 对象。
CMS 在并发标记 阶段,会并发的发生 对象的引用关系变化, 以便后续重新扫描,是否可以直接复用 card table?
答案是不行的。
如果CMS垃圾回收正在后台做标记工作,它也需要看这个小本本记录的老年代变化。结果YGC一擦本子,CMS要用的信息就没了!
这是因为每次 YGC 过程中,都涉及重置和重新扫描 Card table,都要把卡表这个小本本擦干净重新记(为了自己下次扫描方便。
YGC对Card Table的破坏性操作, 卡表重置问题:
每次YGC执行时,会清空Card Table中的脏标记(即“擦除记录”),以便为下一次YGC的跨代引用跟踪做准备。
问题来了,若CMS并发标记阶段正在使用Card Table记录老年代变更,YGC的清空操作会导致CMS丢失关键标记信息,引发漏标或错标。
所以,YGC 擦掉了Card table, 这样是满足了 YGC 的需求,但却破坏了CMS的需求。 CMS 需要的信息可能被 YGC 给重置掉了。
为了避免丢失信息,JVM又搞了个备胎 表,在 card table 之外, 另外加了一个 Bitmap 叫做 mod-union table 联合修改表。
Mod Union Table (联合修改表)的引入与分工
为解决YGC与CMS并发标记对Card Table的冲突,JVM引入Mod Union Table 联合修改表,作为CMS并发标记阶段的临时记录载体,避免YGC清空操作干扰CMS的标记流程。
Mod Union Table是一个位图,用于记录在CMS并发标记期间发生引用变化的Card。它是为了避免在YGC期间丢失CMS需要的引用信息而引入的。
1 YGC执行期间:
在 CMS 并发标记正在运行的过程中,每当发生一次 YGC,当 YGC 要重置 card table 里的某个记录时,就会更新 mod-union table 对应的 bit,相当于将 card table 里的信息转移到了 mod-union table 里。
YGC正常清空Card Table,但CMS所需的脏卡页已转移至Mod Union Table,不受影响 。
2 重新标记阶段:
CMS 处理card table 加 mod-union table 中的脏卡页,重新标记 老年代到新生代的 引用变化 。
这样,最后到 Final remark 的时候,card table 加 mod-union table 就足以记录在并发标记过程中老年代发生的所有引用变化了。
CMS中Mod Union Table与Card Table的协作机制
1:Card Table的核心作用
Card Table以卡页(通常512字节)为粒度,标记老年代中被修改的引用关系(如老年代对象A引用了新生代对象B),Card Table 用于辅助YGC快速定位跨代引用 。
在YGC时,新生代回收时, 需确定老年代是否存在对新生代的引用。通过扫描Card Table中的脏卡页,YGC可仅处理相关区域,避免全量扫描老年代 。
CMS 写屏障 的两个作用
在 HotSpot虚拟机中,写屏障本质上是引用字段被赋值这个事件的一个环绕切面(Around AOP),即一个引用字段被赋值的前后可以为程序提供额外的动作(比如更新卡表)。
CMS 的写屏障可以通俗地理解为一个“引用关系 观察员”、“引用关系 监督员”、“引用关系 小卫士” 。
内存中有很多对象,这些对象之间会有引用关系。 写屏障的作用就是,在程序修改对象的引用关系时,比如把一个对象的引用从一个对象指向另一个对象,Write-Barrier 写屏障 记录下这些变化。
Write-Barrier 就像是一个“小监督员”,时刻关注着对象引用的变动情况。
当垃圾回收器进行垃圾回收时,它会参考写屏障记录下的信息,确保不会漏掉任何存活对象,也不会错误地回收还在被使用的对象。
作用1: 维护跨代引用记录
写屏障与卡表(Card Table)协同工作,当 年轻代对象引用 被 老年代对象 时,写屏障会将对应内存区域标记为“脏”(Dirty Card),确保YGC时仅扫描这些区域而非整个老年代。
作用2: 增量更新机制 解决 漏标
当黑色对象/灰色对象(已标记完成的对象)新增对白色对象(未标记对象)的引用时,写屏障会触发重新标记流程,防止因引用变化导致漏标。
CMS通过 写屏障+ 增量更新(Incremental Update) 解决上述问题:
- 写屏障 拦截引用变更:
当用户线程修改对象引用时(如黑色对象新增对白色对象的引用),写屏障会捕获这一变更,并将该黑色对象记录到重新扫描队列(Incremental Update Queue) 中 。
- Incremental Update:
在重新标记阶段(Final Remark), 重新扫描 队列(Incremental Update Queue) 中 其所有 对象被 强制降级为灰色 , 重新扫描 重新扫描队列中 其所有灰色对象的 引用链,确保新增的白色对象被标记为存活 。
并发标记 与 对象少标(漏标) 问题和解决方案
并发标记 (Concurrent Mark) 的并发,是指应用线程和 GC线程可以并发执行。
在并发标记阶段, 主要完成 2个事情:
(1) 遍历对象图,标记从 GC Roots可以追踪到所有可达的存活对象;
(2) 处理 并发 引用关系 修改
无论是 CMS还是G1 ,在 并发标记阶段,都会存储 对象少标问题(漏标问题) 和 多标问题(浮动垃圾)问题。
在本小节,尼恩仅仅 聚焦 少标问题, 这个是一个 巨大的功能性问题,而多标则是一个性能问题,所以,尼恩在这里,暂时不讨论 浮动垃圾。
如下图:从 GC Roots追溯 所有可达对象,并将它们修改为已标记,即黑色。
因为是并发标记, GC 线程和应用线程并发的,应用线程 仍在继续工作,因此老年代的对象可能会发生以下几种变化:
- 新生代的对象晋升到老年代;
- 直接在老年代分配对象;
- 老年代对象的引用关系发生变更;
为了防止 并发的 引用关系 修改被遗漏,CMS 使用了写屏障(Write Barrier)机制,并且是 后置 的 (Write Barrier),确保这些引用关系更改,会被记录在“卡表(Card Table)”中,同时将相应的卡表条目标记为脏(dirty),以便后续处理。
当老年代中,D 删除了到 E 的引用,就会触发写屏障(Write Barrier) 机制,最终 E就会被写进脏页,如下图:
并发标记 会 不是万能的。
并发标记 出现对象可达性误判问题,如下图:假如对象 D对象被标记成黑色,E对象被标记为灰色(图左半部分),
这时,工作线程进行 2个 关系修改。
第一个修改:删掉 E对象 到 F 的引用 (图右半部分)。
第二个修改:新增 D对象对 F对象的引用(图右半部分)。
按照三色标记算法,D对象为黑色,不会再往下 遍历了。
所以图右半部分 F对象 就无法被标记从而变成垃圾,“存活”对象凭空消失了, 存活对象被误回收 。
存活对象被误回收 , 这是很可怕的问题,会出现 巨大的bug, 那么 如何解决这种问题的呢?
解决这种问题,理论层面, 通常有两种方案:
-
方案之一:增量更新(Incremental Update)
-
方案之二:原始快照(Snapshot At The Beginning,SATB)
方案之一:增量更新(Incremental Update)
当新增 黑色对象指向白色对象关系时(D->F),需要记录这次新增,比如加入到待重新扫描队列,等并发扫描结束后,将这些黑色的对象作为 GC Root,重新扫描一次。这就是 增量更新(Incremental Update)机制。
增量更新(Incremental Update)机制 就是把这些黑色对象看成灰色对象,它们指向的白色对象就可以被正常标记。
CMS采取的就是这种方式。
增量更新的多标问题:
增量更新强制保留所有新增引用,即使这些引用后续被删除(如A→B→C变为A→C),原引用链中的对象(B)仍会被错误标记为存活,需等待下次GC才能回收,这就是 浮动垃圾。
或者说,增量更新 强制保留所有新增引用链的目标对象,即使这些引用在重新标记完成后,被用户线程删除(如A→B→C变为A→C),原引用链中的对象(B)仍会被错误标记为存活,成为浮动垃圾,需等待下次GC回收
浮动垃圾 会在下次GC回收, 所以, 影响也不大。
方案之二:原始快照/删除快照 (Snapshot At The Beginning,SATB)
当删除灰色对象指向白色对象关系时(E->F),需要记录这次删除 之前的关系快照,记录 删除 E对象指向 F对象前一刻的快照(也就是E->F 还是可达的)。
等并发扫描结束后,将这 些 Snapshot 灰色的对象作为 GC Root,按照删除 前一刻的快照(也就是E->F 还是可达的)重新扫描一次,即不管关系删除与否,都会按照删除前那一刻快照的对象图来进行搜索标记。
G1 采取的是这种方式。
SATB的多标问题:
写屏障记录被删除的旧引用(如A→B变为A→C时,保留A→B的旧引用)。
在标记阶段,基于初始快照(A→B)完成标记,即使引用B被删除,旧引用B仍被视为存活,避免漏标(少标),但可能多标,多标 这就是 浮动垃圾。
增量更新 与删除快照 算法的对比
- 增量更新:
通过写屏障记录新增的引用(如CMS使用),适用于新增引用较少的场景,但可能产生 很多浮动垃圾。
增量更新 会产生更多的(多标),产生更多浮动垃圾,但CMS通过并发清理机制可快速回收,对整体吞吐量影响较小 。
为啥CMS 不使用SATB呢? 若CMS 使用SATB,SATB 虽减少多标,但需维护 快照队列(两个队列),可能因老年代对象基数大导致内存占用升高,与CMS的低内存开销目标冲突
- SATB 删除快照/原始快照 :
优先保证初始快照的完整性,通过写屏障 记录 被删除的引用,适用于存活对象多、引用变更频繁的场景(如G1的老年代回收)。
删除快照 其实是两个bitmap位图, 一个旧的位图 + 一个新的位图,两个位图交替使用。
SATB 内存占用相对稳定,且通过G1 的Region分区设计,减少全局扫描压力。
由于 快照位图 需要更多的 辅助内存,G1使用更合适,而 CMS不适合。 另外,G1面向大堆内存(如8GB以上),多用点 辅助内存 ,影响不大。
Garbage First(垃圾优先)收集器
Garbage First(简称G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于Region的内存布局形式。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异。
G1 以前的收集器分代是划分新生代、老年代、持久代等。
G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
收集器能够对扮演不同角色的Region采用不同的策略去处理。
这样就避免了收集整个堆,而是按照若干个Region集进行收集,同时维护一个优先级列表,跟踪各个Region回收的“价值,优先收集价值高的Region。
G1收集器的运行过程大致可划分为以下四个步骤:
- 初始标记(initial mark),标记了从GC Root开始直接关联可达的对象。STW(Stop the World)执行。
- 并发标记(concurrent marking),和用户线程并发执行,从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象、
- 最终标记(Remark),STW,标记 在并发标记过程中产生的垃圾。
- 筛选 回收(Live Data Counting And Evacuation),制定回收计划,选择多个Region 构成回收集,把回收集中Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。需要STW。
相比CMS,G1的优点有很多,可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集。
从内存的角度来看,与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
G1垃圾回收器的算法实现具有混合性特点
G1垃圾回收器的算法实现具有混合性特点,具体如下:
1:整体采用标记-整理算法
G1在全局视角下通过标记-整理算法实现内存回收,目的是减少内存碎片并保证堆的整体连续性。这种方式有助于在长时间运行中维持较高的内存利用率 。
2:局部采用标记-复制算法
在具体的Region回收过程中,G1会通过标记-复制算法将存活对象复制到新的Region中,实现局部内存的快速回收。这种策略降低了单次GC停顿时间,尤其适用于新生代或高回收价值的Region 。
3:设计背景与优势
G1基于Region的堆内存布局(划分为约2048个独立区域),允许对不同Region按需选择回收策略。这种设计既保留了标记-复制算法的高效性(局部快速回收),又通过全局整理避免了长期碎片化问题 。
总结:G1并非单一使用某一种算法,而是根据内存区域特点混合使用标记-整理(全局)和标记-复制(局部)算法,以平衡吞吐量、延迟和内存利用率 。
G1最终标记(Final Marking)阶段工作机制
初始标记、并发标记比较简单, 尼恩给大家 重点介绍一下 最难的阶段, G1 最终标记(Final Marking)阶段.
最终标记(Final Marking) 是 G1垃圾回收的关键STW阶段。
最终标记(Final Marking) 位于并发标记(Concurrent Marking)之后、筛选回收(Evacuation)之前,负责修正并发标记期间因用户线程并发修改对象引用导致的标记遗漏。
最终标记(Final Marking) 核心目标是 修正并发标记阶段因用户线程并发修改引用导致的标记偏差,确保标记结果准确反映堆中所有存活对象。
最终标记(Final Marking) 需要STW,为避免标记结果被用户线程干扰,Final Marking 需要暂停所有用户线程,在一个 一致性的内存视图 下完成最终修正。
最终标记(Final Marking) 阶段背景
并发标记阶段与用户线程同时运行,期间用户线程可能修改对象引用(如新增、删除跨代引用),导致部分引用关系未被及时纳入标记流程。
最终标记(Final Marking) 核心工作步骤
1:通过 脏页扫描 ,实现 增量 变更的 处理(Dirty Card Scanning & Incremental Reference Scanning)
2:精准化 更新记忆集(RSet Update)
3: 完成 SATB 位图收尾处理 (SATB Bitmap Finalization)
4:标记终止与元数据更新
接下来,尼恩给大家详细介绍一下。
1:通过 脏页扫描 ,实现 增量 变更的 处理(Dirty Card Scanning & Incremental Reference Scanning)
这个步骤,目标是 找出并发标记阶段遗漏的 跨代引用(如老年代对象对新生代对象的引用)和 本地引用变化(如同一 Region 内的引用修改)。
卡表是粗粒度数据结构,将堆内存划分为 512 字节的 “卡页”。上一个阶段(并发标记阶段),当卡页内的对象引用发生变化(如老年代→新生代引用修改),对应的卡页会被标记为 “脏页”(通过写屏障实现) 。 并发阶段,所有对象引用的修改都会被写屏障捕获,并记录到卡表的脏页中,确保最终标记阶段能精准定位需要处理的区域。
脏页扫描与卡表增量处理(Dirty Card Scanning) 具体流程:
- 定位脏页:从并发标记阶段记录的最后一个脏页开始,遍历所有标记为 “脏” 的卡页,快速定位到发生引用变化的内存区域(避免扫描全堆)。
- 扫描脏页内容:对每个脏页对应的内存区域进行细粒度扫描,提取其中的 跨代引用(如老年代对象对新生代对象的引用)和 跨 Region 引用,这些引用可能未被并发标记阶段处理, 在最终标记阶段,需要进行 三色标记处理。
这里插入一个 题外话,尼恩给大家来看看,G1卡表与RSet如何协同:
G1同时使用卡表辅助跨代引用跟踪(如老年代→新生代),而RSet用于处理Region间任意方向引用。通过写屏障 更新两种结构,兼顾效率与精度。
G1 写屏障的 在并发阶段,进行 引用变更 的捕获与更新:
- Points-out模式下,写屏障标记卡页为脏卡 。
- Points-in模式下,写屏障将外部Region ID加入目标Region的RSet , 这里是用过 DCQ进行的异步操作。
卡表是 points-out , RSet Update 是 Points-in。 但是 RSet Update 是异步更新的,可能有延迟。
2. 精准化 更新记忆集(RSet Update)
RSet 的作用:每个 Region 的 RSet 记录其他 Region 对该 Region 内对象的引用。回收阶段,如果一个Rset里边的对象回收了,只需扫描 RSet 中的引用,而非全堆,将时间复杂度降至 O (M)(M 为 RSet 记录的引用数量),大幅减少扫描范围。
没有 RSet 的问题:若不记录跨 Region 引用,每次回收某个 Region 时,需扫描全堆,查找所有指向该 Region 对象的引用,时间复杂度为 O (N),堆越大效率越低。
并发标记阶段,为了高并发,是通过写屏障 ,异步 更新 RSet Update 结构 ,这里有延迟,所以 最终标记阶段需根据脏页扫描结果,更新 RSet 以反映最新的引用关系。
更新(RSet Update) 的逻辑为:
-
RSet 新增引用 的补充:
若脏页中存在老年代对象对新生代对象的新增引用,将这些引用添加到新生代目标 Region 的 RSet 中,确保这些引用在后续标记中被识别为存活对象的依赖。
-
RSet 失效引用的清理:
若脏页中存在已失效的跨代引用(如原引用对象已被回收),从 RSet 中移除这些无效记录,避免后续回收时误判对象存活状态。
通过 RSet 的更新,确保每个 Region 的 RSet 始终准确记录外部引用,为标记和回收提供精确数据。
3: 完成 SATB 位图收尾处理 (SATB Bitmap Finalization)
SATB 的作用:SATB 在并发标记阶段通过前后两个位图(previous bitmap 和 next bitmap)记录初始快照和引用变化。最终标记阶段需要将 next bitmap 中记录的所有 旧引用(并发阶段被修改前的引用)纳入标记流程。
具体操作:
- 遍历 next bitmap 中记录的所有旧引用,对这些引用指向的对象进行 重新标记,确保即使对象引用在并发阶段被删除,其旧引用仍被正确处理(避免漏标存活对象)。
- 清空 next bitmap,为下一次垃圾回收周期做准备。
4. 标记终止与元数据更新
- 标记完成确认:所有脏页扫描完成后,确认堆中所有可达对象已被正确标记(标记为 “存活”),未被标记的对象将被视为垃圾。
- 更新元数据:记录各 Region 中存活对象的数量、占用内存大小等信息,为后续筛选回收阶段的 Region 优先级排序提供依据。
最终标记(Final Marking)总结
G1 的最终标记阶段是 并发标记的 “收尾修正阶段”,通过卡表的脏页定位、记忆集的精准引用更新、SATB 的旧引用补标,在 STW 下快速修正并发标记的偏差,为后续高效回收奠定基础。
这一阶段平衡了 标记准确性 和 性能开销,是 G1 实现低停顿垃圾回收的关键环节之一。
G1三大 组件RSet、SATB 、卡表 的核心作用
三大 组件 | 核心组件作用 |
---|---|
卡表 | 核心作用:最终标记阶段 提升性能,通过脏页列表,最终标记只需要 定位需要扫描的脏页的内存区域,避免全堆扫描,降低开销。 |
记忆集(RSet) | 核心作用:回收阶段 提升性能,精准更新跨 Region 引用关系,确保标记时能正确识别外部引用。RSet的价值在于使得 垃圾回收时,不需要扫描整个堆,能够快速定位到真正引用它的堆对象地址,进行 对象地址的修改。 |
SATB | 核心作用:解决 并发阶段记录的 导致的漏标,解决最大的bug。 |
G1 的最终标记 总结
G1的最终标记阶段通过SATB快照技术与写屏障协作,在STW暂停期间修正并发标记期间遗漏的引用变更,确保标记结果的正确性,同时通过容忍浮动垃圾降低实时追踪的开销。
其设计在吞吐量与延迟之间实现平衡,适用于大内存、低延迟的应用场景。
G1对比CMS 的最终标记 几个大的差异
特性 | **G1最终标记(Final Marking)** | **CMS重新标记(Remark)** |
---|---|---|
技术基础 | 基于SATB快照,处理引用删除的变更 | 基于增量更新(Incremental Update),处理引用新增的变更 |
浮动垃圾处理 | 允许保留并发阶段的浮动垃圾 | 同样允许保留浮动垃圾,但增量更新可能导致更多浮动垃圾残留 |
标记范围 | 依赖卡表(Card Table) 筛选脏页, 依赖 SATB队列 进行 引用删除的快照扫描 | 依赖卡表(Card Table)+ MOD Union Table 筛选脏页解决 跨代漏标, 依据 增量更新队列,进行新增对象的扫描修正 |
STW时间优化 | 通过SATB队列缩小扫描范围,降低暂停时间 | 依赖卡表(Card Table)+ MOD Union Table 筛选脏页,但需全量处理增量更新队列 |
面试小问题:G1垃圾回收器 YGC 场景 如何避免全堆扫描?
由于G1本身是有分代的,在某种特殊情况下,有一个老年代的对象引用了新生代的对象。
那么此时如果要触发G1的YGC,怎么才能找到这个老年代的对象呢?
如果找不到这个老年代的对象,就没办法找到它引用了哪些新生代对象。
由于YGC针对的是整个新生代的空间,也就是会选择所有新生代Region,拿到GC Roots,然后遍历整个新生代。
所以如果找不到老年代对新生代的引用关系,垃圾回收时就可能误操作。
要么多清理要么少清理,不管是多清理或者少清理,其实都比较麻烦。
-
如果多清理了,系统就会直接报错。
-
如果少清理了,垃圾对象占用新生代,可能会更加频繁GC。
所以这个跨代引用关系是必须知道的。
应该怎么获取这个跨代引用关系呢?
想要获取这些引用关系,那么就要找到哪些老年代的对象引用了新生代的对象,也就是要找到老年代里引用了新生代对象的那些对象。
最简单的方式就是直接把老年代也遍历一遍来看看引用关系。
但是此时做的是新生代的回收,却要把老年代也遍历一遍,就不合适了。不仅标记时间长,且遍历老年代,从分代隔离回收的思路来看也不合适。那么应该怎么记录跨代的引用关系?
记忆集会通过记录跨代的引用关系,来避免遍历整个分代如老年代
举个例子:在G1中,如果老年代的对象引用了新生代的对象,那么直接针对被引用对象开辟一块内存,用来存储到底是谁引用了该对象。
当准备遍历新生代所有对象时,直接把这块内存里的老年代对象,也加入到GC Roots中,然后进行遍历。这样就能避免遍历整个老年代,而且从效率和分代隔离角度都非常合理。
面试小问题:并发标记场景下,跨代引用面临的哪些问题?
首先,产生跨代引用场景是发生YongGC的过程。此时新生代的对象会开始寻找根,看自己是否属于根可达对象,从而判断自己是否是垃圾。
那很多同学就开始有疑惑了?
不是判断对象是否存活,应该是从GC Roots开始寻找,使用复杂的三色标记算法后,将判定不存活的对象删除掉么?
但我们知道,并不是所有老年代的对象都会引用着新生代的对象。
那么相对频繁的YongGC,每次都从根节点遍历一次,效率就会被严重影响。
因此,就引入了卡表和记忆集的概念。卡表将整个老年代分成了多个层级,card[0],card[1],card[2]…。
如果某个card区域中的老年代对象引用着新生代的对象,那么就被叫做脏卡。
当YongGC发生时,某个新生代的对象发现其GCRoots发现在老年代,并进行跨代寻找的时候,只需要在对这些脏卡中的GCRoots,使用可达性分析算法,判断是否存活即可。加速了垃圾回收的速度与回收成本。
那么具体是如何执行的呢?我们接着看。
GCRoots在新生代
假设现在整个堆空间只有两个对象。
此时两个对象都在新生代。此时GC线程是非常容易判断这两个对象被GCRoots引用,属于存活对象。
随着程序的长时间运行。此时出现了以下情况:
GCRoots移动到老年代
可以看到,老年代的对象HumongN被GCRoots所引用,此时HumongN->S就是跨代引用。
S又引用着E,因此这三个对象都不是垃圾。
我们说在新生代中,由E找到S是非常快速简单的。
然而由S找到HumongN就需要遍历整个老年代的对象,这个过程是相当耗时的。
所以要避免每次 YGC 时扫描整个老年代,减少开销。
G1 解决跨代引用流程
在判断新生代对象是否根可达时,一部分对象是朝生夕死的对象,而另一部分可能是有由相当老年代的对象引用而来的。
而一般老年代的引用关系都相当复杂,为了节约扫描时间成本,我们在每个新生代对象中存入一个RSet记录卡表。在检索新生代引用即将跨代时,会根据卡表的Key,Value快速定位到正确的老年代引用,以达到减少开销的目的。
CardTable
在CMS +ParNew的组合中, 由于ParNew 新生代GC时,需要扫描整个old区,效率非常低。所以old区就是用 卡表的方式, 进行一次逻辑分区。
在 G1中,最终标记阶段,需要扫描整个堆,效率也非常低。 所以全堆就是用 卡表的方式, 进行一次逻辑分区。 需要对并发标记的 脏卡 进行标记,进行重新扫描,
一般一页卡表的大小是2的n次幂。 每一个区域也是用Key->Value结构进行记录。
每一区域记录为Key不重复,Value则记录这片区域的老年代对象与新生代对象是否存在引用关系,存在则标记为1,否则为0。
RSet(记忆集)
记录了其它Region中的对象到Region的引用。
RSet的价值在于使得 垃圾回收阶段,不需要扫描整个堆,能够快速定位到真正引用它的堆对象地址。
ReSet本身就是一个Hash表,存储在新生代的每个Region中。
但是ReSet 存储需要消耗空间,多的能达到百分之20,而卡表 则 比较少, 一个512个字节的page,只需要一个字节, 千分之二。所以 RSet比卡表 重量级多了。
因此G1对内存的空间要求较高(小空间没资本玩),空间越大性能越彪悍。
在回收某个 Region 时,通过 RSet 找到所有指向该 Region 中对象的外部引用:
-
若这些对象被标记为存活,则需要将引用更新到新的内存位置(如对象移动到其他 Region)。
-
若对象被标记为垃圾,则直接清除引用,避免悬空指针。
G1卡表与RSet的协同
G1同时使用卡表 辅助跨代引用跟踪(如老年代→新生代),而RSet用于处理Region间任意方向引用。
通过写屏障异步更新两种结构,兼顾效率与精度。
写屏障的作用
两种模式均依赖写屏障捕获引用变更:
- Points-out模式下,写屏障标记卡页为脏卡 。
- Points-in模式下,写屏障将外部Region ID加入目标Region的RSet , 这里是用过 DCQ进行的异步操作。
并发标记阶段, G1卡表 记录完毕后,在被引用的 Region的 ReSet中,进行 ReSet 记录的异步维护: 把value为1的key作为ReSet的key进行记录,并且ReSet的value存储 被引用的page,从而提高跨代引用的查询效率。
注意,这里RSET的 更新是异步的。
G1就设计了一个队列,叫做DCQ(Dirty Card Queue)队列,在每次有引用关系变更的时候,就把这个变更操作,发送一个消息放到DCQ里面,然后有一个专门的线程去异步消费。
在G1中,有一个refine线程的概念,refine线程,其主要的工作内容,就是去消费DCQ里面的消息,然后去更新RSet。
所以说: CardTable 的更新是同步的, RSET 的更新是异步的。
如果 Rset的更新失败,可以把CardTable 卡表作为后备机制,在RSet维护异常时提供冗余保障 。
详解:G1 写屏障 的 作用
G1垃圾回收器中的写屏障(Write Barrier)是维持内存一致性和并发标记准确性的核心机制,其作用主要体现在以下几个方面:
1: 维护跨代引用记录的完整性
写屏障在对象引用关系发生修改时,触发对记忆集(RSet)的更新。例如,当老年代对象引用新生代对象时,写屏障会记录该跨代引用到对应区域的RSet中,避免全堆扫描以提升GC效率 。
通过卡表(CardTable)标记引用修改的内存区域(卡页),写屏障将修改的卡页标记为“脏卡”,后续由异步线程(如DCQ/Dirty Card Queue)处理这些脏卡,将其关联的引用更新到RSet 。
2: 支持并发标记的正确性
在并发标记阶段(如三色标记算法),程序线程与GC线程同时运行,可能导致已标记对象(黑色对象)新增对未标记对象(白色对象)的引用。写屏障通过捕获此类引用变更,确保白色对象不会被错误回收。
写屏障还会处理灰色对象断开对白色对象引用的情况,避免因引用关系变化导致标记遗漏。
3: 触发卡表更新与异步处理
写屏障将引用变更操作封装为“脏卡”事件,加入DCQ队列,由独立线程(如DCQS/Dirty Card Queue Set)异步更新RSet。这种机制减少GC停顿时间,同时保证内存状态的一致性。
4: 减少全局停顿时间
通过异步处理引用变更,写屏障将原本需要在STW(Stop-The-World)阶段完成的工作分散到并发阶段,从而降低单次GC暂停的时长。
G1 写屏障 的 作用 总结
写屏障在G1中既是内存保护机制,也是并发标记和跨代引用管理的关键组件。它通过实时捕获引用变更、维护卡表与记忆集的准确性,以及支持异步处理,最终实现低延迟和高吞吐的垃圾回收目标。
详解:G1的STAB删除快照
G1写屏障与SATB(Snapshot-At-The-Beginning)是解决并发标记阶段漏标问题的核心技术组合,两者的协作关系如下:
1. SATB的原理与目标
SATB算法要求在并发标记开始时,将对象引用关系建立逻辑快照,后续标记过程基于该快照进行,即使程序线程修改了对象引用关系,仍保证标记结果的准确性。
其核心目标是避免因并发标记期间引用变化导致的对象漏标(即存活对象被误回收)。
2:SATB - Snapshot At The Beginning 初始快照
- 由Taiichi Yuasa开发的一个算法 - 首先它是一种思想
- 主要用于GC的并发标记阶段
- 记录并发标记阶段 mutator(用户线程)修改的引用记录,在 final mark阶段(STW)无需全量重新扫描标记
- 快照是以BitMap(位图)的方式实现,BitMap存放对象存活标记,1 为黑色 即存活
见上图 一个 Region,总共包含了5个指针:
-
Bottom - 总是指向Region的起始位置
-
Previous TAMS - 指向上一次并发处理后的地址
-
Next TAMS - 指向并发标记开始之前,内存已经分配成功的地址
-
top - 指向当前内存分配成功的地址
-
End - 总是指向Region的终点位置
TAMS : top-at-mark-start , 表示 对象在Region中是连续分配的
可以知道:
- [Bottom,End] 区间就是Region内存空间大小
- [Bottom,Prev] 区间就是上次并发标记结束后,已经标记过的对象内存
- [Prev TAMS,Next TAMS] 区间就是本次并发标记标记到的对象内存
- [Next,Top] 区间就是并发标记过程中Mutator新增的对象分配的内存地址
- [Top,End] 区间就是Region还未分配的空闲内存
由上可知,Prev和Next指针解决了并发标记中内存区域问题,并发标记引入了两个 数据结构 来记录内存标记状态,
-
PrevBitMap - 记录Prev指针之前内存标记情况、即【Bottom,Prev】区间
-
NextBitMap - 记录Next指针之前内存标记情况、即【Bottom,Next】区间
每次 GC 周期结束后, previous bitmap 和 next bitmap 会 交换角色 :
- 原
next bitmap
成为下一次 GC 周期的previous bitmap
(记录新的初始快照)。 - 原
previous bitmap
被清空,作为新的next bitmap
用于记录下一次并发阶段的引用变化。
这种设计实现了位图的复用,避免频繁创建和销毁数据结构,降低内存开销。
3: previous bitmap 和 next bitmap 协作流程示例
**1 初始标记(STW阶段)**:标记GC Roots直接关联的对象,建立初始快照。
**2 并发标记阶段:**用户线程与GC线程并行运行。
若用户线程修改对象引用(如断开灰色对象对白色对象的引用 ),写屏障会记录新引用到next bitmap
,然后沿着旧的引用previous bitmap
继续扫描。
**3 最终标记(STW阶段)**:处理next bitmap
中写屏障记录的变更,补全标记结果 , 确保原引用链中的白色对象仍被标记为正确,避免漏标。
第一次并发标记开始前 :
PrevBitMap 为空,NextBitMap 待标记
第一次标记结束后:
NextBitMap 标记了分区对象存活情况
NextBitMap位图中黑色区域表示对应对象存活
并发标记过程中Mutator继续运行,产生新的对象,Top指针继续增长
第二次并发标记开始前:
重置指针,Prev指针指向Next指针位置
交换位图,PrevBitMap获取NextBitMap记录,NextBitMap 之前是空的
第二次并发标记结束后:
Next指针指向标记前已分配内存顶部,即Top指针位置,即完成上次标记时新增对象【Next,Top】区间的扫描标记
NextBitMap记录所有已扫描对象内存标记状态
Top指针持续增长
最终标记-Remark开始之前:
同上
最终标记-Remark结束之后:
Remark阶段STW,Mutator暂停执行,Top不会继续增长
Prev指向Next
Next 和Top都指向了已分配对象顶部
NextBitMap 记录所有对象标记情况
上面步骤可以看出:
- 每次并发标记后,将本次标记结果【Bottom,Prev】区间做了一次Snapshot快照,以BitMap位图存储,所有垃圾对象通过快照被识别出来
- 并发标记中Mutator新增的对象都认为是存活对象,设置为灰色
- SATB关注的是引用的删除(比如,将
o1.filed = new O2()
修改为o1.field = null
这种),会将O2对象置为灰色,加入操作栈,重新进行扫描,解决漏标问题 - 在并发标记开始时,G1 会对整个堆的对象引用关系进行一次快照,将其存储为 BitMap 位图。这个快照代表了堆在标记开始时刻的状态。当 Mutator(应用程序线程)进行引用删除操作时,写屏障会拦截这个操作。例如,当执行
o1.field = null
,写屏障会检测到o1.field
这个引用从指向一个对象(如O2
)变为null
。将旧引用 B 记录到next bitmap
。GC 线程基于previous bitmap
继续标记,不受 A→B 删除的影响(B 仍被视为存活,直到确认无其他引用)。
4、两者的协作机制(以引用删除为例)
1 初始标记阶段 :
previous bitmap
记录初始引用 A→B(B 被标记为灰色 / 存活)。
2 并发阶段 :
用户线程执行 A→B 删除(A→∅),预写屏障拦截操作,将旧引用 B 记录到 next bitmap
。
GC 线程基于previous bitmap
继续标记,不受 A→B 删除的影响(B 仍被视为存活,直到确认无其他引用)。
3 最终标记阶段 :
GC 线程扫描next bitmap
中的旧引用 B,检查是否有其他引用(如 C→B)。若存在,则 B 仍存活;若不存在,则 B 可被回收。
处理完成后,next bitmap
清空,两者交换角色,为下一次 GC 做准备。
5、核心价值与设计意义
位图 | 核心作用 | 关键场景 |
---|---|---|
previous bitmap | 冻结初始对象图,作为并发标记的基准,确保标记逻辑基于 “标记开始时的状态”。 | 避免并发删除引用导致的漏标(如旧引用指向的存活对象未被扫描)。 |
next bitmap | 记录并发阶段的旧引用,补全初始快照未覆盖的动态变化,确保标记完整性。 | 处理引用更新 / 删除时的旧引用,如 A→B→C 场景中 B 的存活状态判断。 |
通过双位图机制,SATB 实现了 “初始快照基准 + 并发变化增量记录” 的标记策略,既保证了并发标记的正确性(基于稳定的初始状态),又能高效处理动态引用变化(仅记录旧引用,避免全堆重新扫描)。
这是 G1 在低停顿垃圾回收中实现 “精准标记” 的核心技术之一。
6 写屏障对SATB的支持
写屏障是实现SATB机制的具体技术手段,其作用体现在:
- 捕获旧引用状态:当用户线程修改对象引用时(如
A.field = B
变为A.field = C
),写屏障会在引用更新前记录被覆盖的旧值(即原引用A.field = B
),确保这些旧引用在后续标记中被处理。 - 维护逻辑快照:通过记录并发标记期间所有被删除的旧引用,SATB将整个标记过程限定在初始快照的引用关系范围内,即使对象被用户线程修改,GC线程仍能正确追踪初始快照中的存活对象。
G1的写屏障是SATB算法的实现载体,通过记录引用变更前的状态,确保并发标记阶段的内存操作不影响初始快照的准确性。两者协同解决了并发标记中的漏标问题,使G1在低停顿的前提下仍能保证标记的正确性 。
7、CMS 增量更新 与 G1 的 SATB 的区别
特性 | CMS 增量更新 | G1 SATB |
---|---|---|
处理场景 | 新增引用(黑→新增→白) | 删除旧引用(记录旧引用,如黑→删除→白) |
屏障类型 | 前写屏障(Pre-write,处理新增前) | 预写屏障(处理旧引用,记录修改前的引用) |
标记逻辑 | 重新标记新增的白色对象为灰色,纳入扫描 | 基于STAB 初始快照,通过旧引用补全标记 |
漏标场景 | 解决 “黑→白新增引用” 漏标 | 解决 “旧引用删除导致的漏标” |
总结:G1的三大核心组件:RSet、Card Table、SATB
G1(Garbage - First)垃圾回收器是一款面向服务端应用的垃圾回收器,RSet(Remembered Set)、SATB(Snapshot At The Beginning)和卡表(Card Table)是其三个重要组件,它们在不同阶段发挥着核心作用,共同提升了垃圾回收的性能和准确性。
1: 卡表(Card Table)
卡表的核心作用是在最终标记阶段提升性能,通过脏页列表减少全堆扫描的开销。
卡表(Card Table) 工作原理
- 内存划分:卡表将堆内存划分为固定大小的 “卡页”(Card Page),通常每个卡页大小为 512 字节。每个卡页对应卡表中的一个字节,这个字节作为该卡页的标记位。
- 标记脏页:当卡页内的对象发生修改时(例如对象的引用被更新),对应的卡表标记位会被置为 “脏”(通常用特定的值表示,如 1)。这种修改操作会触发写屏障来更新卡表。
- 最终标记优化:在最终标记阶段,垃圾回收器不需要扫描整个堆,只需要根据卡表中的脏页标记,定位到需要扫描的脏页所在的内存区域。这样可以避免对大量未修改的内存区域进行扫描,从而显著降低了标记阶段的开销。
示例说明
假设堆内存被划分为 1000 个卡页,对应的卡表有 1000 个标记位。
在并发标记过程中,只有 10 个卡页内的对象发生了修改,那么卡表中只有这 10 个卡页对应的标记位被置为 “脏”。
在最终标记阶段,垃圾回收器只需要扫描这 10 个脏页对应的内存区域,而不是整个堆的 1000 个卡页。
2:记忆集(RSet)
RSet 的核心作用是在回收阶段提升性能,精准更新跨 Region 引用关系,确保标记时能正确识别外部引用。
记忆集(RSet) 工作原理
- Region 划分:G1 将堆内存划分为多个大小相等的 Region,每个 Region 可以是 Eden 区、Survivor 区或老年代。不同 Region 之间可能存在对象引用关系。
- 记录引用:RSet 为每个 Region 维护一个数据结构,用于记录其他 Region 中的对象对该 Region 内对象的引用。当一个对象引用跨 Region 时,写屏障会更新相关 Region 的 RSet。
- 精准回收:在垃圾回收时,通过 RSet 可以快速定位到真正引用某个 Region 内对象的外部对象地址,而不需要扫描整个堆。这样可以减少不必要的扫描,提高回收效率,并且确保在标记过程中能正确识别所有存活对象。
示例说明
假设有 Region A 和 Region B,Region A 中的对象 objA
引用了 Region B 中的对象 objB
。
当这个引用关系建立时,写屏障会将 objA
的信息记录到 Region B 的 RSet 中。
当对 Region B 进行垃圾回收时,通过 Region B 的 RSet 可以快速找到 objA
,从而确定 objB
是存活对象。
3:SATB(Snapshot At The Beginning)
SATB 的核心作用是解决并发标记阶段可能导致的漏标问题。
SATB 工作原理
- 快照创建:在垃圾回收开始时,SATB 会对整个堆中的对象引用关系进行一次快照。这个快照记录了堆在标记开始时刻的状态。
- 写屏障拦截:当发生引用删除操作(例如
o1.field = null
)时,SATB 写屏障会拦截这个操作。写屏障会将被删除引用所指向的对象标记为存活(通常标记为灰色),并将其加入到 新bitmap 位图中。 - 重新扫描:垃圾回收器会在合适的时机对 新bitmap 位图 的对象进行重新扫描。从 新bitmap 位图 中取出灰色对象,检查它的所有引用,并递归地标记这些引用所指向的对象。通过这种方式,确保在并发标记过程中不会遗漏任何存活的对象,解决了漏标问题。
示例说明
假设在标记开始时,对象 o1
引用了对象 o2
,并且这个引用关系被记录在快照中。
在并发标记过程中,执行了 o1.field = null
操作,SATB 写屏障会将 o2
标记为灰色并加入标记栈。
后续垃圾回收器会重新扫描 o2
及其引用的对象,确保它们被正确标记为存活。
综上所述,卡表、RSet 和 SATB 这三个组件在 G1 垃圾回收器中分别在最终标记阶段、回收阶段和并发标记阶段发挥着重要作用,它们相互配合,共同提升了垃圾回收的性能和准确性。
ZGC 工作流程
ZGC(The Zero Garbage Collector)是JDK 11中推出的一款追求极致低延迟的垃圾收集器,它曾经设计目标包括:
- 支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。
- 最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。
- 奠定未来GC特性的基础。
- 最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。
另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!
也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。
ZGC(Zero Garbage Collector) 是 Java 平台自 JDK 11 起引入的一款低延迟、可扩展的垃圾回收器,专为大堆内存(TB级)和亚毫秒级停顿场景设计。
其核心目标是通过完全并发操作,消除传统垃圾回收器(如 G1、CMS)在处理大堆内存时的长停顿问题,适用于对延迟极度敏感的实时系统(如金融交易、在线游戏、实时数据处理等)。
ZGC的优势(毫秒级暂停,0暂停)
场景需求 | ZGC的优势 | 传统GC(如G1)对比 |
---|---|---|
低延迟要求(<10ms) | 所有回收阶段并发执行,无STW停顿(仅转移阶段极短同步) | G1的Remark阶段需STW,停顿时间随堆增大而增加 |
超大堆内存(TB级) | 染色指针减少内存占用,并发处理无堆大小限制 | G1的卡表维护导致内存和CPU开销剧增 |
实时性敏感业务 | 支持亚毫秒级响应(如高频交易、游戏服务器) | 传统GC的长STW可能导致业务超时或中断 |
长期运行稳定性 | 无内存碎片风险,避免Full GC触发 | CMS可能因碎片触发Full GC,导致分钟级停顿 |
ZGC 工作流程的 三大阶段
ZGC 的步骤大致可分为三大阶段分别是标记、转移、重定位。
- 标记:从根开始标记所有存活对象
- 转移:选择部分活跃对象,转移到新的内存空间上
- 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上。
并且这三个阶段都是并发的。
这是理论上的三个阶段划分 ,具体的实现上重定位其实是糅合在标记阶段的。
在标记的时候,如果发现引用的还是老的地址,则会修正成新的地址,然后再进行标记。
简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。
在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。
所以说重定位是糅合在下一步的标记阶段中。
ZGC标记阶段:初始标记
这个阶段其实大家应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中。
当然还有其他动作,比如重置 TLAB、判断是否要清除软引用等等,不做具体分析。
标记前:
标记后:
ZGC标记阶段:并发标记
就是根据初始标记的对象开始, 并发遍历对象图,还会统计每个 region 的存活对象的数量。
这个并发标记其实有个细节,标记栈其实只有一个,但是并发标记的线程有多个。
为了减少之间的竞争每个线程其实会分到不同的标记带来执行。
你就理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的segment 一样。
那肯定有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。
看到这有没有想到啥?
没错就是 ForkJoinPool 的工作窃取机制!
ZGC标记阶段:再标记阶段
这一阶段是 STW 的,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况。
因此需要个再标记阶段来标记漏标的那些对象。
如果这个阶段执行的时间过长,就会再次进入到并发标记阶段,因为 ZGC 的目标就是低延迟,所以一有高延迟的苗头就得扼制。
这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。
有些非强根可以并发,有些不行,具体不做分析。
ZGC转移阶段:初始转移
这个阶段其实就是从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间。
如果不在转移分区集合中,则将对象标记为 Remapped。
注意这个阶段是 STW,只转移根直接可达的对象。
ZGC转移阶段:并发转移
这个阶段和并发标记阶段就很类似了,从上一步转移的对象开始遍历,做并发转移。
这一步很关键。
G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多。
至此十个步骤就完毕了,一次 GC 结束。
主要工作如下:
1 对象迁移与地址映射
根据转移表(Forwarding Table),将存活对象从旧Region复制到预分配的新Region,并更新对象引用关系;
染色指针的Marked0/Marked1状态标识对象迁移进度,确保并发标记与转移阶段的协同47。
2 读屏障驱动的并发协作
应用线程访问对象时,读屏障检测指针的Remapped标记,若对象未迁移则触发以下操作:
- 查询转移表获取新地址;
- 协助完成对象复制并更新引用;
该机制将部分转移负载分摊到应用线程,降低GC线程压力。
3 分区状态同步
迁移完成后,旧Region被标记为“可回收”,加入空闲Region池供后续分配;
调用系统接口(如madvise
)释放长期未使用的物理内存,降低资源占用。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
ZGC重定位阶段
(1) 标记阶段
重定位阶段:上次GC周期完成对象转移后,需要再下次GC周期访问到引用链上对于以转移对象的指针进行更正,这个机制称为指针自愈
上图对于B对象标记可以走2条引用链
- 上面引用链,D -> A ->B
- 下面引用链,C->B
如果走其中一条引用链,标记B,则染色指针M0置位,重点强调下B被标记后,B的普通对象指针(oop)的oopDesc染色指针置位M0
其他指针遍历到后进行指针颜色自愈工作
(2) 转移阶段
转移阶段工作:
- 转移对象B到新地址
- 记录新地址和老地址到转移表
- B对象的oop.oopDesc从M0变成remaped
但是引用链上的指针引用还没有更正,这个工作发生在下次GC周期
(3) 重定位阶段
在下次GC周期,包括程序常规运行,下次GC标记,转移阶段,总之通过旧指针引用访问,已转移对象B时
触发ZGC读屏障:
- 判断指针引用地址视图M0 != B.oop.oopDesc地址视图Remaped,说明B被转移
- 进入慢路径,更正指针,从转发表拿到地址进行更正,同时M0置位成remaped
什么是oop(普通对象指针)?
在Java虚拟机(JVM)中,**oop(Ordinary Object Pointer)** 是对象在内存中的内部表示形式。
你可以把它理解为一个对象的“物理身份证”,它直接对应到内存中的一块数据区域。
下面用通俗的语言和结构图来解析一个Java对象在内存中的布局。
一个Java对象在内存中的oop的整体结构 分为三部分:**对象头(Header) + 实例数据(Instance Data) + 对齐填充(Padding)**。
用伪代码表示如下:
class oop {
Header header; // 对象头(存储元数据)
InstanceData data; // 实例数据(对象的字段值)
byte padding[]; // 对齐填充(可选,用于内存对齐)
};
ZGC 在视图切换过程中,通过修正对象引用,确保所有对象引用指向最新的视图。
这一过程利用了 oop (普通对象指针) 和 oopDesc 的信息,避免了重复标记和引用错误。
在JVM中,oop (普通对象指针) 有一个oopDesc信息(也就是对象头),oopDesc在oop的头部,所以可以通过oop获取oopDesc的地址,这个是一个 和对象 绑定了的地址, 通过oopDesc 地址视图判断Obj3处于哪个视图中。
oop (普通对象指针) 与 oopDesc信息(也就是对象头) 之间的关系, 这就好比一本书的封面,封面包含了书籍的关键信息,而对象头包含了对象的关键元数据。
因为 oopDesc 在 oop 头部,只要获取了 oop,就能轻松得到 oopDesc 的地址,这个地址与对象紧密绑定,在对象的生命周期内保持稳定。
相关伪代码如下:
inline uintptr_t ZOop::to_address(oop o) {
return cast_from_oop<uintptr_t>(o);
}
template inline T cast_from_oop(oop o) {
return (T)((oopDesc*)o);
}
oopDesc:在 JVM 中,每个对象都有一个 oopDesc 信息,它就像是对象的“身份证”,包含了对象的基本信息,例如对象头等。
获取 oopDesc:通过对象的引用,获取该对象的 oopDesc 信息。oopDesc 位于对象的头部,可以通过对象的引用获取。
这一步就像是通过一个指向对象的指针,找到对象的“身份证”。
在 oopDesc 中,有一个地址视图的信息。这个地址视图会标识该对象当前处于哪一个视图空间(M0、M1 或 Remapped)。
根据 oopDesc 中的地址视图信息,就可以判断出该对象当前处于哪一个视图空间中。
例如:
- 如果地址视图为 M0,则说明该对象在当前垃圾回收周期中被标记为活跃对象;
- 如果地址视图为 Remapped,则说明该对象可能在上一次垃圾回收中被转移过,或者在本次垃圾回收中未被标记为活跃。
什么是读屏障(Load Barrier)
在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。
- 写屏障 是 在 修改 对象引用 时 的 AOP
- 读屏障 是 在 读取 对象引用 时的 AOP。
比如 Object a = obj.foo;
这个过程就会触发读屏障。
也正是用了读屏障,ZGC 可以并发转移对象。而 G1 用的是写屏障,所以转移对象时候只能 STW。
简单的说就是 GC 线程转移对象之后,应用线程 读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移。
如果是的话修正对象的引用,按照上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。
当程序尝试读取一个对象时,读屏障会触发以下操作:
- 检查指针染色: 读屏障首先检查指向对象的指针的颜色信息。
- 处理移动的对象: 如果指针表示对象已经被移动(例如,在垃圾回收过程中),读屏障将确保返回对象的新位置。
- **确保一致性: **通过这种方式,ZGC 能够在并发移动对象时保持内存访问的一致性,从而减少对应用程序停顿的需要。
// 伪代码示例,展示读屏障的概念性实现
Object* read_barrier(Object* ref) {
//如果对象已经被移动,返回新地址
if (is_forwarded(ref)) {
return get_forwarded_address(ref); // 获取对象的新地址
}
return ref; // 对象未移动,返回原始引用
}
读屏障可能被GC线程和业务线程触发,并且只会在访问堆内对象时触发,访问的对象位于GC Roots时不会触发,这也是扫描GC Roots时需要STW的原因。
何谓转移表/转换表(ForwardingTable)?
转移表ForwardingTable
是ZGC确保转移对象后,其他引用指针能够指向最新地址的一种技术,每个页面/分区中都会存在,其实就是该区中所有存活对象的转移记录,一条线程通过引用来读取对象时,发现对象被转移后就会去转移表中查询最新的地址。
同时转移表中的数据会在发生第二次GC时清空重置,也包括会在第二次GC时触发重映射/重定位操作。
通过读屏障+ ForwardingTable, ZGC的指针拥有“自愈”的能力。
GC发生后,堆中一部分存活对象被转移,当应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移,如果读取的对象已经被转移,那么则修正当前对象引用为最新地址(去ForwardingTable 转移表中查)。
这样做的好处在于:下次其他线程再读取该转移对象时,可以正常访问读取到最新值。 当然,这种情况在有些地方也被称为:ZGC的指针拥有“自愈”的能力。
分代ZGC 工作流程
上面是 不分代ZGC, 不推荐生产使用 , 分代ZGC 更加强悍,推荐生产使用 。
分代ZGC 的原理, 请 参见 尼恩五大 GC 学习圣经 , 具体如下:
第一大 gc 学习圣经:cms
第二大 gc 学习圣经: G1
第3、4 大 gc 学习圣经: ZGC
《分代 ZGC 圣经:分代ZGC 底层原理和 大厂实战案例学习》
各种GC的 多维度对比总结
| Serial GC | Parallel GC | CMS | G1 | ZGC(不分代) | 分代ZGC |
---|---|---|---|---|---|---|
目标 | 单核 | 高吞吐量 | 低延迟 | 平衡延迟和吞吐量 | 极低延迟 | 极低延迟 |
分代 | 分代 | 分代 | 仅老年代 | 逻辑分代,物理分区 | 不分代 | 分代 |
算法 | 标记-复制(年轻代) 标记-整理(老年代) | 标记-复制(年轻代) 标记-整理(老年代) | 标记-清除 写屏障 | 标记-复制(年轻代) 标记-整理(老年代) 写屏障 | 并发标记-整理 染色指针 读屏障 | 标记-整理 染色指针 、 读屏障、写屏障 |
暂停 | 全程STW | 全程STW | 初始标记 重新标记 | YoungGC全程 MixedGC初始标记 MixedGC最终标记 MixedGC对象转移 | 初始标记 重新标记 | 初始标记 重新标记 |
并发 | 无 | 无 | 并发标记 并发清理 | MixedGC并发标记 | 并发标记 并发回收 | 并发标记 并发回收 |
GC 标记 | 对象头Mark Word | 对象头Mark Word | 对象头Mark Word 卡表 | 对象头Mark Word 位图、卡表、记忆集 | 染色指针 | 染色指针 记忆集 |
停顿 | 很高 | 高 | 中等 | 可预测 | 亚毫秒级(<10ms) | 亚毫秒级(年轻代<1ms) |
碎片 | 无 | 无 | 严重 | 较少(局部整理) | 无 | 无 |
内存 | <100M | 1G - 4G | <4GB | 4GB~32GB | 8MB~16TB | 8MB~16TB |
版本 | JDK1.3 发布 | JDK 1.4 发布 | JDK1.5~JDK8 JDK17 - 移除 | JDK1.7 发布 JDK9 默认 | JDK11 发布 JDK15 转正 | JDK21 发布 JDK23 默认 |
场景 | 单核低内存(嵌入设备) | 多核高吞吐(离线计算) | 旧系统低延迟需求 | 通用服务端 | 超大堆、低延迟 | 延迟敏感且需高吞吐 |
缺点 | 超高停顿 | 高停顿 | 内存碎片、并发失败风险、已淘汰 | 内存占用高 | 吞吐量略低于G1 | 实验性阶段,未来的GC |
GC性能对比
STW停顿对比
收集器 | 主要STW阶段 | 大概停顿时间 |
---|---|---|
Serial GC | 全阶段 | 秒级 |
Parallel GC | 全阶段 | 秒级 |
CMS | 初始标记、重新标记 | 1ms–100ms |
G1 | Young GC、Mixed GC | 10ms–300ms(停顿可控) |
ZGC | 初始标记、转移准备 | <1ms–10ms |
分代ZGC | 年轻代回收 | <1ms(理论) |
Serial GC,单线程执行标记-清除-压缩所有阶段,用户线程完全暂停
Parallel GC,年轻代(复制算法)与老年代(标记-压缩)均采用多线程并行回收,缩短单次停顿时间
CMS的停顿分析
- 初始标记:单线程标记GC Roots直接引用,停顿时间1-10ms
- 重新标记:多线程修正并发标记期间引用变更,停顿时间10-100ms
G1的停顿分析
- Young GC:多线程复制存活对象,停顿时间10-200ms(与Eden区大小相关)
- Mixed GC:并发标记后选择性回收高垃圾率Region,转移对象,单次停顿50-300ms(可控)
ZGC的停顿分析
- 仅初始标记与转移准备阶段需短暂STW,停顿时间<1ms至10ms(与堆大小无关)
- 全并发操作:标记、转移、重映射阶段均与用户线程并行
分代ZGC,分代设计减少全堆扫描频率,年轻代STW时间<1ms(理论值)
并发性对比
收集器 | 并发阶段支持 | STW时间特点 |
---|---|---|
Serial | 无 | 超长(单线程) |
Parallel | 无 | 长(多线程) |
CMS | 部分并发(标记、清理) | 短(初始/重新标记阶段) |
G1 | 部分并发(标记) | 可控短(Region分区优化) |
ZGC | 全阶段并发(标记/转移/重定位) | 极短(亚毫秒级目标) |
- CMS,标记和清理阶段并发执行,尽可能实现低延迟,但是随着碎片严重,可能触发串行GC
- G1,标记阶段并发执行,回收阶段多线程并行回收(需STW),优先收集垃圾比例高的区域,卡顿可控
- ZGC,标记和转移阶段能够实现全阶段并发,能够实现极低卡顿,STW时间可以控制在10ms以内
- 分代ZGC,标记和转移阶段能够实现全阶段并发,能够实现极低卡顿,YoungGC STW时间可以控制在1ms以内,分代后避免全堆标记回收,更加极致的提升卡顿时间
对于串行回收器来说,不支持并发执行意味着所有步骤是串行执行的。对于其他垃圾回收器,不支持并发执行又分成两种情况,一种是并行执行,例如转移、引用处理、弱引用处理;另一种是串行执行,如符号表、字符串表、类卸载
吞吐量对比
收集器 | 吞吐量水平 | 设计优先级 | 适用堆大小 |
---|---|---|---|
Serial GC | 极低 | 无并发,单线程简单 | <100MB |
Parallel GC | 高 | 多线程并行最大化吞吐 | 1GB–4GB |
CMS | 中低 | 低延迟优先 | 4GB–8GB |
G1 | 中等 | 吞吐与延迟平衡 | 4GB–16GB |
ZGC | 中低 | 极致低延迟优先 | >16GB |
分代ZGC | 中高 | 分代优化提升吞吐 | >32GB |
Serial GC,单线程执行全阶段STW,用户线程长时间等待,无法利用多核CPU资源
Parallel GC,多线程并行回收年轻代与老年代,最大化利用CPU资源缩短单次STW时间
CMS,影响吞吐量的原因
- 并发标记/清理阶段与用户线程竞争CPU资源,降低应用吞吐量(尤其CPU核数<4时)
- 短时STW阶段(初始标记、重新标记)对吞吐量影响较小
G1,影响吞吐量的原因
- 并发标记阶段占用部分CPU资源
- 混合回收阶段多线程并行处理高收益Region,通过Region分区减少单次STW时间,平衡吞吐量与延迟。
ZGC,影响吞吐量的两个原因
- 全阶段并发(标记、转移、重映射)占用大量CPU资源(约15%-20%),降低应用吞吐量,
- 无分代设计导致每次回收需处理全堆对象,资源消耗较高。
分代ZGC,分离年轻代与老年代,优先高频回收年轻代,减少全堆扫描频率(理论提升10%-30%吞吐量)
标记机制对比
收集器 | 核心标记算法 | 状态存储方式 | 辅助数据结构 |
---|---|---|---|
Serial GC | 标记-复制/整理 | 对象头Mark Word | 无 |
Parallel GC | 标记-复制/整理 | 对象头Mark Word | 无 |
CMS | 标记-清除 三色标记 增量更新 | 对象头Mark Word 卡表 | 卡表、 写屏障 |
G1 | 标记-移动 三色标记 SATB算法 | 对象头Mark Word 位图、记忆集、卡表 | 记忆集、卡表、SATB队列、 写屏障 |
ZGC | 染色指针 | 染色指针 | 转发表、 写屏障 |
分代ZGC | 分代染色指针 | 分代染色指针 | 转发表、记忆集、 写屏障、读屏障 |
Serial GC和Parallel GC 对象标记机制
标记方式:采用标记-复制(年轻代)与标记-整理(老年代)算法,遍历GC Roots链。
GC状态的存储:通过对象头中的标记位(Mark Word) 记录存活状态。依赖全堆暂停,不记录中间状态。
CMS 对象标记机制
标记方式:
- 通过三色标记法,在初始标记(STW)标记GC Roots直接引用对象
- 并发标记:灰色对象遍历引用链,逐步标记为黑色,允许用户线程修改引用
- 重新标记(STW):通过卡表 修正并发阶段的引用变更。
GC状态存储
- 对象头标记位:记录对象颜色(黑/灰/白)
- 卡表(Card Table):记录老年代对新生代的跨代引用,的内存页(脏页),缩小重新标记范围。
G1 对象标记机制
标记方式:
- 初始标记(STW)建立对象图快照。
- 并发标记阶段处理快照后的引用变更,通过写屏障记录变更到SATB队列。
GC状态存储:
- 位图:每个Heap Region维护独立位图,记录存活对象
- 记忆集:记录其他Region指向本Region的引用,避免全堆扫描
- 卡表:记忆集存储的是卡表中的脏页
- Mark Word:GC年龄等还是存储在Mark Word
ZGC 对象标记机制
标记方式
- 在指针元数据中存储标记状态(4种颜色),无需修改对象头
- 下次GC周期,并发标记阶段通过读屏障动态处理指针颜色变更
GC状态存储
- 元数据空间映射:利用虚拟内存多重映射技术,将染色指针中颜色信息映射到物理内存。
- 转发表(Forwarding Table):记录对象转移后的新地址,支持并发转移
对象标记机制 总结
标记算法维度:从STW全堆扫描(Serial)→ 并发三色标记(CMS)→ 分区使用记忆集(G1)→ 全堆染色指针(ZGC)
数据结构维度:卡表(CMS)→ 记忆集(G1)→ 染色指针(ZGC),逐步减少内存占用和扫描范围
对象回收机制对比
GC类型 | 并发 | 回收算法 | 关键数据结构 |
---|---|---|---|
Serial GC | 否 | 分代复制/整理算法 | 无复杂结构,依赖STW暂停全堆扫描 |
Parallel GC | 否 | 多线程版分代复制/整理算法 | 无复杂结构,依赖STW暂停全堆扫描 |
CMS | 是 | 并发标记-清除算法 | 卡表(Card Table)记录脏页 |
G1 | 否 | 标记/移动 + SATB快照 | 记忆集 + 卡表 +位图 |
ZGC | 是 | 染色指针标记 + 并发转移 | 染色指针 + 转发表 |
分代ZGC | 是 | 分代染色指针标记(年轻代/老代)+ 并发转移 | 分代染色指针 + 多级转发表 |
Serial GC & Parallel GC 对象回收
Serial GC 单线程,Parallel GC 多线程,全程STW暂停,基于分代模型:新生代使用复制算法(存活对象复制到Survivor区),老年代采用标记-整理(整理内存避免碎片),Serial GC适用于客户端低负载场景(如桌面应用);Parallel GC(吞吐量收集器)使用比如离线计算场景
CMS 并发清除机制
CMS通过标记/清除算法,并发清除减少停顿,核心机制如下:
- 清除阶段:无需暂停应用线程,直接回收未标记的垃圾对象内存。
- 不整理内存:采用标记-清除算法,产生内存碎片,碎片严重,无法晋升对象,回退到Full GC(STW,Serial Old)进行碎片整理。
G1 对象转移机制
G1采用局部标记-复制算法实现对象转移,核心机制如下:
(1) 分区管理:堆划分为等大小Region,回收时优先选择垃圾比例高的Region(Collection Set,CSet)进行转移
(2) YoungGC:全程STW,采用标记/移动算法,进行回收区域对象转移**
(3) 混合回收:在转移阶段(Evacuation)暂停应用线程(STW),将CSet中存活对象并行复制到空闲Region,更新引用
(4) 辅助结构:
- 记忆集Remembered Set(RSet):记录跨Region引用,避免全堆扫描
- SATB队列:快照标记存活对象,处理并发引用修改
ZGC 对象转移机制
ZGC通过全并发转移实现零STW停顿,关键技术如下:
(1) 染色指针:在指针中嵌入状态标记(如Remapped
),直接判断对象是否已转移,无需访问对象头。
(2) 读屏障:应用线程访问对象时自动触发,若发现未转移的旧地址,通过转发表查询新地址并更新引用。
(3) 转发表:维护旧地址到新地址的映射,支持高并发查询与原子更新。
流程:
- 标记阶段确定存活对象后,GC线程与应用线程并发复制对象到新Region。
- 读屏障实时修正引用,转移完成后回收旧Region,彻底避免内存碎片。
参考:
【1】:周志朋编著《深入理解Java虚拟机:JVM高级特性与最佳实践》
【2】:《垃圾回收算法手册 自动内存管理的艺术》
【3】:Garbage Collection in Java – What is GC and How it Works in the JVM
【5】:GC Algorithms: Implementations
遇到问题,找老架构师取经
借助此文,尼恩给解密了一个高薪的 秘诀,大家可以 放手一试。保证 屡试不爽,涨薪 100%-200%。
后面,尼恩java面试宝典回录成视频, 给大家打造一套进大厂的塔尖视频。
通过这个问题的深度回答,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,里边有大量的大厂真题、面试难题、架构难题。
很多小伙伴刷完后, 吊打面试官, 大厂横着走。
在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
另外,如果没有面试机会,可以找尼恩来改简历、做帮扶。
遇到职业难题,找老架构取经, 可以省去太多的折腾,省去太多的弯路。
尼恩指导了大量的小伙伴上岸,前段时间,刚指导一个40岁+被裁小伙伴,拿到了一个年薪100W的offer。
狠狠卷,实现 “offer自由” 很容易的, 前段时间一个武汉的跟着尼恩卷了2年的小伙伴, 在极度严寒/痛苦被裁的环境下, offer拿到手软, 实现真正的 “offer自由” 。