在对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时候计数器值为零的对象就是不可能再被使用的,原理简单,判定效率高。但是单纯的引用计数就很难解决对象之间相互循环引用的问题。
当前主流的商用程序语言(Java、C#、Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis) 算法来判定对象是否存活的。这个算法就是通过一系列称为“GC Roots” 的根对象作为起始节点集,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,则些对象是不可能再被使用的。
Java 可作为 GC Roots 的对象包括以下几种:
JDK 1.2 版之后对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。
如果对角在可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没要必要执行”。
如果判断为有必要执行 finalize() 方法,那该对象将会被放在一个名为 F-Queue 的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。
finalize() 方法是对象逃脱回收的最后一次机会,稍后收集器会对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中重新与引用链上的任何一个对象建立联系,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量。那将会在第二次标记时被移出“即将回收”的集本,成功拯救自已。因为 finalize() 方未能都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会再次执行,所以还是会被回收掉。
不建议使用这个方法来拯救对象,尽量避免使用,已被官方明确声明为不推荐使用的语法。
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数
首先标记出所有需要回收的对象,然后统一回收掉所有被标记的对象。
缺点:
一、执行效率不稳定,如果 Java 堆中包含大量对象,且大部分是需要被回收的,就必须进行大量标记和清除,导致标记和清除两个过程的执行效率随对象数量增长而除低;
二、空间碎片化 还需依赖更为复杂的内存分配器和内存访问器来解决,如“分区空闲分配链表”来解决内存分配问题。
三、内存空间碎片化的问题,标记、清除后会产生大量空间不连续的内存碎片,可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的连续的内存而不得不提前触发另一次垃圾收集动作。
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,将存活的对象复制到另一块上,然后将已使用过的内存空间一次清理掉。如果内存中多数对象存活,这种算法将会产生大量的内存间复制的开销,但对于多数对象是可回收的情况,需要复制的就是少量的存活对象,且每次针对半个区进行回收,分配内存时也就不用考虑空间碎片问题,只要推动顶指针,按顺序分配即可。简单高效。
缺陷也显而易见,这种复制回收算法将可用内存缩小了一半,空间浪费 太多。
HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor,发生垃圾收集时,将Eden 和 Survivor 中仍然存活的对象复制到另外一块 Survivor 空间上。然后直接清理掉 Eden 和已用过的 Survivor 空间。HotSpot 默认 Eden 和 Survivor 的大小比例是 8:1,即只有10%的新生代空间会被浪费的。如果另外一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代。
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都 向内存空间一端移动,然后直接清理掉边界以外的内存。
标记复制和标记整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
缺点:如果移动大量对象并更新所有引用这些对象的地方将会是一种极为负重的操作,必须全程暂停用户应用程序才能进行(Stop The World,STW)。(最新的 ZGC 和 Shenandoah 收集器使用内存读屏障 Read Barrier 技术实现了整理过程与用户线程的并发执行。)
HotSpot 虚拟机里关注吞吐量的 Parallel Scavenge 收集器是基于标记 - 整理算法的,而关注延迟的 CMS 收集器则是基于标记 - 清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记 - 整理算法收集一次。
现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程并发,但根节点枚举还是必须在一个能保障一致性的快照中进行,这导致垃圾收集过程必须停顿所有用户线程的其中一个原因,即使是号称停顿时间可控,或几乎不会发生停顿的CMS,G1,ZGC等收集器,枚举根节点也必须要停顿的。
在 OopMap 的协助下,HotSpot 可以快速的完成 GC Roots 枚举,但引用关系可能会变化。实际上 HotSpot 也没有为每一条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为 安全点,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到安全点后才能暂停。
安全点的机制解决了程序执行时,让虚拟机进入垃圾回收状态的问题。但是,程序不执行的时候呢?所谓的不执行就是没有分配处理器时间,典型的场景便是用户线程处理 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,虚拟机不可能等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域 (Safe Region) 来解决。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因些在这个区域中任意地方开始垃圾收集都是安全的。
当用户线程执行到安全区域里面的代码时,首先标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点的枚举(或垃圾收集过程中其它需要暂停用户线程的阶段),如果没有完成,则必须等待直到收到可以离开安全区域的信号为止。
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为 记忆集(Remembered Set) 的数据结构,用以避免把整个老年代加入 GC Roots 扫描范围。事实上所有涉及部分区域(Partial GC)行为的垃圾收集器,如G1、ZGC 和 Shenandoah 收集器都会面临相同的问题。记忆集 是一种用于记录从非收集区域指向收集区需域的指针集合的抽象数据结构。
卡表(Card Table)是记忆集的一种具体实现,每个记录精确到一块内存区域,卡表最简单的形式可以只是一个字节数组,字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作为“卡页“(Card Page),卡页大小都是以2的 N 次幂的字节数。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。垃圾收集发生时,只需筛选出卡表中变脏的元素,把它们加入 GC Roots 中一并扫描。
卡表元素变脏时间点原则上应该发生在引用类型字段赋值的那一刻,但如何在对象赋值的同时去更新维护卡表呢?如果是解释执行的字节码,虚拟机负责每条字节码指令的执行,有充会的介入空间;但在编译执行中,即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。在 HotSpot 虚拟机这里是通过 写屏障(Write Barrier) 技术维护卡表状态的。(这里的“写屏障”和并发乱序执行问题中的“内存屏障”区分开来。内存屏障 Memory Barrier 的目的是为了指令不因编译优化、CPU 执行优化等原因而导致乱序执行。也是可以细分为仅确保读操作顺序正确性和仅确保写操作顺序正确性的内存屏障)。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面。在赋值前的部分的写屏障叫作写前屏障(Pre Write Barrier),在赋值后的则叫作写后屏障(Post Write Barrier)。HotSpot 虚拟机的许多收集器中都有使用到写屏障,但直至 G1 收集器之前,其它的收集器都只用到了写后屏障。
除了写屏障外,卡表在高并发场景下还面临着“**伪共享”(False Sharing)问题。现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享一个缓存行,就会彼此影响而导致性能降低,这就是伪共享问题。假设处理器的缓存行在小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享一个缓存行,如果不同线程更新的对象正好都处理这64个卡表所对应的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。一种简单的处理方案就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表标记未被标记过时才将其标记为变脏。JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能避免伪共享问题。
我们引入三色标记(Tri-color Marking)作为工具来推导遍历对象图过程,把对象按照“是否访问过”这个条件标记成以下三种颜色:
但当用户线程与收集器并发工作时,收集器在对象图上标记颜色,同时用户线程在修改引用关系,这样可能现两种后果。一种是把原本消亡的对象错误标记为存活;另一种是原本存活的对象错误标记为已消亡,程序肯定就会因此发生错误。当满足以下两个条件时,就会产生“对象消失”的问题,即原本应该是黑色的对象被误标记为白色:
赋值器插入了一条或多条从黑色到该白色对象的直接或间接引用
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因些在解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个,分别产生了两种解决方案:
增量更新(Incremental Update),破坏的是第一个条件,当黑色对象一但新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照(Snapshot At The Begining, SATB),破坏的是第二个条件,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象快照来进行搜索。
CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
图上左边六种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。(在 JDK9 中完全取消了 Serial + CMS,Parnew + Serial 组合的支持)
JDK诞生 Serial追随,为提高效率,诞生了PS,为了配合CMS,诞生了PN,CMS是1.4版本后期引入,CMS是里程碑式的GC,它开启了并发回收的过程,并发垃圾回收是因为无法忍受STW。
并行 (Parallel) :并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发 (Concurrent) :并发描述的是垃圾收集器线程与用户线程之间的关系。说明同一时间垃圾收集器线程和用户线程都在运行。
Serial:最基础、历史最悠久的收集器,Serial 是单线程工作的收集器,只会使用一个处理器或一条收集线程去完成垃圾收集工作,它在进行垃圾收集时,必须暂停其它所有工作的线程(Stop The World, STW)直到它收集结束。适用年轻代,串行回收。
ParNew 实质上是 Serial 收集器的多线程并行版本,适用年轻代,配合CMS的并行回收
PS(Parallel Scavenge) 适用年轻代,并行回收,目标是达到一个可控的吞吐量 (Throughput) 。所谓的吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
吞吐量 = 运行用户代码时间 / 运行用户代码时间 + 运行垃圾收集时间
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,
a. 控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis ,允许设置一个大于0的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定的值
b. 直接设置吞吐量大小的 -XX:GCTimeRatio,一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率。
-XX:UseAdaptiveSizePolicy 当这个参数被激活后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold) 等参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。垃圾收集的自适应策略( GC Ergonomics)。
SerialOld 是 Serial 收集器老年代版本,同样是一个单线程收集器,使用标记-整理算法。作为 CMS 收集器发生失败时的后备预案。
ParallelOld 是 Parallel Scavenge 收集器的老年代版本,支持多线徎并发收集,基于标记-整理算法实现。
CMS(ConcurrentMarkSweep) 老年代,并发的,是一种以获取最短回收停顿时间为目标的收集器。垃圾回收和应用程序同时运行,降低STW(Stop The World)的时间(200ms)
整个过程分为4个步骤:
a. 初使标记(CMS initial mark)
b. 并发标记(CMS concurrent mark)
c. 重新标记(CMS remark)
d. 并发清除(CMS concurrent sweep)
CMS问题比较多,所以现在没有一个版本默认是CMS,只能手工指定,在 JDK 9以后官方不推荐使用,在 JDK 14 已经移除。
CMS既然是MarkSweep,就一定会有碎片化的问题,碎片到达一定程度,CMS的老年代分配对象分配不下的时候,使用SerialOld 进行老年代回收。
优点:并发收集,低停顿
缺点:对处理器资源非常敏感,占用大量的CPU;无法处理浮动垃圾,出现Concurrent Mode Failure,空间碎片;
-XX:UseCMSCompactAtFullCollection(默认开启的,在JDK9开始废弃) 用于在CMS收集器不得不进行 Full GC 时开启内存碎片的合并整理过程。
-XX:CMSFullGCsBeforeCompaction(默认值为0,表示每次进入 FullGC 时都进行碎片整理,在JDK9开始废弃)
G1 (Garbage First) 收集器开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式要。G1 不再是坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立的区域 (Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor空间或者老年代空间。收集器能够扮演不同角色的 Region 采用不同的策略去处理。G1 仍然何留新生代和老年代的概念,但新生代和老年代不再是固定的。它们都是一系列区域(不需要连续)的动态集合。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1MB ~ 32MB,且应为2的N次幂。大对象将会被存放在N个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。
在 G1 收集器之前垃圾收集的目标要么是整个新生代(Minor Gc),要么是整 个老年代(Major GC),再要么是整个堆(Full GC)。而 G1 可以页向堆内任何部分来组成加收集 (Collection Set, 简称 CSet) 进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。这就是 G1 收集器的Mixed GC 模式。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收最小单元,即每次收集到的空间都是 Region 大小的整数倍,这样可以有计划的避免在整个 Java 堆中进行全区域的收集。
JDK 9 默认收集器就是 G1。
G1 收集器的整个运行过程大致可划分为以下4个步骤:
a. 初使标记(Initial Marking)标记一下 GC Roots 能直接关联到的对象,需要停顿线程,但耗时很短。
b. 并发标记(Concurrent Marking)从 GC Roots 开始对堆中对象进行可达性分析。耗时较长,但可并发执行。
c. 最终标记(Final Marking)对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB (Snapshot At The Begining) 记录。
d. 筛选回收(Live Data Counting and Evacuation)更新 Region 的统计数据,对各 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须停顿用户线程,由多条收集器线程并行完成的。
优势:并行与并发,分代收集,空间整合,可预测的停顿
缺点:
内存占用(Footpoint),G1 的卡表实现更为复杂,且每一个 Region 中都必须有一份卡表,这导致 G1 的记忆集和其它内存消耗可能会占整个堆容量的20% 甚至更多的内存空间
程序运行时的额外执行负载(Overload),CMS使用写后屏障来更新维护卡表;而G1 除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。
Shenandoah 是由 RedHat 公司独立发展的新型收集器项目,在2014年 RedHat 把 Shenandoah 贡献给了 OpenJDK, 并推动它成为 OpenJDK 12 的正式特性之一。目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的垃圾收集器,同CMS、G1相比,Shenandoah 不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理运作。
同 G1 相比至少有三个明显不同之处
a. 最重要的是支持并发整理算法,G1 的回收阶段是可以多线程并行的,却不能与用户线程并发。
b. Shenandoah 默认是不使用分代收集的。
c. Shenandoah 摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵” (Connection Matrix)的全局数据结构来记录跨 Region 的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。连接矩阵可以简单理解为一张二维表格,如果 Region N 有对象指向 Region M,就在表格的 N 行 M 列中打上一个标记。通过这张表格可以得出哪些 Region 之间产生了跨代引用。
Shenandoah 收集工作过程大致可以划分为以下9个阶段:
a. 初始标记(Initial Marking)标记与GC Roots 直接关联的对象,会产生 STW
b. 并发标记(Concurrent Marking)
c. 最终标记(Final Marking)
d. 并发清理(Concurrent Cleanup)清理整个区域内连一个存活对象都没有找到的 Region
e. 并发回收(Concurrent Evacuation)Shenandoah 要把回收集里面的存活对象先复制一份到其它未被使用的 Region 中。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。Shenandoah 将会通过 读屏障 和被称为 "Brooks Pointers" 的转发指针来解决
f. 初使引用更新(Initial Update Reference)并发回收复制阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新初使化阶段实际上并未做什么操作,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初使引用更新时间很短,会产生一个非常短的停顿。
g. 并发引用更新(Concurrent Update Reference)与用户线程一起并发的,并发引用更新与并发标记不同,它不需要再沿着对象图来搜索,只需要按照内存物理地址的顺序,线性的搜索出引用类型,把旧值改为新值。
h. 最终引用更新(Final Update Reference)解决了堆中的引用更新后,还需要修正存在于 GC Roots 中的引用。这个阶段是 Shenandoah 的最后一次停顿,停顿时间只与 GC Roots 的数量相关。
i. 并发清理(Concurrent Cleanup)
Brooks Pointer
此前做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。但时如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价非常大。
Brooks 提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,当对象拥有了新副本时,只需要修改一处指针的值,即旧对象上转发指针的位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。每次对象的访问会带来一次额外的转向开销,但它比起内存保护陷阱的方案已经好很多。
Brooks Pointer 多线程竞争问题:
并发读:那无论是读到旧对象还是新对象上的字段,返回的结果都是一样的。
并发写:设想以下三个事情并发执行场景:
收集器线程复制了新的对象副本
用户线程列新对象的某个字段
收集器线程更新转发指针的引用值为新副本地址
如果不做保护措施,将导致用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器和用户线程对转发指针的访问只有其中之一能成功,另一个必须等待。实际上 Shenandoah 是采用 CAS(Compare And Swap) 操作来保证并发时对象的访问正确性。
ZGC 收集器是一款基于 Region 内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC 在 x64 硬件平台下的 Region 可以具有大、中、小三类容量
小型 Region: 容量固定为 2MB,用于存放小于 256KB 的小对象。
中型 Region: 容量固定为 32MB,用于存放大于等于 256KB 小于 4MB 的对象
大型 Region: 容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。每个大型 Region 中只会放一个大对象。
JVM的命令行参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
HotSpot参数分类
标准: - 开头,所有的HotSpot都支持
非标准:-X 开头,特定版本HotSpot支持特定命令
不稳定:-XX 开头,下个版本可能取消
java -X
所谓调优,首先确定,追求啥?吞吐量优先,还是响应时间优先?还是在满足一定的响应时间的情况下,要求达到多大的吞吐量…
问题:
科学计算,吞吐量。数据挖掘,thrput。吞吐量优先的一般:(PS + PO)
响应时间:网站 GUI API (1.8 G1)
调优,从业务场景开始,没有业务场景的调优都是耍流氓
无监控(压力测试,能看到结果),不调优
步骤:
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
https://docs.oracle.com/en/java/javase/13/
http://java.sun.com/javase/technologies/hotspot/vmoptions.jsp
JVM调优参考文档:https://docs.oracle.com/en/java/javase/13/gctuning/introduction-garbage-collection-tuning.html
https://www.oracle.com/technical-resources/articles/java/g1gc.html