java G1 垃圾收集器解析

转载、引用请标明出处
https://www.jianshu.com/p/35805f809a21
本文出自zhh_happig的博客,谢谢

以下内容,是本人学习的笔记和工作中的总结,仅供大家参考,有误的地方还请指正

一 G1简介

  • JDK7增加,成为HotSpot重点发展的垃圾回收技术,被HotSpot团队寄予取代CMS的使命,将会被安排成为JDK9的默认垃圾收集器
  • 低停顿(stw)、高吞吐;调优简单,设置2个参数
    • 最基本设置堆大小和最大stw时间
    • java -Xmx32G -xx:MaxGCPauseMillis=100 ...
    • G1的最大stw时间默认是250ms
    • G1的还有很多调优参数
    • 对比CMS,G1的调优要简单的多
  • G1是一种分代收集器,只有逻辑上的分代概念,与物理上分代有本质区别
    • 年轻代:采用复制算法
    • 年老代:标记-清楚算法,类似CMS
  • G1的特点
    • G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间自动调整年轻代和总堆大小,暂停越短年轻代空间越小、总空间就越大
    • G1采用内存分区(Region)的思路,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩)
    • G1的收集都是STW的,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分年老代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿
  • G1有详细的日志信息,建议使用下面的参数,当G1出问题,可以获取很多有用的信息
    • -xx:+PrintGCDateStamps 打印日期和正常云行时间
    • -xx:+PrintGCDetails 打印G1详细信息
    • -xx:+PrintAdaptiveSizePolicy 打印自适应调节策略;自适应策略:GC会根据中统计的GC时间、吞吐量、内存占用量,重新计算堆内存中各区大小
    • -xx:+PrintTenuringDistribution 打印survivor region区域内的对象的age信息

二 G1内存布局
java G1 垃圾收集器解析_第1张图片

  • G1整个堆空间分成若干个大小相等的内存区域-Regions
  • 默认将整堆划分为2048个分区,可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂)
  • Regions分为
    • Eden Regions
    • Survivor Regions
    • Old Regions
    • Humongous Regions
      • 当一个对象大于Region大小的50%,称为巨型对象;它就会独占一个或多个Region,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区-Humongous Region
  • Card
    • 很小的内存区域,G1将Java堆划分为相等大小的一个个区域,这个小的区域大小是512 Byte,称为Card
    • Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用这个数组映射着每一个Card
    • Card中对象的引用发生改变时,Card在Card Table数组中对应的值被标记为dirty,就称这个Card被脏化了
    • 分配对象会占用物理上连续若干个卡片
  • G1保留了分代的概念,但是年轻代和年老代不再是物理上的隔离,他们都是一部分的Regions(不需要连续)的集合,每个Region都可能随G1的运行在不同代之间切换
    • 年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲Region加入到年轻代空间。
    • 整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。
  • TLAB
    • G1中每个线程本地的内存分配不需要顾及分区是否连续

三 G1工作解析

1 分配内存

  • 当jvm开始运行时,堆内存开始分区,分成若干个大小相等的Regions
  • 新的对象被分配到Eden Region,一个Eden Region满了之后,分配下一个Eden Region
  • 当所有的Eden Regions都满了,就进行一次Young GC
    • Old Region中的对象也会指向了Eden Region中的对象:例如 一个"old" Map 存放进了 一个 "new" 的对象
      java G1 垃圾收集器解析_第2张图片
    • 上图中B指向了E,但是没有其他对象指向B了,显然B和E都是垃圾对象
    • Young GC只会清理Eden Region,发现E有引用指向它,就不会去回收E,G1必须追踪这些内部区域之间的引用指向,才能正确的标记存活对象,回收垃圾对象
  • 进过Young GCEden之后,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据对象的年龄而晋升到新的survivor分区和老年代分区

