Java21手册(八):垃圾回收

avatar
@比心

Java21手册(八):垃圾回收

本篇主要讲述新一代垃圾回收器 ZGC 的演进及原理,以及简单讲解Java 其他垃圾回收器的发展。

8.1 垃圾回收器的演进

自jvm引入垃圾回收器这个概念后,垃圾回收器的更新迭代就从未停止过。随着计算机硬件软件的发展,越来越多的需求促使垃圾回收器这一个JVM核心模块不断发展和完善,Java 开发团队也一直在这方面进行着优化和更新。

JDK11 引入了新的垃圾回收器 ZGC,ZGC 在产生之初就可达到30ms以下的停顿时长和4TB级别的内存管理,而后在JDK12又开发出了ZGC的“竞品” Shenandoah 垃圾回收器。虽然新的垃圾器不算涌现,但解决垃圾回收问题的核心思想变化并不大,都是围绕着复制、标记、清除、转移这些核心步骤在进行,而性能和效率提升的关键都在于某些场景是否可以并发/并行执行。在Java8中使用最多的的垃圾回收器一共有两个:CMS 和 G1。

CMS

CMS是一款非常成功的垃圾回收器,也是使用最多且最广的垃圾回收器,它实现了分代回收和并发机制,在垃圾回收器的发展中无疑是一个里程碑式的产品,但它也同样有自己的问题,其调参方式也曾让广大程序员苦不堪言。

CMS整个回收期间分为多个阶段,初始标记,并发标记,重新标记,并发清除等,在初始标记和重新标记阶段需要STW。CMS的垃圾回收流程大致如下:

G1

G1是从JDK7版本开始正式提供的,在多CPU和大内存的服务器上,G1可以提供高吞吐的处理能力和可预测的执行时长。从结果上来看G1也是一款非常优秀的产品,从各个评测结果上都能看出它完美地满足了设计目标,因此从JDK9开始G1已经成为默认的垃圾回收器。具体来说G1的几个特点:

  1. 基于分区的内存管理:G1垃圾回收器与以往垃圾回收器不同,以往的设计对于堆内存的管理都是连续的,连续的内存将导致垃圾回收时长时间过长,停顿时间不可控。G1将堆拆分成一系列的分区 region,一个时间段内大部分的垃圾回收都只针对一部分分区进行执行,从而满足在指定的停顿时间内完成垃圾回收。G1中依然有新生代和老年代,但其对应的堆内存并不是连续的,而是基于一个个region块,同一个region块在某一段时间内,可能是老年代也可能是年轻代。
  2. G1新生代和以往JVM垃圾回收器一样,采用复制算法,一旦发生GC整个新生代都会被回收,不同点在于,G1会根据预测时间动态调整新生代的大小。G1的老年代采用的是部分回收方式,任意时刻只会有一部分老年代进行了GC,且这部分老年代分区会在下一次增量回收时与所有的新生代一起回收,所以也被称为mixed GC。

G1的垃圾回收流程大致如下:

8.2 ZGC 的实现与演进

ZGC 在特性提出时的完整介绍是 A Scalable Low - Latency Garbage Collector ZGC,即一个可扩展的低延迟垃圾收集器,可扩展性体现在哪里,本篇后面将会介绍。zgc垃圾回收实现存在两个版本,分代和非分代版本,两者的实现原理差距很大,Java21 将正式引入分代版本,下面主要介绍单代ZGC的原理和实践,对于分代ZGC会讲述其与单代的不同。

8.2.1 单代ZGC

自Java11 ZGC 发布时,ZGC就被设计为单代,原因是为了进行更大的内存管理,以及追求更短的暂停时间。在Java11中,经过测试ZGC的暂停时间平均已经小于30ms,且吞吐量相比于G1影响不超过15%,已经达到了设计之初的要求,为了快速发布不得不暂时放弃分代问题的解决。

ZGC 地址空间设计

