《剑指JVM》读书笔记之垃圾收集器

笔记整理JVM中常见的垃圾收集器

1. 评估垃圾收集器的性能指标

吞吐量:运行用户代码的时间占总运行时间的比例。总运行时间=程序的运行时间+内存回收的时间。

垃圾收集开销:吞吐量的补数,内存回收所用时间与总运行时间的比例。

停顿时间:执行垃圾收集时,程序的工作线程被暂停的时间。

收集频率:垃圾收集操作发生的频率。

内存占用:Java堆区大小设置。

2. 垃圾收集器的分类

首先,Java堆分为新生代和老年代,生命周期较短的对象一般放在新生代,生命周期较长的对象会进入老年代。不同区域的对象,采取不同的收集方式,以便提高回收效率。因此根据垃圾收集器工作的内存区间不同,可分为新生代垃圾收集器、老年代垃圾收集器和整堆垃圾收集器。

  • 新生代收集器:Serial、ParNew、Parallel Scavenge。

  • 老年代收集器:Serial Old、Parallel Old、CMS。

  • 整堆收集器:G1。

image-20231108140123163

其次,新生代在每次垃圾收集发生时,大部分对象会被回收,存活对象数量较少,因此每次回收进行碎片整理是非常高效的。而老年代的每次回收,存活对象数量较多,复制算法明显变得不合适,一般选用标记–清除算法,或者标记–清除算法与标记–压缩算法混合实现。因此垃圾收集器可分为压缩式垃圾收集器和非压缩式垃圾收集器。压缩式垃圾收集器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片,如果再次分配对象空间,使用指针碰撞技术实现,比如Serial Old就是压缩式垃圾收集器。非压缩式垃圾收集器不进行这步操作,如果再分配对象空间,只能使用空闲列表技术实现,比如CMS就是非压缩式垃圾收集器。

最后,垃圾收集器还可以分为串行垃圾收集器、并行垃圾收集器、并发式垃圾收集器等。

  • 串行垃圾收集器是指使用单线程收集垃圾,即使存在多个CPU可用,也只能用一个CPU执行垃圾回收,所以应用程序一定会发生STW。使用串行方式的垃圾收集器有Serial等。

  • 并行垃圾收集器指使用多个垃圾收集线程并行工作,当多个CPU可用时,并行垃圾收集器会使用多个CPU同时进行垃圾回收,因此提升了应用的吞吐量,但此时用户线程仍会处于等待状态,即STW现象仍然会发生。使用并行方式的垃圾收集器有ParNew、Parallel Scavenge、Parallel Old等。

  • 并发垃圾收集器是指用户线程与垃圾收集线程“同时”,但此时用户线程和垃圾收集线程不一定是并行的,可能会交替执行。如果此时存在多个CPU或者一个CPU存在多核的情况,垃圾收集线程在执行时不会“停顿”用户程序的运行,即垃圾收集线程不会独占CPU资源,用户程序再继续运行,而垃圾收集程序线程运行于另一个CPU上。使用并发方式的垃圾收集器有CMS和G1等。

因此,根据进行垃圾收集的工作线程数不同,垃圾收集器可以分为串行垃圾收集器和并行垃圾收集器。根据垃圾收集器的工作模式不同,即垃圾收集器工作时是否独占CPU资源,可以把垃圾收集器分为并发式垃圾收集器和独占式垃圾收集器。

独占式垃圾收集器一旦运行,就停止应用程序中的其他所有线程,直到垃圾收集过程完全结束。并发式垃圾收集器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。

我们把上面提到的垃圾收集器分类如下

  • 串行收集器:Serial、Serial Old。

  • 并行收集器:ParNew、Parallel Scavenge、Parallel Old。

  • 并发收集器:CMS、G1。

三种类型的垃圾收集器的工作流程如图所示,图中实线表示应用线程(Application threads),虚线表示垃圾回收线程(GC threads)。串行垃圾收集器是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且Java应用中的所有线程都要暂停,等待垃圾回收的完成。并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为多线程进行垃圾回收,这样可以缩短垃圾回收的时间。并发垃圾收集器是指垃圾收集线程和用户线程同时运行。

image-20231108142204510

其中经典的7个垃圾收集器之间的组合关系如图所示。

image-20231108142236069

