六、HotSpot中的垃圾收集

简介: 六、HotSpot中的垃圾收集


👋HotSpot中的垃圾收集

⚽️1. HotSpot中的垃圾收集

回顾一下,与 C/C++ 和类似环境不同, Java 不使用操作系统来管理动态内存。相反,当JVM 进程启动时, JVM 预先分配(或保留)内存,并在用户空间管理单一连续的内存池。

正如我们所看到的,这个内存池是由多个具有特定用途的不同区域组成的,而且对象的地址会经常发生变化。这是因为垃圾收集器会重新安置(relocate)通常在 Eden 区创建的对象。执行重新安置的收集器被称为“疏散”收集器。

⚾️1.1 线程本地分配

JVM 使用了一个性能增强来管理 Eden 区。这是一个需要进行高效管理的关键区域,因为大多数对象是在这里创建的,而且寿命非常短的对象(生命周期小于下一个垃圾收集周期剩余时间的那些)永远不会再出现在其他地方。

为了提高效率, JVM 将 Eden 区划分成若干个缓冲区,并将这些缓冲区交给应用程序线程使用,用于新对象的分配。这种方法的好处是,每个线程都知道自己不需要考虑其他线程在该缓冲区内分配的可能性。这些区域叫作线程本地分配缓冲区(thread-local allocationbuffer, TLAB)。

应用程序线程对其 TLAB 的这种排他性控制,意味着对 JVM 线程来说,分配操作的时间复杂度是 O(1)。这是因为当一个线程创建一个新对象时,将为该对象分配存储空间,并且将线程本地指针更新为指向下一个空闲的内存地址。从 C 运行时角度看,这是一个简单的指针碰撞,也就是多了一条指令,将“next free”指针向前移动了一下。

这种行为如下图所示,其中每个应用程序线程都持有一个用来分配新对象的缓冲区。如果应用程序线程填满了其缓冲区,那么 JVM 会提供一个指向 Eden 区中的新区域的指针。

⚾️1.2 半空间收集

疏散式收集器有种特殊情况值得注意。这种类型的收集器有时被称为半空间疏散式收集器(hemispheric evacuating collector),它会使用两个空间(通常大小相同)。其核心思想是将这些空间作为那些实际寿命比较短的对象的临时存储区。这样可以防止短寿命的对象把老年代弄乱,还可以降低整堆收集(Full GC)的频率。这些空间有两个基本属性:

• 当收集器正在收集当前活跃的半空间时,对象会被以压缩方式移动到另一半空间,完成收集的一半空间会被清空以等待复用;

• 任何时候,总有一半的空间是空的。

当然,这种方法使用的内存是收集器半空间部分实际可以容纳的内存的两倍。这在某种程度上是浪费,但如果空间不太大,这种技术往往就非常有用。 HotSpot 就是利用这种半空间方法,结合 Eden 空间,为新生代提供了一个收集器。

HotSpot 新生代堆的半空间部分被称为 Survivor 空间。从下图所示的 VisualGC 视图中可以看到,与 Eden 相比, Survivor 空间通常比较小,而且它的角色会随着每一次新生代的垃圾收集而互换。

⚽️2. 并行收集器

在 Java 8 和更早的版本中, JVM 的默认收集器是并行收集器。它们的新生代收集和老年代收集都是全部停顿的(STW),而且针对吞吐量进行了优化。在停止所有的应用程序线程之后,并行收集器会使用所有可利用的 CPU 核心来尽快回收内存。可用的并行收集器如下。

  • Parallel GC:用于新生代的最简单的收集器。
  • ParNew:与 CMS 收集器配合使用的并行垃圾收集的变种。
  • ParallelOld:用于老年代的并行收集器。

这几个并行收集器彼此在某些方面有些相似——在设计上,它们都使用多个线程来尽快找到活对象,同时尽可能减少簿记信息。不过它们之间也有一些区别,所以下面我们来看看新生代收集和老年代收集这两种主要类型。

⚾️2.1 新生代并行收集

最常见的收集类型是新生代收集。它通常发生在这样的情况下:一个线程试图向 Eden 空间中分配一个对象,但在其 TLAB 中已经没有足够的空间,而且 JVM 也无法为该线程分配一个新的 TLAB。此时 JVM 除了停止所有的应用程序线程之外别无选择,因为如果一个线程无法分配,那么很快所有的线程就都将无法分配了。

一旦所有的应用程序线程(或者用户线程)停止, HotSpot 就会查看新生代(它被定义为Eden 空间和当前非空的 Survivor 空间),并识别出所有非垃圾对象。它会将 GC 根作为并行标记扫描的起点(还会利用卡表来识别来自老年代的 GC 根)。