不同于以往垃圾回收器将GC信息保存在对象头的方式,ZGC 为每个对象设置了一个64位的指针,低44位保存对象地址,高16位暂时不使用,中间42~45为染色指针位,染色指针是为了保存GC信息,便于在并发收集过程中做标记,这个后面还会继续介绍。指针结构如下图所示:

多地址空间映射

为了更高效的使用对象指针,ZGC 在申请内存时会为这个物理内存地址申请三块虚拟内存地址,虚拟内存申请使用了mmap技术,详细内容可参考《Unix Netword programming》卷二12.2节。简单来讲,申请的三块虚拟内存地址,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。适配上ZGC为自己设立的对象指针可以实现高效的标记转移能力。

如下图所示,应用在申请内存空间时,会映射为M0、M1、Remapped三个视图,通过这三个内存地址的修改可以直接反映到内核空间:

染色指针

染色指针即上述内存地址空间所标记的四个部分,包含M0,M1,Remapped和finalizable四个标记,在ZGC 中我们可以称他们为视图,在ZGC垃圾回收算法中可以认为这些视图是用做表示当前JVM运行环境时应用线程需要访问的内存区域。例如:在JVM启动时ZGC会将全局视图设置为Remapped视图,那么应用线程申请内存和访问内存区域均会向Remapped视图进行访问;当进入垃圾回收第一阶段标记阶段时,ZGC控制线程会将全局视图设置为M0视图,同理那么应用线程申请内存和访问内存区域均会向M0视图进行访问。染色指针和多视图映射就是为了完成这样的切换和标记工作,其结构和映射如下图所示:

读屏障

读屏障是一种类似于aop的技术,在执行某个逻辑前先执行一段逻辑。ZGC使用读屏障用于在并发标记或转移阶段对对象指针进行染色,这样可以保证应用线程和GC线程的并发执行,具体细节为:

  1. 当应用线程从堆中加载引用对象时,触发读屏障。
  2. 查看该对象指针颜色,如果不是当前视图颜色,则触发事件逻辑。
  3. 读屏障机制保证对象引用颜色为正确的颜色。

并发垃圾回收算法

ZGC 的垃圾回收过程简单可以概括为两个阶段,标记阶段与转移阶段。标记阶段分为初始标记、并发标记和再标记,转移阶段分为初始转移和并发转移,如图所示:

初始标记和初始转移都是只标记根节点所以暂停时间非常小,并发阶段时应用线程和GC线程全并发的,因为读屏障和染色指针的作用使得这个并发过程得以实现。图中还有一个并发转移准备阶段,这个阶段是将标记阶段对页面标记完成后对页面内存活对象进行统计,对页面垃圾大于设定值的页进行页对象转移。转移之后会涉及到对象的重定位,这个重定位会放在下次垃圾回收的并发标记阶段和触发读屏障时进行。

接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:

  1. 初始化阶段:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
  2. 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
  3. 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。

8.2.2 生产实践

ZGC作为新一代垃圾回收器,对比以往的垃圾回收器在JVM参数配置方面简化了很多,且调整的方向比较明确。官方为ZGC提供的可调参数并不多,下面表格列举了ZGC常用参数及其配置含义:

参数含义
-XX:ZAllocationSpikeTolerance这个参数表示ZGC在执行垃圾回收预测算法时的级别,默认为2,这个数值越大越会提前收集垃圾,经生产验证在不配置的情况下设置会再内存达到99%的时候会触发回收。
-XX:ZCollectionInterval允许自定义执行垃圾回收的时间间隔,0表示不执行自定义触发的规则
-XX:ZFragmentationLimit指定页面中再垃圾回收期间允许存在的垃圾的最大比例,在垃圾回收期间,当页面中的垃圾空间超过该比例,则页面会进入回收,否则页面不会被回收
-XX:ZMarkStackSpaceLimit设置标记占用空间的上限
-XX:ZProactive主动触发垃圾回收,长时间没有垃圾回收的情况下,ZGC会主动触发垃圾回收,基于策略在每增长10%或者每过一段时间的场景下触发一次垃圾回收

