写点什么

万字长文!深入理解 JVM 垃圾收集机制

用户头像
Java架构师
关注
发布于: 2021 年 06 月 02 日
万字长文!深入理解JVM垃圾收集机制

垃圾收集算法

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一个的垃圾收集动作。

复制算法

为了解决效率问题,一种称为复制(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上,然后再把已经使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价是内存缩小为原来的一半。

商业虚拟机用这个回收算法来回收新生代。IBM 研究表明 98%的对象是“朝生夕死“,不需要按照 1-1 的比例来划分内存空间,而是将内存分为一块较大的”Eden“空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一个 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。Hotspot 虚拟机默认 Eden 和 Survivor 的比例是 8-1.即每次可用整个新生代的 90%, 只有一个 survivor,即 1/10 被”浪费“。当然,98%的对象回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion).

如果另外一块 survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

eden survivor 复制过程概述

Eden Space 字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的 survivor 区域。

Survivor Space 幸存者区,用于保存在 eden space 内存区域中经过垃圾回收后没有被回收的对象。Survivor 有两个,分别为 To Survivor、 From Survivor,这个两个区域的空间大小是一样的。执行垃圾回收的时候 Eden 区域不能被回收的对象被放入到空的 survivor(也就是 To Survivor,同时 Eden 区域的内存会在垃圾回收的过程中全部释放),另一个 survivor(即 From Survivor)里不能被回收的对象也会被放入这个 survivor(即 To Survivor),然后 To Survivor 和 From Survivor 的标记会互换,始终保证一个 survivor 是空的。

为啥需要两个 survivor?因为需要一个完整的空间来复制过来。当满的时候晋升。每次都往标记为 to 的里面放,然后互换,这时 from 已经被清空,可以当作 to 了。

标记-整理算法

复制收集算法在对象成活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以,老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出一种”标记-整理“Mark-Compact 算法,标记过程仍然和标记-清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存.

分代收集算法

当前商业虚拟机的垃圾收集都采用”分代收集“(Generational Collection)算法,这种算法根据对象存活周期的不同将内存划分为几块。一般把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代,每次垃圾收集时都发现大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率较高,没有额外的空间对它进行分配担保,就必须使用”标记-清理“和”标记-整理“算法来进行回收。

HotSpot 算法实现

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 方法去中类静态属性引用的对象

  • 方法区中常量引用的对象

  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

从可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查里面的引用,必然消耗很多时间。

可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行--这里”一致性“的意思是指整个分析期间整个执行系统看起来就像被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun 公司将这件事情称为”Stop The World“)的一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也必须停顿的。

安全点,Safepoint

垃圾收集器

Serial 收集器

标记-复制。

单线程,一个 CPU 或一条收集线程去完成垃圾收集工作,收集时必须暂停其他所有的工作线程,直到它结束。

虽然如此,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。简单而高效。

ParNew 收集器

ParNew 是 Serial 收集器的多线程版本。Server 模式下默认新生代收集器,除了 Serial 收集器之外,只有它能与 CMS 收集器配合工作。

并行 Parallel

指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

并发 Concurrent

指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序再继续运行,而垃圾收集程序运行于另一个 CPU 上。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器。看上去来 ParNew 一样,有什么特别?

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间。而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间和 CPU 总小号时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100min,其中垃圾收集花费了 1min,那吞吐量就是 99%.

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效地利用 CPU 时间,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis以及直接设置吞吐量大小的-XX:GCTimeRatio

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器。给 Client 模式下的虚拟机使用。

新生代采用复制算法,暂停所有用户线程;

老年代采用标记-整理算法,暂停所有用户线程;

Parallel Old 收集器

这里注意,Parallel Scavage 收集器架构中本身有 PS MarkSweep 收集器来收集老年代,并非直接使用了 Serial Old,但二者接近。本人 win10 64 位系统,jdk1.8.0_102,测试默认垃圾收集器为:**PS MarkSweep **和 PS Scavenge。 也就是说 Java8 的默认并不是 G1。

