JVM垃圾回收器

87 阅读34分钟

串行收集器

串行收集器采用单线程 stop-the-world 的方式进行收集。当内存不足时,串行 GC 设置停顿标识,待所有线程都进入安全点(Safepoint)时,应用线程暂停,串行 GC 开始工作,采用单线程方式回收空间并整理内存

特点

  • 单线程意味着复杂度更低、占用内存更少,垃圾回收效率高;但同时也意味着不能有效利用多核优势。
  • 特别适合堆内存不高、单核甚至双核 CPU 的场合

Serial + Serial Old 收集器

开启选项:-XX:+UseSerialGC 打开此开关后,使用 Serial + Serial Old 收集器组合来进行内存回收。等价于新生代用Serial GC,且老年代用Serial old GC。

运行示意图

image-20231023162553675

针对年轻代:Serial收集器采用复制算法串行回收stop-the-World机制。

针对老年代:Serial old收集器采用标记-整理算法,也采用了串行回收stop-the-World机制。

特点

  • 简单高效:对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销
  • 占用内存小

场景

  • 对于堆内存很小的应用(几十或者一两百兆的新生代或老年代)
  • 桌面应用(客户端模式下的默认垃圾收集器)
  • Serial Old在JDK 5之前的版本中和Parallel Scavenge收集器搭配使用
  • Serial Old作为CMS收集器发生失败时的后备预案

并行收集器

并行收集器是 server 模式下的默认收集器。

开启选项:-XX:+UseParallelGC 打开此开关后,使用 Parallel Scavenge + Serial Old 收集器组合来进行内存回收。 开启选项:-XX:+UseParallelOldGC 打开此开关后,使用 Parallel Scavenge + Parallel Old 收集器组合来进行内存回收。

其他收集器都是以关注停顿时间为目标,而并行收集器是以关注吞吐量(Throughput)为目标的垃圾收集器。

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验;
  • 而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

特点

  • 并行收集器与串行收集器工作模式相似,都是 stop-the-world 方式,只是暂停时并行地进行垃圾收集
  • 并行收集器年轻代采用复制算法老年代采用标记-整理,在回收的同时还会对内存进行压缩
  • 并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 收集器 + Parallel Old 收集器。

Parallel Scavenge + Parallel Old收集器

特点

  • 新生代使用Parallel Scavenge标记复制老年代使用Parallel Old收集器标记整理
  • 吞吐量优先
  • 并行收集

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是:

  • -XX:MaxGCPauseMillis :控制最大垃圾收集停顿时间,收集器将尽可能保证内存回收时间不超过设定值。
  • -XX:GCTimeRatio: 直接设置吞吐量大小的(值为大于 0 且小于 100 的整数)。

缩短停顿时间是以牺牲吞吐量年轻代空间来换取的:年轻代空间变小,垃圾回收变得频繁,导致吞吐量下降。

Parallel Scavenge 收集器还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定年轻代的大小-Xmn、Eden 和 Survivor 区的比例-XX:SurvivorRatio、晋升老年代对象年龄-XX:PretenureSizeThreshold等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。

并发标记清除收集器

开启选项:-XX:+UseConcMarkSweepGC 打开此开关后,使用 CMS + ParNew + Serial Old 收集器组合来进行内存回收。

并发标记清除收集器是以获取最短停顿时间为目标。

开启后,年轻代使用 ParNew 收集器;老年代使用 CMS 收集器,如果 CMS 产生的碎片过多,导致无法存放浮动垃圾,JVM 会出现 Concurrent Mode Failure ,此时使用 Serial Old 收集器来替代 CMS 收集器清理碎片。

CMS 收集器

CMS 收集器是一种以获取最短停顿时间为目标的收集器。JVM9移除

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法.

运行示意图

image-20231023164255376

CMS收集器各个阶段

阶段1:初始标记—STW

仅仅只是标记一下 GC Roots 能直接关联到的对象(包括根对象直接引用的对象,以及被年轻代中所有存活的对象所引用的老年代对象),速度很快,需要停顿。

image-20231024112453873

阶段2:并发标记—并发

遍历所有的对象,标记存活的对象,从前一阶段“初始标记”找到的根元素开始算起。

此阶段由于与用户线程并发执行,对象的状态可能会发生变化,如下:

  • 年轻代的对象从年轻代晋升到老年代
  • 有些对象被直接分配到老年代
  • 老年代和年轻代的对象引用关系变化

JVM会通过Card(卡片)的方式将发生改变的老年代区域标记为“脏”区,这就是所谓的卡片标记(Card Marking)

image-20231024145830642

阶段3:并发预清理—并发

标记老年代存活的对象,此阶段仍然是与应用线程并发执行的,不需要停止应用线程。

目的: 让最终/重新标记的STW时间尽可能短

