JVM-GC回收器

by shihang.mai

0. 概念

并发: 工作线程与GC线程同时执行
并行: 多个GC线程同时执行

1. 常见的垃圾回收器组合

常见的垃圾回收器

常见垃圾回收器组合

Y区 O区 特点 适用内存
Serial(copying) Serial Old(mark-compact) 单线程回收 几十兆
Parallel Scavenge(PS-copying) Parallel Old(PO-mark-compact) 多线程并行回收,默认回收组合 上百兆 - 几个G
PartNew(copying) CMS(mark-sweep) PartNew是PS的一个变种,为了配合CMS使用。默认线程数=CPU核心数 20G

G1 - 上百G
ZGC - 4T - 16T

2. 各垃圾回收器详解

2.1 Serial+Serial Old

使用场景:在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合

使用方法:-XX:+UseSerialGC = Serial New (DefNew) + Serial Old

工作流程:stw,清理

java -XX:+PrintFlagsFinal -version //查看所有的参数的最终值
-Xmn //设置年轻代大小
-Xms40M -Xmx60M //设置堆最小最大,一般都要设置成一样,因为防止浪费在内存回弹
-Xss //设置栈大小
-XX:NewRatio = 4//配置新生代与老年代在堆结构的占比,老年代是新生代的4倍,默认是2
-XX:SurvivorRatio =8//设置新生代中Eden和S0/S1空间的比例,eden:s1:s2 = 8:1:1
-XX:MaxTenuringThreshold =15//设置对象存活多少次才能进入老年代
-XX:PrintPromotionFailure =false//是否设置空间分配担保 

2.2 PS+PO

使用场景: 在并发能力比较强的 CPU 上,并且追求吞吐量

使用方法: -XX:+UseParallelGC = -XX:+UseParallelOldGC = PS+PO

工作流程:stw,清理

-XX:MaxGCPauseMillis //最大垃圾回收停顿时间
-XX:GCTimeRatio = 99 //设置吞吐量大小参数,最大GC时间占总时间公式1/(1+99)=1%
-XX:+UseAdaptiveSizePolicy//开启jvm根据当前运行情况动态去调整最大吞吐量和停顿时间等参数,自适应调节策略

2.3 ParNew+CMS

使用场景: 在并发能力比较强的 CPU 上,并且追求响应时间

使用方法:-XX:+UseConc(urrent)MarkSweepGC = ParNew + CMS + Serial Old

工作流程:


CMS
  1. 工作线程执行
  2. 初始标记。会进行stw,标记GC Root
  3. 并发标记:stw状态恢复。并发标记环节运用了三色标记+Incremental update算法
  4. 重新标记。会进行stw,重新标记是因为并发标记过程,会产生新的垃圾。
  5. 并发清理。但是并发清理过程中,工作线程也会产生垃圾,这些垃圾叫做浮动垃圾,这些垃圾等待下次CMS才能回收

注意点:

  1. 因为它用的是mark-sweep算法,会造成内存碎片化。为了解决堆空间浪费问题,把一些未分配的空间汇总成一个列表,当 JVM 分配对象空间的时候,会搜索这个列表找到足够大的空间来存放住这个对象。

  2. jdk5,默认当老年代使用68%的时候(jdk6后92%),CMS就开始行动了。

  3. CMS在以下两种情况会触发单线程回收

  • 并发模式失败(concurrent mode failure)
    当 CMS 在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时


    image
  • 晋升失败(promotion failed)
    当新生代发生垃圾回收,老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败


    image

2.4 G1

2.4.1 G1的概念

使用场景: 配备多核处理器及大容量内存的机器(上百G)。追求响应时间,大于等于jdk8以上才有G1,并且在jdk10及之前都是单线程回收

使用方法: -XX:+UseG1GC

G1内存布局

G1 GC 首先将堆分为大小相等的 Region, 而g1可以为Region 动态地分配给

  • Eden
  • Survivor
  • 老年代
  • 大对象空间(当对象大小超过 Region 的一半,则认为是巨型对象,直接被分配到老年代的巨型对象区)
  • 空闲区间

