- 为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
- 在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 正在运⾏的线程
- 锁住的对象
这种算法的优点是简单、高效,但缺点是有可能出现"内存泄漏",即存在一些对象已经无用了,但是由于这些对象与GC Roots对象之间存在间接引用,导致这些对象不能被回收。
除了可达性分析算法外,还有一些高级的垃圾回收算法,如G1垃圾收集器的Region To Space算法,以及ZGC垃圾收集器的读屏障算法等。这些算法都能够更加精确地判断对象是否可以被回收,以及更加高效地进行垃圾回收。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。
- 经过可达性分析后如果对象没有域root有任何相关的引用链,则会被标记,随后经过一次筛选,筛选的条件就是是否需要执行finalize()方法,如果对象没有覆盖finalize方法,或者已经被虚拟机调用过。则没必要执行
- 如果需要执行,则把对象放在一个名为F-Queue的队列中中,并由一个优先级低的由虚拟机创建的线程去执行他们的finalize()方法,这里的执行只是触发,并不一定要等待他们执行完,否则如果该方法之心缓慢甚至死循环,则会阻塞队列
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
引用类型的种类
- 被强引用关联的对象不会被回收。
- 使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();
- 被软引用关联的对象只有在内存不够的情况下才会被回收。
- 使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null;//使对象只被软引用关联
- 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
- 使用 WeakReference 类来创建弱引用。
Object obj = new Object(); WeakReference<Object> sf = new WeakReference<>(obj); obj = null;
- 又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
- 为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
- 使用 PhantomReference 来创建虚引用。
Object obj = new Object(); PhantomReference<Object> sf = new PhantomReference<>(obj,null); obj = null;
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)[1]的理论进 行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分 代假说之上:
- 1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储
指目标只是新生代的垃圾收集。
- 当Eden区满了的时候,会触发Young GC
指目标只是老年代的垃圾收集。目前只有CMS收集器会有单 独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。
指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收 集器会有这种行为。
收集整个Java堆和方法区的垃圾收集
- 在发生Young GC的时候,虚拟机会检测之前每次晋升到老年代的平均大小是否大于年老代的剩余空间,如果大于,则直接进行Full GC;
- 如果小于,但设置了Handle PromotionFailure,那么也会执行Full GC。
- -XX:HandlePromotionFailure:是否设置空间分配担保 JDK7及以后这个参数就失效了. 只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行MinorGC,否则FullGC
- 永久代空间不足,会触发Full GC
- System.gc()也会触发Full GC
- 堆中分配很大的对象 所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。
首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象
- 优点
- 简单
- 缺点
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
- 为了解决标记-清除算法面对大量可回收对象时执行效率低 的问题
- 它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
- 新生代采用此算法
- 优点
- 不会产生内存碎片
- 缺点
- 空间浪费
- 对象存活率较高时就要进行较多的复制操作,效率将会降低
- 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 老年代采用此算法
- 优点
- 不会产生内存碎片
- 缺点
- 需要移动大量对象,处理效率比较低。
- 必须暂定用户线程STW
- 需要在一个能保障一致性的快照中进行
- 如果在分析过程对象引用关系不断变化肯定是不行的
- 但是如果在此时依次枚举成百上万的关系肯定会耗费大大量的时间
- 所以应当是能直接获取到哪些地方存放着对象的引用
- 一个数据结构OopMap
- 一旦类加载完成,就会把对象内什么偏移量上是什么类型的数据计算出来,在特定的位置记录下栈里和寄存器里哪些位置是引用
- 相当于把引用已经存在了这里,扫描时直接来拿就好了
- 在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而 来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成 对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法 忍受的高昂。
- 实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录 了这些信息,这些位置被称为安全点(Safepoint)。
- 让线程跑到安全点再GC
- 抢先式中断
- GC时中断所有的用户线程
- 如果发现该线程不在安全点则恢复,直到它跑到了安全点
- 现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
- 主动式中断
- 不主动中断线程,设置一个标志位,让线程自己主动轮询这个标志位,一旦发现标志位为真,就自己在最近的安全点主动中断挂起
- 抢先式中断
如果线程未获取到处理器时间则无法到达安全点
- 于是设置一个安全区域,在这个区域的任意地方开始GC都是安全的
- 执行到安全区时标识自己已进入,当离开时检查是否完成了根节点枚举,完成了继续执行,否则一直等待直到收到离开安全区的信号为止
在Java虚拟机(JVM)中,垃圾收集器(GC)负责回收不再使用的对象以释放内存。Java堆内存通常被分为几个部分,主要包括:
- 年轻代(Young Generation):新创建的对象被分配在这里,大部分对象在这里很快被回收。
- 老年代(Old Generation 或 Tenured Generation):经过一定次数垃圾回收仍然存活的对象会被移到这里。
- 永久代(Permanent Generation,在Java 8中被元空间Metaspace取代):存放类和方法信息等。
跨代引用,即一个老年代对象引用了一个年轻代对象,或者反过来,会影响垃圾收集器的效率。在JVM中,主要使用了两种算法来处理跨代引用的问题:
-
记忆集(Remembered Set):用于记录从老年代到年轻代的引用。垃圾收集器在进行Minor GC(只清理年轻代的GC)时,只会检查年轻代内存。然而,如果存在老年代对象引用年轻代对象,这些年轻代的对象即使没有在年轻代内部被引用,也不能被当作垃圾回收。记忆集就是用来避免整个老年代扫描这种情况的,它记录下哪些老年代的区域包含指向年轻代对象的引用,从而只扫描这些区域。
-
卡表(Card Table):它是记忆集的一种实现,通常使用一个字节数组来实现。堆被分成了许多小的区域,这些区域称为“卡(cards)”。每个卡对应于记忆集中的一个条目。当老年代对象持有年轻代对象的引用时,这个引用所在的卡被标记为脏卡(dirty card),表示该区域有横跨不同代的引用。Minor GC时,垃圾收集器将只检查这些脏卡对应的老年代部分,来更新跨代引用。
处理好跨代引用对于垃圾回收的性能至关重要。一个高效的GC必须尽可能快地确定哪些对象是垃圾,而跨代引用如果处理不当,会大大增加GC的时间,因此记忆集和卡表等机制是GC优化中非常关键的组成部分。
- 还没有解决卡表元素如何维 护的问题,例如它们何时变脏、谁来把它们变脏等
- 卡表元素何时变脏的答案是很明确的
- 有其他分代区域中对象引用了本区域对象时,其对应的 卡表元素就应该变脏
- 变脏时间点原则上应该发生在引用类型字段赋值的那一刻
- 但问题是如何变 脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟 机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代 码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一 个赋值操作之中。
- 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的
- 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切 面[2],在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的 前后都在写屏障的覆盖范畴内
- 在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)
- 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新 卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外 的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
首先GC的前提是:基于一个能保证一致性的快照中,这就意味必须要STW,STW的停顿时间是和需要标记的堆的对象数量成正比的,所以能相对削弱这部分的停顿时间,收益是客观的,如果不冻结用户线程就需要GC线程和用户线程同时运行,这就需要并发标记
在遍历对象图的过程中,把访问的对象都按照是否访问过标记成3种颜色
白色
还未被垃圾回收器访问过,开始阶段所有对象都是白色的,在结束阶段如果对象还是白色则不可达黑色
对象已经被垃圾回收器访问过,且这个对象的所有引用都已经被扫描过,是安全存活的灰色
对象被垃圾回收器访问过,但是这个对象至少存在一个引用没有被访问过
问题:
如果现在GC和用户线程同时运行,GC在标记颜色,用户线程在修改引用关系,就会出现并发标记的问题
- 把原本死亡的对象标记为存活
- 其实这个是小问题因为这就产生了浮动的垃圾,下次清理就好了
- 出现浮动垃圾
- 本来应该被回收的对象,但是因为并发标记的原因,被标记为存活,这就产生了浮动垃圾
- 例子:GC线程先标记了A为存活,用户线程修改A=null,这时候A依然可达,就是浮动垃圾
- 把原本存活的对象标记为死亡【对象消失的情况】
- 这个问题就非常严重了,需要的对象被回收,程序肯定会发生错误
- 例子:某个对象 A 在标记过程中被标记为黑色(即标记已经完成),表示 A 和 A 所引用的所有对象都已被访问。但在标记完成后,应用程序线程修改了 A 的引用,将其引用指向了一个新对象 B。此时,如果 B 尚未被标记,而 A 已经是黑色,则 B 可能永远不会被标记为存活对象。这种情况会导致对象 B 被误认为是死亡对象,即使它实际上仍然被引用并存活。
- 正在扫描的灰色对象的一个引用被切断,与一个原本已经被扫描过的黑色对象产生了引用关系
- 原本是引用链的一部分,但是被切断了并与原本扫描的黑色节点产生了引用关系
当且仅当以下两个条件同时满足时会产生对象消失问题
- 条件一 赋值器插入了一条或者多条黑色到白色对象的引用
- 条件二 赋值器删除了全部灰色对象到白色对象的直接或间接引用
这两个条件只要破坏一个即可
写屏障是一种技术,用于捕获应用程序线程对对象引用的修改。当应用程序修改一个引用时,写屏障可以确保新引用的对象也被正确地标记。例如:
-
增量更新(Incremental Update):当一个对象已经是黑色时,如果其引用发生了变化(指向一个新的白色对象),则写屏障会将新的引用对象标记为灰色,确保它在之后的标记过程中被扫描。增量更新破坏的是第一个条件,在新增一条引用时,将该记录保存。实际的实现中,通常是将引用相关的节点进行重新标记
-
原始快照(Snapshot-at-the-Beginning,SATB):在标记开始时快照所有的引用关系,写屏障会将旧的引用标记为灰色,即使它被更新。这可以确保所有的对象都会被正确标记,而不会因为引用更新而漏标。原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发扫描结束后,在将这些记录重新扫描一次。
读屏障是在读取引用对象时触发的机制,通常用于更加激进的垃圾收集器(例如 ZGC)。当应用线程试图访问某个对象时,读屏障可以确保这个对象是被正确标记的。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB(原始快照)
- ZGC:读屏障
增量更新:黑色对象新增一条指向白色对象的引用,那么要进行深入扫描白色对象及它的引用对象。
原始快照:灰色对象删除了一条指向白色对象的引用,实际上就产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程。
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
- 单线程
- 停顿时间长
- 但对应资源紧张的服务器却是最优的选择
- 单核
- 内存小
- 因为它没有切换线程的开销
- 它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
- 多线程
- 实际就是Serial的多线程版本
- 只有它能与CMS 收集器配合工作。
- 基于标记-复制算法
- 多线程
- 其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量
- 这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值
- 吞吐量优先收集器
- -XX:MaxGCPauseMillis
- 大于0的毫秒值
- 控制最大垃圾收集停顿时间,收集器尽力暴走内存回收花费的时间不超过用户设定的值
- 如果设置小一些,使得垃圾回收快一点,其实是通过减少新生代的大小来实现的,回收300M肯定比回收500M花的时间少,但是GC 也会变得更频繁,吞吐量就下来了
- -XX:GCTimeRatio
- 直接设置吞吐量大小,也就是GC时间占总时间的比率
- 大于0小于100的整数
- 譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间
- -XX:+UseAdaptiveSizePolicy
- 激活后不需要人工指定新生代的大小、新生代和老年代的比值、晋升老年代的大小等细节参数,虚拟机会自适应,自动调节
- Serial的老年代版本
- 单线程
- 使用标记-整理算法
- Parallel Scavenge收集器的老年代版本
- 多线程
- 注重吞吐量
- 初始标记
- 仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
- 需要STW
- 并发标记
- 从GC Roots的直接关联对象开始遍历整个对 象图的过程
- 并发
- 重新标记
- 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的 标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短
- 并发
- 并发清除
- 清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
- 并发
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS
- 并发阶段用户线程也在产生垃圾,只能下一次清理
- 浮动垃圾
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
- 初始标记
- 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
- STW
- 并发标记
- 从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象
- 并发
- 最终标记
- 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录
- STW
- 筛选回收
- 负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。
- STW
- 主要面向服务端应用的垃圾收集器。
- 基于Region的堆内存布局
-
不在基于分代,而是回收任何区域,基于哪块内存存放的垃圾数量最多,回收收益最大
-
G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间
- 收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的 旧对象都能获取很好的收集效果
- 跨Region引用对象如何解决
- 使用记忆集避免全堆作为GC Roots扫描,
- 但是G1的记忆集设计更复杂,在本质上是一种哈希表,key是别的region的起始位置,value是一个集合储存卡表的索引号,这里的卡表是“双向的”(卡表是我指向谁,但是这种结构还记录谁指向我)比之前的要复杂
- 并发标记新对象的标记
- 原始快照
- 每一个Region上都设计了两个名为TAMS(top at mark start)的指针,把region的一部分空间划分出来用于并发回收过程中的新对象的内存分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上
-
每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免 在整个Java堆中进行全区域的垃圾收集
-
- Humongous区域 专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代 的一部分来进行看待
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:
- GC之痛:介绍实际业务中遇到的GC痛点,并分析CMS收集器和G1收集器停顿时间瓶颈;
- ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因,以及背后的技术原理;
- ZGC调优实践:重点分享对ZGC调优的理解,并分析若干个实际调优案例;
- 升级ZGC效果:展示在生产环境应用ZGC取得的效果。
很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。
在介绍ZGC之前,首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:
G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。 四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。
G1的Young GC和CMS的Young GC,其标记-复制全过程STW,这里不再详细阐述。
全并发的ZGC
与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。
ZGC垃圾回收周期如下图所示
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性
理解ZGC重要配置参数
以我们服务在生产环境中ZGC参数配置为例,说明各个参数的作用:
重要参数配置样例:
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
-Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。 -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize:设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。 -XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。 -XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。 -XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。 -XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。 -Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
- 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
- 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
- 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
- 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。 日志中关键字是“Proactive”。
- 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
- 外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
- 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。
一次完整的GC过程,需要注意的点已在图中标出。
注意:该日志过滤了进入安全点的信息。正常情况,在一次GC过程中还穿插着进入安全点的操作。
GC日志中每一行都注明了GC过程中的信息,关键信息如下:
- Start:开始GC,并标明的GC触发的原因。上图中触发原因是自适应算法。
- Phase-Pause Mark Start:初始标记,会STW。
- Phase-Pause Mark End:再次标记,会STW。
- Phase-Pause Relocate Start:初始转移,会STW。
- Heap信息:记录了GC过程中Mark、Relocate前后的堆大小变化状况。High和Low记录了其中的最大值和最小值,我们一般关注High中Used的值,如果达到100%,在GC过程中一定存在内存分配不足的情况,需要调整GC的触发时机,更早或者更快地进行GC。
- GC信息统计:可以定时的打印垃圾收集信息,观察10秒内、10分钟内、10个小时内,从启动到现在的所有统计信息。利用这些统计信息,可以排查定位一些异常点。
日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。
我们在实战过程中共发现了6种使程序停顿的场景,分别如下:
- GC时,初始标记:日志中Pause Mark Start。
- GC时,再标记:日志中Pause Mark End。
- GC时,初始转移:日志中Pause Relocate Start。
- 内存分配阻塞:当内存不足时线程会阻塞等待GC完成,关键字是”Allocation Stall”。
- 安全点:所有线程进入到安全点后才能进行GC,ZGC定期进入安全点判断是否需要GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程挂起。
- dump线程、内存:比如jstack、jmap命令。
我们维护的服务名叫Zeus,它是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎Aviator。Aviator内部将每一条表达式转化成Java的一个类,通过调用该类的接口实现表达式逻辑。
Zeus服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致Aviator生成的类和方法会产生很多的ClassLoader和CodeCache,这些在使用ZGC时都成为过GC的性能瓶颈。接下来介绍两类调优案例。
内存分配阻塞,系统停顿可达到秒级
日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。
分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。
解决方法:
- 开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
- 增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。
日志信息:平均1秒GC一次,两次GC之间几乎没有间隔。
分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。
解决方法:增大-XX:ConcGCThreads, 加快并发标记和回收速度。ConcGCThreads默认值是核数的1/8,8核机器,默认值是1。该参数影响系统吞吐,如果GC间隔时间大于GC周期,不建议调整该参数。
GC Roots 数量大,单次GC停顿时间长
日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。
分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道ClassLoader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。
解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。
日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。
分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+PrintCodeCacheOnCompilation参数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。
解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了大量Aviator方法进入CodeCache中。
值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。
TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。
在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:
- TP999:下降12~142ms,下降幅度18%~74%。
- TP99:下降5~28ms,下降幅度10%~47%。
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。
对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。
Zeus在升级JDK 11+ZGC中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC停顿也几乎不再影响系统可用性。
最后推荐大家升级ZGC,Zeus系统因为业务特点,遇到了较多问题,而风控其他团队在升级时都非常顺利。
Shenandoah GC是一种面向高吞吐量、低延迟的垃圾回收器,它与G1 GC类似,也是基于标记-清除算法。与G1 GC不同的是,Shenandoah GC在运行过程中会自动调整堆内存大小,以适应不同负载。同时,Shenandoah GC支持在运行过程中动态调整GC参数,以适应不同负载。
-
并发压缩:Shenandoah GC 的最大特点是其**并发压缩(Concurrent Compaction)**能力。在垃圾收集过程中,Shenandoah GC 可以与应用线程并发地压缩堆内存空间。这意味着在整个垃圾收集过程中,只有极短的停顿时间,因为传统 GC 中需要长时间暂停应用线程来进行内存压缩的部分被并发完成了。
-
极低停顿时间:Shenandoah GC 设计的核心目标是将垃圾收集的停顿时间控制在 10 毫秒以下,即使在大内存(如数百 GB)的情况下,也能实现这一目标。
-
区域化堆布局:与 G1 GC 类似,Shenandoah GC 也将堆分成多个区域(Region),这些区域可以独立地进行垃圾收集和压缩。
-
并发阶段:Shenandoah GC 的大部分垃圾收集工作在并发阶段完成,包括标记、更新引用、压缩等操作。这减少了应用线程的停顿时间。
-
标记-清除-压缩算法:Shenandoah 使用标记-清除-压缩的算法,其中压缩阶段是并发的。这是它实现低停顿的关键之一。
-
“Brooks Pointer”:Shenandoah 使用一种称为 “Brooks Pointer” 的技术来支持并发压缩。每个对象都有一个额外的指针(Brooks Pointer),指向其自身或移动后的新位置,这样即使在压缩过程中,应用线程也能正确地访问对象。
-
停顿时间:
- G1 GC:G1 GC 的设计目标是提供可预测的停顿时间,它通过增量收集来减少长时间停顿。G1 GC 的堆压缩在混合收集阶段进行,可能会引发较长的停顿。
- Shenandoah GC:Shenandoah GC 在所有阶段都尽量并发执行,特别是压缩阶段,这使得 Shenandoah GC 的停顿时间更短。
-
并发压缩:
- G1 GC:在 G1 GC 中,老年代的压缩(或者说堆压缩)会导致较长时间的停顿。
- Shenandoah GC:Shenandoah 的压缩是并发进行的,因此不会导致长时间的停顿。
-
支持范围:
- ZGC:ZGC 主要设计用于处理超大堆(TB 级)的内存场景,并且也以极低的停顿时间为目标。ZGC 使用的是基于颜色指针的算法(Colored Pointers)和读屏障(Read Barriers)。
- Shenandoah GC:Shenandoah 支持的堆大小虽然大,但不及 ZGC 的 TB 级别。Shenandoah 的目标是提供低停顿时间的同时在大多数场景中都能高效运行。
-
复杂性:
- ZGC:ZGC 的实现相对复杂,它需要操作系统和硬件支持(如页表保护和内存映射)来管理并发的内存回收和指针更新。
- Shenandoah GC:Shenandoah 的设计相对简单,它依赖于 "Brooks Pointer" 和标准的指针操作,而不是像 ZGC 那样复杂的机制。
目标:
- Parallel GC:追求高吞吐量,垃圾收集时会暂停所有应用线程,进行并行收集。
- Shenandoah GC:追求低停顿时间,通过并发收集来最大限度地减少应用线程的暂停。
Shenandoah GC 的最大特点是其并发压缩能力,这使得它在垃圾收集过程中几乎不会长时间暂停应用线程,从而实现极低的停顿时间。与 G1 GC 和 ZGC 相比,Shenandoah GC 的目标是为中到大型堆内存提供低停顿时间的垃圾收集,同时其实现方式也相对较为直接和简单。Shenandoah 适合那些对停顿时间非常敏感的应用场景。
JDK版本 | 默认垃圾收集器 |
---|---|
JDK 8 | Parallel |
JDK 11 | G1 |
JDK 17 | G1 |
JDK 21 | G1 |