这是”吞吐量优先“,注重吞吐量以及 CPU 资源敏感的场合都可以优先考虑 Parallel Scavenge 和 Parallel Old(PS Mark Sweep)。Java8 默认就是这个。

CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类尤其重视服务的响应速度,希望系统停顿时间最短。CMS 收集器就非常符合这类应用的需求。

CMS 基于 标记-清除算法实现。整个过程分为 4 个步骤:

  1. 初始标记(CMS initial mark) -stop the world

  2. 并发标记(CMS concurrent mark)

  3. 重新标记(CMS remark) -stop the world

  4. 并发清除(CMS concurrent sweep)

初始标记,重新标记这两个步骤仍然需要 Stop The World, 初始标记仅仅标记以下 GC Roots 能直接关联的对象,速度很快。

并发标记就是进行 GC Roots Tracing 的过程;

而重新标记阶段则是为了修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段停顿比初始标记稍微长,但远比并发标记的时间短。

整个过程耗时最长的并发标记和并发清除过程,收集器都可以与用户线程一起工作。总体上来说,CMS 收集器的内存回收过程与用户线程一起并发执行的。

CMS 特点:并发收集,低停顿。

缺点

1.CMS 收集器对 CPU 资源非常敏感。默认启动的回收线程数是(CPU+3)/4. 当 CPU 4 个以上时,并发回收垃圾收集线程不少于 25%的 CPU 资源。

2.CMS 收集器无法处理浮动垃圾(Floating Garbage), 可能出现”Concurrent Mode Failure“失败而导致另一次 Full GC 的产生。由于 CMS 并发清理时,用户线程还在运行,伴随产生新垃圾,而这一部分出现在标记之后,只能下次 GC 时再清理。这一部分垃圾就称为”浮动垃圾“。

由于 CMS 运行时还需要给用户空间继续运行,则不能等老年代几乎被填满再进行收集,需要预留一部分空间提供并发收集时,用户程序运行。JDK1.6 中,CMS 启动阈值为 92%. 若预留内存不够用户使用,则出现一次Concurent Mode Failure失败。这时虚拟机启动后备预案,临时启用 Serial Old 收集老年代,这样停顿时间很长。

3.CMS 基于”标记-清除“算法实现的,则会产生大量空间碎片,空间碎片过多时,没有连续空间分配给大对象,不得不提前触发一次 FUll GC。当然可以开启-XX:+UseCMSCompactAtFullCollection(默认开),在 CMS 顶不住要 FullGC 时开启内存碎片合并整理过程。内存整理过程是无法并发的,空间碎片问题没了,但停顿时间变长。

面试题:CMS 一共会有几次 STW

首先,回答两次,初始标记和重新标记需要。

然后,CMS 并发的代价是预留空间给用户,预留不足的时候触发 FUllGC,这时 Serail Old 会 STW.

然后,CMS 是标记-清除算法,导致空间碎片,则没有连续空间分配大对象时,FUllGC, 而 FUllGC 会开始碎片整理, STW.

即 2 次或多次。

CMS 什么时候 FUll GC

除直接调用 System.gc 外,触发 Full GC 执行的情况有如下四种。

1. 旧生代空间不足

旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行 Full GC 后空间仍然不足,则抛出如下错误:java.lang.OutOfMemoryError: Java heap space 为避免以上两种状况引起的 FullGC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

2. Permanet Generation 空间满

PermanetGeneration 中存放的为一些 class 的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space 为避免 Perm Gen 占满造成 Full GC 现象,可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC。

3. CMS GC 时出现 promotion failed 和 concurrent mode failure

对于采用 CMS 进行旧生代 GC 的程序而言,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 两种状况,当这两种状况出现时可能会触发 Full GC。promotionfailed 是在进行 Minor GC 时,survivor space 放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。应对措施为:增大 survivorspace、旧生代空间或调低触发并发 GC 的比率,但在 JDK 5.0+、6.0+的版本中有可能会由于 JDK 的 bug29 导致 CMS 在 remark 完毕后很久才触发 sweeping 动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为 ms)来避免。

4. 统计得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间