两个收集器之间由实线连线,表明它们可以搭配使用,常见的组合有:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1,其中Serial Old作为CMS出现“Concurrent Mode Failure”失败的后备预案

两个收集器之间由单虚线连接,表示由于维护和兼容性测试的成本,在JDK 8时将Serial/CMS和ParNew/Serial Old这两个组合声明为废弃,并在JDK 9中完全移除了这些组合。

两个收集器之间由双虚线连接,表示JDK 14中,弃用Parallel Scavenge和Serial Old GC组合。需要注意的是JDK 14中已经彻底删除了CMS垃圾收集器。

3. 垃圾回收器详解

3.1 Serial收集器:串行回收

Serial收集器是最基本、历史最悠久的垃圾收集器了,是JDK1.3之前回收新生代唯一的选择。Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器,采用的是复制算法、串行回收和STW机制的方式执行内存回收。

除了新生代,Serial收集器还提供了用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样采用了串行回收和STW机制,只不过内存回收算法使用的是标记—压缩算法。

Serial Old是运行在Client模式下默认的老年代的垃圾收集器。Serial Old在Server模式下主要有两个用途。

(1)与新生代的Parallel Scavenge垃圾收集器搭配。

(2)作为老年代CMS收集器的后备方案。

Serial/Serial Old收集器是单线程的收集器,它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程——“Stop The World”,直到它收集结束。这就意味着每次垃圾收集时都会给用户带来一定的卡顿现象,造成不良的用户体验

image-20231108143224048

Serial垃圾收集器相比于其他收集器也有一定的优点:简单而高效。Serial收集器由于没有线程交互的开销,只需要专心做垃圾收集,自然可以获得很高的单线程收集效率。虚拟机的Client模式下使用Serial垃圾收集器是个不错的选择。只要垃圾回收不频繁发生,使用Serial收集器是一个不错的选择。

在HotSpot虚拟机中,可以通过设置“-XX:+UseSerialGC”参数明确指定新生代和老年代都使用串行收集器。配置完该参数以后表示新生代用Serial垃圾收集器,老年代用Serial Old垃圾收集器。

现在已经几乎不用该类型的垃圾收集器了,通常在单核CPU场景下才用

3.2 ParNew收集器:并行回收

如果说Serial GC是新生代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。Par是的缩写,New指的是该收集器只能处理新生代

ParNew收集器除了采用并行回收的方式执行内存回收外,和Serial垃圾收集器之间几乎没有任何区别。ParNew收集器在新生代中同样也是采用复制算法和STW机制。ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。

对于新生代,回收次数频繁,使用并行方式高效。对于老年代,回收次数少,使用串行方式更加节省CPU资源。

image-20231108144512413

ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。

但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

除Serial外,目前只有ParNew垃圾收集器能与CMS收集器配合工作。在程序中,开发人员可以通过选项“-XX:+UseParNewGC”手动指定使用ParNew收集器执行内存回收任务。它表示新生代使用并行收集器,不影响老年代。

3.3 Parallel Scaveng收集器:吞吐量优先

Parallel Scavenge收集器同样也采用了复制算法、并行回收和STW机制。目标是达到一个可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是Parallel Scavenge与ParNew一个重要区别,Parallel Scavenge获取应用程序的运行情况收集系统的性能监控信息,动态调整参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为垃圾收集的自适应调节策略。

高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel Scavenge收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。Parallel Old收集器采用了标记—压缩算法,但同样也是基于并行回收和STW机制。

在程序吞吐量优先的应用场景中,Parallel Scavenge收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。Parallel Scavenge/Parallel Old收集器中GC线程和用户线程之间的运作关系如图所示。在JDK8中,默认使用此垃圾收集器。

image-20231108145556100

Parallel垃圾收集器常用参数配置如下。

(1)-XX:+UseParallelGC:指定新生代使用Parallel并行收集器执行内存回收任务;-XX:+UseParallelOldGC:指定老年代都是使用并行回收收集器,JDK8默认开启。默认情况下,开启其中一个参数,另一个也会被开启(互相激活)。

(2)-XX:ParallelGCThreads:设置新生代并行收集器的线程数。一般最好与CPU核心数量相等,以避免过多的线程数影响垃圾收集性能。

在默认情况下,当CPU核心数量小于8个,ParallelGCThreads的值等于CPU核心数量。当CPU核心数量大于8个,ParallelGCThreads的值等于3+[5*CPU_Count]/8]。

