上帝视角看JVM-垃圾回收

818 阅读23分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

随着互联网的飞速发展,我们开发的应用从之前的单体项目到现在的微服务、云原生项目,以及不断更新迭代的缓存技术,其最终目的就是让我们的系统更快,用户体验更好。

JVM的垃圾收集算法也在不断的更新迭代,说到垃圾收集器大家应该都知道STW(Stop The World)。JVM在GC时会暂停用户线程,给用户带来不好的体验。而垃圾回收算法的不断更新优化,都是为了缩短STW的时间。可以说STW已经成为阻碍 Java 广泛应用的一大顽疾。


今天我们就来再认识下JVM的垃圾回收,给后期JVM调优做些前置的准备吧。


Serial收集器

Serial收集器是最基本,历史最悠久的垃圾收集器了。Serial英文翻译为串行,从名称我们大概也能猜出来他是一个单线程收集器。而他的单线程其实是有两层含义的:

  • 只会使用一个CPU或者一条收集线程去完成收集工作
  • 在进行垃圾收集工作的时候必须暂停其他所有的工作线程(也就是我们上边说的STW),直到Serial收集结束。
image.png

Serial收集器的新生代采用复制算法老年代采用标记整理算法

优点呢就是在单线程下,比其他收集器的单线程收集要简单而高效

JVM参数

  • -XX:+UseSerialGC
  • -XX:+UseSerialOldGC

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

Parallel Scavenge

从名字可以大概猜出来Parallel是多线程收集器,确实Parallel是Serial收集器的多线程版本,Parallel除了使用多线程进行垃圾收集外,其余的行为都跟Serial类似,比如控制参数、收集算法、回收策略等。

image.png

Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU),提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

Parallel Scavenge收集器的新生代采用复制算法老年代采用标记-整理算法

JVM参数:

  • -XX:ParallelGCThreads(指定收集线程数,不建议修改)
  • -XX:+UseParallelGC(年轻代)
  • -XX:+UseParallelOldGC(老年代)

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。

ParNew

ParNew收集器跟Parallel收集器很类似,主要区别是ParNew可以跟CMS收集器配合使用。

ParNew收集器的新生代采用复制算法老年代采用标记-整理算法

image.png

许多运行在Server模式下的虚拟机的垃圾收集器首先选择都是ParNew,除了Serial收集器外,只有它能跟CMS收集器配合使用。

JVM参数:

  • -XX:+UseParNewGC

CMS

终于到CMS了,我们上边有多次提到他,他到底有什么特殊之处?

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是HotSpot虚拟机第一款真正意义上的并发收集器,它非常适合使用在那些注重用户体验的系统应用上,CMS第一次实现了让垃圾收集线程与用户线程基本上同时工作。

同样,从名称我们就能看出CMS是一种标记-清除算法实现的收集器。它的工作过程相对于我们前几个讨论的收集器要更加复杂。

CMS作用于老年代

CMS收集器的整个过程主要分为以下几个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清理

image.png


1.初始标记

暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快

2.并发标记

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。

3.重新标记

重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。

4.并发清理

开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色(三色标记)不做任何处理

5.并发重置

重置本次GC过程中的标记数据。


CMS优点

  • 并发收集
  • 低停顿

CMS缺点

  • 对CPU资源敏感(会和服务抢资源)
  • 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了)
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
  • 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收

JVM参数:

  • -XX:+UseConcMarkSweepGC:启用cms
  • -XX:ConcGCThreads:并发的GC线程数(默认启动的核心线程数:(处理器核心数量+3)/4)
  • -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片,缺点中的第三点可使用此参数解决)
  • -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
  • -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  • -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
  • -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
  • -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  • -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW

G1(Garbage-First)

G1是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。

G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M。G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%。

一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能 可能会动态变化。

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配 大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一 个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放 入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。 Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收

G1收集器一次GC的运作过程大致分为以下几个步骤:

  1. 初始标记(initial mark)
  2. 并发标记(Concurrent Marking)
  3. 最终标记(Remark)
  4. 筛选回收(Cleanup)

image.png


1.初始标记STW

暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快

2.并发标记

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。

3.最终标记STW

最终标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。

4.筛选回收STW

筛选回收阶段首先对各个Region的回收价值和成本进行排序根据用户所期望的GC停顿时间来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶 段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并 发收集,Shenandoah可以看成是G1的升级版本)

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收 方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。



G1收集器的特点:

  • 并行与并发: G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
  • 分代收集: 虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合: 与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿: 这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"- XX:MaxGCPauseMillis"指定)内完成垃圾收集。

G1垃圾收集分类:

  • YoungGC: YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时 间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC

  • MixedGC:不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

  • Full GC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

JVM参数:

  • -XX:+UseG1GC:使用G1收集器
  • -XX:ParallelGCThreads:指定GC工作的线程数量
  • -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
  • -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
  • -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
  • -XX:G1MaxNewSizePercent:新生代内存最大空间
  • -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
  • -XX:MaxTenuringThreshold:最大年龄阈值(默认15)
  • -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
  • -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
  • -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
  • -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。