这是一个较为复杂的触发情况,Hotspot 为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行 Minor GC 时,做了一个判断,如果之前统计所得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发 Full GC。例如程序第一次触发 MinorGC 后,有 6MB 的对象晋升到旧生代,那么当下一次 Minor GC 发生时,首先检查旧生代的剩余空间是否大于 6MB,如果小于 6MB,则执行 Full GC。当新生代采用 PSGC 时,方式稍有不同,PS GC 是在 Minor GC 后也会检查,例如上面的例子中第一次 Minor GC 后,PS GC 会检查此时旧生代的剩余空间是否大于 6MB,如小于,则触发对旧生代的回收。除了以上 4 种状况外,对于使用 RMI 来进行 RPC 或管理的 Sun JDK 应用而言,默认情况下会一小时执行一次 Full GC。可通过在启动时通过- java-Dsun.rmi.dgc.client.gcInterval=3600000 来设置 Full GC 执行的间隔时间或通过-XX:+ DisableExplicitGC 来禁止 RMI 调用 System.gc。

G1

什么是垃圾回收

首先,在了解 G1 之前,我们需要清楚的知道,垃圾回收是什么?简单的说垃圾回收就是回收内存中不再使用的对象。

垃圾回收的基本步骤

回收的步骤有 2 步:

1.查找内存中不再使用的对象

2.释放这些对象占用的内存

1,查找内存中不再使用的对象

那么问题来了,如何判断哪些对象不再被使用呢?我们也有 2 个方法:

1.引用计数法引用计数法就是如果一个对象没有被任何引用指向,则可视之为垃圾。这种方法的缺点就是不能检测到环的存在。

2.根搜索算法

根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

现在我们已经知道如何找出垃圾对象了,如何把这些对象清理掉呢?

2. 释放这些对象占用的内存

常见的方式有复制或者直接清理,但是直接清理会存在内存碎片,于是就会产生了清理再压缩的方式。

总得来说就产生了三种类型的回收算法。

1.标记-复制

2.标记-清理

3.标记-整理

基于分代的假设

由于对象的存活时间有长有短,所以对于存活时间长的对象,减少被 gc 的次数可以避免不必要的开销。这样我们就把内存分成新生代和老年代,新生代存放刚创建的和存活时间比较短的对象,老年代存放存活时间比较长的对象。这样每次仅仅清理年轻代,老年代仅在必要时时再做清理可以极大的提高 GC 效率,节省 GC 时间。

Java 垃圾收集器的历史

第一阶段,Serial(串行)收集器

在 jdk1.3.1 之前,java 虚拟机仅仅能使用 Serial 收集器。 Serial 收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

PS:开启 Serial 收集器的方式

-XX:+UseSerialGC
复制代码

第二阶段,Parallel(并行)收集器

Parallel 收集器也称吞吐量收集器,相比 Serial 收集器,Parallel 最主要的优势在于使用多线程去完成垃圾清理工作,这样可以充分利用多核的特性,大幅降低 gc 时间。

PS:开启 Parallel 收集器的方式

-XX:+UseParallelGC -XX:+UseParallelOldGC
复制代码

第三阶段,CMS(并发)收集器

CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。在 Full GC 时不再暂停应用线程,而是使用若干个后台线程定期的对老年代空间进行扫描,及时回收其中不再使用的对象。

PS:开启 CMS 收集器的方式

-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
复制代码

第四阶段,G1(并发)收集器

G1 收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于 4GB)时产生的停顿。相对于 CMS 的优势而言是内存碎片的产生率大大降低。

PS:开启 G1 收集器的方式

-XX:+UseG1GC
复制代码

了解 G1

G1 的第一篇 paper(附录 1)发表于 2004 年,在 2012 年才在 jdk1.7u4 中可用。oracle 官方计划在 jdk9 中将 G1 变成默认的垃圾收集器,以替代 CMS。为何 oracle 要极力推荐 G1 呢,G1 有哪些优点

首先,G1 的设计原则就是简单可行的性能调优

