1

G1学习笔记.md

 2 years ago
source link: https://segmentfault.com/a/1190000040681316
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

G1学习笔记.md

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

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

  • 几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带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
巨型对象永远不会被移动,要么直接被回收,要么一直存在,定巨型对象开始位置的成本非常高,应用程序应避免生成巨型对象。

可达性分析

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

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

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

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 。
  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系列十六(三色标记法与读写屏障)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK