深入浅出 Java 虚拟机(四)GC

本文章为《深入浅出 Java 虚拟机》系列课程学习笔记,侵删。学习地址为 深入浅出 Java 虚拟机

1 垃圾回收?

按照语义上的意思,垃圾回收,首先就需要找到这些垃圾,然后回收掉。但是 GC 过程正好相反,它是先找到活跃的对象,然后把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。

2 垃圾回收算法

我们先来介绍几种常见的算法:

  1. 标记:垃圾回收的第一步,就是找出活跃的对象。我们之前提到过 GC Roots。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记
  2. 清除:清除阶段就是把未被标记的对象回收掉,但它会造成碎片问题
  3. 复制:提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。它虽然解决了碎片问题,但它浪费了几乎一半的内存空间
  4. 整理:移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收
  5. 分代

它们的主要特点如下:

  1. 复制算法:效率最高,但会造成一定的空间浪费
  2. 标记-清除:效率一般,会造成内存碎片问题
  3. 标记-整理:效率比前两者要差,但没有空间浪费,也消除了内存碎片问题

3 分代

研究表明,大部分对象,可以分为两类:

  1. 大部分对象的生命周期都很短
  2. 其他对象则很可能会存活很长时间

大部分死的快,其他的活的长。这个假设我们称之为弱代假设。

现在的垃圾回收器,都会在物理上或者逻辑上,把这两类对象进行区分。我们把死的快的对象所占的区域,叫作年轻代。把其他活的长的对象所占的区域,叫作老年代。

年轻代

年轻代使用的垃圾回收算法是复制算法。因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。

我们前面也了解到复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。

在这里插入图片描述
如图所示,年轻代分为:一个 Eden ,两个 Survivor 。

当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GC(Minor GC)。具体过程如下:

  1. 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称from)
  2. Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理。存活的对象会被复制到 to 区;接下来,只需要清空 from 区就可以了

所以在这个过程中,总会有一个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费。这个比例,是由参数 -XX:SurvivorRatio 进行配置的(默认为 8)。

TLAB

TLAB 即 Thread Local Allocation Buffer,JVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配。这个 buffer 就放在 Eden 区中。

这个道理和 Java 语言中的 ThreadLocal 类似,避免了对公共区的操作,以及一些锁竞争。

在这里插入图片描述
对象的分配优先在 TLAB上 分配,但 TLAB 通常都很小,所以对象相对比较大的时候,会在 Eden 区的共享区域进行分配。

老年代

老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。

那么对象是如何进入老年代的呢?

  1. 提升:如果对象够老,会通过“提升”进入老年代。关于对象老不老,是通过它的年龄(age)来判断的。每当发生一次 Minor GC,存活下来的对象年龄都会加 1。直到达到一定的阈值,就会把这些“老顽固”给提升到老年代。这个阈值,可以通过参数 ‐XX:+MaxTenuringThreshold 进行配置,最大值是 15,因为它是用 4bit 存储的
  2. 分配担保:年轻代每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配
  3. 大对象直接在老年代分配:超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的。默认为 0,意思是全部首选 Eden 区进行分配
  4. 动态对象年龄判定:有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 将作为新的阈值,年龄大于此阈值的对象直接进入老年代

对象分配逻辑

一个对象的分配逻辑如下:

在这里插入图片描述

卡片标记

对象的引用关系是一个巨大的网状。有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?

对于是、否的判断,我们通常都会用 Bitmap(位图)和布隆过滤器来加快搜索的速度。

JVM 也是用了类似的方法。其实,老年代是被分成众多的卡页(card page)的(一般数量是 2 的次幂)。卡表(Card Table)就是用于标记卡页状态的一个集合,每个卡表项对应一个卡页。

如果年轻代有对象分配,而且老年代有对象指向这个新对象,那么这个老年代对象所对应内存的卡页,就会标识为dirty,卡表只需要非常小的存储空间就可以保留这些状态。垃圾回收时,就可以先读这个卡表,进行快速判断。

GC 种类

  1. Minor GC:发生在年轻代的 GC
  2. Major GC:发生在老年代的 GC
  3. Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收

4 垃圾回收器

我们回顾一下分代垃圾回收。
在这里插入图片描述

年轻代垃圾回收器

Serial 垃圾收集器