什么场景适合使用G1

  1. 50%以上的堆被存活对象占用
  2. 对象分配和晋升的速度变化非常大
  3. 垃圾回收时间特别长,超过1秒
  4. 8GB以上的堆内存(建议值)
  5. 停顿时间是500ms以内

ZGC

ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的 C4(Concurrent Continuously Compacting Collector) 收集器。

ZGC的设计目标

  • 停顿时间不超过10ms(JDK16已经达到不超过1ms)
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加
  • 支持8MB~4TB级别的堆,JDK15后已经可以支持16TB

ZGC的内存布局

ZGC收集器是一款基于Region内存布局的, 暂时不设分代的,Region可以具有大、 中、 小三类容量:

  • 小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象
  • 中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)的, 因为复制一个大对象的代价非常高昂。

image.png

ZGC核心概念

以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息使用指针着色技术保存在指针中。颜色指针可以说是ZGC的核心概念。因为他在指针中借了几个位出来做事情,所以它必须要求在64位的机器上 才可以工作。并且因为要求64位的指针,也就不能支持压缩指针。

  • ZGC中低42位表示使用中的堆空间
  • ZGC借几位高位来做GC相关的事情(快速实现垃圾回收中的并发标记、转移和重定位等)

image.png

(取自官方截图)

每个对象有一个64位指针,这64位被分为:
  • 18位:预留给以后使用;
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
  • 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合)
  • 1位:Marked1标识;
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
  • 42位:对象的地址(所以它可以支持2^42=4T内存):

为什么有2个mark标记?
每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。 GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。 GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。

颜色指针的三大优势:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指 向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数 据,以便日后进一步提高性能。

ZGC工作流程

  1. 初始标记(Mark Start)
    这个阶段需要暂停(STW),初始标记只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,停顿时间不会随着堆的大小或者活跃对象的大小而增加。

  2. 并发标记(Concurrent Mark)
    这个阶段不需要暂停(没有STW),扫描剩余的所有对象,这个处理时间比较长,所以走并发,业务线程与GC线程同时运行。但是这个阶段会产生漏标问题。

  3. 最终标记(Mark End)
    这个阶段需要暂停(STW),主要处理漏标对象,通过SATB算法解决(G1中的解决漏标的方案)。

  4. 并发重分配准备(Concurrent Prepare For Relocate, 分析最有价值GC分页<无STW>)
    这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

  5. 初始转移(重分配Relocate Start)(转移初始标记的存活对象同时做对象重定位<有STW>)

  6. 并发转移(重分配Concurrent Relocate)(对转移并发标记的存活对象做转移<无STW>) 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。

  7. 并发重映射(Concurrent Remap) 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

image.png

ZGC中GC触发机制(JAVA16)

  • 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”.JVM启动预热,如果从来没有发生过GC,则在堆内存使用超过10%、20%、30%时,分别触发一次GC,以收集GC数据.
  • 基于分配速率的自适应算法:最主要的GC触发方式(默认方式),其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。日志中关键字是“Allocation Rate”。
  • 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。
  • 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。
  • 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
  • 外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
  • 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。

JVM参数:(大致可以分为三类)

  • 堆大小:Xmx。当分配速率过高,超过回收速率,造成堆内存不够时,会触发 Allocation Stall,这类 Stall 会减缓当前的用户线程。因此,当我们在 GC 日志中看到 Allocation Stall,通常可以认为堆空间偏小或者 concurrent gc threads 数偏小。
  • GC触发时机:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用来估算当前的堆内存分配速率,在当前剩余的堆内存下,ZAllocationSpikeTolerance 越大,估算的达到OOM 的时间越快,ZGC 就会更早地进行触发 GC。ZCollectionInterval 用来指定 GC 发生的间隔,以秒为单位触发 GC。
  • GC线程:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是设置 STW 任务的 GC 线程数目,默认为 CPU 个数的 60%;ConcGCThreads 是并发阶段 GC 线程的数目,默认为 CPU 个数的12.5%。增加 GC 线程数目,可以加快 GC 完成任务,减少各个阶段的时间,但也会增加 CPU 的抢占开销,可根据生产情况调整

ZGC典型应用场景

  • 超大堆应用。超大堆(百 G 以上)下,CMS 或者 G1 如果发生 Full GC,停顿会在分钟级别,可能 会造成业务的终端,强烈推荐使用 ZGC。
  • 当业务应用需要提供高服务级别协议(Service Level Agreement,SLA),例如 99.99% 的响应时间不能超过 100ms,此类应用无论堆大小,均推荐采用低停顿的 ZGC。



介绍了这么多垃圾收集器,那我们在实际开发中应该如何选择呢?后期我们通过JVM调优实战再结合实际情况来具体分析,下面先给出一个大致的建议:

  1. 优先调整堆的大小让服务器自己来选择
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
  4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选
  5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器
  6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC