本文是学习G1过程中,记下的一些笔记,大部分内容是从参考文章里复制的。

介绍

GC演进

随着内存大小不断增长而演进:

  • 几M - 几十M:Serial,单线程STW(Stop The World)垃圾回收。
  • 上百M – 1G:parallel,并行多线程垃圾回收。
  • 几G:cms,Concurrent Gc。
  • 几十G:G1。
  • 上百G – TB:ZGC。

image.png

问题

G1之前的回收器,STW阶段在Heap区越来越大的情况下需要的时间越长,并且CMS由于内存碎片,需要压缩的话也会造成较长停顿时间。所以需要一种高吞吐量的短暂停时间的收集器,而不管堆内存多大。

简介

G1全称是Garbage First,于JDK 6u14版本发布,JDK 7u4版本发行时被正式推出,旨在取代CMS垃圾回收器,在JDK9时已经成了默认的垃圾回收器。
G1是一个响应时间优先的GC算法,最大特点是暂停时间可配置,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。那么G1怎么满足用户的期望呢?就需要停顿预测模型(Pause Prediction Model)了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。

内部结构

堆被划分为N个(可配置,默认2048)大小的相等的区域(region),每个区域占用一段连续的地址空间,以区域为单位进行垃圾回收,而且这个区域的大小是可配置的,如果不配置, G1会根据堆大小自动决定你区域大小 。在分配时,如果选择的区域已经满了,会自动寻找下一个空闲的区域来执行分配。
image.png

一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定(堆大小/2048)。
image.png
G1中的区域,主要分为两种类型:

  • 年轻代区域: G1不需要设置年轻代大小(默认5-60%),

    • Eden区域 - 新分配的对象
    • Survivor区域 - 年轻代GC后存活但不需要晋升的对象
  • 老年代区域

    • 晋升到老年代的对象
    • 直接分配至老年代的巨型对象,占用多个区域的对象

Humongous Region:
G1有专门分配巨型对象的Region,而不是进入老年代Region。一个大小达到甚至超过分区大小一半(可配置)的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
巨型对象永远不会被移动,要么直接被回收,要么一直存在,定巨型对象开始位置的成本非常高,应用程序应避免生成巨型对象。

GC基础

可达性分析

怎么判断对象是否是垃圾?
JVM采用可达性分析算法,以“GC ROOT”为根节点,根据引用关系向下搜索。
以下图a和b对象不可达,将被标记为垃圾。
image.png

GC Root的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中的参数、局部变量、临时变量。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 方法区中常量引用的对象,比如字符串常量池里的引用。
  • Jvm内部的引用,如基本数据类型对应的Class对象,系统类加载器等。

三色标记算法

遍历对象过程中,按“是否访问过”这个条件将对象标记成以下三种颜色:

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了。
  • 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。
    image.png

浮动垃圾
image.png
假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开)。
此刻之后,对象 E/F/G 是“应该”被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
这部分本应该回收,但是没有回收到的内存,被称之为“浮动垃圾”。等到下一轮垃圾回收中才被清除。

漏标
image.png
假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先执行了:

var G = objE.fieldG; 
objE.fieldG = null; // 灰色E 断开引用 白色G 
objD.fieldG = G; // 黑色D 引用 白色G

漏标造成的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

G1的解决方案是写屏障+SATB

写屏障

写屏障:给某个对象的成员变量赋值时,在赋值操作前后,加入一些处理(类似AOP的概念)

void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field); // 写屏障-写前操作 
    *field = new_value; 
    post_write_barrier(field, value); // 写屏障-写后操作 
}

SATB

SATB (Snapshot At The Beginning,初始快照),是一种将并发标记阶段开始时对象间的引用关系,以逻辑快照的形式进行保存的手段。简单理解就是,在并发标记时,以当前的引用关系作为基础引用数据,不考虑Mutator并发运行时对引用关系的修改(Snapshot命名的由来),标记时是存活状态就认为是存活状态。gc时会扫描SATB数据。

Rset

image.png
上图Region B和C是老年代,Region A是新生代。Region A对于GC Root来说是不可达的:

  • young gc时,需要扫描全部老年代对象?
  • mix gc(回收部分对象)时,需要扫描全部老年代?
    RememberedSet(简称RS或RSet)就是用来解决这个问题的,RSet会记录引用的关系(记录old引用young,old引用old,其他不记录)。
    image.png
    每个Region中都有一个RSet,通过hash表实现,这个hash表的key是引用本区域的其他区域的地址(只记录old引用youngold引用old,不记录young引用youngyoung引用old),value是一个数组,数组的元素是引用方的对象所对应的Card Page在Card Table中的下标。mix gc时会重置Rset。
    image.png
    在做young GC的时候,只需要选定young region的RSet作为根集(即进行标记的时候,将RSet也作为ROOTS进行遍历),这些RSet记录了old->young的跨代引用,避免了扫描整个old generation, mixed gc的时候,也一样。所以RSet的引入大大减少了GC的工作量。
    摘一段R大的解释:G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。 这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。 举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。
    image.png

