第3章
ZGC线程
ZGC垃圾回收过程主要依靠后台线程完成。ZGC的后台线程有两类:控制线程和工作线程。控制线程主要控制垃圾回收的触发、执行;工作线程是真正执行垃圾回收具体任务的线程,比如对象的标记、转移等。
本章首先回顾了JVM中线程的基本概念,然后介绍控制线程和工作线程,着重介绍4个控制线程:ZDirector、ZDriver、ZStat和VMThread。ZDirector用于控制如何触发垃圾回收,ZDriver用于控制垃圾回收执行,ZStat用于控制统计数据收集和输出,VMThread用于执行STW的线程。最后还介绍了ZGC垃圾回收触发的时机。
3.1线程的基本概念
线程是操作系统程序执行的基本单元。不同的操作系统线程的实现方式不同,JVM重新定义了线程,封装了操作系统线程,屏蔽了不同操作系统线程实现的细节。图3-1是JVM中的线程类图。
这里只介绍ZGC中涉及的几类线程:
- JavaThread:要执行的Java代码的线程,比如一个Java代码启动后会变成一个JavaThread运行;对于Java代码的启动线程,通过JNI_CreateJavaVM来创建一个JavaThread,而对于一般的Java线程,都是调用java.lang.thread里面的start方法,这个方法通过JNI调用创建JavaThread对象,完成真正的线程创建。
- CompilerThread:执行JIT的线程。
- NameThread:是JVM内部使用的线程,分类如图3-1所示。
图3-1线程类结构图
- VMThread:JVM执行垃圾回收的同步线程,是JVM最关键的线程之一,主要的用途之一是处理垃圾回收。简单地说,所有的垃圾回收操作都是从VMThread触发的,如果是多线程回收,则启动多个线程,如果是单线程回收,则使用VMThread进行。VMThread提供了一个队列(queue),任何要执行垃圾回收的操作都实现了VM_GC_Operation,在JavaThread中执行VMThread::execute(VM_GC_Operation)把垃圾回收操作放入队列中,然后在VMThread的run方法中轮询这个队列就可以了。当这个队列有内容时,它就开始尝试进入安全点,然后执行相应的垃圾回收任务,完成垃圾回收任务后会退出安全点。
- ConcurrentGCThread:并发执行垃圾回收任务的线程,本章中的3个控制线程ZDirector、ZDriver和ZStat都继承于该线程,实现并发执行。
- GangWorker:工作线程,在ZGC中ZWorkers就包含了多个GangWorker,这个线程是并行执行的(个数一般和CPU个数相关),所以可以认为这是一个线程池。线程池里面的线程用于执行任务(如执行ZMarkRootsTask、ZMarkTask等任务),进行垃圾回收。
JVM封装了线程的状态,提供了统一的接口函数。关于JVM线程更多的内容可以参考其他的文献。
3.2控制线程
ZGC新引入的3大基础线程分别为ZDirector、ZDriver和ZStat。它们的功能分别为:
- ZDirector:控制什么时候启动垃圾回收。
- ZDriver:控制垃圾回收执行的步骤,ZGC垃圾回收一共分为10步,这10步是串行执行的。
- ZStat:收集JVM在运行过程中回收垃圾时各个阶段的数据,同时控制统计信息的输出。
3大基础线程的设计思路类似,它们都继承于基类ConcurrentGCThread,基类提供的功能主要有:
1)创建并启动线程。
2)重载虚函数run(),完成线程资源的初始化,如栈大小的设置、设置线程的名称、设置JNI句柄的分配空间等;调用抽象方法run_service(),各控制线程实现自己的业务逻辑;终止本线程。
ConcurrentGCThread类中run方法的伪码如下:
void ConcurrentGCThread::run() {
//初始化线程
initialize_in_thread();
wait_for_universe_init();
//虚函数,供子类重载,子类的业务逻辑都在run_service中
run_service();
//线程终止
terminate();
}
ZDirector、ZDriver和ZStat都是主动轮询,查询是否可以执行对应的业务逻辑。这3个控制线程都通过实现虚函数run_service()来完成轮询。run_service()的实现可以总结为:执行无限循环,判断是否满足业务逻辑的触发条件,如果满足,则执行相应的业务,伪代码如下:
run_service() {
// 主循环,满足触发条件则执行业务逻辑,不满足则等待
while (is_ready_for_trigger()) {
run_business();
}
}
ZDirector和ZStat通过时钟来控制是否触发业务逻辑。ZDirector每100ms检查1次是否需要触发,如果可以触发则发送消息给ZDriver启动垃圾回收;ZStat每1s检查1次是否需要触发,如果可以触发则启动收集运行信息,并输出收集到的运行信息;ZDriver的主循环判断线程是否终止,如果不终止则会一直循环,在循环内部使用消息的通知机制,如果ZDirector有消息到达则执行垃圾回收,如果没有消息到达则等待。先看一下ZDirector和ZStat的时钟触发器。
3.2.1时钟触发器
ZGC的时钟触发器是利用操作系统的等待通知机制完成的,关于等待通知机制可以参考相关操作系统的书籍。ZGC的时钟触发器运行后,在满足条件时会成功地返回,不满足条件时会一直等待,等待将导致线程放弃执行,并让出CPU,如图3-2所示。
图3-2时钟触发逻辑
ZDirector和ZStat都是通过时钟触发器来控制是否执行业务。ZDirector的流程图如图3-3所示。
ZDirector提供了4种触发垃圾回收的方法,分别是基于固定时间间隔、预热规则、分配速率和主动触发规则。ZDirector依次判断这4种规则是否满足,实际上这也说明了规则的优先级。
图3-3ZDirector流程图
ZDirector虽然实现为并发线程,但在ZGC中只有一个,所以ZDirector不会涉及并发的问题。
统计线程ZStat的流程如图3-4所示。
图3-4ZStat流程图
统计线程为每1s收集信息一次。但是在输出时统计线程会把收集到的数据进行聚合,目前提供了3种粒度的统计数据,分别为过去10s,过去10min和过去10h的统计数据。这3个粒度的数据可以定义为:统计线程最近10次运行的数据,60个过去10s的数据和60个过去10min的数据,所以实际需要存储的只有130个数据。相关的类如图3-5所示。
图3-5统计数据存储类图
ZStatSamplerData是每秒ZStat进行收集信息时收集到的数据,主要有过去1s中待收集项执行的次数(_nsamples)、过去1s中待收集项数据和(_sum)和过去1s中待收集项数据的最大值(_max)。
ZStatSamplerHistoryInterval是一个模板类,它的模板参数为size,指定了模板类中收集收据数组的长度,它的含义是收集数据的个数。类成员变量如表3-1所示。
ZStatSamplerHistory就是统计线程目前信息存储的数据结构,有3个成员变量_10seconds、_10minutes和_10hours分别存储最近10s、10min和10h的数据,_total是JVM启动以来收集到的所有数据。每收集到10s数据,会把10s的数据存入10min数据的元素中,10min共有60个桶(bucket),6010s = 600s,即10min数据,每收集到10min数据,会把10min的数据存入10h数据的元素中,10h共有60个桶(bucket),6010min = 600min,即10h数据。其结构如图3-6所示。
表3-1ZStatSamplerHistoryInterval类成员变量介绍
图3-6统计线程数据存储示意图
为了便于控制统计信息输出的间隔,ZGC提供一个参数ZStatisticsInterval,其默认值为10,表示每10次统计之后输出一次信息。统计线程收集信息的频度为1s,所以是每10s输出统计信息。
ZStat在ZGC中也只有一个,不会涉及并发问题。
3.2.2消息触发
ZDriver是通过消息触发执行的。
通常ZGC的垃圾回收时间比较短,不存在多个垃圾回收并发执行的情况。但是在内存负载比较重的情况下,可能要求频繁地进行垃圾回收,如果垃圾回收的周期很长,可能出现当前垃圾回收还未结束,新的垃圾回收请求已经到达的情况。在设计垃圾处理时,必须考虑这种情况。
在进行消息处理时,ZGC设计了两种消息处理方式:同步垃圾回收和异步垃圾回收。同步垃圾回收主要是保证垃圾回收一定会发生,并且直到垃圾回收完成才会继续执行;异步垃圾回收则是为了实现更高的吞吐量,如果有多个异步消息在同一垃圾回收周期到达,则只有一个请求被处理,即垃圾回收只会执行一次。ZGC通过消息处理器处理请求,其类结构如图3-7所示。
图3-7信号处理器结构
消息处理器ZMessagePort的主要成员变量功能如表3-2所示。
消息处理器ZMessagePort的主要成员函数有send_async和send_sync,它们主要的功能为:
send_async:发送异步垃圾回收的消息,当没有垃圾回收执行时,向ZDriver发送信号,通知ZDriver执行垃圾回收;如果垃圾回收正在执行,则丢弃该消息。
send_sync:发送同步垃圾回收的消息,然后等待该消息被ZDriver处理之后才能继续执行。如果ZDriver正在执行垃圾回收,同步消息将被放入信息队列等待该消息被ZDriver处理。当垃圾回收结束后,ZDriver会通知等待线程继续执行。ZDriver在这里有一个优化,当有多个同一类型的同步消息在一个垃圾回收周期内到达,在ZDriver完成垃圾回收后通知所有同一类型等待该类型消息的线程。
表3-2消息处理器成员变量介绍
ZGC针对不同的消息(也就是垃圾回收的原因)选择不同的处理方式,表3-3是一个简单的总结。
ZGC中触发垃圾回收的主要是由ZDirector产生的异步消息。在3.4节中将进一步介绍常见的触发垃圾回收的消息,以及它们是如何触发的。ZMessagePort对消息进行处理,经过消息处理器ZMessagePort处理后,如果有垃圾回收的请求,则通知ZDriver启动垃圾回收,如果没有请求ZDriver则等待,ZDirector、ZMessagePort和ZDriver的关系如图3-8所示。
表3-3ZDriver垃圾回收消息同步和异步总结
图3-8垃圾处理消息处理流
前面已经介绍过ZDirector的流程图,现在看一下ZDriver的流程图,如图3-9所示。
ZDriver定义了垃圾回收的步骤,一共分10步,我们将在第5章详细介绍每一步所做的工作。
ZGC中除了这3个新引入的控制线程外,还有一个重要的控制线程就是VMThread。
图3-9ZDriver流程图
3.2.3VMThread
VMThread不是ZGC引入的,它在JVM中已经有很长的历史了。VMThread的主要目的是提供一个串行执行任务的地方。
在整个JVM中有很多事件不能并发执行,只能串行执行。比如我们最常见的垃圾回收,如果一边回收垃圾,将对象标记为垃圾待回收,一边执行应用程序变更原有对象之间的引用关系,把标记的垃圾对象变成活跃对象,这将导致不正确的识别和回收垃圾。
VMThread就是为了串行执行引入的,当要执行垃圾回收时,告诉VMThread把其他的线程都暂停掉,然后由VMThread执行请求的业务。因为VMThread串行执行,引起应用程序暂停,所以一直希望发展新的垃圾回收器,设计优秀的算法减少暂停时间。
目前JVM中需要串行执行的业务请求有几十个,比如垃圾回收、代码逆优化、堆对象遍历、死锁检测等。为了让大家对VMThread有一个全面的认识,下面给出VMThread和其他线程的交互以及VMThread的执行流程情况,如图3-10所示。
在VMThread线程设计和实现中有以下3个注意点。
1.如何进入安全点
安全点指让待暂停的线程在某一时刻全部暂停。不同的线程进入的方法不同。
图3-10VMThread交互图和流程图
解释执行的线程,Java代码被编译成字节码,线程对字节码解释执行。在JVM中解释方式的实现主要通过模板解释器完成,在模板解释器中,每一个字节码对应一段可以执行的机器代码。目前JVM中提供了202个字节码,我们看一下在X86架构下字节码对应的机器代码,如表3-4所示。
表3-4字节码正常执行对应的模板解释表
该表称为DispatchTable,解释线程正常运行时会根据相应的字节码从DispatchTable表中找到对应的函数并执行,当解释线程要进入安全点的时候,会把这个DispatchTable表中字节码对应的函数替换成进入安全点的函数。所有的字节码都对应一个函数,此时DispatchTable如表3-5所示。
表3-5字节码对应安全点的模板解释表
在at_safepoint函数中,实际上是通过操作系统提供的等待/通知机制让出CPU,从而完成线程暂停,并在安全点退出时通知线程继续执行代码。
编译执行线程,因为解释代码被编译成机器码,所以不可能采用上面的方法。JVM的做法是在编译机器码时插入一些安全点检查的指令。这些指令会轮询一个全局的状态,如果发现满足条件,则通过操作系统的等待/通知机制让出CPU,从而完成线程暂停,并在安全点退出时通知线程继续执行代码。
在JVM中编译执行线程的实现方法为:JVM初始化的时候产生一个全局的轮询页面Polling page,在VMThread中会把这个页面设置为不可读,然后安全点指令访问这个页面就知道此时需要让出CPU。JVM在Linux的实现中,访问一个不可读页面会产生一个SIGSEGV,然后JVM注册这个信号的处理函数,在信号处理中会通过等待/通知机制让出CPU,在安全点退出时通知线程继续执行。
执行本地代码的线程,线程运行在本地代码(native code)的时候,本地代码访问的内存空间和Java堆空间不是一个,即它不能直接访问Java对象,所以VMThread是不需要等待线程暂停的,也就是说VMThread和正在执行本地代码的线程可以并发执行。但是如果从本地代码切换到Java代码执行时,就需要让这个线程暂停,使这个线程进入安全状态的方式是:让VMThread设置一个标志位,当线程从本地代码切换到Java执行时判断一下标志位,如果发现已经设置,就让自己暂停。
2.全部暂停和部分暂停
在JDK 10之前,VMThread进入安全点执行业务之前,需要将应用程序暂停(也就是我们常说的STW),等待上述提到的3类线程全部暂停之后才会执行业务。等待全部线程暂停执行,这是很花费时间的,所以在JDK 10中引入JEP312 Thread-Local HandShake,该项目实现单个线程的暂停,而不是暂停所有线程。HandShake机制也是通过VMThread机制完成的,只不过HandShake中指定了一个暂停的目标线程。
单个线程的暂停非常有用,也非常高效。在ZGC中不仅有全部线程暂停的情况,也有单个线程暂停的情况。例如在垃圾回收中的初始标记、再标记和初始转移都暂停了所有的线程。在终止并发标记时会使用单个线程的暂停,例如在并发标记阶段为了减少再标记的暂停时间,在并发标记结束前,把每一个应用程序线程在并发标记过程中标记的对象(这是并发标记算法导致的,应用程序线程在并发标记线程工作的时候,也会标记对象)转移到并发标记工作线程的标记栈中,由并发标记工作线程去标记对象,更多细节参考第5章。
3. ZGC的控制线程如何暂停
ZGC的ZDirector和ZStat不会访问Java对象,所以在垃圾回收的执行过程中不会暂停。ZDriver是控制垃圾回收线程工作的,在垃圾回收工作线程启动后(即要求进入安全点之后),通过通知/等待机制实现暂停/恢复。
3.3工作线程
工作线程是真正执行垃圾任务的线程。为什么设计工作线程?简单地说是为了提高程序执行的速度。举一个简单的例子,在并发初始标记的时候从根集合出发,对根集合所有引用的对象进行标记;而JVM中的根有很多,如线程栈、字符串表、对象监视器、元数据对象等,所以可以并行地执行,对每个根使用一个线程进行标记,从而加快速度。
在ZGC中,ZDriver作为垃圾回收执行的控制线程,用于控制垃圾回收的步骤,而为了提高执行的效率,整个垃圾回收周期被划分成10步(如图3-9所示),并且这10步中的大多数步骤都是使用多线程执行的。根据垃圾回收的算法设计,有3步需要并行执行(在STW之后,只有垃圾回收线程工作),有7步需要并发执行(工作线程和应用程序线程并发执行)。为什么这样设计?能不能优化?读者可以先思考一下,在第5章我们再来回答这个问题。
为了描述方便,我们把并行执行任务的线程称为并行工作线程,把和应用程序并发工作的垃圾回收线程称为并发工作线程。本书中谈论的工作线程指的就是这两类,我们做一个简单的总结:
并行工作线程:并行执行。指的是在垃圾回收时,首先执行STW,然后多个GC工作线程并行工作。在ZGC中有3步是并行执行的,分别为初始标记、再标记和转移根对象。
并发工作线程:并发执行。指的是在垃圾回收时,除了3步由GC工作线程并行执行外,其余的7步都是可以并发地和应用程序同时执行,这7步分别为并发标记、非强引用处理、重置转移集、回收无效的页面、选择待回收的转移集、准备转移、并发转移,这7步中有3步由并发工作线程处理,有4步是由ZDriver完成的,而非通过并发工作线程完成,第5章中有详细的介绍。
这里读者可以再思考一个问题:ZGC为什么设计两类工作线程,而不是一类?ZDriver的10步是串行执行的,所以每一步之间的线程是可以共用的,为什么ZGC没有共用,而是设计成两类,分别服务于并行工作和并发工作?
答案是为了提高ZGC执行的灵活性。如果只有一类工作线程,那么在并发工作和并行工作时,后台线程的数目是一样的。而ZGC设计了两类线程,使用者可以根据应用程序和垃圾回收的情况,分别在这两类线程执行时设置不同的线程数目,从而达到性能最优,也给性能调优带来更大的灵活性。
ZGC重用了参数ParallelGCThreads和ConcGCThreads来设置并行工作线程和并发工作线程的数目,如果用户没有显式地设置这两个参数,ZGC会启发式地推断这两个参数。
总体来说,我们希望ZGC暂停执行的时间短,所以并行工作的线程数目可以设置得大一些,同时希望并发线程对应用的影响尽量小,所以并发线程数目会小一些。如果我们打算设置这两个参数,应该按照这个原则来设置。这两个参数可以设置也可以不设置,当没有设置时,ZGC会推断它们的值,下面我们介绍ZGC如何推断这两个参数的值。
并行线程数ParallelGCThreads为总CPU数目的60%,向上取整,例如CPU个数为8个,则并行工作线程为8*0.6 = 4.8,向上取整后为5,即在CPU数为8的机器上启动ZGC,如果没有设置参数ParallelGCThreads,并行工作线程为5个。
并发线程数ConcGCThreads为总CPU数目的12.5%,向上取整,例如CPU个数为8个,则并发工作线程为8*0.125 = 1,向上取整后为1,即在CPU数为8的机器上启动ZGC,如果没有设置参数ConcGCThreads,并发工作线程为1个。
ZGC还提供动态调整并发工作线程的能力。实际中可能还存在内存使用紧张的情况,例如应用程序可用的内存非常少了,这个时候应该让垃圾回收工作尽快完成,所以可以增大并发工作线程的数目。并发工作的线程数和并行工作线程数一样。关于如何判断内存使用紧张情况,我们将在第5章介绍。
这里需要注意一点,在ZGC中不能显式地把ParallelGCThreads和ConcGCThreads设置为0,如果设置为0将导致ZGC启动失败。通常这两个参数并不需要我们设置,但是ZGC启发式设置的参数给出的是一般性的建议,实际中还需要根据机器的配置做动态的调整。例如CPU个数并不多,比如8个,内存为64GB。ZGC推断ParallelGCThreads和ConcGCThreads的数字为5和1,这通常可能比较小,这个时候可以适当考虑设置ParallelGCThreads和ConcGCThreads为更大的值,如8和4。实际上ZGC面向的是更高性能的机器,CPU个数也比较多,内存也很大,所以这两个参数是一个相对公平的数字。当然,如果你的机器配置非常好,可能还需根据实际情况降低这两个参数的值。
最后,简单地看一下ZGC工作线程相关类的结构及关系,如图3-11所示。
图3-11工作线程类结构图
其中,ZWorkers是ZGC中工作线程管理器,它的成员变量_workers是WorkGang类型,WorkGang的allocte_worker负责创建GangWorker线程,run_task负责运行任务,成员变量_boost用于记录内存使用是否紧张。
ZGC中统一定义了ZTask是任务抽象类,所有ZGC的任务都实现了ZTask的work方法。ZWorkers中的run_parallel和run_concurrent都接受ZTask,并运行ZTask,当然实质上还是通过GangWorker线程来执行具体的任务。
3.4垃圾回收触发的时机
在表3-3中,总结了消息处理器要处理的消息。本节主要介绍ZGC中的这些消息是何时触发的。
ZGC中,为了实现更高的性能,尽量避免进行同步垃圾回收,也就是说尽量避免触发同步垃圾回收的消息。ZGC中触发同步消息的场景也比较少,总体以触发异步消息为主。异步消息主要由ZDirector根据规则判断是否可以触发,在ZDirector流程图(见图3-3)中介绍了ZDirector有4种触发规则,本节主要介绍这4种规则是如何触发的,最后还会简要介绍其他的垃圾回收消息是如何触发的。
1. 基于固定时间间隔触发
ZDirector提供的第一个规则就是基于固定时间间隔触发垃圾回收。这个规则的目的非常简单,就是希望ZGC的垃圾回收器以固定的频率触发。在这一些场景中非常有用,例如我们的应用程序在晚上请求量比较低的情况下运行了很长时间,但是ZGC不满足其他垃圾回收器的触发条件,所以一直不会触发垃圾回收,这通常没什么问题,如果在早上某一个时间点开始请求暴增,这可能导致内存使用也暴增,而垃圾回收器来不及回收垃圾对象,将降低应用系统的吞吐量。所以ZGC提供了基于固定时间间隔触发垃圾回收的规则。
这个规则的实现也非常简单,就是判断前一次垃圾回收结束到当前时间是否超过时间间隔的阈值,如果超过,则触发垃圾回收,如果不满足,则直接返回。
需要说明的是,时间间隔由一个参数ZCollectionInterval来控制,这个参数的默认值为0,表示不需要触发垃圾回收。
实际工作中,可以根据场景设置该参数。
2. 预热规则触发
ZDirector提供的第二个规则是预热启动垃圾回收。为什么设计这一规则?设计这一规则的目的是当JVM刚启动时,还没有足够的数据来主动触发垃圾回收的启动,所以设置了预热规则。
预热规则指的是JVM启动后,当发现堆空间使用率达到10%、20%和30%时,会主动地触发垃圾回收。ZGC设计前3次垃圾回收可由预热规则触发,也就是说当垃圾回收触发(无论是由预热规则,还是主动触发垃圾回收)的次数超过3次时,预热规则将不再生效。
3. 根据分配速率
ZDirector提供的第三个规则是根据分配速率来预测是否能触发垃圾回收。这一规则设计的思路是:
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中主要是基于正态分布来预测。
学过概率论的读者大多知道这一概念。我们先来回顾一下正态分布。首先它是一条中间高,两端逐渐下降且完全对称的钟形曲线。图形形状如图3-12所示。
图3-12正态分布图
正态分布也非常容易理解,指的是大多数数据应该集中在中间附近,少数异常的情况才会落在两端。
对于垃圾回收算法中的数据:内存的消耗时间,垃圾回收的时间也应该符合这样的分布。注意,并不是说G1中的停顿预测模型不正确或者效果不好,而是说使用正态分布来做预测有更强的数学理论支撑。在使用中,ZGC还对这个数学模型做了一些改变。
通常使用N表示正态分布,假设X符合均值为、方差为2的分布,做数学变换,令Y = (X - )/,则它符合N(0, 1)分布。如下所示:
假设内存分配的时间符合正态分布,我们可以获得抽样数据,从而估算出内存分配所需时间的均值和方差。这个均值和方差是我们基于样本数据估算得到的,它们可能和实际真实的均值和方差有一定的误差。如果我们直接使用这个均值和方差,可能会因样本数据波动导致不准确的结果,所以在概率论中引入了置信度和置信区间。简单地说,置信区间指的是这个参数估计的一个区间,区间是这个参数的真实值在一定概率落在测量结果周围的程度,而置信度指的就是这个概率。
假定给定一个内存分配花费的时间序列X1,X2,…,Xn,我们想要知道在99.9%的情况下内存分配花费的时间。方法如下:
已知点估计量服从的分布为:
其中为样本均值,为样本标准差。
对应99.9%置信度,查标准正态分布表得到统计量为3.290527(可以参考统计学书籍,或者直接查询统计量表得到该值),所以可以得到99.9%的情况下内存分配花费的时间的概率为:
等价于
由此可以得到置信区间为 - 3.290527×< < + 3.290527×。可以得到最大的内存消耗在满足99.9%的情况下不会超过 + 3.290527×这个时间。在ZGC中对这个公式又做了一点修改,实际上是把这个值变得更大,对均值提供了一个参数,用于放大或者缩小均值,参数为ZAllocationSpikeTolerance,简单记为Tolerance,则公式为 ×Tolerance + 3.290527×。Tolerance默认值为2,这样的结果使得置信度更高,即远大于99.9%。
在ZGC中内存分配的速率memratio的处理和timegc完全相同。从而ZGC利用正态分布完成预测,并利用预测的时间来设计触发垃圾回收的规则。这个规则应该是ZGC中最常见的垃圾回收触发规则。
从统计角度来说,当数据样本足够大的时候(比如样本个数大于30个时),使用正态分布比较准确;当样本个数不多时,使用t分布效果比较好。在上述代码中实际上修正了真正的置信区间,使得置信度更高。如果读者有兴趣,可以实现t分布,并验证t分布和正态分布预测的准确度。
4. 主动触发
ZDirector提供的第四个规则是主动触发规则,该规则是为了应用程序在吞吐量下降的情况下,当满足一定条件时,还可以执行垃圾回收。这里满足一定条件指的是:
1)从上一次垃圾回收完成到当前时间,应用程序新增使用的内存达到堆空间的10%。
2)从上一次垃圾回收完成到当前时间已经过去了5min,记为timeelapsed。
如果这两个条件同时满足,预测垃圾回收时间为timegc,定义规则:如果numgc * timegc < timeelapsed,则触发垃圾回收。其中numgc是ZGC设计的常量,假设应用程序的吞吐率从50%下降到1%,需要触发一次垃圾回收。
这个规则实际上是为了弥补程序吞吐率骤降且长时间不执行垃圾回收而引入的。有一个诊断参数ZProactive来控制是否开启和关闭主动规则,默认值是true,即默认打开主动触发规则。
实际上这个规则和第一个规则(基于固定时间间隔规则)在某些场景中有一定的重复,第一个规则只强调时间间隔,本规则除了考虑时间之外还会考虑内存的增长和吞吐率下降的快慢程度。
5. 阻塞内存分配请求触发
阻塞内存分配由参数ZStallOnOutOfMemory控制,当参数ZStallOnOutOfMemory为true时进行阻塞分配,如果不能成功分配内存,则触发阻塞内存分配。
该触发请求是异步消息,并非同步消息。我们在2.3.2节中提到,页面阻塞分配会触发垃圾回收,直到垃圾回收完成并成功分配页面为止。因为是异步消息,所以页面阻塞分配请求需要额外的实现等待成功分配的功能,其实非常简单,可以通过一个循环来实现。
那为什么ZGC不把阻塞内存分配实现成同步消息,而是通过异步消息加上循环的方式?
原因在于同步消息请求的线程在发出同步消息后是通过通知等待机制完成的,通知等待机制通常会让出CPU,而页面阻塞分配采用异步消息加上循环的方式,不会让出CPU,在循环中判断垃圾回收是否完成,如果完成,则继续向下执行,这样的设计可以减少页面分配时因线程调度带来的额外开销。从这一点也可以看出,设计一款优秀的软件,需要从每一个细节出发,并仔细斟酌。
6. 外部触发
外部触发是指在Java代码中显式地调用System.gc()函数,在JVM执行该函数时,会触发垃圾回收。该触发请求是从用户代码主动触发的,从编程角度来看,说明程序员认为此时需要进行垃圾回收(当然首先是程序员正确使用System.gc()函数),所以ZGC把该触发规则设计为同步请求,只有在执行完垃圾回收后,才能进行后续代码的执行。
7. 元数据分配触发
元数据分配失败时,ZGC会尝试进行垃圾回收以确保元数据能正确分配。
异步垃圾回收后会尝试是否可以分配元数据对象空间,如果不能,将尝试进行同步垃圾回后可以分配元数据对象空间,如果还不成功,则尝试扩展元数据空间,再分配成功则返回内存空间,不成功则返回NULL。