G1 把堆内存划分成一个个 Region 的意义在于:

  1. 每次 GC 不必都去处理整个堆空间,而是每次只处理一部分 Region,实现大容量内存的 GC。

  2. G1跟踪各个Region里面的垃圾堆积的价值大小(回收获得的空间大小以及回收所需时间的经验值),在后台维护一个线性列表,每次根据允许的收集时间,优先回收价值最大的Region。

Rset、Cset、CardTable、Region关系:

  • Cset: Collection Set,它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可
  • Rset: Rememberd Set,每个Region都有一个RSet,RSet记录了谁引用了当前Region,它其实是一个hash table(key: 别的Region的起始地址 value:Card Table的Index集合)
  • CardTable: 每个Card 覆盖一定范围的Heap(一般为512Bytes)
Rset和Cset和Region的关系.png

如图所示

  • RegionA、B、C分别切分为4个card
  • card1中的对象引用了card4、card6中的对象
  • card2中的对象引用了card6中的对象
  • card10中的对象引用了card6中的对象

那么出现右边的图示,首先明确Rset是属于每个Region的;CardTable是属于每个Card的,并且它是一个bitmap

  • card 1的bitmap,如右上方所示,第5、7位置为1,表示card1引用了card4、card6
  • card 10的bitmap,如右下方所示,第7位置为1,表示表示card 10引用了card6
  • RegionB的Rset,如右中方所示,记录的是其他Region引用当前Region的信息,记录了两组数据
    1. key: RegionA的起始地址 val: card1、card2
    2. key: RegionC的起始地址 val: card10

正因为有Rset的存在,大大加快了GC的速度

  1. 在做YGC的时候,只需要选定young region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。
  2. mixed gc的时候,old generation中记录了old->old,young->old的RSet,old->young的引用由扫描全部young region的Rest得到,这样也不用扫描全部old region

Rest的维护:
对于Rest的维护并不是修改完引用对象时,立刻去修改,而是有一种异步的思想,利用write barrier将【跨Region的引用更新】记录缓存日志中,当缓存日志满了,write barrier停止,由另外一条线程(Concurrent refinement threads)处理这些缓冲区日志,更新到Rest中

2.4.2 G1 GC模式

G1 GC的工作模式有2个, Young GC 和 Mixed GC

Young GC

  • 发生时机: 年轻代Region用尽
  • 范围: 选定所有年轻代里的Region。。
  • 控制gc时间的措施: 计算下次 Young GC 所需的 Eden 区和 Survivor 区的空间,动态调整新生代所占 Region 个数来控制 Young GC 开销。即通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销

Mixed GC

  • 发生时机: 由参数G1HeapWastePercent控制,在标记回收价值结束之后,我们可以知道old region中有多少空间要被回收,在每次YGC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC
  • 范围: 选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region
  • 控制gc时间的措施: 在用户指定的开销目标范围内尽可能选择收益高的老年代Region
  • 工作流程:
    1. 标记阶段
      1.1 初始标记阶段
      会进行STW,标记一下GC Roots
      1.2 并发标记阶段
      恢复STW,从GC Roots开始对堆中对象进行可达性分析,找出存活对象。使用的是三色标记+SATB算法。这一期间的变化记录在Remembered Set Log 里
      1.3 最终标记/再标记阶段
      进行STW,重新标记那些在并发标记阶段发生变化的对象。并把Remembered Set Log里的信息合并到Remembered Set里
    2. 清理阶段
      该阶段延续上面的STW,清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。
    3. 复制阶段
      该阶段延续上面的STW,使用Mark-compact做复制压缩,回收价值大的Region,并移动对象(分配新内存和复制对象)。

Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap,jdk10前是单线程的。

2.4.3 计算回收价值

  • 就是上文提到的global concurrent marking。它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。
  • global concurrent marking的执行过程分为四个步骤:
    1. 初始标记。它标记了从GC Root开始直接可达的对象。
    2. 并发标记。这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
    3. 最终标记。标记那些在并发标记阶段发生变化的对象,将被回收。
    4. 清除垃圾。清除空Region,加入到free list。
  • 第一阶段初始标记是共用了Young GC的暂停,所以可以说global concurrent marking是伴随Young GC而发生的。第四阶段Cleanup只是回收了没有存活对象的Region,所以它并不需要STW