TLAB

TLAB(Thread Local Allocation Buffer):本地线程缓冲区。
由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步,为了避免加锁,G1 GC会默认会启用TLAB优化。每一个应用程序的线程会被分配一个TLAB,每个TLAB都是一个线程独享的,当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁。

PLAB

PLAB(Promotion Thread Local Allocation Buffer):“提升”线程本地分配缓冲区
思路跟TLAB一样,G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了。

GC

g1的gc分:

  • young gc,采用标记-复制算法。
  • mix gc,采用标记-复制算法。
  • full gc,采用标记-整理算法。

young gc

image.png

  • 当JVM无法将新对象分配到eden区域时(新生代的区域总大小超过新生代大小的限制),如果超出就会进行young gc。
  • young gc只选择年轻代区域(Eden/Survivor)进入回收集合(Collection Set,简称CSet)进行回收的模式。
  • G1为了满足用户停顿时间的配置,每次GC后,会在遵循用户设置的GC暂停时间上限的基础上,动态调整年轻代大小。
  • young gc是STW 。

gc步骤

  1. 选择收集集合(Choose CSet):G1会在遵循用户设置的GC暂停时间上限的基础上,选择一个最大年轻带区域数,作为收集集合。
    image.png
  2. 根处理(Root Scanning):接下来,需要从GC ROOTS遍历,查找从ROOTS直达到收集集合的对象,移动他们到Survivor区域的同时将他们的引用对象加入标记栈。
    image.png
  3. RSet扫描(Scan RS):将RSet作为ROOTS遍历,查找可直达到收集集合的对象,移动他们到Survivor区域的同时将他们的引用对象加入标记栈。
    image.png
  4. 移动(Evacuation/Object Copy),遍历上面的标记栈,将栈内的所有所有的对象移动至Survivor区域。
    image.png
  5. 剩下的就是一些收尾工作,Redirty(配合下面的并发标记),Clear CT(清理Card Table),Free CSet(清理回收集合),清空移动前的区域添加到空闲区等等,这些操作一般耗时都很短。
    image.png

mix gc

image.png

  • 混合回收:young + old。
  • 会选择所有年轻代区域(Eden/Survivor)和部分老年代区域进去回收集合进行回收的模式。
  • 当老年代使用的内存加上本次即将分配的内存,超过整堆比IHOP阈值(InitiatingHeapOccupancyPercent,默认45%)时,将启动mx gc。

image.png

先进行一次年轻代回收过程,这个过程是STW的。
初始标记
初始标记 Initial Mark:标记所有GC Root出发可以直接到达的对象,young gc后survivor的对象也会被视为GC Root,STW,会复用young gc的暂停时间(跟young gc一起执行)。
初始标记负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描
根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记
并发标记 Concurrent Marking: 并发阶段。从上一个阶段扫描的对象出发逐个遍历查找,每找到一个对象就将其标记为存活状态,会扫描SATB。
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-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,虽然前面的并发标记过程中扫描了SATB,但是毕竟上一个阶段依然是并发过程,因此需要在并发标记完成后,再次暂停所有用户线程,再次标记SATB。
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
清除
清除 Cleanup:识别高收益的老年代分区,清理和重置标记状态,STW。
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节; 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合; 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。

full gc

当mix gc无法跟上内存分配的速度,导致老年代也满了,就会进行Full GC对整个堆进行回收。G1中的Full GC也而是单线程串行的,而且是全暂停,代价非常高。
以下场景中会触发full gc:

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

总结

G1并不属于一个高效率的回收器,对老年代使用复制式的回收算法,虽然没有碎片问题,但效率是较低的。因为老年代对象大多数是存活的,所以每次回收需要移动的对象很多。而清除算法中是清除死亡的对象,所以从效率上来看,清除算法在老年代中会更好。
但是由于G1这个可控制暂停的增量回收,可以保证每次暂停时间在允许范围内,对于大多数应用来说,暂停时间比吞吐量更重要。再加上G1的各种细节优化,效率已经很高了。

参考:
这可能是最清晰易懂的 G1 GC 资料
JVM系列十六(三色标记法与读写屏障)

noname
314 声望49 粉丝

一只菜狗