(3)-XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。

对于用户来讲,停顿时间越短体验越好,但是在服务器端,我们更加注重高并发和应用程序的吞吐量,所以Parallel垃圾收集器更适合服务器端。

(4)-XX:GCTimeRatio:设置垃圾收集时间占总时间的比例(1 /(N+1))。用于衡量吞吐量的大小。该参数取值范围是(0,100),默认值是99,表示垃圾收集时间不超过1%。

该参数与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。停顿时间越长,GCTimeRatio参数就越容易超过设定的比例。

(5)-XX:+UseAdaptiveSizePolicy:开启自适应调节策略。在这种模式下,新生代的大小、Eden区和Survivor区的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。

3.4 CMS收集器:低延迟

3.4.1 CMS收集器介绍

CMS(Concurrent Low Pause Collector)是JDK1.4.2开始引入的新GC算法,在JDK5和JDK6中得到了进一步改进,它的主要适合场景是对响应时间的需求大于对吞吐量的要求。CMS垃圾收集器在强交互应用中几乎可认为有划时代意义。它是HotSpot虚拟机中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作。

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短,延迟就越低,就越适合与用户强交互的程序,因为良好的响应速度能更好地提升用户体验

目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望减少系统停顿时间,以给用户带来较好的使用体验。CMS收集器就非常符合这类应用的需求。

CMS的垃圾收集算法采用标记–清除算法,并且也会STW。不幸的是,CMS作为老年代的收集器,却无法与新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。

3.4.2 CMS的工作原理

CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

image-20231124140508347

(1)初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。

(2)并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

(3)重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要一次重新标记操作,通常这个阶段的停顿时间会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

(4)并发清除(Concurrent-Sweep)阶段:此阶段清理已经被标记为死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

尽管CMS收集器采用的是并发回收,但是在其初始化标记和再次标记这两个阶段中仍然需要执行STW机制暂停程序中的工作线程,不过停顿时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要STW,只是尽可能地缩短停顿时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了

CMS收集器的垃圾收集算法采用的是标记–清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片,清理完内存之后零碎的小内存区域就是所谓的内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配

为什么不把算法换成标记–压缩呢?

当CMS并发清除的时候,原来的用户线程依然在使用内存,所以也就无法整理内存。标记—压缩算法更适合在STW这种场景下使用。

CMS的优点是并发收集和低延迟。CMS的弊端也很明显。

(1)会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。

(2)对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。

(3)由于在垃圾收集阶段用户线程没有中断,要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败而导致另一次Full GC的产生。

(4)无法处理浮动垃圾。在并发清除阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发清除阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。

3.4.3 CMS收集器的参数设置

CMS收集器可以设置的参数如下。

(1)-XX:+UseConcMarkSweepGC:指定使用CMS收集器执行内存回收任务。开启该参数后会自动将-XX:+UseParNewGC打开。即垃圾收集器组合为ParNew(Young区用)、CMS(Old区用)和Serial Old(CMS的备用方案)。

(2)-XX:CMSlnitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%。如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数,可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。

(3)-XX:+UseCMSCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

(4)-XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理。

(5)-XX:ParallelCMSThreads:设置CMS的线程数量。CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是新生代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

如果想要最小化地使用内存和并行开销,请选Serial垃圾收集器;如果想要最大化应用程序的吞吐量,请选Parallel垃圾收集器;如果想要最小化垃圾收集的停顿时间,请选CMS垃圾收集器。

3.5 G1收集器:区域化分代式

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,担负着“全功能的垃圾收集器”的重任和期望。G1是一款基于并行和并发的收集器,它把堆内存分割为很多区域(Region),它们虽然物理上不连续,但是逻辑上是连续的。然后使用不同的Region来表示Eden区、Survivor 0区、Survivor 1区、老年代等。

G1有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1取一个名字就是垃圾优先(Garbage First)。

3.5.1 G1收集器的特点和使用场景

G1是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,极大可能降低垃圾回收停顿时间的同时,还兼具高吞吐量的性能特征。与其他垃圾收集器相比,G1使用了全新的分区算法,其特点如下。

1 并行与并发

(1)并行性是指G1在回收期间,可以有多个垃圾收集线程同时工作,有效利用多核计算能力。此时用户线程STW。

(2)并发性是指G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。

2 分代收集