标记目标:

  • 老年代中在并发标记阶段中被标记为dirty的card
  • 幸存区(from和to)中引用的老年代对象

关闭参数:-XX:-CMSPrecleaningEnabled默认开启

image-20231024171341252

阶段4:可取消的并发预清理—并发

不停止应用程序,本阶段尝试在STW的最终标记阶段之前尽可能多做一些工作。可取消。

存在价值:在进入最终标记前尽量等到一个Minor GC,尽量缩短最终标记阶段的停顿时间。

触发条件: 在预清理步骤后,如果满足下面这个个条件,就会开启可中断的预清理,直接进入重新标记阶段

  • Eden的使用空间大于-XX:CMSScheduleRemarkEdenSizeThreshold,这个参数的默认值是2M

取消条件:

  • 设置了CMSMaxAbortablePrecleanLoops循环次数,并且执行的次数大于或者等于这个值的时候。默认为0
  • CMSMaxAbortablePrecleanTime,执行可中断预清理的时间超过了这个值,这个参数的默认值是5000毫秒
  • Eden的使用率达到-XX:CMSScheduleRemarkEdenPenetration这个参数的默认值是50%

CMS提供了参数CMSScavengeBeforeRemark在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。

阶段5:最终标记/重标记—STW

第二次(也是最后一次)STW停顿。

作用: 重新扫描堆中的对象,因为之前的预清理阶段是并发执行的,有可能GC线程跟不上应用程序的修改速度。

扫描范围: 新生代对象+GC Roots+被标记为脏区的对象。如果预清理阶段没有做好,这一步扫描新生代的时候就会花很多时间。

阶段6:并发清除 Concurrent Sweep

作用:JVM在此阶段删除不再使用的对象,并回收他们占用的内存空间。

阶段7:并发重置 Concurrent Reset

作用:重置CMS算法相关的内部数据,为下一次GC循环做准备

缺点:

  • 吞吐量降低:对处理器资源敏感,执行垃圾收集时会占用一部分线程导致程序吞吐量降低。
  • 占用CPU资源,与CPU核数挂钩:CMS默认启动的回收线程是(CPU核心数 +3)/4,当CPU核数越多,垃圾回收线程占用的资源就越少,反正CPU核数越少,占用资源就越多。
  • 内存碎片问题:CMS使用的是标记-清除算法,这种算法的弊端就是会产生内存碎片,导致大对象无法分配,就会触发Full GC。
    • CMS收集器提供了一个参数-XX:+UseCMSCompactAtFullCollection(默认开启,JDK9废弃),在进行Full GC之前进行一次内存整理(无法并发,Shenandoah和ZGC可以)。
    • CMS还提供了一个参数-XX:CMSFullGCsBeforeCompaction=n(默认为0,表示每次进入Full GC时都进行碎片整理),参数作用是当CMS收集器执行过n次不整理内存碎片后,下一次进入Full GC前先进行碎片整理。
  • 无法处理浮动垃圾:在并发收集阶段时,用户线程创建了一个对象年轻代放不下,直接晋升到老年代,或者年轻代对象晋升到老年代时老年代。因此CMS垃圾收集器必须要预留一部分空间给用户线程(需要更大的堆空间),不能等到老年代满了才收集(JDK5及之前是68%,JDK6之后调整为92%,可通过 -XX:CMSInitiatingOccupancyFraction_=数值+ -XX:+UseCMSInitiatingOccupancyOnly来设置)
    • 当设置-XX:CMSInitiatingOccupancyFraction过大时,就可能会出现垃圾收集过程中无法分配对象的问题,导致并发失败(Concurrent Mode Failure),此时会临时启用Serial Old收集器来重新进行老年代收集,这会导致停顿时间更长。

ParNew 收集器

开启选项:-XX:+UseParNewGC

ParNew 收集器其实是 Serial 收集器的多线程版本。主要是为了配合 CSM 的垃圾收集而提供的年轻代的垃圾收集器,其只有年轻代的收集版本。

Server 模式下的虚拟机首选年轻代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC 后的默认年轻代收集器。

ParNew 收集器默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

G1收集器

开启选项:-XX:+UseG1GC

Garbage First:每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。

前面提到的垃圾收集器一般策略是关注吞吐量或停顿时间。而 G1 是一种兼顾吞吐量和停顿时间的 GC 收集器。G1 是 Oracle JDK9 以后的默认 GC 收集器。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

G1 最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至 CMS 的众多缺陷

分区Region

G1 取消了永久代,并把年轻代和老年代划分成多个大小相等的独立区域(Region),年轻代和老年代不再物理隔离。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表每次根据允许的收集时间优先回收价值最大的 Region

在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可。

启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

卡片Card

在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度。

所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。

每次对内存的回收,都是对指定分区的卡片进行处理。