处理 GC 的只有一条线程,并且在垃圾回收的过程中暂停一切用户线程。

这可以说是最简单的垃圾回收器,但千万别以为它没有用武之地。因为简单,所以高效,它通常用在客户端应用上。因为客户端应用不会频繁创建很多对象,用户也不会感觉出明显的卡顿。相反,它使用的资源更少,也更轻量级。

ParNew 垃圾收集器

ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。清理过程依然要停止用户线程。

ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。

Parallel Scavenge 垃圾收集器

另一个多线程版本的垃圾回收器,与 ParNew 相比,Parallel Scavenge 追求 CPU 吞吐量,ParNew 追求降低用户停顿时间。

老年代垃圾收集器

Serial Old 垃圾收集器

与年轻代的 Serial 垃圾收集器对应,都是单线程版本,同样适合客户端使用。

年轻代的 Serial,使用复制算法。老年代的 Old Serial,使用标记-整理算法。

Parallel Old 垃圾收集器

Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。

CMS 垃圾收集器

CMS(ConcurrentMarkSweep)收集器是以获取最短 GC 停顿时间为目标的收集器,它在垃圾收集时使得用户线程和 GC 线程能够并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

长期来看,CMS 垃圾回收器,是要被 G1 等垃圾回收器替换掉的。在 Java8 之后,使用它将会抛出一个警告。

5 聊聊 STW

如果在垃圾回收的时候(不管是标记还是整理复制),又有新的对象进入怎么办?

为了保证程序不会乱套,最好的办法就是暂停用户的一切线程。也就是在这段时间,你是不能 new 对象的,只能等待。表现在 JVM 上就是短暂的卡顿,什么都干不了。这个头疼的现象,就叫作 Stop the world。简称 STW。

标记阶段,大多数是要 STW 的。如果不暂停用户进程,在标记对象的时候,有可能有其他用户线程会产生一些新的对象和引用,造成混乱。

现在的垃圾回收器,都会尽量去减少这个过程。但即使是最先进的 ZGC,也会有短暂的 STW 过程。我们要做的就是在现有基础设施上,尽量减少 GC 停顿。

6 CMS 垃圾收集器详解

CMS 即 Mostly Concurrent Mark and Sweep Garbage Collector,是一个主要并发­标记­清除­垃圾收集器。它在年轻代使用复制算法,而对老年代使用标记-清除算法。

CMS 的设计目标,是避免在老年代 GC 时出现长时间的卡顿。

CMS 使用的是 Sweep 而不是 Compact,所以它的主要问题是碎片化。随着 JVM 的长时间运行,碎片化会越来越严重,只有通过 Full GC 才能完成整理。

为什么 CMS 能够获得更小的停顿时间呢?主要是因为它把最耗时的一些操作,做成了和应用线程并行。我们接下来来介绍一下 CMS 的执行过程。

CMS 回收过程

初始标记

初始标记阶段,只标记直接关联 GC root 的对象,不用向下追溯。因为最耗时的就在 tracing 阶段,这样就极大地缩短了初始标记时间。

这个过程是 STW 的,但由于只是标记第一层,所以速度是很快的。

在这里插入图片描述
注意,这里除了要标记相关的 GC Roots 之外,还要标记年轻代中对象的引用,这也是 CMS 老年代回收,依然要扫描新生代的原因。

并发标记

在初始标记的基础上,进行并发标记。这一步骤主要是 tracinng 的过程,用于标记所有可达的对象。

这个过程会持续比较长的时间,但却可以和用户线程并行。在这个阶段的执行过程中,可能会产生很多变化,例如老年代或者新生代的对象引用发生了变化

还记得我们之前提到的卡片标记么?在这个阶段受到影响的老年代对象所对应的卡页,会被标记为 dirty,用于后续重新标记阶段的扫描。

并发预清理

并发预清理也是不需要 STW 的,目的是为了让重新标记阶段的 STW 尽可能短。这个时候,老年代中被标记为 dirty 的卡页中的对象,就会被重新标记,然后清除掉 dirty 的状态。

由于这个阶段也是可以并发的,在执行过程中引用关系依然会发生一些变化。我们可以假定这个清理动作是第一次清理。

所以重新标记阶段,有可能还会有处于 dirty 状态的卡页。

并发可取消的预清理