开发人员仅仅需要声明以下参数即可:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200
复制代码

其中-XX:+UseG1GC 为开启 G1 垃圾收集器,-Xmx32g 设计堆内存的最大内存为 32G,-XX:MaxGCPauseMillis=200 设置 GC 的最大暂停时间为 200ms。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

其次,G1 将新生代,老年代的物理空间划分取消了。

这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

取而代之的是,G1 算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者 Survivor 空间。老年代也分成很多区域,G1 收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1 完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 cms 内存碎片问题的存在了。

在 G1 中,还有一种特殊的区域,叫 Humongous 区域。 如果一个对象占用的空间超过了分区容量 50%以上,G1 收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。

PS:在 java 8 中,持久代也移动到了普通的堆内存空间中,改为元空间。

对象分配策略

说起大对象的分配,我们不得不谈谈对象的分配策略。它分为 3 个阶段:

1.TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区 2.Eden 区中分配 3.Humongous 区分配

TLAB 为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在 Eden 空间中,每一个线程都有一个固定的分区用于分配对象,即一个 TLAB。分配对象时,线程之间不再需要进行任何的同步。

对 TLAB 空间中无法分配的对象,JVM 会尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中进行分配空间。

最后,G1 提供了两种 GC 模式,Young GC 和 Mixed GC,两种都是 Stop The World(STW)的。下面我们将分别介绍一下这 2 种模式。

G1 Young GC

Young GC 主要是对 Eden 区进行 GC,它在 Eden 空间耗尽时会被触发。在这种情况下,Eden 空间的数据移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到年老代空间。Survivor 区的数据移动到新的 Survivor 区中,也有部分数据晋升到老年代空间中。最终 Eden 空间的数据为空,GC 停止工作,应用线程继续执行。

这时,我们需要考虑一个问题,如果仅仅 GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是,G1 引进了 RSet 的概念。它的全称是 Remembered Set,作用是跟踪指向某个 heap 区内的对象引用。

在 CMS 中,也有 RSet 的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种 point-out,在进行 Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。

但在 G1 中,并没有使用 point-out,这是由于一个分区太小,分区数量太多,如果是用 point-out 的话,会造成大量的扫描浪费,有些根本不需要 GC 的分区引用也扫描了。于是 G1 中使用 point-in 来解决。point-in 的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次 GC 时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在 G1 中又引入了另外一个概念,卡表(Card Table)。一个 Card Table 将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于 128 到 512 字节之间。Card Table 通常为字节数组,由 Card 的索引(即数组下标)来标识每个分区的空间地址。默认情况下,每个卡都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。一般情况下,这个 RSet 其实是一个 Hash Table,Key 是别的 Region 的起始地址,Value 是一个集合,里面的元素是 Card Table 的 Index。

Young GC 阶段

阶段 1:根扫描

静态和本地对象被扫描

阶段 2:更新 RS

处理 dirty card 队列更新 RS

阶段 3:处理 RS

检测从年轻代指向年老代的对象

阶段 4:对象拷贝

拷贝存活的对象到 survivor/old 区域

阶段 5:处理引用队列

软引用,弱引用,虚引用处理

G1 Mix GC

Mix GC 不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

它的 GC 步骤分 2 步:

1.全局并发标记(global concurrent marking)2.拷贝存活对象(evacuation)

在进行 Mix GC 之前,会先进行 global concurrent marking(全局并发标记)。 global concurrent marking 的执行过程是怎样的呢?

在 G1 GC 中,它主要是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。global concurrent marking 的执行过程分为五个步骤:

初始标记(initial mark,STW)

在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关。

