并发标记清除之内存空间调整

空间调整指的是控制线程首先尝试对老生代进行扩展(注意,不会对老生代进行收缩)。空间调整也是并发执行,执行之前需要获取Heaplock、FreeListLock,也需要获得控制线程的控制权。在执行空间调整时,虽然也是并发执行,但是执行过程中因为成功抢占到锁,所以Mutator不能在老生代中分配内存,不能在新生代空间不足时扩展内存(Mutator可以继续执行不需要上述锁的操作)。

另外,空间调整相对来说比较耗时,所以在空间调整执行之前,如果发现有待执行的GC,会先执行GC。空间调整是单线程执行的。

并发标记清除之复位

复位指的是控制线程对老生代回收中所用到相关数据结构进行重置,便于下一次老生代回收的执行。复位是并发执行的,但是需要获得BitmapLock和控制线程的控制权。当获得控制权以后,才能对标记位图进行清除。思路是每次针对一定的空间进行清除,空间的大小通过参数

CMSBitMapYieldQuantum来控制,默认值是10MB。每清除完一块空间以后,就会轮询是否需要放弃CPU,如果需要则放弃CPU。复位也是单线程执行的。

并发算法难点

前文介绍了标记清除的细节,其中提到Mutator可以并发执行。通常来说,Mutator的执行需要读写内存,其中读写内存主要指访问、修改整个堆空间的对象,写内存还包括在Eden中分配对象。并发执行指的是上述操作不应该受到老生代回收的影响,实际上大多数情况下也确实如此。但是还存在一些例外的情况,会导致其实现非常复杂。最典型的情况是当Mutator无法在Eden中分配对象时该如何处理?最常见的做法是执行Minor GC以便回收Eden中的死亡对象,这样Mutator就可以继续执行。另外还有一种情况,就是Mutator可能基于种种原因需要在老生代中分配对象。

对于这两种情况的处理都非常复杂,主要原因是这些操作会与老生代回收的一些操作发生竞争。例如Minor GC需要VMThread执行STW,而初始标记和再标记也需要VMThread执行STW;另外Minor GC执行时会晋升对象(在老生代中分配对象),而老生代的一些操作,例如清除(sweep),也会针对空闲内存块进行操作(比如合并内存块);还有就是若Mutator触发Minor GC后仍然可能无法满足内存分配请求时该如何处理。一般的操作是将Minor GC升级为Major GC或者Full GC。

Major GC或者Full GC和当前正在执行的老生代回收该如何设计和交互呢?为了区分各种回收,把主动触发的老生代回收称为后台GC,而由Mutator触发的Minor GC或者Major GC和Full GC称为前台GC。

首先来看一下后台GC和前台GC的并发操作交互。从概念上说,后台GC一般是主动触发,而前台GC是被动触发,通常是Mutator遇到分配请求无法满足的情况,所以前台GC的优先级应该更高一些。如果后台GC正在执行,要体现前台GC优先级更高的表现就是,前台GC可以抢占后台GC的执行。

另外,无论是前台GC还是后台GC,都有STW阶段,都需要VMThread参与工作,那么该如何设计回收才能体现前台GC的优先级更高一些?JVM的实现是在后台GC执行的过程中,如果发现有前台GC的请求,会进入前台GC中执行。但是由于前台GC的执行可能会对后台GC产生影响,因此此处还需要再对前台GC到底是触发MinorGC、Major GC还是Full GC再做一下区分。

如果前台GC是Minor GC,则对后台GC影响最小,只会影响老生代标记过程,所以触发Minor GC最好是在不影响后台GC标记的过程中执行。因此后台GC在InitialMark、Remark和Resize阶段执行之前可以允许前台GC执行。此时后台GC和前台GC的交互示意图如图4-30所示。

jvm内存分配回收策略(JVM垃圾回收器详解)(1)

图4-30 前台GC和后台GC交互示意图