因为重新标记是需要 STW 的,所以会有很多次预清理动作。并发可取消的预清理,顾名思义,在满足某些条件的时候,可以终止,比如迭代次数、有用工作量、消耗的系统时间等。

这个阶段是可选的。换句话说,这个阶段是“并发预清理”阶段的一种优化。

这个阶段的第一个意图,是避免回扫年轻代的大量对象;另外一个意图,就是当满足最终标记的条件时,自动退出。

我们在前面说过,标记动作是需要扫描年轻代的。如果年轻代的对象太多,肯定会严重影响标记的时间。如果在此之前能够进行一次 Minor GC,情况会不会变得好了许多?CMS 提供了参数 CMSScavengeBeforeRemark,可以在进入重新标记之前强制进行一次 Minor GC。

最终标记

通常 CMS 会尝试在年轻代尽可能空的情况下运行最终标记阶段,以免接连多次发生 STW 事件。

这是 CMS 垃圾回收阶段的第二次 STW 阶段,目标是完成老年代中所有存活对象的标记。我们前面多轮的预清理阶段,一直在和应用线程玩追赶游戏,有可能跟不上引用的变化速度。本轮的标记动作就需要 STW 来处理这些情况。

如果预处理阶段做的不够好,会显著增加本阶段的 STW 时间。你可以看到,CMS 垃圾回收器把回收过程分了多个部分,而影响最大的不是 STW 阶段本身,而是它之前的预处理动作。

并发清除

此阶段用户线程被重新激活,目标是删掉不可达的对象,并回收它们的空间。

由于 CMS 并发清理阶段用户线程还在运行中,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

并发重置

此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。

内存碎片

由于 CMS 在执行过程中,用户线程还需要运行,那就需要保证有充足的内存空间供用户使用。如果等到老年代空间快满了,再开启这个回收过程,用户线程可能会产生“Concurrent Mode Failure”的错误,这时会临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了(STW)。

这部分空间预留,一般在 30% 左右即可,那么能用的大概只有 70%。参数 -XX:CMSInitiatingOccupancyFraction 用来配置这个比例(记得要首先开启参数 UseCMSInitiatingOccupancyOnly)。也就是说,当老年代的使用率达到 70%,就会触发 GC 了。如果你的系统老年代增长不是太快,可以调高这个参数,降低内存回收的次数。

其实,这个比率非常不好设置。一般在堆大小小于 2GB 的时候,都不会考虑 CMS 垃圾回收器。

另外,CMS 对老年代回收的时候,并没有内存的整理阶段。这就造成程序在长时间运行之后,碎片太多。如果你申请一个稍大的对象,就会引起分配失败。CMS 提供了两个参数来解决这个问题:

  1. UseCMSCompactAtFullCollection(默认开启),表示在要进行 Full GC 的时候,进行内存碎片整理。内存整理的过程是无法并发的,所以停顿时间会变长
  2. CMSFullGCsBeforeCompaction,每隔多少次不压缩的 Full GC 后,执行一次带压缩的 Full GC。默认值为 0,表示每次进入 Full GC 时都进行碎片整理

预留空间加上内存的碎片,使用 CMS 垃圾回收器的老年代,留给我们的空间就不是太多,这也是 CMS 的一个弱点。

CMS 出现停顿(STW)的情况

  1. 初始标记,这部分的停顿时间较短
  2. Minor GC(可选),在预处理阶段对年轻代的回收,停顿由年轻代决定
  3. 重新标记,由于预清理阶段的介入,这部分停顿也较短
  4. Serial-Old 收集老年代的停顿,主要发生在预留空间不足的情况下,时间会持续很长
  5. Full GC,永久代空间耗尽时的操作,由于会有整理阶段,持续时间较长

对 CMS 垃圾回收器的总结

我们可以将 CMS 垃圾回收器分为四个阶段:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清理

CMS 的优势为低延迟,尤其对于大堆来说。大部分垃圾回收过程并发执行。

其劣势为:

  1. 内存碎片问题。Full GC 的整理阶段,会造成较长时间的停顿
  2. 需要预留空间,用来分配收集阶段产生的“浮动垃圾”
  3. 使用更多的 CPU 资源,在应用运行的同时进行堆扫描
发布了133 篇原创文章 · 获赞 249 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/Geffin/article/details/104721386