3.2.2 可达性分析算法
当前主流的商用程序语言(Java、 C#,上溯至前面提到的古老的 Lisp)的内存管理子系统,都是通过可达性分析( Reachability Analysis)算 法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为 “GC Roots”的根对象作为起始节点集,从这些节点开始,根据 引用关系向下搜索,搜索过程所走过的路径称为 “引用链 Reference Chain),如果某个对象到),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是间没有任何引用链相连,或者用图论的话来说就是从从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。到这个对象不可达时,则证明此对象是不可能再被使用的。
如图3-1所示,对象 object 5、 object 6、 object 7虽然互有关联,但是它们到 GC Roots是不可达的,因此它们将会被判定为可回收的对 象。
在Java技术体系里面,固定可作为 GC Roots的对象包括以下几 种:
- ·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- ·在方法区中类静态属性引用的对象,譬如 Java类的引用类型静态变量。
- ·在方法区中常量引用的对象,譬如字符串常量池( String Table)里的引用。
- ·在本地方法栈中 JNI(即通常所说的 Native方法)引用的对象。
- ·Java虚拟机内部的引用,如基本数据类型对应的 Class对象,一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError)等,还有 系统类加载 器。
- ·所有被同步锁( synchronized关键字)持有的对象。
- ·反映 Java虚拟机内部情况的 JMXBean、 JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象的内存区域不同,还可以有其他对象“临时性临时性”地加入,共同构成完整地加入,共同构成完整GC Roots
3.2.3 再谈引用
在JDK 1.2版之后, Java对引用的概念进行了扩充, 将引用分为强引用( Strongly Re-ference)、软引用 Soft Reference)、弱引用 Weak Reference)和虚引用 Phantom Reference 4种,这 4种引用强度依次逐渐减弱。
- ·强引用是最传统的 “引用 ”的定义,是指在程序代码之中普遍存在的引用赋值,即类似 “Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- ·软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象, 在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2版之后提供了SoftReference类来实现软引用。
- ·弱引用也是用来描述那些非必须对象,但是它 的强度比软引用更弱一些,被弱引用关联的对 象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2版之后提供了WeakReference类来实现弱引用。
- ·虚引用也称为 “幽灵引用 ”或者 “幻影引用 ”,它是 最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2版之后提供了 PhantomReference类来实现虚引用。
3.2.5 回收方法区
虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也 确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11时期的ZGC收集器就不支持类卸载),方法区垃圾收集的 “性价比 ”通常也是比较低的:在 Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70%至 99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java堆中的对象非常类似。在大量使用反射、动态代理、 CGLib等字节码框架,动态生成 JSP以及 OSGi这类频繁自定义类加载器的场景中,通常都需要 Java虚拟机具备类型卸载的能力,以保证不会对方法区 造成过大的内存压力。
3.3 垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集 ”Reference Counting GC)和 “追踪式垃圾收集 Tracing GC)两大类,这两类也常被称作 “直接垃圾收集 ”和 “间接垃圾收集 ”。 由于引用计数式垃圾收集算法在本书讨论到的主流 Java虚拟机中均未涉及,所以我们暂不把它作为正文主要内容来讲解,本节介绍的所有算法均属于追踪式垃圾收集的范畴。】
3.3.1 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集 Generational Collection))[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java堆划分出不同的区域,然后 将回收对象依据其年龄(年 龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只 回收其中某一个或者某些部 分的区域 ——因而才有了 “Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法 ——因而发展出了 “标记 -复制算法 ”“标记 -清除算法 ”“标记 -整理算法 ”等针对性的垃圾收集算法。
3.4 HotSpot的算法细节实现
3.5 经典垃圾收集算法
3.5.1 Serial 收集器
Serial收集器是最基础、历史最悠久的收集器,曾经 (在 JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的 “单线程 ”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
特点:新生代标记复制算法,一个gc线程,老年代标记整理算法,一个gc线程(期间都暂停所有用户线程)。
优点:简单而高效,高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗( Memory Footprint))最小的;迄今为止,它依然是 HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而运行在客户端模式下的默认新生代收集器。
3.5.2 ParNew 收集器
ParNew收集器实质上是 Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial收集器可用的所有控制参数(例如: :-XX SurvivorRatio、 -XX PretenureSizeThreshold、 -XX HandlePromotionFailure等)、收集算法、 Stop The World、对象分配规则、回收策略等 都与 Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
特点:新生代标记复制算法,多个gc线程;老年代标记整理算法,单个gc线程(期间都暂停所有用户线程)。
自 JDK 9开始, ParNew加 CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被 G1所取代,甚至还取消了 ParNew加 Serial Old以及 Serial加 CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了 XX +UseParNewGC参数,这意味着 ParNew和 CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。读者也可以理解为从此以后, ParNew合并入 CMS,成为它专门处理新生代的组成部分。 ParNew可以说是 HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
3.5.3 Para llel Scavenge 收集器
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记 -复制算法实现的收集器,也是能够并行收集的多线程收集器 ……Parallel Scavenge的诸多特性从表面上看和 ParNew非常相似,那它有什么特别之处呢?Parallel Scavenge收集器的特点是它的关注点与其他收集器不同, CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量( Throughput 。 所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值)。Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX MaxGCPauseMillis参数以及直接设置吞吐量大小的 -XXGCTimeRatio参数。
特点:新生代标记复制算法,多个gc线程。期间都暂停所有用户线程
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作 “吞吐量优先收集器 ”
3.5.4 Serial Old 收集器
Serial Old是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记 -整理算法 。这个收 集器的主要意义也是供客户端模式下的 HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在 JDK 5以及之前的版本中与 Parallel Scavenge收集器搭配使用 [1],另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure时使用。这两点都将在后面的内容中继续讲解。
特点:老年代标记整理算法,单gc线程(期间都暂停所有用户线程)。
3.5.5 Parallel Old 收集器
Parallel Old是 Parallel Scavenge收集器的老年代版本,支 持多线程并发收集,基于标记 -整理算法实现。这个收集器是直到 JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了 Parallel Scavenge收集器,老年代除了 Serial Old PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如 CMS无法与它配合工作。由于老年代 Serial Old收集器在服务端应用性能上的 “拖累 ”,使用 Parallel Scavenge收集器也未必能在整体上获得吞吐
量最大化的效果。
特点:老年代标记整理算法,多gc线程并行(期间都暂停所有用户线程)。
3.5.6 CMS 收集器
CMS Concurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java应用集中在互联网网站或者基于浏览器的 B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。 CMS收集器就非常符合这类应用的需求。
CMS收集 器是基于标记 -清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,
包括:
初始标记(CMS initial mark),需要暂停用户线程,速度很快。单gc线程
并发标记(CMS concurrent mark),耗时较长,但不需要暂停用户线程。gc roots的直接关联对象开始遍历整个对象图的过程。单gc线程
重新标记(CMS remark),需要暂停用户线程。多gc线程
并发清除(CMS concurrent sweep),不需要暂停用户线程。单gc线程
有三个明显的缺点:
首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感,需要占用处理器的计算能力;
然后,由于CMS收集器无法处理 “浮动垃圾 Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全 “Stop The World”的 Full GC的产生。(在 CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结 束以后, CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为 “浮动垃圾 ”。那就还需要预留足够内存空间提供给用户线程使用,因此 CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。但这又会更容易面临另一种风险:预留的内存无法满足程序分配新对象的需要,就会出现一次 “并发失败 Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用 Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了)
还有最后一个缺点,CMS是一款基于 “标记 -清除 ”算法实现的收集器,这意味着会产生大量空间碎片。参数 -XX CMSFullGCsBeforeCompaction(此参数从 JDK 9开始废弃),这个参数的作用是要求 CMS收集器在执行过 若干次(数量由 参数值决定)不整理空间的 Full GC之后,下一次进入 Full GC前会先进行碎片整理(默认值为 0,表示每次进入 Full GC时都进行碎片整理)。
3.5.7 Garbage First 收集器
Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的 设计思路和基于 Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。未来可以替换掉 JDK 5中发布的 CMS收集器。JDK 9发布之日, G1宣告取代 Parallel Scavenge加 Parallel Old组合,成为服务端模式下的默认垃圾收集器,而 CMS则沦落至被声明为不推荐使用Deprecate)的收集器 [1]。、
进行回收,衡量标准不再是它属于哪个分 代,而是哪块内存中存放的垃圾数量最多, 回收收益最大,这就是 G1收集器的 Mixed GC模式。
G1开创的基于 Region的堆内存布局是 它能够实现这个目标的关键。Region中还有一类特殊的 Humongous区域,专门用来存储大对象。 G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。每个 Region的大小可以通过参数 -XX G1HeapRegionSize设定,取值范围为 1MB 32MB,且应为 2的 N次幂。而对于那些超过了整个 Region容量的超级大对象,将会被存放在 N个连续的 Humongous Region之中, G1的大多数行为都把 Humongous Region作为老年代的一部分来进行看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。 G1收集器之所以能建立可预测的停顿时间模型,是因为它将 Region作为单次回收的最小单元,即每次收集到的内存空间都是 Region大小的整数倍,这样可以有计划地避免在整个 Java堆中进行全区域的垃圾收集。更具体的处理思路是让 G1收集器去跟踪各个 Region里面的垃圾堆积的 “价值 ”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定 允许的收集停顿时间(使用 参数 -XX MaxGCPauseMillis指定,默认值是 200毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。这种使用 Region划分内存空间,以及具有优先级的区域回收方式,保证了 G1收集器在有限的时间内获取尽可能高的收集效率。
缺点:
收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验, G1至少要耗 费大约相 当于 Java堆容量 10%至 20%的额外内存来维持收集器工作。
G1收集器的运作过 程大致可划分为以下四个步骤:
1. 初始标记 Initial Marking):仅仅只是标记一下 GC Roots能直接关联到的对象,并且修改 TAMS 指针的 值,让 下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿用户线程,但耗时很短,而且是借用进行 Minor GC的时候同步完成的。单gc线程,需要停顿用户线程
2.并发标记 Concurrent Marking):从 GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较 长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB记录下的在并发时有引用变动的对
象。单gc线程,不需要停顿用户线程
3.最终标记 Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB记录。多gc线程,需要停顿用户线程
4.筛选回收 Live Data Counting and Evacuation):负责更新 Region的统计数据,对各个 Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region的存活对象复制到空的 Region中,再清理掉整个旧 Region的全部空间。操作涉及存活对象的移动,由多条gc线程,需要停顿用户线程
通常把期望停顿时间设置为一两百毫秒或者两三百毫秒 会是比较合理的。 G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。目前在小内存应用上 CMS的表现大概率仍然要会优于 G1,而在大内存应用上 G1则大多能发挥其优势,这个优劣势的 Java堆容量平衡点通常在 6GB至 8GB之间。
3.6 低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量Throughput)和延迟 Latency),三者共同构成了一个 “不可能三角 [1]”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的 “完美 ”收集器是极其困 难甚至是不可能 的,一款优秀的收集器通常最多可以同时达成其中的两项。而延迟指标是越来越被关注的的。硬件的发展让内容占用变得不重要了,但是内存越多,延迟却会越大。
可见,在 CMS和 G1之前的全部收集器,其工作的所有步骤都会产生 “Stop The World”式的停顿;CMS和 G1分别使用增量更新和原始快照(见 3.4.6节)技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停 顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。 CMS使用标记 -清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过 “Stop The World”的命运。 G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
最后的两款收集器, Shenandoah和 ZGC,几乎整个工作过程全部都是并发的,只 有初始标记、最终标记这些阶段有短暂的 停顿,这部分停顿的时间基本上是固 定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在上,它们都可以在任意可管理的(譬如现在 ZGC只能管理只能管理4TB以内的堆)堆容量下,以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器低延迟垃圾收集器”
3.6.1 Shenandoah 收集器
Shenandoah相比起 G1又有什么改进呢?虽然 Shenandoah也是使用基于 Region的堆内存布局,同样有着用于存放大对象的 Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region……但在管理堆内存方面,它与 G1至少有三个明显的不同之处,
1.最重要的当然是支持并发的整理算法, G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。
2.其次, Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代 Region或者老年代 Region的存在,没有实现分代,并不是说分代对 Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位
置上。
3.Shenandoah摒弃了在 G1中耗费 大量内 存和计算资源去维护的记忆集,改用名为 “连接矩阵 Connection Matrix)的全局数据结构来记录跨 Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见 3.4.4节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果 Region N有对象指向 Region M就在表格的 N行 M列中打上一个标记,如图 3-15所示,如果 Region 5中的对象 Baz 引用了 Region 3的 Foo Foo又引用了 Region 1的 Bar,那连接矩阵中的 5行 3列、 3行 1列就应该被打上 标记。在回收时 通过这张表格就可以得出哪些 Region之间产生了跨代引用。
Shenandoah收集器的工作过程大致可以划分为以下九个阶段(此处以 2016年发表论文介绍)
1.初始标记 Initial Marking):与 G1一样,首先标记与 GC Roots直接关联的对象,这个阶段仍是 “Stop The World”的,但停顿时间与堆大小无关,只与 GC Roots的数量相关。
2·并发标记 Concurrent Marking):与 G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
3·最终标记 Final Marking):与 G1一样,处理剩余的 SATB扫描,并在这个阶段统计出回收价值最高的 Region,将这些 Region构成一组回收集( Collection Set)。最终标记阶段也会有一小段短暂的停顿。
4·并发清理 Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的 Region(这类 Region被称为 Immediate Garbage Region)。
5.并发回收 Concurrent Evacuation):并发回收阶段是 Shenandoah与之前 HotSpot中其他收集器的核心差异。在这个阶段, Shenandoah要把回收集里 面的存活对象 先复制一份到其他未被使用的 Region之中。其困难点是在移动 对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,对于并发回收阶段遇到的这些困难,Shenandoah将会通将会通过读屏障或被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
6.·初始引用更新 Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
7·并发引用更新 Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
8·最终引用更新 Final Update Reference):解决了堆中的引用更新后,还要修正存在于 GC Roots 中的引用。这个阶段是 Shenandoah的最后一次停顿,停顿时间只与 GC Roots的数量相关。
9·并发清理 Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的 Region已再无存活对象,这些 Region都变成 Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些 Region的内存空间,供以后新对象分配使用。
读屏障或被称为“Brooks Pointers”的转发指针
Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前 面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用 指向对象自己,有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的 ——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程 度,譬如以下所示:mov r13,QWORD PTR [r12+r14*8 0x8] 。不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。
转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。会出现多线程竞争问题,就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上 Shenandoah收集器是通过比较并交换Compare And Swap CAS)操作 [8]来保证并发时对象的访问正确性的。
转发指针另一点必须注意的是执行频率的问题。对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作, Shenandoah不得不同时设置读、写屏障去拦截。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多
得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。
Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性能开销会是 Shenandoah被诟病的关键点之一 [9],所以计划在 JDK 13中将 Shenandoah的内存屏障模型改进 为基于引用访问屏障 Load Reference Barrier))[10]的实现,所谓 “引用访问屏障 ”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。
3.6.2 ZGC 收集器
ZGC((“Z”并非什么专业名词的缩写,这款收集器的名字就叫作 Z Garbage Collector)是一款在 JDK 11中新加入的具有实验性质 [1]的低延迟垃圾收集器,是由Oracle公司研发的。ZGC就更像是 Azul System公司独步天下的 PGC Pauseless GC)和 C4 Concurrent Continuously Compacting Collector)收集器的同胞兄弟。早在2005年,运行在 Azul VM上的 PGC就已经实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集,而运行在 Zing VM上的 C4收集器是 PGC继续演进的产物,主要增加了分代收集支持,大幅提升了收集器能够承受的对象分配速度。ZGC几 乎 所有的关键技术上,与PGC和 C4都只存在术语称谓上的差别,实质内容几乎是一模一样的。
ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记 -整理算法的,以低延迟为首要目标的一款垃圾收集器。
不同点:
1.动态的区域容量大小。在 x64硬件平台下, ZGC的 Region可以具有如图 3-19所示的大、中、小三类容量:
·小型 Region Small Region):容量固定为 2MB,用于放置小于 256KB的小对象。
·中型 Region Medium Region):容量固定为 32MB,用于放置大于等于 256KB但小于 4MB的对象。
·大型 Region Large Region):容量不固定,可以动态变化,但必须为 2MB的整数倍,用于放置 4MB或以上的大对象。每个大型 Region中只会存放一个大对象,这也预示着虽然名字叫作 “大型 Region”,但它的实际容量完全有可能小于中型 Region,最小容
量可低至 4MB。大型 Region在 ZGC的实现中是不会被重分配。
2.ZGC的核心问题 ——并发整理算法的实现。 Shenandoah使用转发指针和读屏障来实现并发整理, ZGC虽然同样用到了读屏障,但用的却是一条与 Shenandoah完全不同,更加复杂精巧的解题思路。染色指针是一种直接将少量额外的信息存储在指针上的技术。ZGC的染色指针技术继续盯上了这剩下的 46位指针宽度,将其高 4位提取出来存储四个标志信息。通过这些标志 位,虚拟机可以直接从指针中看到其引用对象 的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过 finalize()方法才能被访问到。当然,由于这些标志位进一步压缩了原本就只有 46位的地址空间,也直接导致 ZGC能够管理的内存不可以超过 4TB 2的 42次幂) )虽然染色指针有 4TB的内存限制,不能支持 32位平台,不能支持压缩指针( (-XX +UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的,染色指针的三大优势:
(1)·染色指针可以使得一旦某个 Region的存活对象被移走之后,这个 Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该 Region的引用都被修正后才能清理。
(2)·染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上,到目前为止 ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是 ZGC现在还不支持分代收集,天然就没有跨代引用的问题)。
(3)·染色指针可以作为一种可 扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
不过,要顺利应用染色指针有一个必须解决的前置问题:Linux/x86-64平台上的 ZGC使用了多重映射( Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着 ZGC在虚拟内存中看到的地址 空间要比实际的堆内存容 量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用 染色指针正常进行寻址了
收集器是如何工作的。 ZGC的运作过程大致可划分为以下四个 大的阶段。全部四个阶段都是可以 并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化 GC Root直接关联对象的 Mark Start,与之前 G1和 Shenandoah的 Initial Mark阶段并没有什么差异,笔者就不再单独解释了。
1.并发标记 Concurrent Mark):与 G1、 Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于 G1、 Shenandoah的初始标记、最终标记尽管 ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与 G1、 Shenandoah不同的是, ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked 0、 Marked 1标志位。
2.并发预备重分配 Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region组成重分配集Relocation Set)。重分配集与 G1收集器的回收集( Collection Set)还是有区别的ZGC划分 Region的目的并非为了像 G1那样做收益优先的增量回收。相反, ZGC每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1中记忆集的维护成本。因此, ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的 Region中,里面的 Region会被释放,而并不能说回收行为就只是针对这个集合里面的 Region进行,因为标记过程是针对全堆的。此外,在 JDK 12的 ZGC中开始支持的类卸载以及弱引用的处理,也是在 这个阶段中完成的。
3.并发重分配 Concurrent Relocate):重分配是 ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region上,并为重分配集中的每个 Region维护一个转发表(维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指),记录从旧对象到新对象的转向关系。得益于染色指针的支持,针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上的转发表记录将访问转发到新复制的对象上,并同时修正上,并同时修正更新该引用的值,使其直接指向新对象,更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的将这种行为称为指针的“自愈自愈”((Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比对比Shenandoah的的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此地说就是每次都慢,因此ZGC对用户程序的运行时负载要比对用户程序的运行时负载要比Shenandoah来得更低一来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的的存活对象都复制完毕后,这个存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发就可以立即释放用于新对象的分配(但是转发表表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这未更新指针也没有关系,这些旧指针一旦被使用,它们都是可些旧指针一旦被使用,它们都是可以自愈的。以自愈的。
4.并发重映射 Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与 Shenandoah并发引用更新阶段一样的,但是 ZGC的并发重映射并不是一个必须要 “迫切 ”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转 发表这样的附带收益),所以说这并不是很 “迫切 ”。因此, ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图 [9]的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
zgc收集器的缺点:没有分代,如果这种新生代对象高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空 间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息 的时间。但是若要从根本上提升 ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。
ZGC还有一个优点是支持 “NUMA-Aware”的内存分配。NUMA Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。在 NUMA架构下, ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对 象,以保证高效内存访问。在 ZGC之前的收集器就只有针对吞吐量设计的 Parallel Scavenge支持 NUMA内存分配 [13],如今ZGC也成为另外一个选择。