3. ZGC

ZGC适用于大内存低延迟服务的内存管理和回收,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题(当移动对象的过程中,工作线程访问对象,对象可能已经被移动)

3.1 基础概念

  1. jdk11开始推出的垃圾回收器,它有几个目标
  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)
  1. 只支持64位。

  2. 各个阶段基本全并发

3.2 回收过程

ZGC.png

如图所示,只有3个阶段是STW的,分别是初始标记,再标记,初始转移,其他阶段全部并发。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段

即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加

3.3 核心原理

颜色指针Colour pointer+读屏障,将"停顿"粒度细化到每个对象上,提升gc效率

并发转移要解决的问题:GC线程在转移对象的过程中,工作线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误

解决方式: 工作线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样工作线程始终访问的都是对象的新地址

怎么做到发现对象被移动呢?,那就是颜色指针了。着色指针是一种将信息存储在指针中的技术。

  1. 对象状态存储在引用指针上
  2. 在业务线程访问对象时候,如果发现被着色(利用指针访问对象,很容易发现),那么增加读屏障,等待mark-compact修改完地址后,返回有效地址再撤除读屏障

4. 三色标记算法

4.1 颜色

看下图例,分为黑、灰、白

4.2 并发标记环节漏标

三色标记漏标

并发标记环节,由原本A状态变为B状态后,因为A已经是黑色,在最终标记环节并不会重新扫描A去做标记D,所以D漏标了,这样会导致D被回收显然不对

4.3 解决漏标

  1. 跟踪A->D的增加。算法名称:Incremental update。将A重新变为灰色,remark阶段重新标记,那么保证了D不会漏标

  2. 跟踪B->D的消失。算法名称:SATB。将B->D的引用重新放到GC的栈,栈里面存放都是是灰色->白色的引用,正因我把消失的引用重新放进去,remark阶段扫描这个栈去标记,那么保证了D不会漏标

G1使用SATB 而不用Incremental update?

因为用Incremental update的话,A的引用要全部扫描一篇,而用SATB的话,只需将改变的扫描,效率高.

考虑以下场景:
1. 如果B->D消失,把B->D的引用放入到GC栈,但是没有A->D
2. 在Remark时,拿到这个引用,D里面因为有RSet的存在,并不需要扫描整个堆有什么对象指向D,直接查找D里面的RSet即可,直接就可以回收D

5. G1与CMS的异同

  • 同:
    都存在并发标记过程
  1. 分代模型: CMS物理分代,G1逻辑分代Region;
  2. 浮动垃圾: CMS有浮动垃圾,G1无;
  3. 有无压缩算法,有无碎片化: CMS用标记-清除法(可通过JVM参数设置CMS压缩),G1用标记-整理法;
  4. 筛选回收:CMS是老年代一并回收,G1是通过预测垃圾优先级别高的进行回收;
  5. 数据结构,G1存在RSet,在每一个Region里面,记录了其他Region中的对象对自己的引用,当垃圾回收的时候就不用扫描整个堆就找到谁引用了当前Region中的对象,只需扫描RSet即可(G1高效回收的关键);card table:(年轻代要确定有没存活对象,要在老年代扫描一次有没引用年轻代的对象,这太费劲了)由于做YGC时,需要扫描整个OLD区,效率非常低,所以JVM设计了CardTable, 如果一个OLD区CardTable中有对象指向Y区,就将它设为Dirty,下次扫描时,只需要扫描Dirty Card 在结构上,Card Table用BitMap来实现
  6. 可预测停顿时间: G1可以预测,CMS不能

参考

https://tech.meituan.com/2016/09/23/g1.html

https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html

https://blog.csdn.net/hejuecan5759/article/details/106390994?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-5.essearch_pc_relevant&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-5.essearch_pc_relevant

你可能感兴趣的:(JVM-GC回收器)