前言:
此时咱们对“zgc垃圾回收”大概比较重视,大家都想要分析一些“zgc垃圾回收”的相关资讯。那么小编也在网络上收集了一些关于“zgc垃圾回收””的相关资讯,希望姐妹们能喜欢,小伙伴们快快来了解一下吧!回收设计
ZGC的并发回收算法采用的也是“目的空间不变性”的设计,关于目的空间不变性的更多内容可以参考第7章。
在第7章中提到,Shenandoah从JDK 13开始也采用“目的空间不变性”的设计。但是ZGC与Shenandoah相比,还是有不少细节并不相同,如表8-3所示。
表8-3 Shenandoah和ZGC比较
本节主要围绕ZGC算法的特殊点进行介绍。
算法概述
ZGC基于分区管理,在回收的时候采用的是单代、部分回收,即选择部分垃圾比较多的分区进行回收。整个回收算法经历3个阶段,分别是标记、转移和重定位。
在使用ZGC时,堆空间按照页使用,在启动垃圾回收时,部分页面已经满了,其中有活跃对象也有死亡对象。另外,整个堆空间还有一部分空闲的页面,这些页面用于垃圾回收过程中对象的转移。关于垃圾回收启动的时机,后文将详细介绍。
在JDK 16之前,ZGC进行回收时一定需要空闲的页面才能完成对象的转移,这实际上降低了内存的利用率。在JDK 16中引入了新的优化,在一定条件下对象的转移还是在本页面内(称为in-place relocation)进行,也称为本地转移。
在标记阶段完成堆内活跃对象的识别。ZGC的标记阶段分为初始标记、并发标记、再标记和弱根标记。
1)初始标记:使用STW的方式,暂停Mutator,完成从根集合出发到堆内对象的标记。
2)并发标记:将根集合识别出的活跃对象作为并发标记的起点,完成整个堆空间内活跃对象的标记。
3)再标记:使用STW的方式,暂停Mutator,再次完成根集合到整个堆内空间的标记。再标记主要是为了解决某些Mutator在并发标记阶段因各种因素,无法执行新增根集合到堆空间的标记的问题。
4)弱根标记:此阶段处理弱根(包括Java语言中的引用),弱根处理的目的是将标记阶段识别出来的对象再次进行标记处理,确定这些对象是否真的活跃。
关于标记的实现将在8.3.1节介绍。在标记完成后,整个堆空间的状态如图8-5所示。
在标记完成后,进入转移阶段。在进入转移阶段时,会选择到底转移哪些页面(活跃对象比较多的页面不转移,以提高转移的效率)。ZGC的转移阶段分为初始转移和并发转移。
1)初始转移:使用STW的方式,暂停Mutator,完成从根集合出发到堆内转移集合对象(即对象必须位于转移页面)的转移;根集合中引用到的不在转移集合中的对象则不会转移。
2)并发转移:根据选择的转移集合,对其中的活跃对象进行转移。
关于转移的实现在8.3.2节介绍。转移完成后,整个堆空间的状态如图8-6所示。
在转移的过程中使用了转移表(Forwarding Table)来记录对象转移前后的地址。这样在转移完成后,转移集合中的页面都可以被释放,然后被立即重用。
当转移完成后释放转移完成的分区,整个堆空间的状态如图8-7所示。
在转移完成后,进入重定位阶段。在ZGC的实现中将重定位和标记阶段进行了合并。在标记的时候,如果发现对象使用了过时的对象(例如这个对象发生了转移),只需要从转移表中根据当前的地址找到转移后的地址,并更新相关引用地址即可。
在ZGC的设计中采用了目标空间不变性来保证并发操作的正确性。在实现中通过读屏障来完成,当读到堆内对象时,首先判断对象状态是否正确,如果不正确,则通过屏障来保证对象的正确性。所以在标记阶段,读屏障的目的是帮助标记活跃对象;在转移阶段,读屏障的目的是帮助将转移集中的活跃对象转移到新的页面中。
为了区别不同的阶段,ZGC引入了视图状态,并在不同的阶段采用不同的读屏障。下面介绍一下视图和读屏障。
视图状态
ZGC使用了3种视图状态,分别为Marked0(也称为M0)、Marked1(也称为M1)和Remapped。其中M0和M1表示标记阶段,Remapped表示转移阶段。ZGC在初始化之后,整个内存空间的地址视图被设置为Remapped,当进入标记阶段时,视图转变为M0或者M1,标记阶段结束进入转移阶段时,视图再次被设置为Remapped。采用视图表示后,具体的算法如下。
(1)初始化阶段
在ZGC初始化之后,地址视图为Remapped,程序正常运行,在内存中分配对象,满足一定条件(关于垃圾回收的触发时机将在后文介绍)后垃圾回收启动。此时进入并发标记阶段。
(2)并发标记阶段
第一次进入并发标记阶段时视图为M0,在并发标记阶段应用程序和标记线程并发执行。那么对象的访问可能来自:
1)GC工作线程。GC工作线程访问对象的目的就是对对象进行标记。它从根集合开始标记对象,在标记前先判断对象的地址视图是M0还是Remapped。
如果对象的地址视图是M0,则说明对象是在进入并发标记阶段之后新分配的对象,或者对象已经完成了标记,也就是说对象是活跃的,无须处理。
如果对象的地址视图是Remapped,则说明对象是前一阶段分配的,而且通过根集合可达,所以把对象的地址视图从Remapped调整为M0。
2)Mutator运行用户代码时访问对象,所做的工作有:
如果Mutator创建新的对象,则对象的地址视图为M0。
如果Mutator访问对象并且对象的地址视图是Remapped,则说明对象是前一阶段分配的,只要把该对象的视图从Remapped调整为M0就能防止对象漏标。注意,只标记Mutator访问到的对象还不够,实际上还需要标记对象的成员变量所引用的对象,可以通过递归的方式完成标记(为了不影响Mutator的运行,该工作将会转入GC工作线程中完成)。
如果Mutator访问对象并且对象的地址视图是M0,则说明对象是在进入并发标记阶段之后新分配的对象或者对象已经完成了标记,无须额外处理,直接访问。
总之,在标记阶段结束之后,对象的地址视图要么是M0,要么是Remapped。如果对象的地址视图是M0,则说明对象是在标记阶段被标记的或者是新创建的,是活跃的;如果对象的地址视图是Remapped,则说明对象在标记阶段既不能通过根集合访问到,也没有Mutator访问它,所以是不活跃的,即对象所使用的内存可以被回收。
当并发标记阶段结束后,ZGC使用对象活跃信息表记录所有活跃对象的地址,活跃对象的地址视图都是M0。
(3)并发转移阶段
标记结束后就进入并发转移阶段,此时地址视图再次被设置为Remapped。转移阶段会把部分活跃对象(只有垃圾比较多的页面才会被回收)转移到新的内存中,并回收对象转移前的内存空间。在并发转移阶段,应用程序和标记线程并发执行,那么对象的访问可能来自:
1)GC工作线程。GC工作线程根据标记阶段标记的活跃对象进行转移,所以只需要针对对象活跃信息表中记录的对象进行转移。当转移线程访问对象时:
如果对象在对象活跃信息表中并且对象的地址视图为M0,则转移对象,转移以后对象的地址视图从M0调整为Remapped。
如果对象在对象活跃信息表中并且对象的地址视图为Remapped,则说明对象已经被转移,无须处理。
2)Mutator运行用户代码时访问对象,所做的工作有:
如果Mutator创建新的对象,则对象的地址视图为Remapped。
如果Mutator访问对象并且对象不在对象活跃信息表中,则说明对象是新创建的或者对象无须转移,无须额外处理。
如果Mutator访问对象并且对象在对象活跃信息表中,且对象的地址视图为Remapped,则说明对象已经被转移,无须额外处理。
如果Mutator访问对象并且对象在对象活跃信息表中,且对象的地址视图为M0,则说明对象是标记阶段标记的活跃对象,所以需要转移对象。在对象转移以后,对象的地址视图从M0调整为Remapped。
至此,ZGC一个垃圾回收周期中并发标记和并发转移就结束了。我们提到,在标记阶段存在两个地址视图M0和M1。上面的算法过程显示只用到了一个地址视图,为什么设计成两个呢?简单地说,是为了区别前一次标记和当前标记。
第一次垃圾回收时地址视图为M0,假设标记了两个对象ObjA和ObjB,说明ObjA和ObjB都是活跃的,它们的地址视图都是M0。在转移阶段,ZGC按照页面进行部分内存垃圾回收,也就是说当对象所在的页面需要回收时,页面里面的对象需要被转移,如果页面不需要转移,页面里面的对象也就不需要转移。假设ObjA所在的页面被回收,所以ObjA被转移,ObjB所在的页面在这一次垃圾回收中不会被回收,所以ObjB不会被转移。ObjA被转移后,它的地址视图从M0调整为Remapped,ObjB不会被转移,ObjB的地址视图仍然为M0。
那么下一次垃圾回收标记阶段开始的时候,存在两种地址视图的对象,对象的地址视图为Remapped说明在并发转移阶段被转移或者访问过;对象的地址视图为M0,说明在前一次垃圾回收的标记阶段被标记过。如果本次垃圾回收标记阶段仍然使用M0这个地址视图,那么就不能区分对象是否是活跃的,还是上一次垃圾回收标记过的。所以新一次标记阶段使用了另外一个地址视图M1,则标记结束后所有活跃对象的地址视图都为M1。此时这3个地址视图代表的含义如下。
1)M1:本次垃圾回收中识别的活跃对象。
2)M0:前一次垃圾回收的标记阶段被标记过的活跃对象,对象在转移阶段未被转移,但是在本次垃圾回收中被识别为不活跃对象。
3)Remapped:前一次垃圾回收的转移阶段发生转移过的对象或者被应用程序线程访问的对象,但是在本次垃圾回收中被识别为不活跃对象。
这里通过一个简单的场景来演示并发标记算法。假设在ZGC初始化后,Mutator创建对象0、对象1和对象2,此时它们的地址视图都是Remapped。之后因为某种因素触发垃圾回收,则进入标记阶段。在标记阶段假设对象0和对象2可以通过根集合访问并标记,另外,应用程序线程在并发运行过程中新创建对象3。标记结束后发现对象1不可以从根集合访问到,此时对象0、对象2和对象3的地址视图为M0,表示为活跃对象,对象1的地址视图还是Remapped,表示为垃圾对象,如图8-8所示。
标记阶段结束之后,进入转移阶段。假设应用程序线程并发运行过程中新创建对象4;对象0所在的页面需要回收,所以对象0转移到新的页面,这个新的对象称为对象0’;对象2和对象3没有被访问到。此时对象2和对象3的地址视图为M0,对象4和对象0’的地址视图为Remapped,对象0所在的页面将被回收,对象1所在的页面可能被回收,也可能不被回收。如果对象1所在的页面被回收,则对象1不存在,如果页面没有回收,则对象1的地址视图为Remapped,如图8-9所示。
经过一段时间的运行后,再次触发垃圾回收。因为对象1不活跃,下一次垃圾回收时也不会被标记,所以我们不再关注对象1。假设在新的标记阶段,只有对象4从根集合可达,对象2、对象3和对象0’都是不可达的,对象6是应用程序新分配的对象。此时对象2和对象3的地址视图为M0,表示垃圾对象,对象0’的地址视图为Remapped,也表示为垃圾对象,对象4和对象6的地址视图为M1,表示活跃对象,如图8-10所示。
标记完成后会再次进入转移阶段,转移阶段和前一次转移阶段过程类似,不赘述。
读屏障
由于ZGC是并发执行,也就是说Mutator和GC工作线程可以同时修改同一个对象,如果没有合理的同步机制,将导致运行出错。Mutator修改对象是为了程序的正常执行,而GC工作线程修改对象是为了垃圾回收。两者虽然可能会同时修改同一个对象,但它们所做的事情完全不同。
一种常见的保证正确性的设计是:Mutator在修改对象前先做GC工作线程的工作,然后再修改对象,这样GC工作线程就不用与Mutator竞争修改对象了。
ZGC采用读屏障的方式来确保正确性。即Mutator在读对象的时候,判断GC周期正在执行的操作,然后判断访问的对象是否已经执行了GC的操作,如果没有执行,那么Mutator先执行GC操作,再继续访问对象。
读屏障的具体实现是在字节码层面或者编译代码层面给读操作增加一段额外的处理(执行对应的GC操作)即可。
读屏障是由读命令触发的。JVM有3种运行状态:解释执行、C1和C2优化执行。不同的运行状态,读屏障的触发代码略有不同,但它们使用的读屏障是完全一样的。我们从最简单的解释执行看一下读屏障的实现。读屏障在解释执行时通过load相关的字节码指令加载数据。大家可以参考相关的书籍或者文章了解load指令的具体执行过程。我们直接从堆空间中加载对象的地方了解一下读屏障,其代码如下:
template <DecoratorSet decorators, typename BarrierSetT>
template <typename T>
inline oop ZBarrierSet::AccessBarrier<decorators,
BarrierSetT>::oop_load_in_heap(T* addr) {
verify_decorators_absent<ON_UNKNOWN_OOP_REF>();
const oop o = Raw::oop_load_in_heap(addr);
return load_barrier_on_oop_field_preloaded(addr, o);
}
这里调用的load_barrier_on_oop_field_preloaded就是读屏障,在对象加载完成后做额外的处理。这里不分析具体的代码,直接给出ZGC中读屏障的流程图,如图8-11所示。
整个读屏障会根据垃圾回收的阶段来判断执行什么操作,操作有标记、转移和重定位。
1)标记:将对象标记为活跃对象,在Mutator进行标记后,还需要标记已标记完成对象的成员变量,但为了减少标记对于Mutator的影响,一般将对象送入GC工作线程中标记。为了减少Mutator和GC工作线程之间的影响,需要设计无锁的数据结构来处理这种情况。ZGC使用线程局部栈的结构保存每个Mutator需要遍历的对象,在Mutator的本地标记栈满的情况下,会将其放入GC工作线程的待标记数据结构中。
2)转移:将转移集中的对象转移到新的页面中。Mutator辅助转移仅仅转移对象本身,不会做额外的事情,GC工作线程负责页面集中其他所有对象的转移。但是Mutator的转移有潜在的两个问题。
当有大量的对象需要Mutator辅助转移时,Mutator的效率会下降;当Mutator在转移时遇到没有可供待转移对象分配的内存空间时,会导致Mutator本身暂停。
为了保证转移的效率,Mutator辅助转移和GC工作线程的转移通常使用不同的目标内存,减少锁的使用。这在一定程度上破坏了内存数据的局部性。
3)重定位:发现对象的地址过时(发现对象在上一次GC周期已经转移)时,应根据转移表获取对象转移后的地址,并更新该值即可。
高效的标记和转移设计
在GC的实现中,两个关键的操作分别是标记和转移。在其他的垃圾回收实现中标记需要修改对象头,设置一个特殊的状态表示对象是活跃的。而设置对象头需要发生一次真实的内存访问,并将对象头修改写回内存。
ZGC中采用了一种称为Color Pointer的机制来避免这样的内存访问。具体的思路是:借助于对象的地址位,在地址位上设置不同的标记状态。例如使用一个地址位表示对象是否活跃,当设置为0时表示对象死亡,设置为1时表示对象活跃。那么标记时不再需要修改对象头,只需要修改对象的地址位即可。这样做的好处就是标记对象存活根本不需要真正访问对象,从而减少了因为GC工作频繁地访问内存。
在上面介绍了ZGC使用视图状态来描述GC的工作状态。把视图状态和Color Pointer结合,即用地址位来描述视图状态,既可以表达GC的工作状态,又可以减少内存的访问。
在JDK 11和JDK 12中,ZGC支持的最大的堆空间为4TB。从JDK 13开始,支持的最大的堆空间为16TB。其中最主要的原因就是ZGC使用了对象不同的地址位。我们先以JDK 11为例来介绍一下ZGC如何使用地址位。
ZGC支持64位系统,以ZGC支持4TB堆空间为例,看一下ZGC是如何使用64位地址的。ZGC中低42位(第0~41位)用于描述真正的虚拟地址,接着的4位(第42~45位)用于描述元数据,其实就是上面所说的Color Pointer,还有1位(第46位)暂时没有使用(所以也设置为0),最高17位(第47~63位)固定为0,如图8-13所示。
42位地址最大的寻址空间就是4TB。在JDK 13中堆空间扩展为16TB,其地址位使用的示意图如图8-14所示。
从JDK 13开始,ZGC设计为支持4TB、8TB和16TB的内存。那么是否可以支持更大的内存空间呢?目前来说非常困难,主要是受硬件限制。目前大多数处理器地址线只有48条,也就是说64位系统支持的地址空间为256TB。为什么处理器的指令集是64位的,但是硬件仅支持48位的地址呢?最主要的原因是成本。即便到目前为止由48位地址访问的256TB的内存空间也是非常巨大的,也没有多少系统有这么大的内存,所以CPU在设计时仅仅支持48位地址,可以少用很多硬件。如果未来系统需要扩展,则无须变更指令集,只要从硬件上扩展即可。
对于ZGC来说,由于多视图(也称为Color Pointer)的缘故,会额外占用4位地址位,所以真正可用的应该是44位。理论上ZGC最大可以支持16TB的内存,但是如果要扩展得更多,超过16TB时,则需要重新设计这一部分。
ZGC使用Color Pointer机制减少内存的访问,还需要解决一个问题,就是需要有一个机制来识别对象设置不同的地址位(即对象的地址不同),但是对象仍然处于同一个内存地址。这看起来非常怪异,一个对象是由唯一的地址确定的,但是目前需要有一个机制把多个地址和一个对象关联起来,只有这样的机制才能真正解决内存访问的问题。幸运的是,目前的OS都支持这样的方式,即多地址视图映射机制,把多个虚拟地址映射到一个物理地址上。
以JDK 11管理的4TB内存为例,按照图8-13的介绍,堆空间被划分为3个视图,分别是M0(即Marked0)、M1(即Marked1)和Remapped。这3个视图的地址布局如图8-15所示。
在ZGC中常见的虚拟空间有[0,4TB)、[4TB,8TB)、[8TB,12TB)、[16TB,20TB),其中[0,4TB)对应的是Java的堆空间;[4TB,8TB)、[8TB,12TB)、[16TB,20TB)分别对应M0、M1和Remapped这3个视图。最为关键的是M0、M1和Remapped这3个视图会映射到操作系统的同一物理地址。这几个空间的关系如图8-16所示。
该图是ZGC在运行时虚拟地址和物理地址的转化。从图8-16中我们可以得到:
1)4TB是的堆空间,其大小受限于JVM参数。
2)0~4TB的虚拟地址是ZGC提供给应用程序使用的虚拟空间,它并不会映射到真正的物理地址。
3)操作系统管理的虚拟内存为M0、M1和Remapped这3个空间,且它们对应同一物理空间。
4)在ZGC中,这3个空间在同一时间点有且仅有一个空间有效。为什么这么设计?这就是ZGC的高明之处,利用虚拟空间换时间。这3个空间的切换由垃圾回收的不同阶段触发。
5)应用程序可见并使用的虚拟地址为0~4TB,经ZGC转化,真正使用的虚拟地址为[4TB,8TB)、[8TB,12TB)和[16TB,20TB),操作系统管理的虚拟地址也是[4TB,8TB)、[8TB,12TB)和[16TB,20TB)。应用程序可见的虚拟地址[0,4TB)和物理内存直接的关联由ZGC来管理。
使用地址视图的好处就是加快标记和转移的速度。比如对于对象在标记阶段只需要转换地址视图。而地址视图的转化非常简单,只需要设置地址中第42~45位中相应的标志位即可。而在以前的垃圾回收器中,要修改对象的对象头,把对象头的标记位设置为已标记,这就会产生内存存取访问。而在ZGC中无须任何的对象访问。这就是ZGC在标记和转移阶段速度更快的原因。在标记过程中有一个技术细节值得注意:当对象被多个对象引用时,如何保证对象仅仅标记一次?下面通过一个简单的例子来演示这个问题,假定对象引入关系初始状态如图8-17所示。
假设标记开始前地址视图为Remapped,GC工作线程将Obj1和Obj3标记,首先从一个视图(Remapped)映射到另外一个视图(M1)。此时Obj1和Obj3的地址视图为M1,而Obj2尚未完成标记,地址视图仍然为Remapped,并且Obj2中成员变量也没有更新,所以它指向的Obj3仍然是老的地址视图。也就是说,Obj1中指向的Obj3其地址位为M1+Address,Obj2指向的Obj3其地址位为Remapped+Address。部分对象标记后的地址视图如图8-18所示。
当Obj2完成标记后,其地址视图也变成了M1,但是Obj2指向的Obj3地址仍然为Remapped+Address。实际上Obj3已经通过Obj1的引用链完成了标记。
该如何处理Obj2中仍然指向的过时对象视图呢?示意图如图8-19所示。
由于Obj3已经被标记过,意味着Obj2指向过时对象Obj3,无须再次标记。所以对于Obj2处理时只需要让Obj2修正引用指针即可。注意,这里的修正和前文提到的因为对象转移后对象地址变化的修正稍有不同,这里进行修正,是因为标记过程对象地址视图不同。
处理方法是,通过Obj2的引用获得Obj3过时的指针,通过该指针访问oop对象。因为底层oop只有一个,所以此时获取的对象既可能反映Remapped视图,也可能反映M1视图,只要保证通过oop对象获得M1地址视图,就说明对象Obj3已经标记,无须再次标记。所以可以访问oop的成员,只要该成员能反映Obj3的地址视图就可以。在JVM中,oop有一个oopDesc信息(也就是对象头),oopDesc在oop的头部,所以可以通过oop获取oopDesc的地址,通过oopDesc的地址视图判断Obj3处于哪个视图中。相关伪代码如下:
inline uintptr_t ZOop::to_address(oop o) {
return cast_from_oop<uintptr_t>(o);
}
template <class T> inline T cast_from_oop(oop o) {
return (T)((oopDesc*)o);
}
垃圾回收触发的时机
ZGC中采用了主动垃圾回收的方式,设计了一系列规则,只要系统运行时满足其中的一个规则就会触发并执行垃圾回收。设计的规则如下。
(1)基于固定时间间隔触发
该规则的目的是希望ZGC的垃圾回收器以固定的频率(或者时间间隔)触发。这在一些场景中非常有用,例如应用程序在请求量比较低的情况下运行了很长时间,但是ZGC不满足其他垃圾回收器的触发条件,所以一直不会触发垃圾回收,这通常没什么问题,但如果在某一个时间点开始请求暴增,则可能导致内存使用也暴增,而垃圾回收器来不及回收垃圾对象,这将降低应用系统的吞吐量。所以ZGC提供了基于固定时间间隔触发垃圾回收的规则。
这个规则的实现非常简单,就是判断前一次垃圾回收结束到当前时间是否超过时间间隔的阈值,如果超过,则触发垃圾回收,如果没超过,则直接返回。
需要说明的是,时间间隔由一个参数ZCollectionInterval来控制,这个参数的默认值为0,表示不需要触发垃圾回收。在实际工作中,可以根据场景设置该参数。
(2)预热规则触发
该规则是说,当JVM刚启动时,还没有足够的数据来主动(或者智能的)触发垃圾回收的启动,所以设置了预热规则,用于强制触发垃圾回收。
预热规则指的是JVM启动后,当发现堆空间使用率达到10%、20%和30%时,会主动触发垃圾回收。ZGC设计最多前3次垃圾回收由预热规则触发。也就是说,当垃圾回收触发(无论是由预热规则还是主动触发垃圾回收)的次数超过3次,预热规则将不再生效。
(3)根据分配速率
根据分配速率来预测是否能触发垃圾回收。这一规则设计的思路如下。
1)收集数据:在程序运行时,收集过去一段时间内垃圾回收发生的次数、执行的时间、内存分配的速率memratio和当前空闲内存的大小memfree。
2)计算:根据过去垃圾回收发生的情况预测下一次垃圾回收发生的时间timegc,按照内存分配的速率预测空闲内存能支撑应用程序运行的实际时间timeoom,例如timeoom = memfree/memratio。
3)设计规则:若timeoom小于timegc(垃圾回收的时间),可以启动垃圾回收。这个规则的含义是,如果从现在起到oom发生前开始执行垃圾回收,刚好在OOM发生前完成垃圾回收的动作,从而避免oom。在ZGC中ZDirector是周期性地运行的,所以在计算时还应该用oom的时间减去采样周期的时间。采样周期记为timeinterval,则规则为:当timeoom< timegc + timeinterval时触发垃圾回收。
那么任务就变成了如何预测下一次垃圾回收时间timegc和内存分配的速率memratio(因为memfree是已知数据,无须额外处理)。
下面以预测垃圾回收时间timegc为例来看看如何预测。最简单的想法是,根据已经发生的垃圾回收所使用的时间来预测下一次垃圾回收可能花费的时间。这里提供几种思路:
1)收集过去一段时间内垃圾回收发生的次数和时间,取过去N次垃圾回收的平均时间作为下一次垃圾回收的预测时间。这一方法最为直观,但是准确度可能不高。
2)收集过去一段时间内垃圾回收发生的次数和时间,建立一个逻辑回归模型,从而预测下一次垃圾回收的预测时间。这一方法虽然比第一种方法有改进,根据垃圾回收的趋势来预测下一次垃圾回收的时间,但这一方法最大的问题是逻辑回归模型太简单。实际上,如果我们能提供更多的输入,比如应用程序使用内存的情况、线程数等建立动态模型,这应该是一个非常好的方法。
3)使用衰减平均时间来预测下一次垃圾回收花费的时间。衰减平均方法实际上是第一种方法和第二种方法组合后的一种简化实现。它是一种简单的数学方法,用来计算一组数据的平均值,但是在计算平均值的时候最新的数据有更高的权重,即强调近期数据对结果的影响。衰减平均计算公式如下:
式中α为历史数据权值,1-α为最近一次数据权值。即α越小,最新的数据对结果影响越大,最近一次的数据对结果的影响最大。不难看出,其实传统的平均就是α取值为(n-1)/n的情况。在G1中预测下一次垃圾回收时间采用的就是这种方法。
4)直接采用已经成熟的模型来预测下一次垃圾回收时间。ZGC中主要基于正态分布来预测。
学过概率论的读者都知道正态分布。简单回顾一下正态分布的相关知识。首先它是一条中间高、两端逐渐下降且完全对称的钟形曲线,如图8-20所示。
正态分布也非常容易理解,它指的是大多数数据应该集中在中间附近,少数异常的情况才会落在两端。
对于垃圾回收算法中的数据——内存的消耗时间和垃圾回收的时间也应该符合这样的分布。注意,并不是说G1中的停顿预测模型不正确或者效果不好,而是说使用正态分布来做预测有更强的数学理论支撑。在使用中,ZGC还对这个数学模型做了一些改变。
通常使用N表示正态分布,假设X符合均值为μ、方差为σ2的分布,做数学变换令Y=(X-μ)/σ,则它符合N(0, 1)分布。如下所示:
正态分布有一些很好的数学特性,均值位于曲线的中间(见图8-20中虚线),当标准差σ=μ时,该区间的概率可以达到68.27%,即大多数情况下都位于该区间。
假设内存分配的时间符合正态分布,我们可以获得抽样数据,从而估算出内存分配所需时间的均值和方差。这个均值和方差是我们基于样本数据估算得到的,它们与真实的均值和方差相比可能有一定的误差。所以如果我们直接使用这个均值和方差,可能会因样本数据波动而出现不准确的情况,因此在概率论中引入了置信度和置信区间。简单地说,置信区间指的是这个参数估计的一个区间,区间是这个参数的真实值的一定概率落在测量结果周围的程度。而置信度指的就是这个概率。
假定给定一个内存分配花费的时间序列X1,X2,…,Xn,我们想要知道在99.9%的情况下内存分配花费的时间。方法如下:
已知点估计量服从的分布如下:
其中μ为样本均值,σ为样本标准差。
对应99.9%置信度,查标准正态分布表得到统计量为3.290 527。所以可以得到99.9%的情况下内存分配花费的时间的概率为
等价于
由此可以得到置信区间为
。可以得到最大的内存消耗在满足99.9%的情况下不会超过
这个时间。在ZGC中对这个公式又做了一点修改,实际上是把这个值变得更大,对均值提供了一个参数,用于放大或者缩小均值,参数为ZAllocationSpikeTolerance,简单记为Tolerance,则公式为
。Tolerance的默认值为2,这样的结果使得置信度更高,即远大于99.9%。
在ZGC中,内存分配的速率memratio的处理和timegc完全相同,从而ZGC利用正态分布完成预测,并利用预测的时间来设计触发垃圾回收的规则。这个规则应该是ZGC中最常见的垃圾回收触发规则。
在这里稍微提一下,从统计角度来说,当数据样本足够大的时候(比如样本个数大于30个时)使用正态分布比较准确;当样本个数不多时,使用t分布效果比较好。在上述代码中实际上修正了真正的置信区间,使得置信度更高。如果读者有兴趣,可以实现t分布,并验证t分布和正态分布预测的准确度。
(4)主动触发
该规则是为了实现应用程序在吞吐量下降的情况下,当满足一定条件时,还可以执行垃圾回收。这里的满足一定条件指的是:
1)从上一次垃圾回收完成到当前时间,应用程序新增使用的内存达到堆空间的10%。
2)从上一次垃圾回收完成到当前时间,已经过去了5分钟,记为timeelapsed。
如果这两个条件同时满足的话,预测垃圾回收时间为timegc,定义规则:
如果numgc×timegc < timeelapsed,则触发垃圾回收。其中numgc是ZGC设计的常量,假设应用程序的吞吐率从50%下降到1%后需要触发一次垃圾回收。
这个规则实际上是为了弥补程序吞吐率骤降且长时间不执行垃圾回收而引入的。有一个参数ZProactive用来控制是否开启和关闭主动规则,默认值是true,即默认打开主动触发规则。
实际上这个规则和第一个规则(基于固定时间间隔规则)在某些场景中有一定的重复,第一个规则只强调时间间隔,本规则除了时间之外还会考虑内存的增长和吞吐率下降的快慢程度。
(5)阻塞内存分配请求触发
阻塞内存分配由参数ZStallOnOutOfMemory控制,当参数ZStallOnOutOfMemory为true时进行阻塞分配,如果不能成功分配内存,则触发阻塞内存分配(该规则在JDK 17中被移除)。
(6)外部触发
外部触发是指在Java代码中显式地调用System.gc()函数,在JVM执行该函数时,会触发垃圾回收。该触发请求是从用户代码主动触发的,从编程角度来看,说明程序员认为此时需要进行垃圾回收(当然前提是程序员正确使用System.gc()函数)。所以ZGC把该触发规则设计为同步请求,只有在执行完垃圾回收后,才能执行后续代码。
(7)元数据分配触发
元数据分配失败时,ZGC会尝试进行垃圾回收,确保元数据能正确地分配。
异步垃圾回收后会尝试是否可以分配元数据对象空间,如果不能,则尝试进行同步垃圾回后是否可以分配元数据对象空间,如果还不成功,则尝试扩展元数据空间,若分配成功,则返回内存空间,不成功则返回NULL。
本篇文章给大家讲解的内容是JVM垃圾回收器详解:ZGC,回收设计下篇文章给大家讲解的内容是JVM垃圾回收器详解:ZGC,垃圾回收实现感谢大家的支持!