(1)从分代上看,G1依然属于分代型垃圾收集器,它会区分新生代和老年代,新生代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、新生代或者老年代都是连续的,也不再坚持固定大小和固定数量。

(2)和之前的各类收集器不同,G1可以工作在新生代和老年代。其他收集器要么工作在新生代,要么工作在老年代。

3 空间整合

(1)CMS采用了标记–清除算法,会存在内存碎片,会在若干次GC后进行一次碎片整理。

(2)G1将内存划分为一个个的Region。内存的回收是以Region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记–压缩算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

4 可预测的停顿时间模型

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为Mms的时间片段内,消耗在垃圾收集上的时间不得超过Nms。由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此STW的情况也可以得到较好的控制。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是比最差情况要好很多

G1垃圾收集器相较于CMS,还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势,平衡点在6~8G

G1收集器主要面向服务端应用,针对具有大内存、多处理器的机器,在普通大小的堆里表现并不惊喜。如果应用需要较低停顿时间,并且需要比较大的堆内存提供支持时,那么G1收集器无疑是比较合适的垃圾收集器,例如在堆大小约6GB或更大时,可预测的停顿时间可以低于0.5秒。

一般我们认为在下面的几种情况中,使用G1可能比CMS更好。

(1)超过50%的Java堆被活动数据占用。

(2)对象分配频率或年代提升频率变化很大。

(3)GC停顿时间过长(长于0.5~1s)。

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行垃圾收集的多线程操作,而G1可以采用应用线程承担后台运行的垃圾收集工作,即当JVM的垃圾收集线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

3.5.2 分区Region:化整为零

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB,且为2的N次幂,即1MB、2MB、4MB、8MB、16MB、32MB。Region块大小可以通过“-XX:G1HeapRegionSize”设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

image-20231124145741580

一个Region有可能属于Eden、Survivor或者Old/Tenured内存区域。注意一个Region只可能属于一个角色。空白区域表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫作Humongous内存区域,如图中的H块,主要用于存储大对象,如果超过1.5个Region,就放到H。设置H的原因是对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

正常的Region的内存大小为4MB左右。Region区域使用指针碰撞算法来为对象分配内存,每一个分配的Region被分成两部分,已分配(allocated)和未分配(unallocate)的,它们之间的界限称为top指针。将变量或对象实体存放到当前的allocated区域,未使用的unallocate区域。当再分配新的对象的时候指针(top)右移将新对象存放到allocated区域。当然在多线程情况下,会有并发的问题,G1收集器采用的是TLAB(Thread Local Allocation Buffer)和CAS(Compare and Swap)来解决并发的安全问题

3.5.3 G1收集器垃圾回收过程

G1可以作用于整个新生代和老年代,G1的垃圾回收过程主要包括如下三个环节。

  • 新生代GC(Young GC)。
  • 老年代并发标记(Concurrent Marking)。
  • 混合回收(Mixed GC)。

作为JVM的兜底逻辑,如果应用程序垃圾收集时内存不足,G1会像其他收集器一样执行Full GC,即强力回收内存。

image-20231124153154645

垃圾回收的流程按图中顺时针走向,以新时代GC→新时代GC+并发标记过程→混合GC顺序进行垃圾回收。首先执行新时代GC,之后执行并发标记过程,该过程会伴随着Young GC的发生,最后执行混合GC。

应用程序分配内存,当新生代的Eden区用尽时开始新生代回收过程。G1的新生代收集阶段是一个并行的独占式收集器。在新生代回收期,G1暂停所有应用程序线程,启动多线程执行新生代回收。然后从新生代区移动存活对象到Survivor区或者老年代区,也有可能是两个区都会涉及。

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。标记完成马上开始混合回收过程。对于一个混合回收期,G1从老年代区移动存活对象到空闲区,这些空闲区也就成为老年代的一部分。G1收集器在老年代的处理方式和其他垃圾收集器不同,G1不需要回收整个老年代,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和新生代一起被回收的。

G1收集器在回收的过程会有很多问题,比如一个对象被不同区域引用的问题,一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确。在其他的分代收集器,也存在这样的问题(而G1更突出)。回收新生代也不得不同时扫描老年代,因为判断对象可达,需要通过GC Roots来判断对象是否可达,那么寻找GC Roots的过程可能会放大范围,查找到老年代的对象,这样会降低Young GC的效率。