2 G1扫描标记对象
java G1 垃圾收集器解析_第3张图片

  • Card Table:
    • Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用这个数组映射着每一个Card
    • Card中对象的引用发生改变时,Card在Card Table数组中对应的值被标记为dirty,就称这个Card被脏化了
    • 所以Card Table其实就是映射着内存中的对象,Young GC的时候只需要扫描状态是dirty的card
  • Remembered Set: RSet
    • 每一个Region都有自己的RSet
    • RSet里面记录了引用——就是其他Region中指向本Region中所有对象的所有引用,也就是谁引用了我的对象
    • RSet其实是一个Hash Table,Key是其他的Region的起始地址,Value是一个集合,里面的元素是Card Table 数组中的index,既Card对应的Index,映射到对象的Card地址。
      • 比如A对象在regionA,B对象在regionB,且B.f = A,则在regionA的RSet中需要记录一对键值对,key是regionB的起始地址,Value的值能映射到B所在的Card的地址,所以要查找B对象,就可以通过RSet中记录的卡片来查找该对象
  • 本分区对象引用本分区自己的对象,这种引用不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此young->old和young->young也不需要在RSet中记录。而对于old->young和old->old的跨代对象引用,需要拥有RSet
  • G1进行GC时,只要扫描本Region中RSet所记录的引用指向的对象是否存活,进而确定本分区内的对象存活情况。而不需要扫描整个堆了。