堆Heap

G1同样可以通过-Xms/-Xmx来指定堆空间大小。

如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低。

目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。

当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起Full GC。Full GC后,也会调整堆空间。

G1分区示意图

image-20231025214845362

分代模型

image-20231025215816612

分代垃圾回收

G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间(默认60%)之间动态变化,且该值由下面三个参数计算

  • 参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)
  • 需要扩缩容的大小-XX:G1MaxNewSizePercent
  • 分区的已记忆集合(RSet)

G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

本地分配缓冲
  • 应用线程可以独占一个**本地缓冲区(TLAB)**来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外)。

  • 每次垃圾收集时,每个GC线程同样可以独占一个**本地缓冲区(GCLAB)**用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间。

  • 对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)

分区模型

image-20231025220346616

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

巨型对象Humongous Region

一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。

巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。

G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

已记忆集合Remember Set (RSet)

G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。

当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。

可能只有老年代分区才会有RSet记录

下面这些情况不需要将引用记录在RSet中:

  • 一个分区确定需要扫描,引用来自该分区的对象。
  • 引用来自年轻代的对象。(因为G1每次都会对年轻代进行整体收集)
Per Region Table (PRT)

为了降低RSet空间,使用下面三种模式记录:

  • 稀少:直接记录引用对象的卡片索引
  • 细粒度:记录引用对象的分区索引
  • 粗粒度:只记录引用数量,每个分区对应一个比特位,所以扫描时最慢,需要扫描整个堆

收集集合 (CSet)

image-20231025222635479

年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

候选老年代分区的CSet准入条件:

  • 活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;

  • 每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

年轻代收集集合 CSet of Young Collection

年轻代回收大致过程:

  1. 当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。

  2. 在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。

  3. 而原有的年轻代分区将被整体回收掉。

年轻代收集还负责维护对象的年龄(存活次数),判断老化对象晋升,条件如下:

  • 年龄表
  • Survivor尺寸
  • Survivor填充容量-XX:TargetSurvivorRatio(默认50%)
  • 最大年龄阈值-XX:MaxTenuringThreshold(默认15)
混合收集集合 CSet of Mixed Collection

当老年代占用空间超过整堆比阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。

为了满足暂停目标,G1可能会产生连续多次的STW混合收集与应用线程交替执行。

为了确定收集能包含到年轻代收集集合CSet的老年代分区,JVM通过下面参数:

  • 混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)
  • 堆废物百分比-XX:G1HeapWastePercent(默认5%)

通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。

并发标记算法(三色标记法)

CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。

image-20231025223728170

GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。

漏标问题

在remark过程中,黑色指向了白色,如果不对黑色重新扫描,则会漏标。会把白色D对象当作没有新引用指向从而回收掉。

image-20231025224130061

并发标记过程中,Mutator删除了所有从灰色到白色的引用,会产生漏标。此时白色对象应该被回收

产生漏标问题的条件有两个:

  • 黑色对象指向了白色对象
  • 灰色对象指向白色对象的引用消失

所以要解决漏标问题,打破两个条件之一即可:

  • 跟踪黑指向白的增加 incremental update:增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS采用该方法。
  • 记录灰指向白的消失 SATB snapshot at the beginning:关注引用的删除,当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被GC扫描到。G1采用该方法。

为什么G1采用SATB而不用incremental update

因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。

也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。

G1垃圾回收机制

【G1垃圾回收生命周期图】:

image-20231025224639513

RSet的维护

由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。

在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)并发优化线程(Concurrence Refinement Threads)

栅栏Barrier

【示意图】:

image-20231025225031116

栅栏的概念:

栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。

写前栅栏 Pre-Write Barrrier

即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。

写后栅栏 Post-Write Barrrier

当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)

起始快照算法Snapshot at the beginning (SATB)

增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。

SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。

并发优化线程Concurrence Refinement Threads

当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。

并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。

并发标记周期 Concurrent Marking Cycle

这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能**)存活对象**,并计算每个分区的活跃度,从而确定GC效率等级。

当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。

整个并发标记周期有下面几个阶段:

  • 初始标记(Initial Mark)—STW
  • 根分区扫描(Root Region Scanning)
  • 并发标记(Concurrent Marking)
  • 重新标记(Remark)——STW
  • 清除(Cleanup)——STW

其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。

初始标记 Initial Mark——SWT

初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Java应用线程暂停掉,也就是需要一个STW的时间段。

事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,分区的初始标记是并发执行,直到所有的分区处理完。

根分区扫描 Root Region Scanning——并发

在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。

此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。

根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。

并发标记 Concurrent Marking——并发

和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。

存活数据计算 Live Data Accounting

存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。

重新标记 Remark——STW

在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。前面几步是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。