下面是我们的一个真实项目在生产中所使用到的启动配置,可以看到我们将ZAllocationSpikeTolerance设置为了5这个级别,经过线上的运行目前在90%左右会进行一次回收,这个参数的调整依赖于应用本身负载的生产情况。

java -server -jar -Xmx6G -Xms6G -XX:MaxMetaspaceSize=512M -XX:AutoBoxCacheMax=20000 -XX:ActiveProcessorCount=4 -XX:+UseZGC -XX:-ZProactive -XX:-OmitStackTraceInFastThrow -Xlog:gc*:file=/data/logs/gc.log:time,tid,tags -XX:+DoEscapeAnalysis -XX:+EliminateLocks -Djava.awt.headless=true -XX:+AlwaysPreTouch -Duser.timezone=Asia/Shanghai -Dclient.encoding.override=UTF-8 -Dfile.encoding=UTF-8 -Duser.region=CN -Djdk.tracePinnedThreads=full --enable-preview --add-opens ... -XX:ZAllocationSpikeTolerance=5 -Dspring.profiles.active=prod /data/chatroom-base-pro-service.jar

8.2.3 日志解读

以下几张截图是我们的一个项目在生产环境的垃圾回收日志截图,截图中日志关键信息旁边附带了文字说明:

在垃圾回收日志中,有一些信息是我们要重点关注的:

GC cause

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

MMU

MMU记录的是应用程序运行所占的比例,该值越大,说明应用程序工作效率越高,下面是几个时间段内的运行情况。

[2023-06-11T18:59:05.084+0800][11][gc,mmu      ] GC(0) MMU: 2ms/99.5%, 5ms/99.8%, 10ms/99.9%, 20ms/99.9%, 50ms/100.0%, 100ms/100.0%

统计信息

可以在日志中看到如下图所示区域

=== Garbage Collection Statistics ======

该信息收集为统计信息,输出过去一段时间内统计的信息。统计线程周期为一秒收集一次,目前提供了3中粒度的数据,分别为过去10s、10min和10h的数据。

8.2.4 分代ZGC

早在jdk12时就有提出过ZGC在单代上的并发收集性能问题,每次的整堆标记转移是一种效率极低的方式,因为90%的对象在创建不久后便成为了非活跃对象,分代的思想可以让某一个区域高频回收,而另一个区域低频触发。

分代ZGC在Java21中引入,与单代ZGC的设计变化非常大,概括来说有以下两个改动点:

  1. 去掉多地址映射,分代ZGC不再使用多地址映射技术来实现垃圾回收的视图转换,而是采用标记指针和存储屏障的方式来完成垃圾回收过程。
  2. 新增存储屏障,弱化读屏障:在ZGC运行过程中读屏障的性能损耗达到了4%,为了降低该屏障性能损耗 ZGC 引入了存储屏障,更改后的读屏障职责为:1、从彩色指针中删除元数据位;2、对象重定位。存储屏障的职责:1、创建有元数据位的染色指针了;2、维护重定向记忆集;3、标记对象为存活。

在Java21中,分代ZGC需要手动开启,通过以下配置:

$ java -XX:+UseZGC -XX:+ZGenerational ...

由于分代ZGC在绝大多数的场景下都要优于单代ZGC,Java将会在未来的版本中将分代ZGC设为默认,并移除单代版本。

ZGC是Java新一代垃圾回收器的代表,也是Java在后续版本中最具吸引力的特性之一,目前我们的新项目也都在使用。从生产环境实际使用下来看,ZGC相比G1在GC卡顿方面确实有比较大的提升;但G1在某些方面也有一些优势,例如在极小内存下ZGC不能像G1一样正常启动,以及在同样大小的JVM内存压测场景下,G1比ZGC可以承载的数据量要大一些。

Java21的默认垃圾回收器依然是G1,但自从Java15 ZGC转为稳定版特性后,G1在后续版本中再也没有进行过更新,而ZGC在后续版本中在不断更新以优化性能。如果你已经或决定使用JDK17以及后续版本部署你的项目,我建议可以直接选择ZGC作为垃圾回收器。