针对上述问题,JVM给出的解决方法如下。

  • 无论G1还是其他分代收集器,JVM都是使用记忆集(Remembered Set,Rset)来避免全局扫描。

  • 每个Region都有一个对应的Remembered Set。

  • 每次Reference类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时中断操作。

  • 然后检查将要写入的引用指向的对象是否和该引用类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)。

  • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中。

  • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

如图所示,存在3个Region,每个Region包含一个Rset,当产生一个新对象放在Region2中时,此时判断指向该对象的引用是否都在Region2中;可以发现该对象存在两个引用对象,分别在Region1和Region3中,所以需要通过CardTable把引用信息记录到Region2中的Rset中。

image-20231124153517150

1 G1回收过程一:新生代GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次新生代垃圾回收过程。新生代垃圾回收只会回收Eden区和Survivor区。

新时代GC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,新生代回收过程的回收集包含新生代Eden区和Survivor区所有的内存分段。如图所示,可以看到内存回收之后部分Eden区和Survivor区直接清空变为新的Survivor区,也有Survivor区的直接晋升为Old区。

image-20231124153615059

image-20231124153635368

然后开始如下回收过程。

第一阶段,扫描根。

根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。

第二阶段,更新RSet。

对于应用程序的引用赋值语句“object.field=object”,JVM会在之前和之后执行特殊的操作,在dirty card queue中入队一个保存了对象引用信息的card。

处理dirty card queue中的card,更新RSet。此阶段完成后,RSet可以准确地反映老年代对所在的内存分段中对象的引用。

那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。

第三阶段,处理RSet。

识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

第四阶段,复制对象。

此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。

第五阶段,处理引用。

处理Soft、Weak、Phantom、Final、JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

新生代GC完成以后,接下来就是老年代并发标记过程了。

2 G1回收过程二:并发标记过程

并发标记过程主要包含5个步骤,如下所示。

初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次新生代GC。

根区域扫描(Root Region Scanning):G1GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在新生代GC之前完成。

并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被新生代GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果,是STW的。G1中采用了比CMS更快的初始快照算法snapshot-at-the-beginning(SATB)。

独占清理(Cleanup):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫,这个过程是STW的。这个阶段并不会实际上去做垃圾的收集。

并发清理阶段:识别并清理完全空闲的区域。

3 G1回收过程三:混合回收(Mixed GC)

当越来越多的对象晋升到老年代区时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个新生代区,还会回收一部分的老年代区。这里需要注意的是回收一部分老年代,而不是全部老年代。可以选择哪些老年代区进行收集,从而可以对垃圾回收的所耗时间进行控制。也要注意的是Mixed GC并不是Full GC。

image-20231124154511233

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。G1的混合回收阶段是可以分多次进行的,但每次都会进入STW状态,次数默认是8次(可以通过“-XX:G1MixedGCCountTarget”设置)被回收。运行逻辑是先STW,执行一次混合回收回收一些Region,接着恢复系统运行,然后再STW,再执行混合回收。

每次混合回收的回收集(Collection Set)包括需要回收的老年代区的八分之一、Eden区以及Survivor区。混合回收的算法和新生代回收的算法完全一样,只是回收集多了老年代的内存Region。具体过程请参考上面的新生代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的Region。垃圾占Region比例越高,越会被先回收。并且有一个阈值会决定Region是否被回收,“-XX:G1Mixe dGCLiveThresholdPercent”默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次,事实上,混合回收阶段具体执行几次回收,看的是空闲的Region数量何时达到堆内存的10%,如果执行3次回收就达到了10%,就不会再继续执行回收了。这个10%可以使用参数“-XX:G1HeapWastePercent”来控制。该参数默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

4 G1回收可选的过程四:Full GC

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

(1)Evacuation的时候没有足够的to-space来存放晋升的对象;

(2)并发处理过程完成之前空间耗尽。

5 G1回收过程:补充

从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

6 G1收集器优化建议

针对G1收集器优化,我们给出以下建议,大家在学习过程中可以参考。

(1)新生代大小不要固定。避免使用“-Xmn”或“-XX:NewRatio”等相关选项显式设置新生代大小,固定新生代的大小会覆盖停顿时间目标。

(2)停顿时间目标不要太过严苛。G1的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间。评估G1的吞吐量时,停顿时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