清除 Cleanup——STW

主要做下面操作:

  • RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;

  • 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;

  • 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期

年轻代收集/混合收集周期

当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。

首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。

GC工作线程数

JVM可以通过参数-XX:ParallelGCThreads进行指定GC工作的线程数量。

参数-XX:ParallelGCThreads默认值并不是固定的,而是根据当前的CPU资源进行计算。

如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;也可以人工指定与CPU核数相等。

年轻代收集 Young Collection

并行活动

  • 外部根分区扫描 Ext Root Scanning:此活动对堆外的根(JVM系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合CSet中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间。
  • 更新已记忆集合 Update RS:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新RSet的时间,可以设置暂停占用百分比-XX:G1RSetUpdatingPauseTimePercent(默认10%,即-XX:MaxGCPauseMills/10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少RSet的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对Java应用线程资源的争夺。
  • RSet扫描 Scan RS:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。如果RSet发生粗化,则会增加RSet的扫描时间。开启诊断模式-XX:UnlockDiagnosticVMOptions后,通过参数-XX:+G1SummarizeRSetStats可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为RSet粗化总数提供窗口。参数-XX:G1SummarizeRSetStatsPeriod=n可设置RSet的统计周期,即经历多少此GC后进行一次统计
  • 代码根扫描 Code Root Scanning:对代码根集合进行扫描,扫描JVM编译后代码Native Method的引用信息(nmethod扫描),进行RSet扫描。事实上,只有CSet分区中的RSet有强代码根时,才会做nmethod扫描,查找对CSet的引用。
  • 转移和回收 Object Copy:通过选定的CSet以及CSet分区完整的引用集,将执行暂停时间的主要部分:CSet分区存活对象的转移、CSet分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个GC线程分配缓冲区GCLAB。G1会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸。
  • 终止 Termination:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止。
  • GC外部的并行活动 GC Worker Other:该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。

串行活动

  • 代码根更新 Code Root Fixup:根据转移对象更新代码根。
  • 代码根清理 Code Root Purge:清理代码根集合表。
  • 清除全局卡片标记 Clear CT:在任意收集周期会扫描CSet与RSet记录的PRT,扫描时会在全局卡片表中进行标记,防止重复扫描。在收集周期的最后将会清除全局卡片表中的已扫描标志。
  • 选择下次收集集合 Choose CSet:该部分主要用于并发标记周期后的年轻代收集、以及混合收集中,在这些收集过程中,由于有老年代候选分区的加入,往往需要对下次收集的范围做出界定;但单纯的年轻代收集中,所有收集的分区都会被收集,不存在选择。
  • 引用处理 Ref Proc:主要针对软引用、弱引用、虚引用、final引用、JNI引用。当Ref Proc占用时间过多时,可选择使用参数-XX:ParallelRefProcEnabled激活多线程引用处理。G1希望应用能小心使用软引用,因为软引用会一直占据内存空间直到空间耗尽时被Full GC回收掉;即使未发生Full GC,软引用对内存的占用,也会导致GC次数的增加。
  • 引用排队 Ref Enq:此项活动可能会导致RSet的更新,此时会通过记录日志,将关联的卡片标记为脏卡片。
  • 卡片重新脏化 Redirty Cards:重新脏化卡片。
  • 回收空闲巨型分区 Humongous Reclaim:G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。
  • 释放分区 Free CSet:回收CSet分区的所有空间,并加入到空闲分区中。
  • 其他活动 Other:GC中可能还会经历其他耗时很小的活动,如修复JNI句柄等
并发标记周期后的年轻代收集 Young Collection Following Concurrent Marking Cycle
混合收集周期 Mixed Collection Cycle

单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。

转移失败的担保机制 Full GC

当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  • 从老年代分区转移存活对象时,无法找到可用的空闲分区
  • 分配巨型对象时在老年代无法找到足够的连续分区

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生

总结

收集器串行/并行/并发年轻代/老年代收集算法目标适用场景
Serial串行年轻代复制响应速度优先单 CPU 环境下的 Client 模式
Serial Old串行老年代标记-整理响应速度优先单 CPU 环境下的 Client 模式、CMS 的后备预案
ParNew串行 + 并行年轻代复制算法响应速度优先多 CPU 环境时在 Server 模式下与 CMS 配合
Parallel Scavenge串行 + 并行年轻代复制算法吞吐量优先在后台运算而不需要太多交互的任务
Parallel Old串行 + 并行老年代标记-整理吞吐量优先在后台运算而不需要太多交互的任务
CMS并行 + 并发老年代标记-清除响应速度优先集中在互联网站或 B/S 系统服务端上的 Java 应用
G1并行 + 并发年轻代 + 老年代标记-整理 + 复制算法响应速度优先面向服务端应用,将来替换 CMS

参考: