📕我是廖志伟,一名Java开发工程师、《Java项目实战——深入理解大型互联网企业通用技术》(基础篇)、(进阶篇)、(架构篇)清华大学出版社签约作家、Java领域优质创作者、优快云博客专家、阿里云专家博主、51CTO专家博主、产品软文专业写手、技术文章评审老师、技术类问卷调查设计师、幕后大佬社区创始人、开源项目贡献者。
📘拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、SpringMVC、SpringCloud、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RocketMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD、Kubernetes容器编排等。不定期分享高并发、高可用、高性能、微服务、分布式、海量数据、性能调优、云原生、项目管理、产品思维、技术选型、架构设计、求职面试、副业思维、个人成长等内容。
🌾阅读前,快速浏览目录和章节概览可帮助了解文章结构、内容和作者的重点。了解自己希望从中获得什么样的知识或经验是非常重要的。建议在阅读时做笔记、思考问题、自我提问,以加深理解和吸收知识。阅读结束后,反思和总结所学内容,并尝试应用到现实中,有助于深化理解和应用知识。与朋友或同事分享所读内容,讨论细节并获得反馈,也有助于加深对知识的理解和吸收。💡在这个美好的时刻,笔者不再啰嗦废话,现在毫不拖延地进入文章所要讨论的主题。接下来,我将为大家呈现正文内容。
文章目录
- 🍊 垃圾回收器
- 🎉 Serial
- 🎉 ParNew
- 🎉 Parallel scavenge
- 🎉 Serial Old
- 🎉 Parallel old
- 🎉 CMS 流程
- 🎉 G1 分区机制
- 🎉 ZGC低延迟原理
- 一、基本概述
- 二、基本关键技术知识总结
- (一)三色标记法(着色指针)
- 1\. 三色标记法概述
- 2\. 三种颜色的含义
- 3\. 工作流程
- (二)读屏障(Read Barrier)
- 1. 什么是读屏障?
- 2. 为什么需要读屏障?
- 3. 读屏障的工作原理
- 4\. 为什么 ZGC 选择读屏障?
- (三)多图映射(Multi-Mapping)
- 1\. 内存区域划分
- 2\. 对象的虚拟地址映射
- 3\. 为什么使用多图映射?
- 4\. 虚拟地址与物理地址映射
- 三、ZGC 并发过程的详细说明
- (一)初始阶段
- (二)标记阶段
- (三)转移阶段
- (四)新一轮的垃圾回收(GC)
- (五)并发标记与回收
- 1\. 标记阶段(Marking Phase)
- 2\. 转移阶段(Relocation Phase)
- 3\. 标记与转移阶段的协作
- 四、ZGC调优案例实践
- (一)调优基础知识分析
- (二)调优案例分析
- **案例一:秒杀活动中流量突增,出现性能毛刺**
- **案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺**
- **案例三: 单次GC停顿时间30ms,与预期停顿10ms左右有较大差距**
- **案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复**
- (三)升级ZGC效果
- **延迟降低**
- **吞吐下降**
- 五、业务升级JDK11与应用ZGC注意事项
- 评估收益
- 评估成本
- 评估风险
- 备注:升级JDK解决组件兼容性
- 🎉 Shenandoah低延迟原理
- 垃圾回收实现-垃圾回收策略
- 垃圾回收模式
- 正常回收算法
- 优化模式垃圾回收
- 垃圾回收的降级
- 遍历回收算法
- 垃圾回收触发的时机
- 🎉 Epsilon无操作回收器
- 🎉 G1/ZGC/Shenandoah/Epsilon对比
- 🍊 垃圾回收算法
- 🎉 复制算法
- 🎉 标记整理算法
- 🎉 标记清除算法
- 🎉 分代收集算法
- 🎉 ZGC与Shenandoah的算法
- 🍊 进入老年代的几种情况
- 🍊 空间分配担保
- 🍊 安全点
🍊 垃圾回收器
垃圾回收器有多个,先说新生代的三个垃圾回收器,serial,parnew,parallel scavenge,然后再说老年代的serial old,parallel old,cms,最后在说一下新生代和老年代都使用的垃圾回收器G1吧。
🎉 Serial
Serial是新生代下使用复制算法,单线程运行的垃圾回收器,简单高效,没有线程交互开销,专注于GC,这个垃圾回收器工作的时候会将所有应用线程全部冻结,而且是单核cpu,所以基本不会考虑使用它。
🎉 ParNew
ParNew是新生代下使用复制算法,多线程运行的垃圾回收器,可以并行并发GC,和serial对比,除了多核cpu并行gc其他基本相同。
🎉 Parallel scavenge
Parallel scavenge也是新生代下使用复制算法,可以进行吞吐量控制的多线程回收器,主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景。可以发现新生代的垃圾回收器都使用,复制算法进行gc。
🎉 Serial Old
Serial Old就是老年代下使用标记整理算法,单线程运行的垃圾回收器。
🎉 Parallel old
Parallel old也是老年代下使用标记整理算法,可以进行吞吐量控制的多线程回收器,在JDK1.6才开始提供,在JDK1.6之前,新生代使用ParallelScavenge 收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器而出现的。
上面的Serial Old,Parallel Old这二个垃圾回收器使用的是标记整理算法.
🎉 CMS 流程
CMS是老年代使用标记清除算法,并发收集低停顿的多线程垃圾回收器。这个垃圾回收器可以重点讲一下,CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:
初始标记,只是标记一下GC Roots,能直接关联的对象,速度很快,需要暂停所有的工作线程。
并发标记,进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
重新标记,为了修正在并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程。
并发清除,清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
但是很明显无法处理浮动垃圾,就是已经标记过的对象,开始进行并发清除的时候,这个时候又有垃圾对象产生,这个时候,没办法清除这部分的浮动垃圾了,还有一个问题就是容易产生大量内存碎片,这和它的算法特性相关。
🎉 G1 分区机制
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,每个区域又可以根据分代理论分为eden区,Survivor区,只要这个区域里面出现了一个对象,超过了这个区域空间的一半就可以把它当作大对象,g1专门开辟了一块空间用来存储大对象,这个区域的大小,可以通过jvm的参数去设置,取值范围是1~32mb之间,那么如果有一个对象超过了32mb,那么jvm会分配二个连续的区域,用来存储这个大对象。
跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,保证了G1 收集器可以在有限时间获得最高的垃圾收集效率。而且基于标记整理算法,不产生内存碎片。可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。在jdk1.9的时候,被设置成默认的垃圾回收器了。
🎉 ZGC低延迟原理
亚毫秒级停顿,支持 TB 级堆内存,基于染色指针和读屏障实现并发整理。无分代设计,全堆并发处理,JDK 15+ 生产可用。
一、基本概述
ZGC (Z Garbage Collector) 是一款由 Oracle 开发的低延迟、高吞吐的垃圾回收器,旨在优化大规模、多核和大内存应用的性能。ZGC 的核心目标是在保证极低暂停时间的同时,支持大规模堆内存(从 100MB 到 16TB),并提供高吞吐量,尤其适用于云计算、大数据处理和高并发系统等需要处理大量内存的场景。
ZGC 的主要特点:
-
低延迟:ZGC 的设计首要目标是减少垃圾回收的停顿时间,最大限度地降低对应用程序响应的影响。通过采用基于读屏障(Read Barrier)和堆栈式(Stack-Style)替代算法,ZGC 避免了传统垃圾回收器中复杂的根扫描和整理过程。此外,ZGC 使用了标记颜色(Mark-Color)算法进行压缩,从而减少了垃圾回收中的暂停时间,确保大部分 GC 操作在应用程序运行时并发进行。
-
高吞吐量:尽管 ZGC 优先考虑低延迟,但它在吞吐量方面同样表现优异。通过多线程并行回收机制,ZGC 能在低延迟的基础上有效提升垃圾回收的效率。在处理大内存堆的同时,ZGC 采用了高效的内存分配器和优化的内存管理策略,保证了应用程序高效运行。
-
大堆支持:ZGC 支持最大 16TB 的堆内存,能够应对从中小型应用到大规模云计算、数据处理和机器学习任务的内存需求。这使得它非常适合需要大量内存资源的现代分布式系统和大数据处理应用。
-
透明性与兼容性:ZGC 设计上对应用程序透明,开发者无需做任何改动即可启用 ZGC 来进行垃圾回收。它与现有的 Java 应用程序完全兼容,能够平滑过渡并无需额外的配置。
-
并发性:ZGC 是一个并发垃圾回收器,它能够在应用程序运行的同时执行垃圾回收任务。结合现代多核处理器的能力,ZGC 充分利用并行计算资源,将垃圾回收操作的开销降到最低,并进一步提升了系统的响应速度和处理能力。
ZGC 是为需要低延迟和高吞吐量的现代应用而设计的垃圾回收器。其独特的并发回收机制、大堆支持、低延迟和透明性,使其成为大规模系统、云计算和高并发场景的理想选择。ZGC 通过将暂停时间控制在 10 毫秒以下,极大提高了应用的响应速度,并能够处理从数百 MB 到 16TB 的大规模堆内存,进一步推动了云平台和大数据应用的性能提升。
二、基本关键技术知识总结
- 染色指针(Colored Pointers)——「智能标签」
记忆口诀:“指针头上戴标签,状态颜色藏里面”
在 64 位地址中嵌入 元数据标签(低 4 位),存储标记(Marked)、重映射(Remapped)等状态。
类比:快递包裹(对象)上的 RFID 标签(染色指针),自动记录包裹是否已分拣(标记)、是否需要改道(重映射)。
- 读屏障(Load Barrier)——「实时导航」
记忆口诀:“取件先问导航仪,绕行新路不迟疑”
每次访问对象时触发读屏障,检查染色指针状态,若对象已转移则自动跳转到新地址。
类比:快递员(应用线程)取件时,导航系统(读屏障)实时提示包裹已转移至新仓库(新地址),无需等待停工。
- 内存多重映射(Multi-Mapping)——「虚拟分身」
记忆口诀:“一房多址靠映射,新旧地址随意切”
同一物理内存映射到多个虚拟地址(旧地址 A 和新地址 B),通过染色指针控制可见性。
类比:同一栋大楼(物理内存)挂两个门牌号(虚拟地址),施工时关闭旧门牌(A),引导所有人从新门牌(B)进入。
- 并发转移(Concurrent Relocation)——「后台搬家」
记忆口诀:“边跑程序边搬货,指针自动换门牌”
后台线程复制对象到新区域,旧地址保留转发信息,应用线程通过读屏障无感知切换。
类比:仓库(堆内存)扩容时,搬运工(GC 线程)在后台转移货物(对象),客户(应用线程)仍能通过旧地址(转发指针)取到货物。
(一)三色标记法(着色指针)
三色标记法(着色指针)是垃圾回收算法中的一种经典技术,广泛应用于标记清除型垃圾回收器(如标记-清除、标记-整理等算法)。其核心思想是通过对对象的状态进行标记,来区分对象的不同生命周期阶段,从而实现高效的垃圾回收。
1. 三色标记法概述
三色标记法通过为对象设置三种颜色(白色、灰色和黑色)来标记对象的不同状态。每个对象在垃圾回收过程中都会从白色开始,逐步转为灰色、黑色,以反映其是否仍然存活,以及是否已经被扫描和处理过。通过这种方式,可以有效地实现垃圾对象的识别和清理。
2. 三种颜色的含义
-
白色(White):白色表示对象尚未被访问,或者是垃圾回收器所认为的“死对象”。最初,所有对象都被标记为白色。白色对象可能是垃圾回收的目标。
-
灰色(Gray):灰色表示对象已经被访问过,但它的引用对象尚未被完全扫描。灰色对象会被继续扫描,直到其所有子对象(引用的对象)都被处理完。
-
黑色(Black):黑色表示对象及其引用的所有对象都已被扫描过,并且它们都是“活跃的”对象。黑色对象不会被回收,其引用的对象也将被标记为灰色或黑色。
3. 工作流程
-
初始化阶段:在垃圾回收的初期,所有的活动对象都被标记为白色。根对象(如线程栈、静态变量等)被首先标记为灰色,这些对象是垃圾回收的入口。
-
标记阶段:从灰色对象开始,递归地扫描其引用的对象,并将这些引用对象标记为灰色。如果某个对象的所有引用对象已经被扫描过,则将该对象标记为黑色。这个过程持续进行,直到所有可达对象都被扫描完毕。
-
清除阶段:在标记阶段完成后,所有白色对象将被回收,因为它们没有被任何活跃的对象引用。灰色和黑色对象则保留,表示它们是存活的。
三色标记法的最主要优势是能够有效避免标记过程中因并发操作而导致的错误。为了解决这个问题,许多现代垃圾回收器采用了着色指针技术,它通过额外的标记机制来避免对象在标记阶段被修改。
着色指针(Colored Pointers):在三色标记法的基础上,垃圾回收器为对象分配额外的标记信息,通过指针的“颜色”来区分对象的状态。这种方式能够精确跟踪对象的状态变化,并确保在标记过程中不会发生数据竞争或状态丢失。
三色标记法(着色指针)通过清晰的三阶段标记和状态跟踪,有效区分存活对象与垃圾对象,避免了对象在垃圾回收过程中被误标或遗漏。它广泛应用于现代垃圾回收器中,尤其在大规模内存管理和并发垃圾回收场景下,表现出较强的性能和可靠性。
产生漏标的充要条件是:已经完成标识的黑色对象新增了对某个白色对象的引用 && 正在被标记的灰色对象删除了对这个白色对象的引用。
解决方法是:在并发标记阶段,应用程序线程所访问到的白色对象都标记为灰色
(二)读屏障(Read Barrier)
在垃圾回收器的实现中,读屏障(Read Barrier)是一种非常重要的机制,尤其在 ZGC 中,它起着至关重要的作用。传统的垃圾回收器通常依赖 写屏障(Write Barrier),而 ZGC 则通过采用读屏障来处理对象的移动,从而避免了长时间的暂停和复杂的锁机制。
1. 什么是读屏障?
读屏障是一种在程序读取对象引用时插入的检查机制。它的主要作用是在对象移动(例如垃圾回收过程中)时,确保程序能够继续访问正确的对象地址,而不需要停止应用程序(即无 STW,Stop-The-World)。具体来说,当程序读取一个对象引用时,读屏障会检查这个引用是否指向一个已被移动的对象。如果对象已经移动,读屏障会自动更新引用,指向对象的新位置。
2. 为什么需要读屏障?
在 ZGC 等并发垃圾回收器中,垃圾回收会在应用程序运行时进行,且回收的过程中可能会移动对象。在传统的垃圾回收器中,如果对象在回收时被移动,那么任何之前持有该对象引用的代码都会面临“悬空引用”的问题。为了避免这种情况,ZGC 通过读屏障来修正引用地址,保证应用代码总是访问最新、有效的对象地址,而无需停顿或锁定线程。
3. 读屏障的工作原理
在垃圾回收过程中,可能有对象从一个内存区域被移动到另一个区域。为了保证对这些对象的引用始终指向正确的位置,ZGC 在读取对象引用时插入读屏障,流程如下:
- 读取引用:当程序访问一个对象的字段(引用类型的字段)时,需要读取该字段的引用。
- 触发读屏障:在读取引用之后,ZGC 会检查该对象是否已经被移动。如果对象已经被移动,则读屏障会自动将引用更新为新地址,指向对象的新位置。
- 无阻塞操作:整个过程发生在应用程序的线程中,不会造成任何的暂停。读屏障能够即时修正指针,保证程序可以继续执行而不会出现错误。
举个简单的例子:
Object o = obj.FieldA; // 从堆中读取对象引用<Load Barrier needed here> // 触发读屏障,检查对象是否已被移动Object p = o; // 不需要读屏障,因为这个不是从堆中读取引用o.dosomething(); // 不需要读屏障,因为对对象的操作不涉及引用读取int i = obj.FieldB; // 也不需要读屏障,因为这是一个普通的基本类型字段
在这段代码中,第一行读取 obj.FieldA
,假设 FieldA
是一个对象引用。此时,ZGC 会插入一个读屏障来确保,若该对象已经被移动,它会自动将 o
更新为新地址。如果对象没有被移动,程序会继续正常执行。
4. 为什么 ZGC 选择读屏障?
在传统的垃圾回收器中,写屏障被用来追踪哪些对象的引用发生了变化,并在发生变化时更新相应的元数据(如更新指针、标记对象为“可达”)。但是,ZGC 则采用了 读屏障,它使得回收过程更加高效,尤其是在对象移动时。相比于写屏障,读屏障的优势在于:
- 无 STW 停顿:ZGC 能够通过读屏障在不暂停应用程序的情况下修正对象引用,确保程序持续运行。
- 并发性更强:因为读屏障是基于对引用的读取操作插入的,它使得 ZGC 在大多数情况下可以与应用程序并发运行,极大地减少了垃圾回收的停顿时间。
- 避免复杂的同步机制:通过读屏障,可以减少应用程序和垃圾回收线程之间复杂的同步需求。
读屏障是 ZGC 的核心特性之一,通过在对象引用读取时插入屏障来保证引用指向正确的对象地址。它与传统的 写屏障 不同,能够避免暂停应用程序,并在对象被移动时自动修正引用指针。通过这种机制,ZGC 在大规模内存管理和低延迟的要求下,能够高效并发地进行垃圾回收,极大提高了系统的吞吐量和响应能力。
(三)多图映射(Multi-Mapping)
ZGC 采用了多图映射技术来优化内存管理,减少垃圾回收时的停顿时间。ZGC 将内存空间划分为多个区域,通过虚拟地址映射和物理地址映射的巧妙结合,实现了空间与时间的平衡,从而提供了低延迟的垃圾回收。
1. 内存区域划分
ZGC 将内存空间划分为多个不同的区域,每个区域负责不同的功能。
具体的划分如下:
- [0 ~ 4TB):对应 Java 堆空间,这里存放着应用程序创建的对象。
- [4TB ~ 8TB):称为 M0 地址空间,用于对 Java 堆的虚拟地址的映射。
- [8TB ~ 12TB):称为 M1 地址空间,用于实现另一种虚拟地址的映射。
- [12TB ~ 16TB):预留未使用,用于未来可能的扩展或其他用途。
- [16TB ~ 20TB):称为 Remapped 地址空间,用于存储已经重新映射的地址。
每个区域实际上是虚拟地址的一个映射区间,而这些区域的设计是为了支持高效的垃圾回收和内存管理。
2. 对象的虚拟地址映射
当应用程序创建一个对象时,ZGC 会在 Java 堆空间中申请一个虚拟地址。但是这个虚拟地址并不会立即映射到实际的物理地址。ZGC 会同时为该对象在 M0 地址空间、M1 地址空间 和 Remapped 地址空间 中申请一个虚拟地址,并且这三个虚拟地址会指向同一个物理地址。
这意味着同一个对象在 ZGC 中会有多个虚拟地址映射,但在任何时刻,只有一个映射空间会被激活有效。通过这种方式,ZGC 实现了灵活的内存管理,并且能够在垃圾回收期间通过不同的地址映射策略来减轻停顿时间。
3. 为什么使用多图映射?
ZGC 使用 多图映射 技术的核心目的是优化 垃圾回收时的暂停时间。通过将一个对象映射到多个虚拟地址空间,ZGC 能够实现以下目标:
- 降低 GC 停顿时间:ZGC 可以通过快速切换虚拟地址映射,避免在回收过程中对对象的访问发生中断。尤其是在垃圾回收期间对堆空间进行整理时,能够更高效地处理对象的移动。
- 空间换时间:ZGC 通过创建多个虚拟地址映射,减少了垃圾回收过程中对物理地址的直接修改,使得对象的物理位置不容易受到回收影响。这样,即使对象被移动或整理,应用程序仍然可以继续访问有效的虚拟地址映射,极大地缩短了停顿时间。
4. 虚拟地址与物理地址映射
在 64 位系统中,ZGC 使用了特定的虚拟地址格式,其中低 42 位(041 位)用于表示真正的虚拟地址。接下来的 4 位(42-45 位)用于存储 元数据(如映射标记等),高 17 位保留,而其中的 46 位也被保留用于特殊用途。
ZGC 将 M0、M1 和 Remapped 地址空间通过设置不同的位(分别是 42、43 和 44 位)来形成不同的虚拟地址映射。
- M0 映射:通过将第 42 位设置为 1,形成对应的虚拟地址映射。
- M1 映射:通过将第 43 位设置为 1,形成另一个虚拟地址映射。
- Remapped 映射:通过将第 44 位设置为 1,形成第三个虚拟地址映射。
这三种不同的虚拟地址映射同时指向相同的物理地址,通过不同的位来区分不同的映射视图。这种设计有效避免了物理地址的频繁修改,从而降低了垃圾回收的停顿时间。
三、ZGC 并发过程的详细说明
在垃圾回收过程中,ZGC 会将内存管理分为多个阶段,并且大部分阶段都是并发执行的,只有一些小部分操作需要短暂的暂停(如标记阶段的少量 STW 操作)。为了实现这一目标,ZGC 采用了 多图映射、读屏障 等技术,使得应用程序可以在垃圾回收的过程中几乎不中断地继续运行。
让我们通过一个简单的场景来逐步解析 ZGC 的并发过程。
(一)初始阶段
假设应用程序创建了 3 个对象(对象 1、对象 2 和对象 3)。在对象创建初期,它们都被分配到 Remapped 地址空间,即这些对象的虚拟地址并没有映射到实际的物理地址,属于一种临时的、待处理的状态。
垃圾回收(GC)触发:当垃圾回收触发时,ZGC 会首先进行 标记阶段。在这一阶段,ZGC 会标记所有活动对象和非活动对象。
(二)标记阶段
标记阶段的目标是标识出哪些对象是活跃的,哪些是不活跃的。这是 ZGC 中最为重要的一个阶段,它需要通过扫描对象的引用关系来确定哪些对象是不可达的(即可以回收的)。
- 在这个阶段,假设对象 0 和对象 2 被标记为 活跃对象,而对象 1 被标记为 非活跃对象。
- 活跃对象会被映射到 M0 地址空间,意味着它们将继续存在,并在后续的回收过程中得到处理。
- 非活跃对象(如对象 1)保持 Remapped 地址空间,即它们不会被回收,仍然保持在待处理的状态。
(三)转移阶段
转移阶段的目的是将活跃对象从 Remapped 地址空间 转移到新的物理地址空间。
这个阶段是 ZGC 并发回收的核心之一。
- 对象 0 被标记为活跃对象,在转移阶段之前,它将从 Remapped 地址空间 被移动到 M0 地址空间,并且在转移过程中,它的虚拟地址视图被更新为 M0。同时,对象 0 会被转移到新的物理页面,确保它占据的是 M0 区域的空间。
- 对象 1 是非活跃对象,由于它未被标记为活跃,因此它保持在 Remapped 地址空间,并不会被转移。
- 对象 2 和 对象 3 没有被移动,它们继续保留在 M0 地址空间,并且在后续的操作中,它们保持原有的 M0 地址空间 映射视图。
这个阶段还涉及到新创建对象的处理。假设在转移阶段应用程序新创建了一个对象 对象 4,这个新对象会被分配到 Remapped 地址空间,以便等到下一个垃圾回收周期来处理。
(四)新一轮的垃圾回收(GC)
ZGC 的 并发性使得它在垃圾回收过程中不会一次性将所有对象处理完,而是采用 分代回收 和 逐步迁移 的策略。在新的回收周期开始时,ZGC 会重新标记活跃对象,并将它们移动到新的虚拟地址空间。
- 在这次新的垃圾回收周期中,对象 2 和对象 3 没有被访问,因此它们仍然保持在 M0 地址空间。
- 活跃对象会被标记为 M1 地址空间,并且转移到新的物理空间。
- 非活跃对象继续保持在 Remapped 地址空间,并没有被迁移。
例如,在标记阶段后,应用程序可能创建了 对象 5。这个新对象会被映射到 M1 地址空间,以便在下一轮垃圾回收时进一步处理。
(五)并发标记与回收
ZGC 在 标记 和 转移 阶段采取并发处理的方式,这样可以避免长时间的垃圾回收停顿。具体来说:
- 标记阶段:ZGC 会在后台并发地标记所有的活动对象。它会使用 读屏障 来保证对象在回收过程中不会被错误地访问或指向无效的内存地址。
- 转移阶段:ZGC 会并发地转移对象,但它会确保在每次操作时只修改一个地址空间。只有在需要完全更新对象的虚拟地址时,才会改变对象的映射视图。
ZGC(Z Garbage Collector)相较于 CMS 和 G1 的显著改进之一是它在 标记阶段 和 转移阶段 的并发性,特别是通过 多图映射、三色标记法 和 读屏障技术 来支持与应用程序线程并发。这些技术使得 ZGC 能够在进行垃圾回收时,最大程度地减少应用程序的停顿时间,并提高垃圾回收的效率。
1. 标记阶段(Marking Phase)
标记阶段是垃圾回收中非常重要的一步,它的目的是标记哪些对象是活跃的,哪些对象是垃圾,可以回收的。ZGC 的标记阶段改进提炼:
-
三色标记法:ZGC 使用了经典的三色标记法(白色、灰色、黑色)来标记对象的状态。这个方法基于 指针追踪,通过遍历对象图来标记活动对象,三种颜色表示对象的不同状态:
- 白色:表示对象尚未被标记,需要检查是否存活。
- 灰色:表示对象已经被标记为活跃,但尚未扫描其引用的对象。
- 黑色:表示对象已经被标记为活跃,且所有它引用的对象也都已被标记。
ZGC 使用并发标记阶段,因此即使在标记过程中,应用程序线程依然可以继续执行。与传统的垃圾回收器(如 CMS 和 G1)相比,ZGC 在标记过程中减少了全局的暂停时间。
-
读屏障技术:为了支持并发标记,ZGC 使用了读屏障(Read Barrier)来确保应用程序线程在访问对象时能正确地发现对象的状态变化。由于 ZGC 采用了 多图映射,每个对象可能有多个虚拟地址视图,因此读屏障可以确保无论对象的位置如何变化,应用程序都能始终访问到最新的有效对象地址。这意味着,即使对象在标记过程中发生了移动或地址更新,应用程序线程也能保证访问到正确的对象。
-
并发标记:ZGC 通过 并发标记 来减少停顿时间。它在标记过程中与应用程序线程并行执行,不需要全局的停止世界(STW)。这意味着,在标记阶段,ZGC 通过对活动对象的跟踪,标记活动对象的同时,应用程序线程可以继续执行。标记过程仅会在非常短的时间内进行 STW 操作,通常在应用程序线程的访问中进行标记,持续时间很短。
2. 转移阶段(Relocation Phase)
转移阶段的目标是将活跃对象从一个地址空间迁移到新的物理空间,这一步对于释放空间和整理内存非常重要。在 ZGC 中,转移阶段的设计使得它能够并发地与应用程序线程执行,最大程度减少停顿时间。ZGC 的转移阶段改进提炼:
-
基于多图映射的转移:ZGC 使用了 多图映射 技术,将对象分配到不同的虚拟地址空间(如 M0、M1 和 Remapped)。这些虚拟地址空间在同一时间指向相同的物理内存区域,但它们的视图是分开的。每个对象有多个地址映射,这使得 ZGC 能够在进行内存转移时,减少对应用程序线程的干扰。具体来说:
- 在转移过程中,活跃对象会被移到新的虚拟地址空间(比如从 M0 到 M1)。
- 在对象迁移时,ZGC 会保证新地址空间始终保持一致性。应用程序通过读屏障技术读取到的地址是始终有效的。
-
并发转移:ZGC 实现了 并发转移,这意味着在垃圾回收的转移过程中,ZGC 会与应用程序线程并行工作。对象的实际转移(如对象从旧空间移动到新空间)是在并发进行的,不会阻塞应用程序的执行。在并发转移阶段,ZGC 通过采用多线程来加速垃圾回收过程,确保转移过程中的并行性,从而减少停顿时间。
-
对象迁移:转移阶段中,ZGC 通过并发线程将标记为活跃的对象迁移到新的内存区域。这一过程使用了多图映射的技术,将原本分散的对象汇聚到更紧凑的空间中。ZGC 使用空间换时间的思想,借助虚拟地址空间的映射,来提高内存的整理效率和对象迁移的速度。新创建的对象也会根据当前的 GC 阶段映射到适当的虚拟地址空间(如 M0 或 M1)。
-
回收和更新:在转移阶段,ZGC 会确保垃圾回收的对象页面能够被回收,且没有被正在使用的活跃对象占用。回收的空闲空间会在后续的垃圾回收过程中再次分配给新对象。
3. 标记与转移阶段的协作
ZGC 的标记和转移阶段相互协作,支持并发执行,使得整个垃圾回收过程更加高效:
-
并发标记与转移并行执行:标记阶段与转移阶段的并发执行是 ZGC 最大的优势之一。在标记阶段确定活跃对象之后,转移阶段可以并发执行,将对象从一个地址空间迁移到另一个地址空间。这样,ZGC 能够充分利用多核处理器的能力,通过并行化来加速回收过程,避免垃圾回收时的长时间停顿。
-
读屏障的作用:在标记和转移阶段,读屏障确保了在对象迁移过程中,应用程序线程总是能够访问到有效的对象地址。当对象从一个空间迁移到另一个空间时,读屏障会修正应用程序访问的指针,避免出现悬挂指针或不一致的状态。即使对象在垃圾回收过程中发生了转移,应用程序也能访问到最新的、正确的对象地址。
总的来说,ZGC 相比 CMS 和 G1 的重大改进,体现在它通过 多图映射、三色标记法 和 读屏障技术 实现了标记和转移阶段的并发处理:
- 标记阶段:ZGC 通过三色标记法并结合读屏障技术,在并发标记时保持活跃对象的一致性,减少了标记过程中的停顿时间。
- 转移阶段:ZGC 利用多图映射支持对象的并发迁移,使得对象能够在垃圾回收过程中高效地从一个地址空间迁移到另一个地址空间,而不会中断应用程序的执行。
这些改进使得 ZGC 能够在垃圾回收过程中最大限度地减少停顿时间,从而提高了系统的响应性和吞吐量。
四、ZGC调优案例实践
(一)调优基础知识分析
这部分主要是对于基本日志的理解和查看技巧。
(二)调优案例分析
维护服务名叫Zeus,是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎Aviator。Aviator内部将每一条表达式转化成Java的一个类,通过调用该类的接口实现表达式逻辑。
Zeus服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致Aviator生成的类和方法会产生很多的ClassLoader和CodeCache,这些在使用ZGC时都成为过GC的性能瓶颈。接下来介绍两类调优案例。主要都来自美团技术团队,有兴趣的可以直接阅读下原文。
案例一:秒杀活动中流量突增,出现性能毛刺
日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。
分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。
解决方法:
- 开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
- 增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。
案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺
**日志信息:**平均1秒GC一次,两次GC之间几乎没有间隔。
分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。
解决方法:增大-XX:ConcGCThreads, 加快并发标记和回收速度。ConcGCThreads默认值是核数的1/8,8核机器,默认值是1。注意,该参数影响系统吞吐,如果GC间隔时间大于GC周期,不建议调整该参数。
GC Roots 数量大,单次GC停顿时间长
案例三: 单次GC停顿时间30ms,与预期停顿10ms左右有较大差距
日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。
分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道ClassLoader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。
解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。
案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复
日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。
分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+PrintCodeCacheOnCompilation参数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。
解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了大量Aviator方法进入CodeCache中。
值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。
(三)升级ZGC效果
延迟降低
TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。
在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:
-
TP999:下降12142ms,下降幅度18%74%。
-
TP99:下降528ms,下降幅度10%47%。
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。
吞吐下降
对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
五、业务升级JDK11与应用ZGC注意事项
在生产环境升级JDK11,使用ZGC,在使用新技术前,首先要做的是评估收益、成本和风险。
评估收益
对于JDK这种世界关注的程序,大版本升级所引入的新技术一般已经在理论上经过验证。我们要做的事情就是确定当前系统的瓶颈是否是新版本JDK可解决的问题,切忌问题未诊断清楚就采取措施。评估完收益之后再评估成本和风险,收益过大或者过小,其他两项影响权重就会小很多。
GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~40ms不等,其中30ms* 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。可见,GC停顿对响应时间的影响较大。
评估成本
这里主要指升级所需要的人力成本。此项相对比较成熟,根据新技术的使用手册判断改动点。跟做其他项目区别不大,不再具体细说。在实践中,两周时间完成线上部署,达到安全稳定运行的状态。后续持续迭代3个月,根据业务场景对ZGC进行了更契合的优化适配。
评估风险
升级JDK的风险可以分为三类:
- 兼容性风险:Java程序JAR包依赖很多,升级JDK版本后程序是否能运行起来。例如我们的服务是从JDK7升级到JDK11,需要解决较多JAR包不兼容的问题。
- 功能风险:运行起来后,是否会有一些组件逻辑变更,影响现有功能的逻辑。
- 性能风险:功能如果没有问题,性能是否稳定,能稳定的在线上运行。
经过分类后,每类风险的应对转化成了常见的测试问题,依旧存在风险,需排期(通过完备的单测、集成和回归测试,保证功能正确性)覆盖主链路所有场景并做线上灰度,灰度较长时间没问题后在进行上线。
备注:升级JDK解决组件兼容性
具体过程如下:
- 编译,需要修改pom文件中的build配置,根据报错作修改,主要有两类:一些类被删除:比如“sun.misc.BASE64Encoder”,找到替换类java.util.Base64即可。组件依赖版本不兼容JDK11问题:找到对应依赖组件,搜索最新版本,一般都支持JDK11。
- 编译成功后,启动运行,此时仍有可能组件依赖版本问题,按照编译时的方式处理即可。
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-parent</artifactId>
<version>6.0.16.Final</version>
</dependency>
<dependency>
<groupId>com.sankuai.inf</groupId>
<artifactId>patriot-sdk</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.39.Final</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
🎉 Shenandoah低延迟原理
通过 Brooks 指针减少内存开销,平衡延迟与吞吐。
垃圾回收实现-垃圾回收策略
Shenandoah为了满足不同的使用场景,在垃圾回收时设计了4种不同的垃圾回收策略,分别是static、aggressive、adaptive和compact。每种策略触发垃圾回收的条件略有不同。
不同的回收策略除了控制如何启动垃圾回收之外,还会控制内存中的哪些内存可以被回收。这4种策略对应的回收触发条件和回收范围总结如表所示。
Shenandoah垃圾回收策略可以通过参数控制,默认的策略是adaptive。另外,这4种模式回收的分区在不同的版本中可能略有区别,但总体来说差别不大。
垃圾回收模式
在JDK 12中只有一种回收模式,但是存在6种回收策略,其中traversal是一种比较特殊的策略。本质上traversal并不是一种回收策略,而是一种回收模式。回收策略定义在回收模式时垃圾回收的粒度,回收模式定义垃圾回收的整个流程。所以traversal策略实际上定义了一种回收模式。但是traversal相关代码复杂度太高,存在不少问题,所以JDK 15将该模式相关代码移除。但同时Shenandoah又引入了一种新的模式,称为增量更新。
在最新的JDK 17中Shenandoah支持3种回收模式:
1)SATB或者Normal模式,在JDK 16之前,名字使用Normal,在JDK 16中名字使用SATB。该模式表示在并发标记时使用SATB的标记算法,可以使用除了Passive策略以外的4种回收策略。
2)Incremental-Update(IU),该模式是在traversal移除后新增的回收模式。该模式指的是在并发标记时使用增量回收的标记算法,可以使用除了Passive策略以外的4种回收策略。
3)Passive模式,该模式仅仅使用Passive回收策略。由于Passive策略仅仅在执行OOM时才会触发垃圾回收,所以Passive模式在执行垃圾回收时是暂停执行的。
其中SATB模式是成熟的模式,IU模式是实验模式,Passive模式几乎不使用。
SATB模式和IU模式最大的区别是通过屏障技术解决并发标记正确性问题的方式不同,SATB模式通过屏障记录修改前的对象,而IU模式通过屏障记录引用者。
除了上述3种回收模式以外,本文也稍微提一下已经移除的traversal模式,该模式是一种非常激进的回收方式。
正常回收算法
在JDK 15之前,Shenandoah中有两种正常回收模式:一般模式和优化模式。
一般模式和优化模式的区别在于是否在标记的时候执行重定位,如果在标记的过程中执行重定位,则称为优化模式,否则称为一般模式。
这两种模式可以通过参数
ShenandoahUpdateRefs-Early控制,取值为off/false表示垃圾回收执行优化模式,on/true/adaptive表示执行一般模式。
一般模式垃圾回收的步骤如下:
1)初始标记:从根集合出发,标记根集合所有引用的对象,这些对象作为下一步并发标记的出发点。这一步是在STW中进行的。
2)并发标记:以第一步标记的对象作为出发点,开始并发地标记对象。
3)预清理:在进入再标记阶段之前,先处理引用对象,把仍然活跃的引用对象重新激活,不进行真正的垃圾回收。该阶段支持并发执行,但是只有一个并发工作线程执行预清理。
4)再标记:该阶段要做3件事情,分别为终止标记、计算回收集、转移根集合直接的引用对象。这一步是在STW中进行的。
5)清理:再标记结束后,部分分区可能已经没有任何活跃对象,这些分区就可以被回收了。
6)并发转移:根据转移集,对所有在转移集中的活跃对象进行转移。
7)初始重定位:初始重定位将根据标记过程中识别的活跃对象更新分区中对象的内存地址。这一步是在STW中进行的。
8)并发重定位:遍历不属于回收集合中的分区的对象,根据BrookPointer更新对象的引用指针。
9)结束重定位:遍历根集合中所有引用的对象,更新对象的引用指针。
这一步是在STW中进行的。
10)再次清理:因为回收集合中对象全部转移完成,所以可以释放空间。
整个垃圾回收的活动如图所示。
整个垃圾回收活动
优化模式垃圾回收
优化模式与一般模式非常类似,唯一的区别在于是否合并标记和引用更新。如果合并这两个阶段,则称为优化模式。优化模式可以减少一次堆遍历,但是在Shenandoah中的优化模式把内存释放一直推迟到下一个垃圾回收周期,这将导致本应该快速释放的内存无法释放。在比较优化模式的成本与收益后,在JDK 15中正式将该模式移除。优化回收的步骤如下。
1)初始标记:和一般模式中的初始标记相同。
2)并发标记:以第一步标记的对象作为出发点,开始并发地标记对象。注意在这一步首先判断对象是否需要重定位,如果需要则进行重定位。
3)预清理:和一般模式中的预清理相同。
4)再标记:该阶段主要做4件事情,分别为更新根集合中所有对象的引用、终止标记、计算回收集、转移根集合直接的引用对象。这一步是在STW中进行的。
5)清理:和一般模式中的清理相同。
6)并发转移:和一般模式中的并发转移相同。
7)结束转移:设置转移结束标记,重置TLAB等信息。这一步是在STW中进行的。
垃圾回收的降级
降级回收算法(也称为Degenerated GC)指在垃圾回收过程中,如果遇到内存分配失败,就进入降级回收。降级回收实质上是在STW中并行执行的。
在正常回收运行的过程中,应用程序和垃圾回收线程都可能需要分配内存空间,也都有可能遇到内存不足导致分配失败的情况,此时正常回收将进入降级回收。如果在降级回收时再遇到内存不足的情况,将进入Full GC。这3种算法交互的流程如图7-15所示。
正常回收、降级回收和Full GC交互的流程
降级回收的步骤和正常回收基本一致,只不过降级回收是并行执行的。
降级回收中若再次遇到分配失败,将被进一步降级为Full GC(在并发回收中的分配失败通常是应用请求内存分配导致的,而降级回收中的分配失败是GC工作线程请求内存分配导致的)。Full GC采用典型的并行标记压缩回收,和G1的实现非常类似,这里不再赘述。
遍历回收算法
在介绍垃圾回收时,可以把重定位阶段和标记阶段进行合并,这个思路就是Shenandoah的优化回收。那么还能不能再进一步优化这个算法,把并发标记、并发转移和并发重定位合并放在一个并发步骤中?Shenandoah中的遍历回收实现就是把这3个并发阶段合并到一个阶段,如图7-16所示。
从图7-16中可以看出,在遍历回收中,第一次垃圾回收启动时进行并发标记,第二次垃圾回收启动时进行并发标记和并发转移,第三次垃圾回收和以后的垃圾回收启动时都可以执行并发标记、并发转移和并发重定位。
遍历回收示意图
由于代码的复杂性,遍历回收在JDK 15中被移除。
垃圾回收触发的时机
Shenandoah中垃圾回收触发的时机与垃圾回收的模式和策略密切相关,在后面介绍相关参数时会详细介绍。例如,Adaptive策略有6种触发垃圾回收的条件。
其他细节
Shenandoah的实现还有很多细节值得仔细推敲,限于篇幅,这里只是稍微介绍读者容易忽略的两个细节。
(1)并发转移是否可以利用SATB相关信息优化
在并发标记中使用了SATB引入的TAMS指针,分区中该指针以后的对象都是并发标记启动以后新分配的对象。
并发转移阶段中的分区分为两种:分区中的对象将要被转移,分区被回收,这些分区位于CSet中;分区不参与回收。CSet中的分区将不会再用于分配对象,非CSet中的分区可以继续用于分配对象。对于非CSet的分区可以利用TAMS指针,在并发转移启动以后,TAMS指针以后新分配的对象状态都是正确的,新分配的对象如果指向尚未完成转移的对象,就会通过读屏障将尚未转移的对象转移到新的位置,所以TAMS指针以后分配的对象都不需要再次更新对象的引用。所以在并发转移中也使用TAMS指针区分新分配对象和尚未完成更新的对象,这将提高并发更新引用的效率。TAMS指针在并发转移中的使用如图7-17所示。
TAMS指针在并发转移中的使用
(2)并发转移中出现转移失败该如何处理
在G1的垃圾回收过程中会申请内存用于转移对象,当无法申请到内存时就会导致对象无法转移,此时称为转移失败。当转移失败后,需要对转移失败的对象进行特殊处理,通常是将转移对象的转移指针指向自己,避免该对象再次被转移,同时并不中断垃圾回收的过程。在垃圾回收结束后,对转移失败的情况重新设置对象头,并更新引用集等信息。
G1的转移是并行处理,整个处理不会出现对象状态不一致的情况。而Shenandoah是并发转移,当出现转移失败时,需要额外处理,否则将出现对象不一致的情况。用一个简单的例子来演示Shenandoah并发转移可能存在的问题。假设垃圾回收处于并发转移阶段,有两个线程T1和T2可以访问同一个对象,运行时信息如图所示。
并发转移阶段两个线程访问同一对象
当线程T1或者T2访问对象时,都会先转移对象到目标空间。假设T1在转移对象时遇到无法分配内存的情况,对T1来说就发生了转移失败,T1会尝试标记对象转移失败(假设也使用转移指针指向自己)。同时,线程T2访问对象时也会转移对象,假设T2有充足的内存(例如T2的TLAB中有空闲空间)可以成功转移内存。此时运行时信息如图7-19所示。
两个线程同时转移一个对象,一个成功,一个失败
线程T1先访问对象,转移失败,返回原始对象;线程T2后访问对象,转移成功,返回目标空间的对象。图7-19中为了说明这一结论直接使用指针指向了不同的对象,实际上是两个线程得到的返回对象地址不同。
在这种情况下就出现了问题,两个不同的线程指向了两个不同的对象,根据并发转移的要求,需要保证目标空间不变性。对于T1出现的转移失败情况需要特殊处理,理想的运行状态是T1也应该访问目标对象,同时在其他的线程转移完成后再进行转移失败处理,如图所示。
那么该如何保证有这样的状态?目前,Shenandoah设计了一个特殊的机制,来处理转移中可能遇到的失败情况。具体步骤如下:
1)当线程进入对象转移时,增加计数器。
2)当线程成功转移对象后,减少计数器。
3)当某一个线程出现转移失败后,等待其他线程完成转移后才能继续执行;线程会重新确定对象是否转移,如果对象已经转移,则获取转移后的对象。
(3)Shenandoah对JNI的优化
当Java应用执行的本地代码中包含JNI Critical API时,因为本地代码会操作Java堆空间中的内存对象,而垃圾回收执行时会移动对象,这两个需求是矛盾的,所以在执行JNI Critical API时会设置一个GCLocker标志,告诉垃圾回收暂停执行,直到JNI Critical API执行完毕才会再执行垃圾回收。这样的设计的合理性是值得商榷的。
在Shenandoah中优化了这一设计,即在本地代码执行JNI Critical API时仍然可以执行垃圾回收。其方法是,仅仅将JNI Critical API访问对象所在的内存固定(称为Pinned),即垃圾回收可以继续执行,当遇到内存固定的区域时不进行回收。由于Shenandoah采用分区设计,因此垃圾回收也是基于分区进行的。固定JNI Critical API访问对象所在内存可以将整个分区固定,只要在垃圾回收时跳过这样的分区即可。该优化在有较多JNI CriticalAPI的应用中有较好的效果。
目前JVM中仅Shenandoah支持该优化,实际上G1 GC和ZGC也是基于分区设计的,要想实现类似的优化并不困难。
(4)为什么Shenandoah需要多种屏障
Shenandoah使用SATB屏障(本质是写屏障)保证并发标记的正确性。在JDK 13之前,并发转移阶段使用读屏障、写屏障和比较屏障;在JDK 13之后,并发转移阶段使用Load屏障(本质是读屏障)。在其他的垃圾回收器实现中,如JVM的ZGC、Android的Concurrent Copying都仅仅使用了Load屏障完成标记和转移。那为什么Shenandoah没有统一多种屏障为一种?原因主要是不同的屏障性能不同。Shenandoah的一个主要维护者Aleksey Shipilev在介绍Shenandoah时比较过使用不同屏障的成本,如表7-3所示。
表SATB屏障和Load屏障在测试集上的成本比较
测试的基准是无屏障的情况。在表中可以明显看出Load屏障的成本更高。这也可能是Shenandoah选择SATB算法进行并发标记的原因。
🎉 Epsilon无操作回收器
仅分配内存,不回收,用于性能测试或极短生命周期应用(如单次任务)
🎉 G1/ZGC/Shenandoah/Epsilon对比
🍊 垃圾回收算法
🎉 复制算法
新生代中每次垃圾回收都要回收大部分对象,所以为了避免内存碎片化的缺陷,这个算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,当这一块存活区内存满后将gc之后还存活的对象复制到另一块存活区上去,把已使用的内存清掉。
🎉 标记整理算法
标记整理算法是标记后将存活对象移向内存的一端,然后清除端边界外的对象。标记整理算法可以弥补标记清除算法当中,内存碎片的缺点,也消除了复制算法当中,内存使用率只有90%的现象,不过也有缺点,就是效率也不高,它不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。
🎉 标记清除算法
标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。
CMS使用标记清除算法看中的就是它的效率高,只不过内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
🎉 分代收集算法
按照分代收集算法的思想,把应用程序可用的堆空间分为年轻代,老年代,永久代,然后年轻代有被分为Eden区和二个Survivor存活区,这个比例又可以分为8比1比1。当第一次eden区发生minor gc,会把存活的对象复制到其中的一个Survivor区,然后eden区继续放对象,直到触发gc,会把eden区和之前存放对象的Survivor区一起gc,二个区存活下来的对象,复制到另一个空的Survivor里面,这二个区就清空,然后将二个存活区角色互换。
🎉 ZGC与Shenandoah的算法
ZGC可能主要使用标记-整理算法,但通过并发执行和染色指针优化,减少了停顿时间。Shenandoah可能结合复制和标记-整理,实现低延迟。Epsilon回收器,它不进行垃圾回收,仅分配内存,Epsilon没有回收功能,所以这里可能不需要提及算法。
ZGC在标记阶段通过 染色指针 实现并发标记,避免传统标记-清除的内存碎片问题;整理阶段通过 内存映射重定向 实现并发对象移动。读屏障(Load Barriers):拦截对象引用访问,动态修正指针指向新地址,确保并发整理期间应用线程无感知。
Shenandoah采用 Brooks 指针(每个对象额外存储转发指针),在对象复制阶段允许应用线程并发访问新旧内存副本。分批次完成对象迁移,避免一次性全堆整理导致的长时间停顿。
🍊 进入老年代的几种情况
当对象在Survivor区躲过一次GC 后,年龄就会+1,存活的对象在二个Survivor区不停的移动,默认情况下年龄到达15的对象会被移到老生代中,这是对象进入到老年代的第一种情况。
这里就会有个问题,JVM分代年龄为什么是15次?
一个对象的GC年龄,是存储在对象头里面的,一个Java对象在JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充。而对象头里面有4个bit位来存储GC年龄。
4个bit位能够存储的最大数值是15,所以从这个角度来说,JVM分代年龄之所以设置成15次是因为它最大能够存储的数值就是15。虽然JVM提供了参数来设置分代年龄的大小,但是这个大小不能超过15。从设计角度来看,当一个对象触发了最大值15次gc,还没有办法被回收,就只能移动到old generation了。另外,设计者还引入了动态对象年龄判断的方式来决定把对象转移到old generation,也就是说不管这个对象的gc年龄是否达到了15次,只要满足动态年龄判断的依据,也同样会转移到old generation。
第二种情况就是,创建了一个很大的对象,这个对象的大小超过了jvm里面的一个参数max tenuring thread hold值,这个时候不会创建在eden区,新对象直接进入老年代。
第三种情况,如果在Survivor区里面,同一年龄的所有对象大小的总和大于Survivor区大小的一半,年龄大于等于这个年龄对象的,就可以直接进入老年代,举个例子,存活区只能容纳5个对象,有五个对象,1岁,2岁,2岁,2岁,3岁,3个2岁的对象占了存活区空间的5分之三,大于这个空间的一半了,这个时候大于等于2岁的对象,需要移动到老年代里面,也就是3个2岁的,一个3岁的对象移动到老年代里面。
🍊 空间分配担保
第四种情况就是eden区存活的对象,超过了存活区的大小,会直接进入老年代里面。另外在发生minor gc之前,必须检查老年代最大可用连续空间,是不是大于新生代所有对象的总空间,如果大于,这一次的minor gc可以确保是安全的,如果不成立,jvm会检查自己的handlepromotionfailure这个值是true还是false。true表示运行担保失败,false则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minorgc,如果小于或者不允许担保失败,那就直接进行fgc了。
举个例子,在minorgc发生之前,年轻代里面有1g的对象,这个时候,老年代瑟瑟发抖,jvm为了安慰这个老年代,它在minor gc之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2g,jvm就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1g的对象全部给你,你也吃的下,你的空间非常充足,这个时候,老年代就放心了。
但是大部分情况下,在minor gc发生之前,jvm检查完老年代最大可用连续空间以后,发现只有500M,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure
的值是不是允许担保失败,如果允许担保失败,就进行第三次检查。
检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300M,现在老年代最大可用连续空间只有500M,很明显是大于的,那么它会进行一次有风险的minorgc,如果gc之后还是大于500M,那么就会引发fgc了,但是根据以往的一些经验,问题不大,这个就是允许担保失败。
假设历次晋升到老年代平均对象大小是700M,现在老年代最大可用连续空间只有500M,很明显是小于的,minorgc风险太大,这个时候就直接进行fgc了,这就是我们所说的空间分配担保。
🍊 安全点
目前Java虚拟机都是采用准确是GC,当执行系统停下来之后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机有办法直接得到哪些地方存放在对象的引用。在HotSpot中,使用了一组OopMap数据结构来实现这个功能。
当一个类加载完之后,HotSpot就把对象是什么类型数据计算出来,在JIT(即时编译)的时候也记录下栈和寄存器哪些位置是引用,这样GC时就可以直接得到有哪些对象的引用。
OopMap不可能为每一条指令都创建一个OopMap只能在特定的位置记录一下,这些位置称为安全点。也就是说程序并非在任何地方都可以进行GC,只有到达安全点之后才可以GC。
安全点的选择不能太少,不能让GC等待的时间太长,也不能太多而影响正常的程序运行速度。所以安全点的选定基本是以程序“是否具有让程序长时间运行的特征”为标准,例如方法调用、循环跳转、异常跳转等地方,具有这些基本功能的指令才产生安全点。
更具体点在HotSpot中,安全点的位置:
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
另外在垃圾收集发生时,多线程的程序要所有的线程都跑到安全点都停下来
如何在垃圾收集发生时让所有线程都跑到最近的安全点?
有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有 用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎 没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个 线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为 了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
程序“不执行”的时候线程如何达到安全点?
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行” 的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续 等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入安全区域来解决。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
📥博主的人生感悟和目标
希望各位读者大大多多支持用心写文章的博主,现在时代变了,信息爆炸,酒香也怕巷子深,博主真的需要大家的帮助才能在这片海洋中继续发光发热,所以,赶紧动动你的小手,点波关注❤️,点波赞👍,点波收藏⭐,甚至点波评论✍️,都是对博主最好的支持和鼓励!
- 💂 博客主页: Java程序员廖志伟
- 👉 开源项目:Java程序员廖志伟
- 🌥 哔哩哔哩:Java程序员廖志伟
- 🎏 个人社区:Java程序员廖志伟
- 🔖 个人微信号:
SeniorRD
📙经过多年在优快云创作上千篇文章的经验积累,我已经拥有了不错的写作技巧。同时,我还与清华大学出版社签下了四本书籍的合约,并将陆续出版。这些书籍包括了基础篇、进阶篇、架构篇的📌《Java项目实战—深入理解大型互联网企业通用技术》📌,以及📚《解密程序员的思维密码–沟通、演讲、思考的实践》📚。具体出版计划会根据实际情况进行调整,希望各位读者朋友能够多多支持!
🔔如果您需要转载或者搬运这篇文章的话,非常欢迎您私信我哦~