3.5.4 G1收集器的参数设置

G1收集器的相关参数说明如下。

-XX:+UseG1GC:指定使用G1收集器执行内存回收任务,JDK 9之后G1是默认垃圾收集器。

-XX:G1HeapRegionSize:设置每个Region的大小,值是2的幂次方,范围是1MB到32MB,目标是根据最小的Java堆大小划分出约2048个区域。默认值是堆内存的1/2000。

-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms。

-XX:ParallelGCThread:设置STW时并行的GC线程数量值。最多可以设置为8。

-XX:ConcGCThreads:设置并发标记的线程数。通常设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。

-XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值,超过此值,就触发GC。默认值是45。

G1的设计原则就是简化JVM性能调优,开发人员只需要简单地配置即可完成调优。首先开启G1垃圾收集器,然后设置堆的最大内存,最后设置最大停顿时间即可。

G1中提供了三种垃圾回收模式,它们分别是Young GC、Mixed GC和Full GC,在不同的条件下被触发。

3.6 垃圾收集器的新发展

垃圾收集器仍然处于飞速发展之中,目前的默认收集器G1仍在不断地改进,例如串行的Full GC在JDK 10以后,已经改成了并行运行。

即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的。

随着云计算的兴起,在Serverless等新的应用场景下,Serial GC也有了新的舞台。

比较不幸的是CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK 9中已经被标记为废弃,并在JDK 14版本中移除。

在JDK 11中出现了两个新的垃圾收集器:Epsilon和ZGC。

在JDK 12中引入了Shenandoah GC。

3.6.1 Epsilon和ZGC

在JDK 11中出现了两个新的垃圾收集器:Epsilon和ZGC。Epsilon垃圾收集器是一个无操作的收集器(A No-Op Garbage Collector)。Epsilon垃圾收集器是为不需要或禁止GC的场景提供的最小实现,它仅实现了“分配”部分,我们可以在它上面来实现回收功能。

ZGC垃圾收集器是一个可伸缩的低延迟垃圾收集器,处于实验性阶段[A Scalable Low-Latency Garbage Collector(Experimental)]。

ZGC与Shenandoah目标高度相似,在尽可能减小对吞吐量影响的前提下,实现在任意堆内存大小下把垃圾回收的停顿时间限制在10ms以内的超低延迟。《深入理解Java虚拟机》一书中这样定义ZGC:“ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记–整理算法的,以低延迟为首要目标的一款垃圾收集器。”

ZGC的工作过程可以分为4个阶段:并发标记→并发预备重分配→并发重分配→并发重映射。

ZGC几乎在所有地方都是并发执行的,除了初始标记的是STW。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

4. 垃圾收集器总结

每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器,目前主流垃圾收集器的特点对比如表所示。

image-20231124155453087

具体怎么选择垃圾收集器?我们可以参考下面的选择标准。

  • 优先让JVM自适应调整堆的大小。
  • 如果内存小于100M,使用串行收集器。
  • 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器。
  • 如果是多CPU、需要高吞吐量、允许停顿时间超过1s,选择并行或者JVM自己选择。
  • 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1s,如互联网应用),使用并发收集器。

最后需要明确一个观点,没有最好的收集器,更没有万能的收集器。调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器。