8.3 Shenandoah 垃圾回收器

Shenandoah是JDK12中引入的一款新的垃圾回收器,最早由Red Had公司发起,目标是利用现代多核CPU的优势,减少大堆内存在垃圾回收时产生的pause时间。Shenandoah的设计之处目的与ZGC相同,都是支持大内存管理和实现毫秒级别的停顿时长,可以说两者是竞争关系。不过shenandoah和ZGC都是基于G1发展起来的,我们先比较一下:

G1ZGCShenandoah
堆内存基于分区最小为1MB最大为32MB基于分页,2MB,32MB,和N*MB基于分区最小为256KB最大为32MB
是否分代回收支持21开始支持不支持(Shenandoah的分代版本项目已经在进行,目前还是实验阶段,并不会跟Java21发布,更多资料可以查看JEP 404)
回收策略新生代分区在YGC/Mixed FGC中会被全部回收,老生代在M写的GC时部分回收 FGC全部回收部分页面回收部分分区回收
屏障使用读屏障、写屏障单代ZGC使用读屏障、分代ZGC使用读屏障 + 存储屏障读屏障、写屏障及比较屏障
并发级别并发标记并发标记、转移、重定位并发标记、转移、重定位
NUMA支持支持支持不支持
字符串去重支持支持支持
引用处理并行并发并行

Shenandoah和ZGC在垃圾回收时是非常类似的,都实现了并发标记、转移和重定位。但是shenandoah算法的思路和ZGC不同,shenandoah仍使用对象头中增加额外数据的方式,并采用读写屏障通过对象头中的Brook pointer访问对象。

Shenandoah的特点是实现了四类垃圾算法、正常垃圾回收算法、降级垃圾回收算法、全回收算法和遍历垃圾回收算法。在不同的场景下可以挑选不同的回收策略:

  • 正常垃圾回收算法:垃圾回收的过程通常按照初始标记、并发标记、再标记、并发转移结束转移的步骤执行。
  • 降级垃圾回收算法:指在垃圾回收过程中,如果遇到内存分配失败,将进入降级回收。降级回收实质上是在STW中进行的并行回收
  • 全回收算法:如果在将机会手中再次遇到内存分配失败的情况,将进入全回收。
  • 遍历回收算法:垃圾回收过程中按照初始遍历、并发遍历、预清理和结束遍历的步骤执行。

8.4 垃圾回收器其他更新

8.4.1 统一垃圾回收器接口

垃圾收集器是JVM的核心组件之一,但在之前的JVM中,不同的垃圾收集器实现通常具有不同的代码结构和API接口,这导致了源代码的混乱和维护的困难。统一垃圾回收器定义了一套纯净的垃圾回收器接口API,对现有垃圾回收器代码进行了重构,目的是从代码上对不同垃圾回收器进行模块隔离,同时也便于新增或删除一个垃圾回收器。

GC代码结构如下,不同的垃圾回收器目录更加清晰,统一接口API在shared包下,所有垃圾回收器都基于这些接口进行开发:

8.4.2 G1 的性能优化

自Java8后,G1在这几方面进行了优化改进:

  • 支持NUMA,提升了G1在大型机上的性能
  • 优化了mixed gc的中断机制
  • 自动将JVM多余的堆内存归还给操作系统,降低了java程序的内存占用
  • 并行full gc,提升full gc效率

8.4.3 Epsilon 垃圾回收器

这是一个不回收垃圾的垃圾回收器,没有什么垃圾回收算法,能比不回收垃圾跑得更快!🤣

Epsilon是Java11引入的一个实验的垃圾回收器,它只进行内存分配,不进行内存管理,使用完毕后JVM就自动销毁掉。Epsilon的使用场景比较有限,例如我们需要跑一个数据分析的脚本,执行下来内存使用量就是2G,那么执行完毕后直接销毁,其中不会发生任何的性能损耗,脚本性能可以达到极致。不过通常我们实际生产中很难用到,Epsilon更多的用途还是用于垃圾回收器的实验研究对比,它可以作为不同场景下的基准测试来使用。