然后, Parallel GC 收集器会将所有幸存的对象疏散到当前为空的 Survivor 空间中(并在重新安置时增加这些对象的代数)。最后,将 Eden 和刚刚疏散完的 Survivor 空间标记为空的、可重复使用的空间,并启动应用程序线程,这样也就可以重新开始向应用程序线程分发 TLAB 了。这一过程下图所示。

⚾️2.2 老年代并行收集

ParallelOld 收集器是目前(从 Java 8 开始)默认的老年代收集器。它与 Parallel GC 在很多地方非常相似,但也有一些根本的区别。特别是, Parallel GC 是半空间疏散型收集器,而ParallelOld 是只有一个连续内存空间的压缩型收集器。

这意味着,由于老年代没有其他空间可以疏散,因此 ParallelOld 收集器要尝试在老年代之内重新安置对象,以回收死亡对象留下的空间。因此,这种收集器可以非常高效地使用内存,而且不会受内存碎片的困扰。

这将产生非常高效的内存布局,但代价是在整个垃圾收集周期内,可能要消耗大量 CPU资源。两种方法的区别如下图所示。

这两种内存空间的行为截然不同,因为它们用于不同的目的。新生代收集的目的是处理寿命较短的对象,所以新生代空间的占用率会随着垃圾收集事件中的分配和清除而发生巨大变化。

相比之下,老年代不会发生明显的变化。偶尔会有大对象直接在老年代中创建,但除此之外,空间只会在垃圾收集时才会发生变化——要么是对象从新生代中晋升过来,要么是老年代收集(Old GC)或整堆收集时全面的重新扫描和重新安排。

⚾️2.3 并行收集器的局限性

并行收集器不仅会一次性处理整个代中的所有内容,而且会尽量提高收集效率。然而,这种设计也有一些缺点。首先,它们完全是全部停顿的。对于新生代而言,这通常不是问题,因为根据“弱分代假说”,只有很少的对象能够存活。

这样的基本设计,再加上堆的新生代区域通常比较小,意味着对大部分工作负载而言,新生代收集的暂停时间会非常短。在一个内存为 2 GB 的现代 JVM 上(各部分采用默认大小),新生代收集典型的暂停时间可能只有几毫秒,而且经常少于 10 毫秒。

然而,老年代的收集大有不同。首先,老年代的大小默认为新生代的 7 倍。光是这一点,就会让一次整堆收集预期的全部停顿时间比新生代长得多。

另一个关键的事实是,标记时间与区域内的活对象的数量成正比。老年代中的对象可能会很长寿,所以可能会有更多的老对象在一次整堆收集后仍然存在。

这种行为也解释了 ParallelOld 收集的一个关键弱点,即全部暂停时间将大致随着堆的大小呈线性扩展。随着堆的大小不断增加, ParallelOld 在暂停时间方面开始变得很糟糕。

有些刚接触垃圾收集理论的人,私下可能会有这样的想法,即对标记和清除算法进行微小的修改可能有助于缓解全部暂停时间,然而事实并非如此。 40 多年来,计算机科学界对垃圾收集理论已经进行了充分的研究,目前还没有发现“你就不能……”这样的改进。

然而,它们并不是万能的,垃圾收集仍然存在一些基本的困难。

下面通过一个例子来看一下垃圾收集的原生实现方法存在的一个主要困难,让我们来考虑TLAB 分配。 TLAB 机制可以大大提升分配的性能,但是对收集周期并没有什么帮助。要了解原因,请考虑以下代码:

public static void main(String[] args) {
    int[] anInt = new int[1];
    anInt[0] = 42;
    Runnable r = () -> {
        anInt[0]++;
        System.out.println("Changed: "+ anInt[0]);
    };
    new Thread(r).start();
}

变量 anInt 是一个数组对象,其中只包含一个单个的 int。它从主线程持有的 TLAB 中分配,但紧接着又被传递给一个新线程。换句话说, TLAB 的关键属性,即它们对单个线程来说是私有的,只有在分配时才成立。这个属性基本上对象一旦分配完就会遭到破坏。

在 Java 环境中可以轻而易举地创建新线程,这是 Java 平台的基本能力,也是非常强大的能力。然而它使垃圾收集的情况变得更加复杂,因为有了新线程,就意味着有了新的执行栈,而其中的每一个栈帧都是 GC 根的来源。

⚽️3. 分配的作用

Java 的垃圾收集过程通常在请求内存分配但手头没有足够的空闲内存来提供所需数量时触发。这意味着垃圾收集周期不是按照固定的或者可预测的时间表发生的,而是纯粹按照需求发生。