【本文完】

  • 24
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: JVM是一种虚拟机(Java Virtual Machine),能够在不同操作系统和硬件平台上运行Java程序。而G1垃圾收集器JVM中的一种垃圾收集算法,可以处理大型Java堆。 使用G1垃圾收集器能够提高应用程序性能,因为它采用分区技术和优先级调度算法,可以根据应用程序的使用情况,动态地分配堆空间分区,从而减少垃圾收集时间和暂停时间。 下面是使用G1垃圾收集器来优化JVM的步骤: 第一步,设置JVM参数,例如开启G1垃圾收集器,设置堆空间大小等。 第二步,使用JVisualVM或其他工具,对应用程序进行分析,确定是否存在内存泄漏或内存过度消耗的现象。如果有问题,则需要对应用程序进行修改,以降低内存使用率。 第三步,对应用程序进行压测,确定最大并发并适当调整G1垃圾收集器参数,例如设置初始停顿时间、最大停顿时间、空闲时间等。 但需要注意的是,使用G1垃圾收集器时需要事先评估应用程序的运行环境和特性,例如硬件配置、每秒请求计数、响应时间等。只有在合适的环境下,G1垃圾收集器才能发挥最佳效果。同时,应用程序也需要符合一些条件,例如堆空间不能太小,内存消耗不能太大等。 总之,通过优化JVM,使用G1垃圾收集器能够提高Java应用程序的性能和可靠性,减少停顿时间。但使用时需要仔细评估和测试,确保最佳效果。 ### 回答2: JVM是Java虚拟机的缩写,是Java运行环境的核心部分。垃圾收集器JVM的一个重要组成部分,负责回收程序的无用对象和内存。G1垃圾收集器JVM中的一种,是目前比较流行的一种。下面介绍G1垃圾收集器的优化使用方法。 首先,G1垃圾收集器最大的优点是能够充分利用多核处理器和大内存的优势。因此,在使用G1垃圾收集器时,需要配置合适的垃圾回收线程数和堆内存大小。通常情况下,推荐将垃圾回收线程数设置为CPU核心数的一半左右,而堆内存大小则应根据应用程序的实际情况设定。 其次,G1垃圾收集器还支持增量垃圾回收和并发标记。这意味着垃圾收集器可以在应用程序执行的同时,进行部分的垃圾回收和标记操作。这有利于减少垃圾回收对应用程序执行的影响。 再次,G1垃圾收集器还支持混合模式。这种模式下,垃圾收集器会将堆内存分为多个区域,并按照各个区域的使用情况决定垃圾回收的策略。一些非常重要的区域可以在短时间内进行Full GC,而其它区域则可以使用增量式垃圾回收策略。这一功能可以进一步提高垃圾回收的效率。 最后,在使用G1垃圾收集器时,还需要注意一些细节问题。例如,需要选择合适的回收阈值、合理配置回收周期等等。此外,一些调试工具如JConsole、JVisualVM等也可以帮助开发者监控和调整G1垃圾收集器的性能。 总之,G1垃圾收集器是目前JVM中性能较优异的垃圾收集器之一。在使用G1垃圾收集器时,需要根据实际情况合理配置线程数、堆内存大小和一些参数,同时关注混合模式、增量垃圾回收和并发标记等特点,才能充分发挥其性能优势。 ### 回答3: JVM是Java虚拟机的缩写,它是Java程序运行的环境,它提供了一个平台无关的执行环境,能够提高Java应用程序的效率和安全性。Java应用程序的性能优化是一个复杂的过程,其中之一的重要方面是垃圾收集器的优化。 G1是JVM中的一种垃圾收集器,它主要用于大堆内存应用程序,它在处理垃圾回收时可以实现高吞吐量和低延迟,而且它可以管理大容量的内存,能够处理高并发情况下的大量垃圾回收。 G1垃圾收集器具有以下优点: 1.低延迟:G1垃圾收集器能够有效地降低应用程序的延迟,因为它是一个分区式的垃圾收集器,它会将内存分成多个区域,每个区域都有自己的垃圾回收时间,这样可以最大程度的减少垃圾回收的时间。 2.高吞吐量:G1垃圾收集器能够管理大容量的内存,能够处理大量的垃圾回收,因此它的吞吐量很高。 3.可预测性:G1垃圾收集器能够实现可预测性垃圾回收,这意味着它可以为每个应用程序分配一个确定的垃圾回收时间,从而避免出现系统性能波动的情况。 4.自适应:G1垃圾收集器能够根据内存的使用情况来自动调整垃圾回收的策略,从而最大程度地优化垃圾回收的效率和性能。 在使用G1垃圾收集器时,需要注意以下几点: 1.适当配置参数:需要根据应用程序的实际情况,适当配置G1的参数,以提高垃圾回收的效率和性能。 2.避免频繁Full GC:G1垃圾收集器能够尽量避免Full GC,但是在一些场景下,还是会出现Full GC的情况,因此需要尽量避免频繁Full GC的情况出现。 3.避免内存泄漏:G1垃圾收集器虽然能够处理大量垃圾,但是它无法处理内存泄漏的情况,因此需要及时发现和解决内存泄漏的问题。 总之,G1垃圾收集器是一个高性能、高效率的垃圾回收器,能够满足大堆内存应用程序的垃圾回收需求。在使用过程中,需要根据实际情况适当配置参数,避免频繁Full GC和内存泄漏的发生。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值