如果前台GC是Major GC,即Minor GC发生后仍然无法满足Mutator的内存请求,在JDK 9之前的版本中有两种执行模式,分别是标记清除算法和标记压缩算法。首先判断是否需要执行压缩,如果需要则执行标记压缩;如果不需要则执行标记清除算法。在标记清除的执行过程中会根据后台GC执行的阶段进行重用,例如后台GC执行完并发标记,标记清除算法直接从再标记开始执行,并且STW的执行会持续到整个GC周期执行完毕。如果是标记压缩,则执行串行的标记压缩算法,具体细节参考第3章中的相关知识。在JDK 9及随后的版本中,移除了标记清除的功能,直接执行标记压缩算法。其主要的原因是此时执行重用的标记压缩能回收的内存有限,并不能缓解应用内存不足的困境,还会导致代码复杂。

另外,在JDK 9中也移除了iCMS模式。iCMS模式在新生代的使用过程中不断地判断是否可以触发后台GC,如果满足条件则会执行后台GC(该模式通过参数CMSIncrementalMode控制,默认值为false)。

使用该模式须谨慎地设置iCMS后台GC触发的条件,JVM提供了几个参数,其中CMSIncrementalDutyCycleMin(默认值为0)和参数CMSIncrementalOffset(默认值为10)最为重要,参数CMSIncrementalDutyCycleMin用于控制Mutator分配时触发和停止iCMS模式下后台GC内存的使用区间,其中触发的边界是Eden使用的上限尚未达到阈值free×DutyCycle/2,停止的阈值是Eden使用的上限超过阈值free×DutyCycle(DutyCycle可以通过信息收集预测出来,但为了防止DutyCycle过小,使用参数CMSIncrementalDutyCycleMin作为其最小值);而参数CMSIncrementalOffset表示对使用的内存额外增加一个比例。

该模式在JDK 8中也不再推荐使用,主要原因是该模式维护成本太高(该模式导致代码更为复杂,bug很多),且调参并不容易。所以不再具体介绍该模式,更多信息可以参考官方文档。

因为后台GC和前台GC都可能需要VMThread协助执行进入STW,所以两者需要通过锁(锁记为CGC_Lock)来解决竞争问题。另外,后台GC需要一个机制释放锁,所以引入了两层的锁抢占机制:第一层是Token,第二层是CGC_Lock。通过Token来判断后台GC控制线程或者前台GC谁能获得锁,通过Token机制保证了前台GC优先级高的问题。

假设后台GC由CMS控制线程控制执行,前台GC由vmThread控制;同时设计了4个Token,分别为vm_has_token、vm_want_token、cms_has_token、cms_want_token。当CMS控制线程或VMThread获得执行时,一定是获得了cms_has_token或vm_has_token;当CMS控制线程或VMThread想得到执行时,先要获得cms_want_token或vm_want_token。通过引入Token机制就能实现优先级控制。

当CMS控制线程想获得执行时,如果没有竞争,则直接获得cms_has_token,然后进入执行(见图4-31b);如果通过检测发现Token是vm_has_token或者vm_want_token,则先设置cms_want_token,等待CGC_lock,直到VMThread完成执行(前台GC操作完成后)才获得执行的机会,然后进入cms_has_token(见图4-31a)。CMS控制线程获得Token的示意图如图4-31所示。

jvm内存分配回收策略(JVM垃圾回收器详解)(2)

图4-31 CMS控制线程获取Token示意图

当VMThread想获得执行时,如果没有竞争则直接获得vm_has_token,然后进入执行(见图4-32c);如果通过检测发现Token是cms_has_token,则说明CMS控制线程已经获得执行机会,所以VMThread等待CMS控制线程执行结束,首先设置vm_want_token阻止CMS控制继续获得执行的机会,然后等待CGC_Lock(CMS控制执行完毕后通知CGC_Lock),得到通知后设置vm_has_token(见图4-32a);如果通过检测发现Token是cms_want_token,则说明CMS控制线程也想得到执行,但是尚未得到执行,此时VMThread直接获得Token,CMS控制线程无法获得执行,即在抢占Token中失败(见图4-32b)。VMThread获得Token的示意图如4-32所示。