这也是垃圾收集最关键的一个方面:它不是确定性的,也不会以固定的节奏发生。相反,当堆中的一个或多个内存空间基本上已满,无法进一步创建对象时,就会触发一个垃圾收集周期。

当垃圾收集发生时,所有的应用程序线程都会被暂停(因为它们不能再创建任何对象,而且也不存在能运行很长时间而不用生成新对象的真实 Java 代码)。 JVM 会接管所有的 CPU核心来执行垃圾收集,并在重新启动应用程序线程之前回收内存。

为了更好地理解为什么分配如此重要,让我们考虑下面这个高度简化的案例研究。堆参数的设置情况如下表所示,假设它们不会随着时间的推移而改变。当然,一个真实的应用程序通常会动态调整堆的大小,但这个例子仅作简单说明之用。

在应用程序达到稳定状态之后,我们观察到以下垃圾收集指标:

这表明, Eden 空间会在 4 秒内被填满,所以在稳定状态下,新生代收集每 4 秒就会发生一次。 Eden 空间一填满,就会触发垃圾收集。 Eden 空间中的大部分对象是死的,但是仍然活着的对象都会被疏散到一个 Survivor 空间中(为方便讨论,我们称其为 SS1)。在这个简单的模型中,因为对象的生命周期是 200 毫秒,所以在过去 200 毫秒内创建的任何对象,寿命都还没有结束,所以它们将存活下来。因此我们有:

GC0 @ 4 s 20 MB Eden → SS1 (20 MB)

又过了 4 秒, Eden 空间再次被填满,其对象需要继续疏散(这次是到 SS2)。不过在这个简化的模型中,通过 GC0 进入到 SS1 中的对象都已经不存在了,因为它们的生命周期只有 200 毫秒,而现在已经过去了 4 秒,所以所有在 GC0 之前分配的对象都已经死了。我们现在有:

GC1 @ 8.002 s 20 MB Eden → SS2 (20 MB)

换种说法,在 GC1 之后, SS2 中存在的都是刚从 Eden 区域过来的对象, SS2 中对象的年龄都不会大于 1。再继续进行一次收集,模式应该就很清楚了:

GC2 @ 12.004 s 20 MB Eden → SS1 (20 MB)

这种理想化的简单模型导致了这样一种情况,即没有任何对象能够晋升到老年代;在整个运行过程中,老年代一直是空着的。当然,这是非常不现实的。

相反,弱分代假说表明,对象的生命周期将是某种分布,而且由于分布的不确定性,有些对象最终会存活下来并进入老年代。

⚽️4. 小结

自平台诞生以来,垃圾收集一直是 Java 社区内讨论的热门话题。介绍了性能工程师需要了解的关键概念,以便有效地与 JVM 的垃圾收集子系统一起工作。这些概念包括:

• 标记和清除收集;

• 对象在 HotSpot 内部的运行时表示;

• 弱分代假说;

• HotSpot 的内存子系统实例;

• 并行收集器;

• 分配及其核心作用。

👬 交友小贴士:

博主GithubGitee同名账号,Follow 一下就可以一起愉快的玩耍了,更多精彩文章请持续关注。

目录
相关文章
|
6月前
|
安全 算法 Java
HotSpot中GC算法的实现
HotSpot中GC算法的实现
37 0
|
6月前
|
算法 Java
JVM GC和常见垃圾回收算法
JVM GC和常见垃圾回收算法
86 0
|
6月前
|
存储 缓存 算法
JVM(四):GC垃圾回收算法
JVM(四):GC垃圾回收算法
|
6月前
|
监控 算法 Java
聊聊JVM中那些垃圾收集器
聊聊JVM中那些垃圾收集器
50 0
|
算法 Java UED
JVM - 垃圾收集器
垃圾收集器大概可以分为: 串行垃圾收集器 并行垃圾收集器 CMS(并发)垃圾收集器
123 0
JVM - 垃圾收集器
|
存储 算法 Oracle
一文带你深入理解JVM - ZGC垃圾收集器
ZGC(Z Garbage Collector)是一款由Oracle公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在JDK 11新加入,还在实验阶段,主要特点是:回收TB级内存(最大4T),停顿时间不超过10ms。
209 0
|
监控 算法 Java
7 种 JVM 垃圾收集器详解
7 种 JVM 垃圾收集器详解
173 0
|
算法 Java
究竟什么是HotSpot 垃圾收集器
究竟什么是HotSpot 垃圾收集器
86 0
|
算法 Java UED
JVM - 再聊GC垃圾收集算法及垃圾收集器
JVM - 再聊GC垃圾收集算法及垃圾收集器
105 0
|
缓存 算法 安全
JVM(四)垃圾收集算法与垃圾收集器
JVM(四)垃圾收集算法与垃圾收集器
101 0