Epsilon的全部代码只有短短几十行,仅做了内存初始化和分配,你可以模仿它来实现一个属于自己的垃圾回收器:

public class EpsilonHeap extends CollectedHeap {
    private static AddressField spaceField;
    private static Field virtualSpaceField;
    private ContiguousSpace space;
    private VirtualSpace virtualSpace;


    private static void initialize(TypeDataBase db) {
        Type type = db.lookupType("EpsilonHeap");
        spaceField = type.getAddressField("_space");
        virtualSpaceField = type.getField("_virtual_space");
    }


    public EpsilonHeap(Address addr) {
        super(addr);
        this.space = new ContiguousSpace(spaceField.getValue(addr));
        this.virtualSpace = new VirtualSpace(addr.addOffsetTo(virtualSpaceField.getOffset()));
    }


    public CollectedHeapName kind() {
        return CollectedHeapName.EPSILON;
    }


    public long capacity() {
        return this.space.capacity();
    }


    public long used() {
        return this.space.used();
    }


    public ContiguousSpace space() {
        return this.space;
    }


    public void liveRegionsIterate(LiveRegionsClosure closure) {
        closure.doLiveRegions(this.space());
    }


    public void printOn(PrintStream tty) {
        MemRegion mr = this.reservedRegion();
        tty.println("Epsilon heap");
        String var10001 = String.valueOf(mr.start());
        tty.println(" reserved:  [" + var10001 + ", " + String.valueOf(mr.end()) + "]");
        var10001 = String.valueOf(this.virtualSpace.low());
        tty.println(" committed: [" + var10001 + ", " + String.valueOf(this.virtualSpace.high()) + "]");
        var10001 = String.valueOf(this.space.bottom());
        tty.println(" used:      [" + var10001 + ", " + String.valueOf(this.space.top()) + "]");
    }


    static {
        VM.registerVMInitializedObserver(new Observer() {
            public void update(Observable o, Object data) {
                EpsilonHeap.initialize(VM.getVM().getTypeDataBase());
            }
        });
    }
}

我们可以实际用Epsilon对比测试一下其他垃圾回收器的性能,看看它们的性能有什么样的差距。以下是一个简单的测试,逻辑很简单,就是疯狂new对象然后输出一下运行时长,JDK版本为Java20:

public class TestEpsilon {
    public static void main(String[] args) {

        System.out.println("Hello Epsilon!");


        long beginTime = System.currentTimeMillis();


        for(int i = 0; i < 50000; i++){
            List<Garbage> list = Lists.newArrayList();
            List<Garbage> outList = Lists.newArrayList();
            for(int j = 0; j < 1000; j++){
                list.add(new Garbage("Garbage"));
            }
            outList.addAll(list);
            doSomeThing();
        }


        long endTime = System.currentTimeMillis();
        System.out.println("over time:" + (endTime - beginTime) + "ms");
    }


    private static void doSomeThing() {
        System.out.println("i am doing something");
    }


    static class Garbage{
        private String garbage;
        public Garbage(String garbage){
            this.garbage = garbage;
        }
    }
}

使用Epsilon作为基准测试,第一次使用Epsilon,内存1G:

-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx1G -Xms1G

果不其然,内存被打满了,并没有进行垃圾回收

我们调大内存为2G:

-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx2G -Xms2G
这次执行成功了,通过GC日志也可以看出,是完全没有进行垃圾回收处理的。

我们再来看看ZGC的表现,比EpsilonGC慢了差不多300ms,还是比较多的,且这个时间记录是多次运行后的最小值:

通过GC日志可以看到,ZGC经历了4次GC,虽然每次暂停时间小于1ms,但暂停恢复及并发垃圾回还是带来不小的开销,应用程序的吞吐量收到了一些影响。G1经过测试和ZGC表现也基本一致。

8.4.4 废弃CMS垃圾回收器

一代经典垃圾回收器CMS,已经从JVM中移除,官方的建议使用G1替换CMS。

image.png