一、垃圾回收算法
1.分代收集理论
当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将Java堆分为新生代、老年代,这样就可根据各代特点选择合适的垃圾收集器。
新生代中,每次收集都会有大量对象(99%)死去,所以选择复制算法,只需少量对象的复制成本就可完成每次对象的垃圾回收。
老年代的存活几率是比较高的,所以选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”和“标记-整理”算法比复制算法慢10倍以上。
2.复制算法
为解决效率问题,复制算法出现了。它将内存分为大小相同的两块,每次使用其中的一块。当这一块内存使用完后,就将存活的对象复制到另一块中去,然后把使用的空间一次清理掉。这样就使每次内存回收都对内存空间的一半进行回收。
3.标记-清除算法
该算法分为“标记”和“清除”两部分,标记存活的对象,统一回收所有未被标记的对象(一般选择这种);也可反过来标记所有要回收的对象,标记完成后统一回收所有被标记的对象。
此算法会带来两个明显的问题:
- 1)效率问题:如果标记对象太多,效率不高;
- 2)空间问题:标记清除后会产生大量不连续的碎片。
4.标记-整理算法
标记过程和“标记-清除”算法一样,后续步骤为让所有存活的对象向一端移动,然后直接清理掉边界以外的内存。
二、垃圾收集器
如果说收集算法是内存回收的方法论,name垃圾收集器就是内存回收的具体实现。
垃圾收集器没有最好,只有最合适的,也没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合的垃圾收集器。
1.Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。它是单线程垃圾收集器,即只会使用一条垃圾收集线程去完成垃圾收集工作。在其进行垃圾收集工作时,必须暂停其他所有的用户线程(Stop The World),直到收集结束。
新生代使用复制算法,老年代使用标记-整理算法。
Stop The World会带来不良的用户体验,后续垃圾收集器设计中停顿时间在不停的缩短。Serial垃圾收集器的优点是简单高效(与单线程垃圾收集器相比),没有线程交互开销。
2.Serial Old收集器
是Serial收集器的老年代版本,同样是单线程收集器。
两大用途:
- 在JDK1.5及以前版本中与Parallel Scavenge收集器搭配使用;
- 作为CMS收集器的额后备方案。
3.Parallel Scavenge收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
其实就是Serial收集器的多线程版本,除了多线程收集垃圾,其余行为和Serial类似。默认收集线程数跟CPU核数相当,也可使用参数 -XX:ParallelGCThreads 指定收集线程数,一般不推荐修改。
该收集器关注的重点是吞吐量(高效率利用CPU)。CMS等垃圾收集器关注的更多是用户线程的停顿时间(提高用户体验)。吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。
新生代采用复制算法,老年代采用编辑-整理算法。
4.Parallel Old收集器
是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量和CPU资源的场合,都可优先考虑Parallel Scavenge和Parallel Old收集器 (JDK8默认的新生代和老年代收集器)。
5.ParNew收集器(-XX:+UseParNewGC)
ParNew收集器其实和Parallel收集器类似,区别在于他可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
它是许多运行在Server模式下虚拟机的首要选择,除了Serial收集器外,只有他能与CMS收集器(真正的并发收集器)配合工作。
6.CMS收集器(-XX:+UseConcMarkSweepGC(old))
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收时间为目标的收集器。它非常适合在注重用户体验的应用上使用,是HotSpot虚拟机里第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
CMS是用“标记-清除”算法实现的,整体过程为:
- 初始标记: 暂停所有的用户线程(STW),记录下GC Roots直接引用对象,该过程速度很快。
- 并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程,此过程耗时较长但不需要停顿用户线程,可与垃圾收集线程一起并发运行。因与用户程序一起运行,可能导致已经标记过的对象状态发生改变。
- 重新标记: 修正并发标记期间因为用户程序继续运行而导致标记产生变动的那部分标记记录,这个阶段停顿时间一般比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新做重新标记。
- 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。此阶段若有新增对象会被标记为黑色不做处理。
- 并发重置: 重置本次GC过程中标记数据。
CMS优点: 并发收集,低停顿。
CMS缺点:
- 对CPU资源敏感(会和服务抢资源);
- 无法处理浮动垃圾(在并发标记和并发清理阶段产生,这种浮动垃圾只能等到下次gc清理);
- 使用的回收算法“标记-清理”算法会导致收集结束后产生大量空间碎片,可通过 -XX:+UseCMSCompactAtFullCollection 参数让JVM执行完标记清除后再做整理了。
- 执行过程不确定性,存在上次垃圾回收没执行完,然后又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,或许没收集完就会再次触发full gc,也就是“Concurrent mode failure”,此时会进入stop the world,用serial old垃圾收集器来回收。
CMS的相关核心参数 :
- -XX:+UseConcMarkSweepGC:启用cms;
- -XX:ConcGCThreads:并发的GC线程数;
- -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。
亿级流量电商系统如何优化JVM参数设置(ParNew+CMS)
一个大型电商系统后端现在一般都是拆成多个子系统部署,如:商品系统,库存系统,订单系统,促销系统,会员系统等。
以核心订单系统为例:
对于8G内存,我们一般是分配4G内存给JVM,正常的JVM参数配置如下:
‐Xms3072M ‐Xmx3072M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
这样设置可能会由于动态对象年龄判断原则导致频繁full gc。
可将参数设置为:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
这样就降低了因为对象动态年龄判断原则导致的对象频繁进入老年代的问题,其实很多优化就是让短期存活的对象尽量都留在survivor里,不要进入老年代,这样在minor gc时这些对象都会被回收,不会进入老年代而导致full gc。
对象的年龄应该设置多少才移动到老年代合适,本例中一次minor gc要间隔二三十秒,大多数对象一般在几秒内会变为垃圾,可将默认的15改为5,即对象要经过5次minor gc才会进入老年代,耗时约两分钟左右,如果此时间后对象还未被回收,可认为这些对象是存活比较长的对象,可移动到老年代空间。
对于多大的对象直接进入老年代(参数 -XX:PretenureSizeThreshold),这个一般结合系统看有没有大对象生成,预估大对象大小,一般设置1M,这些对象一般是系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。
适当的调整JVM参数如下:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M
对于JDK8默认的垃圾回收器 -XX:+UseParallelGC 和 -XX:+UseParallelOld,如果内存大小超过4G(经验值),系统对停顿时间比较敏感,可以使用ParNew + CMS (-XX:UseParNewGC -XX:+UseConcMarkSweepGC)。
老年代CMS参数设置:
系统中哪些对象会长期存活躲过5次以上的minor gc最终进入老年代。Spring容器中的Bean,线程池对象,一些初始化缓存数据对象等,加起来也就几十MB,还有就是某次minor gc完成之后还有超过一两百M的对象存活,那么会直接进入老年代。
如果某一秒瞬间要处理五六百单,那么每秒生成的对象可能有一百多M,再加上整个系统可能压力剧增,一个订单要好几秒才能处理完,下一秒 可能又有很多订单过来。估算下来大概每隔五六分钟出现一次这样的情况,那么大概半小时到一小时之间就可能因为老年代满了触发一次full gc,full gc的触发条件还有我们之前说过的老年代空间分配担保机制,历次的minor gc挪动到老年代的对象大小 肯定是非常小的,所以几乎不会在minor gc触发之前由于老年代空间分配担保失败而产生full gc,其实在半小时后发生 full gc,这时候已经过了抢购的最高峰期,后续可能几小时才做一次full gc。
对于碎片整理,因为都是一小时或几小时才做一次full gc,可以每做完一次就开始碎片整理,或两三次后也行。
所以,只要年轻代参数设置合理,老年代CMS参数设置基本可用默认值,如下:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC ‐XX:CMSInitiatingOccupancyFraction=92 ‐XX:+UseCMSCompactAtFullCollection ‐XX:CMSFullGCsBeforeCompaction=0
垃圾收集底层算法实现—三色标记
并发标记过程中,因标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标漏标的情况就可能发生。引入三色标记解决此问题。
把GC Roots遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记为以下三种颜色:
- 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
- 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
代码示例:
package com.sonny.classexercise.jvm;
/**
* 垃圾收集算法三色标记问题
* a.b中开始引用了d,然后将a.b.d引用置空,将a中的d引用到a.b.d
*
* @author Xionghaijun
* @date 2022/10/5 21:51
*/
public class ThreeColorRemark {
public static void main(String[] args) {
A a = new A();
//开始并发标记
//读
D d = a.b.d;
//写
a.b.d = null;
//写
a.d = d;
}
static class A {
private B b;
private D d;
public A() {
b = new B();
d = null;
}
}
static class B {
private C c;
private D d;
public B() {
c = new C();
d = new D();
}
}
static class C {
}
static class D {
}
}
多标-浮动垃圾
并发标记过程中,如果由方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前又被扫描过 (被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。 另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分 对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
漏标会导致被引用对象被当成垃圾误删掉,这是严重bug,必须解决。有两种方案:
增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)。
增量更新: 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中黑色对象为根,重新扫描一次。即黑色对象一旦插入了指向白色对象的引用后,他就变为灰色对象了。
原始快照: 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发扫描后,再将这些记录过的引用关系中灰色对象为根,重新扫描一次,这样就能扫描白色的对象,将白色对象直接标记为黑色对象 (目的是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)。
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念)
对于读写屏障,以Java HotSpot VM为例,并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新;
- G1、Shenandoah:写屏障 + 原始快照;
- ZGC:读屏障。
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并 发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1使用SATB,CMS使用增量更新?
SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描 被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代 区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC 再深度扫描。
7.G1收集器(-XX:+UseG1GC)
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC 停顿时间要求的同时,还具备高吞吐量性能特征。
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"- XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。 默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个 Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能 可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配 大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一 个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放 入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。 Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
G1收集器运作过程:
- 初始标记(initial mark,STW): 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
- 并发标记(Concurrent Marking): 同CMS的并发标记;
- 最终标记(Remark,STW): 同CMS的重新标记
- 筛选回收(Cleanup,STW): 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期 望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划。回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样 回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。 这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
JDK1.7以上版本Java虚拟机的一个重要进化特征,有以下特点:
- 并行与并发: G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop- The-World停顿时间;
- 分代收集: 保留了分代收集概念;
- 可预测停顿: 除了 追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"- XX:MaxGCPauseMillis"指定)内完成垃圾收集。
“期望值”设置需符合实际,默认停顿时间是两百毫秒,若设置二十毫秒很可能出现由于停顿时间太短,导致每次选出来的回收只占堆内存很少的一部分,收集速度逐渐跟不上分配速度,导致垃圾慢慢堆积。随着运行时间变长,最终占满堆引发Full GC反而降低性能。
G1垃圾收集分类
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时 间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。
MixedGC
不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的 Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够 的空region能够承载拷贝对象就会触发一次Full GC。
FullGC
停止系统程序,然后采用单线程进行标记-整理,以空闲出来一批Region供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1收集器参数设置
-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
- 50%以上的堆被存活对象占用;
- 对象分配和晋升的速度非常大;
- 垃圾回收时间特别长,超过1s;
- 8GB以上的堆内存(建议值);
- 停顿时间是500ms以内。
8.ZGC收集器(-XX:+UseZGC)
ZGC是Java11中加入的具有实验性质的低延迟垃圾收集器,ZGC源于Azul System公司开发的C4(Concurrent Continuously Compacting Collector)收集器。
ZGC的目标:
- 停顿时间不超过10ms(JDK16已经达到不超过1ms);
- 停顿时间不会随着堆大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆,JDK15后已经可以支持16TB。
ZGC内存布局
为细粒度对控制内存的分配,和G1一样,ZGC将内存划分成小的分区,在ZGC中称为页面(page)。
ZGC中没有分代概念 ,支持三种页面:
- 小页面: 2MB的页面空间,对象大小小于等于256KB,分配在此页面;
- 中页面: 32MB的页面空间,对象大小在256KB和4MB之间,分配在此页面;
- 大页面: 操作系统控制的大页,对象大于4MB,分配的大页面。
设计原因:通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。
ZGC支持NUMA
多核心对于内存带宽的争抢访问成为瓶颈,希望把CPU和内存集成在一个单元上(称为Socket),这就是非同一内存访问(Non-Uniform Memory Access,NUMA)。
ZGC是支持NUMA的,进行小页面分配时会优先从本地内存分配,当不能分配时才会从远端内存分配。对于中页面和大页面的分配,ZGC并没有要求从本地内存分配,直接交给操作系统分配。
ZGC核心概念–指针着色技术
在指针中借了几个位来做事情,所以必须要求在64位机器上才可以工作。并且因为要求64位的指针,也就不能支持压缩指针。
ZGC中低42位表示使用中的堆空间,借几位高位来做GC相关的事情(快速实现垃圾回收中的并发标记、转移和重定位等)
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很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。
GC代码示例:
package com.sonny.classexercise.jvm.gc;
import java.util.LinkedList;
import java.util.List;
/**
* 使用JDK16测试STW的差异
* 默认G1: -Xmx2g -XX:+PrintGCDetails
* ZGC:-XX:+UseZGC -Xmx2g -XX:+PrintGCDetails
* PS: -XX:+UseParallelGC -Xmx2g -XX:+PrintGCDetails
*
* @author Xionghaijun
* @date 2022/10/9 22:21
*/
public class StopWorld {
/**
* 不断地填充堆,触发GC
*/
public static class FillListThread extends Thread {
List<byte[]> list = new LinkedList<>();
@Override
public void run() {
try {
while (true) {
byte[] bl;
for (int i = 0; i < 100; i++) {
bl = new byte[512];
list.add(bl);
}
}
} catch (Exception e) {
}
}
}
public static void main(String[] args) {
FillListThread myThread = new FillListThread(); //造成GC,造成STW
myThread.start();
}
}
使用G1收集器结果:
[1.484s][info ][gc,metaspace] GC(13) Metaspace: 791K(960K)->791K(960K) NonClass: 732K(832K)->732K(832K) Class: 59K(128K)->59K(128K)
[1.484s][info ][gc ] GC(13) Pause Young (Normal) (G1 Evacuation Pause) 673M->673M(1590M) 100.133ms
[1.484s][info ][gc,cpu ] GC(13) User=0.14s Sys=0.08s Real=0.10s
[1.573s][info ][gc,start ] GC(14) Pause Young (Normal) (G1 Evacuation Pause)
[1.573s][info ][gc,task ] GC(14) Using 4 workers of 4 for evacuation
[1.698s][info ][gc,phases ] GC(14) Pre Evacuate Collection Set: 0.2ms
[1.698s][info ][gc,phases ] GC(14) Merge Heap Roots: 0.1ms
[1.698s][info ][gc,phases ] GC(14) Evacuate Collection Set: 123.0ms
[1.698s][info ][gc,phases ] GC(14) Post Evacuate Collection Set: 1.3ms
[1.698s][info ][gc,phases ] GC(14) Other: 0.2ms
[1.698s][info ][gc,heap ] GC(14) Eden regions: 111->0(124)
[1.698s][info ][gc,heap ] GC(14) Survivor regions: 15->16(16)
[1.698s][info ][gc,heap ] GC(14) Old regions: 658->769
[1.698s][info ][gc,heap ] GC(14) Archive regions: 2->2
[1.698s][info ][gc,heap ] GC(14) Humongous regions: 0->0
[1.698s][info ][gc,metaspace] GC(14) Metaspace: 791K(960K)->791K(960K) NonClass: 732K(832K)->732K(832K) Class: 59K(128K)->59K(128K)
[1.698s][info ][gc ] GC(14) Pause Young (Normal) (G1 Evacuation Pause) 784M->785M(1590M) 124.875ms
[1.698s][info ][gc,cpu ] GC(14) User=0.15s Sys=0.05s Real=0.13s
[1.763s][info ][gc,start ] GC(15) Pause Young (Concurrent Start) (G1 Evacuation Pause)
[1.763s][info ][gc,task ] GC(15) Using 4 workers of 4 for evacuation
[1.868s][info ][gc,phases ] GC(15) Pre Evacuate Collection Set: 0.4ms
[1.868s][info ][gc,phases ] GC(15) Merge Heap Roots: 0.1ms
[1.868s][info ][gc,phases ] GC(15) Evacuate Collection Set: 104.0ms
[1.868s][info ][gc,phases ] GC(15) Post Evacuate Collection Set: 0.4ms
[1.868s][info ][gc,phases ] GC(15) Other: 0.4ms
[1.868s][info ][gc,heap ] GC(15) Eden regions: 124->0(126)
[1.868s][info ][gc,heap ] GC(15) Survivor regions: 16->18(18)
[1.868s][info ][gc,heap ] GC(15) Old regions: 769->892
[1.868s][info ][gc,heap ] GC(15) Archive regions: 2->2
[1.868s][info ][gc,heap ] GC(15) Humongous regions: 0->0
使用ZGC收集结果:
[14.706s][info ][gc,ref ] GC(7) Clearing All SoftReferences
[14.706s][info ][gc,phases ] GC(7) Pause Mark Start 0.015ms
[14.709s][info ][gc,phases ] GC(7) Concurrent Mark 2.637ms
[14.709s][info ][gc,phases ] GC(7) Pause Mark End 0.016ms
[14.710s][info ][gc,phases ] GC(7) Concurrent Process Non-Strong References 0.537ms
[14.710s][info ][gc,phases ] GC(7) Concurrent Reset Relocation Set 0.001ms
[14.710s][info ][gc ] Allocation Stall (DestroyJavaVM) 3.946ms
[14.714s][info ][gc,phases ] GC(7) Concurrent Select Relocation Set 3.870ms
[14.714s][info ][gc,phases ] GC(7) Pause Relocate Start 0.007ms
[14.715s][info ][gc,phases ] GC(7) Concurrent Relocate 1.124ms
[14.715s][info ][gc,load ] GC(7) Load: 11.81/9.50/8.23
[14.715s][info ][gc,mmu ] GC(7) MMU: 2ms/96.8%, 5ms/98.7%, 10ms/99.3%, 20ms/99.6%, 50ms/99.8%, 100ms/99.9%
[14.715s][info ][gc,marking ] GC(7) Mark: 2 stripe(s), 1 proactive flush(es), 1 terminate flush(es), 0 completion(s), 0 continuation(s)
[14.715s][info ][gc,nmethod ] GC(7) NMethods: 245 registered, 0 unregistered
[14.715s][info ][gc,metaspace] GC(7) Metaspace: 0M used, 0M committed, 1032M reserved
[14.715s][info ][gc,ref ] GC(7) Soft: 24 encountered, 19 discovered, 0 enqueued
[14.715s][info ][gc,ref ] GC(7) Weak: 102 encountered, 82 discovered, 0 enqueued
[14.715s][info ][gc,ref ] GC(7) Final: 0 encountered, 0 discovered, 0 enqueued
[14.715s][info ][gc,ref ] GC(7) Phantom: 65 encountered, 43 discovered, 0 enqueued
[14.715s][info ][gc,reloc ] GC(7) Small Pages: 1024 / 2048M, Empty: 2042M, Relocated: 0M, In-Place: 0
[14.715s][info ][gc,reloc ] GC(7) Medium Pages: 0 / 0M, Empty: 0M, Relocated: 0M, In-Place: 0
[14.715s][info ][gc,reloc ] GC(7) Large Pages: 0 / 0M, Empty: 0M, Relocated: 0M, In-Place: 0
[14.715s][info ][gc,reloc ] GC(7) Forwarding Usage: 0M
[14.715s][info ][gc,heap ] GC(7) Min Capacity: 8M(0%)
[14.715s][info ][gc,heap ] GC(7) Max Capacity: 2048M(100%)
[14.715s][info ][gc,heap ] GC(7) Soft Max Capacity: 2048M(100%)
[14.715s][info ][gc,heap ] GC(7) Mark Start Mark End Relocate Start Relocate End High Low
[14.715s][info ][gc,heap ] GC(7) Capacity: 2048M (100%) 2048M (100%) 2048M (100%) 2048M (100%) 2048M (100%) 2048M (100%)
[14.715s][info ][gc,heap ] GC(7) Free: 0M (0%) 0M (0%) 2040M (100%) 2040M (100%) 2040M (100%) 0M (0%)
[14.715s][info ][gc,heap ] GC(7) Used: 2048M (100%) 2048M (100%) 8M (0%) 8M (0%) 2048M (100%) 8M (0%)
[14.715s][info ][gc,heap ] GC(7) Live: - 1M (0%) 1M (0%) 1M (0%) - -
[14.715s][info ][gc,heap ] GC(7) Allocated: - 0M (0%) 2M (0%) 2M (0%) - -
[14.715s][info ][gc,heap ] GC(7) Garbage: - 2046M (100%) 4M (0%) 4M (0%) - -
[14.715s][info ][gc,heap ] GC(7) Reclaimed: - - 2042M (100%) 2042M (100%) - -
[14.715s][info ][gc ] GC(7) Garbage Collection (Allocation Stall) 2048M(100%)->8M(0%)
使用Parallel收集结果:
[10.638s][info ][gc,start ] GC(19) Pause Full (Ergonomics)
[10.639s][info ][gc,phases,start] GC(19) Marking Phase
[11.277s][info ][gc,phases ] GC(19) Marking Phase 638.602ms
[11.277s][info ][gc,phases,start] GC(19) Summary Phase
[11.277s][info ][gc,phases ] GC(19) Summary Phase 0.067ms
[11.277s][info ][gc,phases,start] GC(19) Adjust Roots
[11.278s][info ][gc,phases ] GC(19) Adjust Roots 0.340ms
[11.278s][info ][gc,phases,start] GC(19) Compaction Phase
[11.440s][info ][gc,phases ] GC(19) Compaction Phase 162.222ms
[11.440s][info ][gc,phases,start] GC(19) Post Compact
[11.444s][info ][gc,phases ] GC(19) Post Compact 3.687ms
[11.444s][info ][gc,heap ] GC(19) PSYoungGen: 232959K(465920K)->232959K(465920K) Eden: 232959K(232960K)->232959K(232960K) From: 0K(232960K)->0K(232960K)
[11.444s][info ][gc,heap ] GC(19) ParOldGen: 1397841K(1398272K)->1397841K(1398272K)
[11.444s][info ][gc,metaspace ] GC(19) Metaspace: 795K(960K)->795K(960K) NonClass: 736K(832K)->736K(832K) Class: 59K(128K)->59K(128K)
[11.444s][info ][gc ] GC(19) Pause Full (Ergonomics) 1592M->1592M(1820M) 805.143ms
[11.444s][info ][gc,cpu ] GC(19) User=2.08s Sys=0.01s Real=0.81s
由结果可以看出ZGC的STW时间最短,只需0.38ms左右
ZGC基于指针着色的重定位算法
如何做到并发转移
- 转发表(类似于HashMap)
- 对象转移和插转发表做原子操作
并发标记对象的重定位
下次GC中的并发标记(同时做上次并发标记对象的重定位)
ZGC中的读屏障
涉及对象: 并发转移但还没做对象重定位的对象(着色指针使用M0和M1可以区分)。
触发时机: 在两次GC之间业务线程访问这样的对象。
触发操作: 对象重定位+删除转发表记录(两个一起做原子操作)
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。
需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
ZGC中GC触发机制(Java16)
预热规则: 服务刚启动时出现,一般不需关注。关键字信息“Warmup”。
[0.044s][info ][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.044s][info ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 3, Narrow klass range: 0x100000000
[0.824s][info ][gc,start ] GC(0) Garbage Collection (Warmup)
[0.827s][info ][gc,phases ] GC(0) Pause Mark Start 0.017ms
[1.475s][info ][gc,phases ] GC(0) Concurrent Mark 637.909ms
[1.475s][info ][gc,phases ] GC(0) Pause Mark End 0.011ms
[1.476s][info ][gc,phases ] GC(0) Concurrent Process Non-Strong References 0.697ms
[1.476s][info ][gc,phases ] GC(0) Concurrent Reset Relocation Set 0.000ms
[1.506s][info ][gc,phases ] GC(0) Concurrent Select Relocation Set 29.711ms
[1.506s][info ][gc,phases ] GC(0) Pause Relocate Start 0.011ms
[1.510s][info ][gc,phases ] GC(0) Concurrent Relocate 3.923ms
[1.510s][info ][gc,load ] GC(0) Load: 5.34/6.39/7.09
[1.510s][info ][gc,mmu ] GC(0) MMU: 2ms/99.2%, 5ms/99.7%, 10ms/99.8%, 20ms/99.9%, 50ms/100.0%, 100ms/100.0%
[1.510s][info ][gc,marking ] GC(0) Mark: 1 stripe(s), 2 proactive flush(es), 1 terminate flush(es), 0 completion(s), 0 continuation(s)
[1.510s][info ][gc,nmethod ] GC(0) NMethods: 249 registered, 0 unregistered
[1.510s][info ][gc,metaspace] GC(0) Metaspace: 0M used, 0M committed, 1032M reserved
基于分配速率的自适应算法: 最主要的GC触发方式(默认方式),其算法原理可简单描述为“ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。通过 ZAllocationSpikeTolerance 参数控制阈值大小,该参数默认为2,数值越大,越早触发GC。日志关键字“Allocation Rate”。
基于固定时间间隔触发: 通过 ZCollectionInterval 控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发时机可能会过晚,导致部分线程阻塞。通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。
主动触发规则: 类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出的时机,我们的服务因为已经加了基于固定时间 间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。
阻塞内存分配请求触发: 当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志 中关键字是“Allocation Stall”。
外部触发: 代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
元数据分配触发: 元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。
ZGC参数设置
ZGC 优势不仅在于其超低的 STW 停顿,也在于其参数的简单,绝大部分生产场景都可以自适应。极端情况下,还是有可能需要对 ZGC 个别参数做个调整,大致可以分为三类:
- 堆大小: 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应用场景
ZGC 不支持压缩指针和分代 GC,其内存占用相对于 G1 来说要稍大,在小堆情况下较为明显,而在大堆 情况下,这些多占用的内存则显得不那么突出。因此,以下两类应用强烈建议使用 ZGC 来提升业务体验:
- 超大堆应用。 超大堆(百G以上)。
- 当业务应用需要提供高服务级别协议 (Service Level Agreement,SLA),例如99.99%的响应时间不能超过100ms,此类应用无论堆大小,均推荐使用低停顿的 ZGC 收集器。