根区域扫描(root region scan

G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。

并发标记(Concurrent Marking)

G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断

最终标记(Remark,STW)

该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。

清除垃圾(Cleanup,STW)

在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

三色标记算法

提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。

黑色:根对象,或者该对象与它的子对象都被扫描

灰色:对象本身被扫描,但还没扫描完该对象中的子对象

白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象

当 GC 开始扫描对象时,按照如下图步骤进行对象的扫描:

根对象被置为黑色,子对象被置为灰色。

继续由灰色遍历,将已扫描了子对象的对象置为黑色。

遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。

这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题

我们看下面一种情况,当垃圾收集器扫描到下面情况时:

这时候应用程序执行了以下操作:

A.c=CB.c=null
复制代码

这样,对象的状态图变成如下情形:

这时候垃圾收集器再标记扫描的时候就会下图成这样:

很显然,此时 C 是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC 标记的对象不丢失呢?有如下 2 中可行的方式:

1.在插入的时候记录对象 2.在删除的时候记录对象

刚好这对应 CMS 和 G1 的 2 种不同实现方式:

在 CMS 采用的是增量更新(Incremental update),只要在写屏障(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来。

在 G1 中,使用的是 STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有 3 个步骤:

1.在开始标记的时候生成一个快照图标记存活对象

2.在并发标记的时候所有被改变的对象入队(在 write barrier 里把所有旧的引用所指向的对象都变成非白的)

3.可能存在游离的垃圾,将在下次被收集

这样,G1 到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了 Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集如下图:

混合式 GC 也是采用的复制的清理策略,当 GC 完成后,会重新释放空间。

调优实践

MaxGCPauseMillis 调优

前面介绍过使用 GC 的最基本的参数:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200
复制代码

前面 2 个参数都好理解,后面这个 MaxGCPauseMillis 参数该怎么配置呢?这个参数从字面的意思上看,就是允许的 GC 最大的暂停时间。G1 尽量确保每次 GC 暂停的时间都在设置的 MaxGCPauseMillis 范围内。 那 G1 是如何做到最大暂停时间的呢?这涉及到另一个概念,CSet(collection set)。它的意思是在一次垃圾收集器中被收集的区域集合。

Young GC:选定所有新生代里的 region。通过控制新生代的 region 个数来控制 young GC 的开销。

Mixed GC:选定所有新生代里的 region,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 region。在用户指定的开销目标范围内尽可能选择收益高的老年代 region。

在理解了这些后,我们再设置最大暂停时间就好办了。 首先,我们能容忍的最大暂停时间是有一个限度的,我们需要在这个限度范围内设置。但是应该设置的值是多少呢?我们需要在吞吐量跟 MaxGCPauseMillis 之间做一个平衡。如果 MaxGCPauseMillis 设置的过小,那么 GC 就会频繁,吞吐量就会下降。如果 MaxGCPauseMillis 设置的过大,应用程序暂停时间就会变长。G1 的默认暂停时间是 200 毫秒,我们可以从这里入手,调整合适的时间。

其他调优参数

-XX:G1HeapRegionSize=n
复制代码

设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。

-XX:ParallelGCThreads=n
复制代码

设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。

-XX:ConcGCThreads=n
复制代码

设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。

-XX:InitiatingHeapOccupancyPercent=45 
复制代码

设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

避免使用以下参数:

避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

触发 Full GC

在某些情况下,G1 触发了 Full GC,这时 G1 会退化使用 Serial 收集器来完成垃圾的清理工作,它仅仅使用单线程来完成 GC 工作,GC 暂停时间将达到秒级别的。整个应用处于假死状态,不能处理任何请求,我们的程序当然不希望看到这些。那么发生 Full GC 的情况有哪些呢?

并发模式失败

G1 启动标记周期,但在 Mix GC 之前,老年代就被填满,这时候 G1 会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads 等)。

晋升失败或者疏散失败

G1 在进行 GC 的时候没有足够的内存供存活对象或晋升对象使用,由此触发了 Full GC。可以在日志中看到(to-space exhausted)或者(to-space overflow)。解决这种问题的方式是:

a. 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。

b. 通过减少-XX:InitiatingHeapOccupancyPercent 提前启动标记周期。

c. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。

巨型对象分配失败

当巨型对象找不到合适的空间进行分配时,就会启动 Full GC,来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象。


用户头像

Java架构师

关注

还未添加个人签名 2021.05.28 加入

还未添加个人简介

评论

发布
暂无评论
万字长文!深入理解JVM垃圾收集机制