3 三色标记算法

  • 并发标记中的三色标记算法,将对象分成三种类型
    • 黑色: 根对象,或者该对象与它的子对象都被扫描
    • 灰色: 对象本身被扫描,但还没扫描完该对象中的子对象
    • 白色: 未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象
  • 三色标记过程
    • 当GC开始扫描对象时,按照如下图步骤进行对象的扫描:根对象被置为黑色,往下扫描,扫描子对象,子对象被置为灰色,如下图
      java G1 垃圾收集器解析_第4张图片
    • 继续由灰色对象往下扫描,扫描子对象,子对象被置为灰色,将已扫描完子对象的对象置为黑色
      java G1 垃圾收集器解析_第5张图片
    • 遍历了所有可达的对象后,所有可达的对象都变成了黑色。绝对不可能有黑对象指向白对象的情况发生。白色对象需要被清理
      java G1 垃圾收集器解析_第6张图片
  • 并发标记引起的对象丢失问题
    • 在标记的同时应用程序也在运行,对象的引用随时会改变。当垃圾收集器扫描到下面情况时:
    • 当垃圾收集器扫描到下图时,应用程序执行了A.c=C;B.c=null;
      java G1 垃圾收集器解析_第7张图片
    • 那么对象之间的引用就会如下图
      java G1 垃圾收集器解析_第8张图片
    • 当垃圾收集器再标记扫描的时候就会变成下图这样
      java G1 垃圾收集器解析_第9张图片
    • 那么此时C是白色的,会被认为是垃圾需要清理掉,显然这是不合理的
  • 并发标记引起的对象丢失问题在CMS和G1的2种不同解决方式
    • 栅栏 Barrier:栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。
      java G1 垃圾收集器解析_第10张图片
      • 写前栅栏 Pre-Write Barrrier:即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。
      • 写后栅栏 Post-Write Barrrier:当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,同样需要记录。
    • 在CMS中:只要在写栅栏(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的
    • 在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有3个步骤
      • 在开始标记的时候生成一个快照图,标记存活对象
      • 在并发标记的时候,在写前栅栏,记录所有引用被改变了的对象,再把这些对象都变成非白的
      • 这样可能会生成游离的垃圾对象,没关系,将在下次收集周期被收集

4 垃圾收集阶段
java G1 垃圾收集器解析_第11张图片

  • Collection Set: CSet

    • 它记录了GC要收集的Regions集合
    • 在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲Region中。
    • CSet包括需要收集的Eden Regions、Survivor Regions,而且还包括部分(1/8)Old Regions
  • G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制
  • 可以看出:初始标记、重新标记、清除、转移回收会造成stw
  • 年轻代收集
    • 年轻代收集会,不会进行并发标记,所以它全程都是STW
    • 应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集
    • 工作过程
      • 根扫描 Root Scanning:静态和本地对象等被扫描
      • 更新已记忆集合 Update RSet:对dirty卡片的分区进行扫描,来更新RSet
      • RSet扫描:在收集当前CSet之前,扫描CSet分区的RSet,检测old->young这种引用情况
      • 转移和回收-Object Copy:讲CSet分区存活对象的转移到新survivor或old Region,回收CSet内垃圾对象
      • 引用处理:主要针对软引用、弱引用、虚引用、final引用、JNI引用;当占用时间过多时,可选择使用参数-XX:+ParallelRefProcEnabled激活多线程引用处理
    • 在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据对象的年龄而晋升到新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。
      java G1 垃圾收集器解析_第12张图片
  • 年老代收集
    • 当堆内存占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会进行年老代收集
      • 在年轻代收集之后或巨型对象分配之后,会去检查这个空间占比
    • 年老代收集同时会执行年轻代收集,进行年老代的roots探测,既初始标记,stw
    • 然后恢复应用线程,进行年老代并发标记
    • stw,重新标记
      • STAB处理
      • 引用处理
    • 继续stw,清楚垃圾
    • 恢复应用线程
    • 下图日志显示了老年代收集的过程
      java G1 垃圾收集器解析_第13张图片
  • 混合收集
    • 在进行正常的年轻代垃圾收集,也会回收一部分老年代分区。会优先选取垃圾多(垃圾占用大于85%,复制算法存活对象越少效率越高)的Regions,一共1/8的年老代Regions加入Cset中
      • 假设一个Region的存活对象达到95%,而进行复制,效率很低,所以G1允许浪费部分内存,那么这个Region不会被混合收集,-XX:G1HeapWastePercent:默认5%
    • stw,然后将Cset中的Regions进行收集,使用复制算法
    • 下一次年轻代垃圾收集进行时,在将第二个1/8的年老代Regions加入Cset中进行收集
    • 当年老代内单个Region的垃圾小于等于G1HeapWastePercent时,复制大量存活对象,效率很低。此时G1会确定结束混合收集周期。所以混合收集次数可能小于8次。
  • 转移失败的担保机制 Full GC
    • 当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。
    • G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure
      • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
      • 从老年代分区转移存活对象时,无法找到可用的空闲分区
      • 分配巨型对象时在老年代无法找到足够的连续分区
    • 由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

5 其他

  • 避免Full GC
    • -xx:+PrintAdaptiveSizePolicy 打印自适应调节策略,当出现Full GC的时候可以看到相关信息
  • 避免内存空间枯竭,根据情况增加G1堆内存
  • 避免过多巨型对象分配
  • 避免"引用处理"过程耗时过长
    • 引用处理默认是stw、单线程,可选择使用参数-XX:+ParallelRefProcEnabled激活多线程引用处理
    • 找出弱引用过多的原因
  • 设置合理的暂停目标,从下面例子可以看出并不是越短的暂停越好,而是需要根据实际情况取一个合理的值; 吞吐量与暂停目标对应关系,
    • 50% -> 250ms:为了达到这个暂停目标,只能缩小Eden 区大小,但是程序分配内存的速率没变,所以会造成频繁的Young GC,从而影响了吞吐量
    • 90% -> 300ms
    • 95% -> 360ms
    • 99% -> 500ms
    • 99.9% -> 623ms
    • 99.99% -> 748ms
    • 100% -> 760ms

以上内容,是本人学习的笔记和工作中的总结,仅供大家参考,有误的地方还请指正

转载、引用请标明出处
https://www.jianshu.com/p/35805f809a21
本文出自zhh_happig的博客,谢谢

你可能感兴趣的:(java G1 垃圾收集器解析)