目录
低延迟垃圾收集器
概要
HotSpot的垃圾收集器从Serial发展到CMS再到G1,经历了逾二十年时间,经过了数百上千万台服务器上的应用实践,已经被锤炼得相当成熟了,但是距离“完美”还是很遥远。
衡量垃圾收集器的三项最重要得指标
- 内存占用(Footprint)
- 吞吐量(Throughput)
- 延迟(Latency)
各款收集器的并发情况
浅色阶段表示必须挂起用户线程,深色阶段表示收集器线程与用户线程是并发工作的。
- CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不冷空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。
- G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
- Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。
Shenandoah和ZGC这两款收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。
Shenandoah收集器
Shenandoah收集器作为第一款不由Oracle公司的虚拟机团队所领导开发的HotSpot垃圾收集器,Oracle明确拒绝在OracleJDK12中支持Shenandoah收集器,并执意在打包OpenJDK时通过条件编译完全排除掉了Shenandoah的代码。
Sheandoah是一款只有OpenJDK才会包含。
最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献 给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。
Shenandoah相比G1的改进之处
在管理堆内存方面,
- 最重要的是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。
- 其次是 默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,更多的是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
- 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“链接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享的发生概率。
链接矩阵
定义
可以理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那链接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
优点
- 降低了处理跨代指针时的记忆集维护消耗
- 降低了伪共享的发生概率
Shenandoah收集器的工作过程
Shenandoah收集器的工作过程一共有九个阶段,最核心的三个阶段并发标记、并发回收、并发引用更新。
- 初始标记(Initial Marking)
与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
- 并发标记(Concurrent Marking)重点理解
与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
- 最终标记(Final Marking)
与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
- 并发清理(Concurrent Cleanup)
这个阶段用于清理哪些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region --->直接垃圾区)。
- 并发回收(Concurrent Evacuation)重点理解
并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
- 初始应用更新(Initial Update Reference)
并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。
引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
- 并发引用更新(Concurrent Update Reference)重点理解
真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
- 最终引用更新(Final Update Reference)
解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
- 并发清理(Concurrent Cleanup)
经过并发回收和引用更新之后,整个回收集中所有的Regin已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。
Brooks Pointer 转发指针技术
在传统的对象布局中,每个对象由对象头和对象数据组成,而在Shenandoah中改变了对象的布局,它为每个对象在对象头之前添加了一个Brooks Pointer转发指针,【默认情况下,这个转发指针指向自己】,当用户线程访问对象时,每次先访问的时转发指针,然后通过转发指针再去访问真实的对象。
转发指针示意图
【当GC线程对存活对象进行复制时,旧的对象的转发指针会指向新对象的地址】,这样当用户线程访问的对象是旧对象时,会通过转发指针转向到访问新对象。
对象移动示意图
通过转发指针【可以解决垃圾线程和用户线程并发读】的问题,但是【对于并发写的问题,却没法解决】。
例如场景:
- 最初转发指针指向的是自己,垃圾回收线程复制对象到新的地址;
- 用户线程修改了旧对象的数据;
- 垃圾回收线程更新旧对象的转发指针。
在这个场景中,用户线程对数据的修改最终会丢失,因此转发指针无法解决并发写的问题。实际上,在Shenandoah中最终是通过CAS算法来解决并发写的问题的。
转发指针的优缺点
优点
- 延迟低
- 解决了并发回收阶段的问题
缺点
- 高运行负担使得吞吐量下降
- 使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销
因此在JDK13中,Shenandoah将内存屏障模型改为了【基于引用访问屏障】来实现,对于基本数据类型的数据读写操作,并不会拦截,它只会拦截数据类型为引用类型的读写操作。
Shenandoah 性能测试
虽然Shenandoah研发团队的目标是将停顿时间控制在10毫秒以内,但是在2016年的性能测试结果中显示,并没有达到这个目标,但是相比其他几款垃圾回收器而言,Shenandoah已经实现了质的飞跃。从《深入Java虚拟机》第二部分-第三章中,截取了一份Shenandoah在2016年的性能测试结果,测试内容是使用ElasticSearch对200GB的维基百科数据进行索引。
Shenandoah性能测试数据结果图
从结果中可以看到,Shenandoah的确对停顿时间进行了大幅的降低,但是它的执行时间却是最久的,这说明Shenandoah的吞吐量有所下降。
Shenandoah 总结
- Shenandoah从特点上看,更像是G1垃圾回收器的继承者,它是一款以低延时为目标的垃圾回收器。
- 与G1相比,它得低延时主要归功于在最后得整理阶段(清除和回收),Shenandoah是并发执行的。
- 在Shenandoah中,通过Brooks Pinter转发指针来实现对象访问的问题,这种解决方式最明显的缺点就是每次访问对象都需要经过一次指针转发,对系统资源消耗过大。
- 在Shenandoah中,默认不使用垃圾分代,它也没有像G1那样使用卡表来维护记忆集,而是采用了“链接矩阵”来记录对象之间跨分区的引用关系,这在很大程度下降低了垃圾回收器对系统内存的占用以及负载。
ZGC 收集器
Java 11包含一个全新的垃圾收集器---ZGC,它由Oracle开发,目前还是一个实验性版本,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector)收集器;ZGC是一款基于Region内存布局的,暂时不设分代的,使用了都屏障、染色指针和内存多重映射等技术实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的Region的容量分类
ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,划分大、中、小三类容量。
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)的,因为复制一个大对象的代价非常高昂。
GC术语
名词 | 描述 |
---|---|
并行 | 在JVM运行时,同时存在应用程序线程和垃圾收集器线程。并行阶段是由多个gc线程执行,即gc工作在它们之间分配。不涉及GC线程是否需要暂停应用程序线程。 |
串行 | 串行阶段仅在单个gc线程上执行。与之前一样,它也没有说明GC线程是否需要暂停应用程序线程。 |
STW | STW阶段,应用程序线程被暂停,以便gc执行其工作。当应用程序应为GC暂停时,这通常是由于Stop The World阶段。 |
并发 | 如果一个阶段是并发的,那么GC线程可以和应用程序线程同时进行。并发阶段很复杂,因为它们需要在阶段完成之前处理可能使工作无效。 |
增量 | 如果一个阶段是增量的,那么它可以运行一段时间之后由于某些条件提前终止,例如需要执行更高优先级的gc阶段,同时仍然完成生产性工作。增量阶段与需要完全完成的阶段形成鲜明对比。 |
染色指针技术(Colored Pointer)
ZGC收集器采用的一个标志性的设计。也可以称为Tag Pointer或Version Pointer。
在ZGC以前,GC的信息都是保存在对象头的MarkWord中。而ZGC则直接将GC的信息存放在对象的指针上面,但是由于借用了指针的高几位,所以ZGC只能在64位上运行,而对应的指针压缩技术也就无法实现。
图例采用《深入理解Java虚拟机》文章中的染色指针示意图
每个对象有一个64位指针,这64位被分为:
- 18位:预留给以后使用。
- 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问(finalizer:object基类的一个空方法,如果被重写则会在GC之前调用该方法,该方法会且只会被调用一次)。
- 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合)。
- 1位:Marked1标识。
- 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC。
- 42位:对象的地址(所以它可以支持2^42=4T内存)。
染色指针的三大优势
- 在一个Region中的所有存活对象都被移走后(复制走后),这个Region就可以被立即释放掉,因为它还有转发表记录着原始地址和新地址,理论上,只要还有一个Region对象空闲,ZGC就能完成垃圾收集。
- 染色指针有指针的“自愈”(Self-Healing)能力,这样子就减少了写屏障(例如三色标记中的增量更新或原始快照),只需要一个读屏障就可以解决问题,减少了内存屏障的使用数量。
- 染色指针有着极大的扩展性,因为还有18位未使用,这样更有利于后续功能的扩展。
为什么会有两个mark标记?
每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0,则周期结束所有引用mark标记都会成为01.
GC周期2:使用mark1,与周期1相同,所有的mark标记都会成为10.
ZGC不能做指针压缩?
指针压缩指的是压缩为32位,寻址位数不能超过35,也就是JVM内存最大为32G(2^35=32GB),这里的寻址位数已经达到了42位。
多重映射寻址
不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实 现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。
Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一 个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内 存容量来得更大。
把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到 同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了.
ZGC的多重映射只是它采用染色指针技术的伴生产物,并不是专门为了实现其他某种特性需求而去做的。
图例采用《深入理解Java虚拟机》文章中的多重映射下的寻址
读屏障
ZGC采用的读屏障的方式来修正指针引用,由于ZGC采用的是复制整理的方式进行GC,很有可能在对象的位置改变之后指针位置尚未更新时程序调用了该对象,那么此时在程序需要并行的获取该对象的引用时,ZGC就会对该对象的指针进行读取,判断Remapped标识,如果标识为该对象位于本次需要清理的region区中,该对象则会有内存地址变化,会在指针中将新的引用地址替换原有对象的引用地址,然后再进行返回。
Object o = obj.fieldA; // 从堆加载对象引用
<load barrier needed here> //这里就需要读屏障
Object p = o; // 这里不是从堆中加载的,所以不需要读屏障
o.doSomething(); // 这里不是从堆中加载的,所以不需要读屏障
int i = obj.fieldB; // 这里不是对象的引用,所以也不需要读屏障
ZGC使用读屏障主要是进行两个操作,而且这两个操作是要一起做的原子操作:
- 对已经转移但还没有重定位的对象进行对象的重定位(这一步可以通过颜色指针的Marked0、Marked1区分)
- 删除对应对象再转发表中记录的指针新旧关系。 触发时机:在两次GC之间业务线程访问这样的对象
ZGC的运作过程
ZGC的运作过程大致可划分为四个大阶段。全部四个阶段都是可以并发执行的,其他小阶段中间会存在短暂的停顿。
1. 初始标记(Mark Start)
先STW,并记录下gc roots直接引用的对象。
2. 并发标记(Concurrent Mark)
与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管SZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。
与G1、Shenandoah不同的,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0、Marked1标志位。
3. 最终标记(Mark End)
先STW,然后修复一些在并发标记过程中垃圾状态出现变化的对象。
4. 并发预备重分配(Concurrent Prepare for Relocate)
- 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
- 重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增 量回收。
- 相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的 维护成本。
- 因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面 的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对 全堆的。
- 此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
5. STW并发初始重分配(Relocate Start)
做一些并发重分配的初始化动作。
6. 并发重分配(Concurrent Relocate)
- 重分配是ZGC执行过程中的核心阶段,这个过程要把重分 配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
- 得益于染色指针的支持,ZGC收集器能仅从引用上就明 确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次 访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(SelfHealing)能力。
- 这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢, 因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染 色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于 新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也 没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
7. 并发重映射(Concurrent Remap)
重映射所做的就是修正整个堆中指向重分配集中旧对象的所 有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第 一次使用时多一次转发和修正操作。
重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束 后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射 阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所 有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧 对象关系的转发表就可以释放掉了。
ZGC优缺点
缺点
浮动垃圾
优点
高吞吐量、低延迟
ZGC核心参数
参数 | 说明 |
---|---|
-XX:+UseZGC | 启用ZGC |
-Xmx | 设置最大堆内存 |
-Xlog:gc | 打印GC日志 |
-Xlog:gc* | 打印GC详细日志 |
ZGC 触发时机
ZGC 中的几种触发 GC场景:
- 定时触发: 默认为不使用,可以通过 ZCollectionInterval 参数配置。GC 日志中的关键字 “Timer”。
- 预热触发: 最多三次,在堆内存空间达到 10%、20%、30% 时机触发、主要是通过 GC 的时间、为其他的 GC 触发准备。GC日志关键字 “Warmup”。
- 分配速率: 基于正态分布统计,计算内存 99% 可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发 GC (耗尽时间,一次 GC 最大持续时间-一次 GC 检测周期时间)。GC日志关键字 “Allocation Rate”。
- 主动触发: (默认开启,可以通过 ZProactictive 参数配置)距上一次 GC 堆内存增长 10%,超过 5 分钟时,对比上次 GC的间隔时间限(一次 GC 最大持续时间),超过则触发。GC 日志关键字 “Proactive”。
- 元数据分配触发: 元数据区不足导致,GC 日志关键中是 “Metadata GC Threshold”
- 直接触发: 代码中显示调用 System.gc() 触发,GC 日志关键字是 “System.gc()”。
- 阻塞内存分配请求触发: 垃圾对象来不及挥手,占满整个堆空间,导致部分线程阻塞,GC 日志关键字是 “Allocation Stall”。
作者:筱白爱学习!!
欢迎关注转发评论点赞沟通,您的支持是筱白的动力!