jvm内存分配回收策略(JVM垃圾回收器详解)(3)

图4-32 VMThread获取Token示意图

VMThread和CMS控制线程通过Token和CGC_Lock解决了线程优先级的问题及竞争的问题。

下面来看一下后台GC和Mutator的并发操作交互。由于后台GC和Mutator并发执行,Mutator在运行时可能修改对象引用,需要更新CT或MUT,也可能在老生代中分配对象。而后台GC执行时同样会访问和修改CT、MUT或者操作内存(如合并)。所以后台GC设计了一些锁,比如BitmapLock、FreeListLock等。Mutator和后台GC需要竞争锁以获得执行权。在后台GC获得上述锁的情况下,会在满足一定条件下检测是否需要放弃CPU的执行权,从而使Mutator获得继续执行的机会。

后台GC在并发执行的阶段,每处理一个对象之后都会主动检测是否需要放弃CPU执行权,如果需要放弃,则通过Yield机制放弃执行。在后台GC执行时,通常由CMS控制线程来控制执行,而在任务真正执行时,大多数情况下是多个线程同时执行。在任务放弃CPU时需要多个线程同时完成放弃CPU执行权,Mutator才能获得执行权。

两者的交互是:Mutator在执行一些操作时会先设置请求后台GC放弃执行的标志位,后台GC执行过程中,如果多个线程发现这些标志位变化,则会执行放弃CPU控制权的动作,直到所有的线程都进入Yielding状态,后台GC才会进入Yielded状态,然后释放锁,让Mutator获得执行的机会,如图4-33所示。

jvm内存分配回收策略(JVM垃圾回收器详解)(4)

图4-33 CMS并发任务主动放弃执行示意图

当然,如果并发阶段是单线程执行(如PreClean、Reset等阶段)的,放弃CPU控制权相对比较简单,不需要像上面的多线程那样同步到Yielded状态,而是直接进入Yielded状态放弃CPU控制权。在预清理阶段提到,后台GC线程放弃CPU控制权后Mutator并不一定能获得CPU控制权(由于OS调度机制),所以引入了一些额外的参数让后台GC线程睡眠。更多信息可以参考前文内容,这里不赘述。

 Full GC

在JDK 9之前的版本和JDK 9及以后的版本中,关于Full GC的触发逻辑稍有不同。JDK 9中对实现的逻辑进行了简化。当然不管在哪个版本中,FullGC都是前台GC,即都是由Mutator主动触发的。

在JDK 9中,如果Mutator在执行过程中发现内存不足,则会触发MinorGC。如果Minor GC获得执行的机会,在Minor GC执行结束后仍然无法满足Mutator的内存请求,则会直接执行Full GC。如果进入Full GC的执行阶段,发现CMS控制线程正在执行后台GC,则会尝试抢占后台GC的执行(通过Token和CGC_Lock),如果抢占成功,则会告诉后台GC需要终止执行(无论后台GC执行到哪一个阶段),直接执行标记压缩算法。关于标记压缩算法更多的信息,可以参考第3章的内容。当Full GC执行结束后,CMS线程重新获得控制权继续执行,如果发现已执行过Full GC,则会终止当前的执行。

JDK 9以前的版本首先判断是否需要执行压缩,如果需要则执行标记压缩,如果不需要则执行标记清除算法。主要有两个参数控制是否需要执行标记压缩:参数UseCMSCompactAtFullCollection控制是否允许执行标记压缩(默认值是true);参数CMSFullGCsBeforeCompaction表示每经过多少次标记清除后执行一次标记压缩(默认值是0)。所以默认情况下每次都执行标记压缩,而不是标记清除。

本文给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-并发标记清除之内存空间调整、复位、并发算法难点、Full GC
  1. 下篇文章给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,标记栈溢出的各种处理方法
  2. 感谢大家的支持!
,