五、垃圾收集基础

简介: 五、垃圾收集基础


👋垃圾收集基础

Java 垃圾收集的本质是,与其要求程序员理解系统中每个对象精确的生命周期,不如让运行时代替程序员记录对象信息,并在不再需要这些对象时自动将其释放。自动回收的内存之后可被清理和复用。

垃圾收集的所有实现都必须遵循下面两个基本规则:

  • 算法必须回收所有垃圾;
  • 不能回收任何活跃对象。

两个规则中,第二个更重要。回收活跃对象会导致段错误(segmentation fault),甚至更糟糕的是,可能会默默破坏程序数据。 Java 的垃圾收集算法需要确保它们不会回收程序仍在使用的对象。程序员不用手动管理每个底层细节,为此,他们必须放弃一些底层控制。这种理念是Java 的托管方式的本质所在。这也体现了 James Gosling 把 Java 当作完成工作的蓝领语言的理念。

⚽️1. 标记和清除

Java的垃圾收集很大程度依赖于一个叫做标记和清除的算法,其中的细节是使用了一个已分配对象链表来保存指向每个已经分配但尚未回收的对象的指针。算法描述如下:

  1. 循环遍历已分配链表,清空标记位。
  2. 从GC根开始,寻找活跃对象。
  3. 在到达的每个对象上设置一个标记位。
  4. 循环遍历已分配链表,对于每个标记位尚未设置的对象。
    a 回收堆中内存,将其放回空闲链表。
    b 从已分配链表中移除该对象。

活跃对象通常是通过深度优先搜索来定位的,生成的对象图叫做活跃对象图。有时也叫做可达对象的传递闭包。

堆的状态很难可视化地表现出来,幸运的是,可以借助一些简单的工具。最简单的是jmap -histo 命令行工具。它会显示每个类型分配的字节数,以及共同占用了这些内存的实例的数量。如下所示:

⚾️1.1 垃圾收集术语
  • 全部停顿( stop-the-world, STW)

在垃圾收集时,垃圾收集周期要求所有的应用程序线程停顿。这可以避免在垃圾收集时,应用程序代码破坏垃圾收集线程所掌握的堆状态信息。大部分简单的垃圾收集算法都是如此。

  • 并发(concurrent)

垃圾收集线程可以在应用程序线程运行的时候运行。实现并发十分困难,而且计算成本也非常高。几乎没有算法是真正并发的。而且,我们需要使用复杂的技巧来挖掘并发收集的优势。

  • 并行(parallel)

有多个线程用于执行垃圾收集。

  • 精确( exact)

对于堆的状态,精确垃圾收集模式有足够的类型信息,从而确保所有垃圾都能在> 一个周期内完成回收。更宽泛地说,精确模式拥有区分一个 int 和一个指针所需的 属性(property)。

  • 保守( conservative)

保守模式缺乏精确模式所具备的信息。因此,保守模式经常会浪费资源,而且因为它们根本不了解自己所代表的类型系统,所以往往不那么高效。

  • 移动( moving)

在移动垃圾收集器中,对象在内存中的位置有可能变化,因此它们的地址不是稳定的。支持使用原始指针的环境(如 C++)不是特别适合移动收集器。

  • 压缩( compacting)

在收集周期结束时,已分配的内存(即存活的对象)被组织为一个单一的连续区域(通常在该区域的开始处),并有一个指针用来指示可供写入对象的空闲空间的起始位置。压缩收集器可以避免内存碎片。

  • 疏散( evacuating)

在收集周期结束时,已收集区域完全为空,所有的活对象都被移动(或疏散)到另一个内存区域。

⚽️2. HotSpot运行时

⚾️2.1 对象的运行时表示

HotSpot 通过一个叫作 oop 的结构来表示运行时 Java 对象。这是普通对象指针(ordinaryobject pointer)的简称,是 C 语言意义上的真正指针。这些指针可以放置在引用类型的局部变量中,在该变量中它们从 Java 方法的栈帧指向包含 Java 堆的内存区域。

有 几 种 不 同 的 数 据 结 构 组 成 了 oop 家 族, 而 用 于 表 示 Java 类 的 实 例 的 类 型 叫 作instanceOop。

instanceOop 的内存布局是从每个对象上都有的两个机器字的头部开始的。其中的第一个是 mark word,它是一个指针,指向特定于该实例的元数据。下一个是 klass word,它指向类级别的元数据。

在 Java 7 和之前的版本中, instanceOop 的 klass word 指向一个名为 PermGen 的内存区域,它是 Java 堆的一部分。一般的规则是, Java 堆中的任何东西都必须有一个对象头。在这些旧的 Java 版本中,我们把元数据称为 klassOop。 klassOop 的内存布局很简单,就是对象头后面紧跟着 klass 元数据。

从 Java 8 开始, klass 被保存在 Java 堆的主要部分之外(但不在 JVM 进程的 C 堆之外)。在这些 Java 版本中, klass 字不需要对象头,因为它们指向 Java 堆之外。

从根本上说, klassOop 包含对应类的虚函数表(vtable),而 Class 对象包含了一个指向 Method 对象的引用数组,用于反射式调用。

oop 通常是机器字,所以在传统的 32 位机器上是 32 位,而在现代处理器上是 64 位。然而,这可能会浪费大量的内存。为了缓解这种情况, HotSpot 提供了一种叫作压缩指针(compressed oops)的技术。如果设置了 -XX:+UseCompressedOops 这个选项(在 Java 7 及以后的版本中,在 64 位机器上该选项默认开启),则堆中的如下 oop 会被压缩:

  • 堆中每个对象的 klass word
  • 引用类型的实例字段
  • 对象数组的每一个元素

一般而言,这意味着一个 HotSpot 对象的头部由以下部分组成:

  • mark word(完整大小)
  • klass word(可能被压缩)
  • length word(如果对象是一个数组),它总是 32 位的
  • 一个 32 位大小的间隙(如果对齐规则有要求的话)

对象的实例字段紧跟在头部之后。对于 klassOop,方法的虚函数表直接跟在 klass word 后面。压缩后的 oop 的内存布局如图所示。

过去,一些对延迟极为敏感的应用程序偶尔可以通过关闭压缩指针特性来提高性能,但代价是增加了堆的大小(通常会增加 10%~50%)。然而,能够获得可测量的性能优势的应用程序种类其实非常少。

正如我们所了解的 Java 基础知识,数组是对象。这意味着 JVM 的数组也是以 oop 的形式来表示的。这就是为什么数组的元数据既有第 3 个字,也有通常的 mark word 和 klass word。第 3 个字是数组的长度,这也解释了为什么 Java 中的数组索引被限制为 32 位值。

除了指向 instanceOop(或 null), JVM 托管环境不允许 Java 引用指向其他任何地方。这意味着在底层:

  • 一个 Java 值是一个比特模式,要么对应一个基本类型的值, 要么对应一个 instanceOop (引用)的地址;
  • 任何被视为指针的 Java 引用均指向 Java 堆主体部分中的一个地址;
  • 作为 Java 引用的目标的地址中,均包含一个 mark word,下一个机器字是 klass word;
  • 一个 klassOop 和一个 Class<?> 的实例是不同的(因为前者存在于堆的元数据区域中),而且一个 klassOop 是不能放在 Java 变量中的。

HotSpot 在一系列 .hpp 头文件中定义了 oop 的层次结构,这些文件被保存在 OpenJDK 8 源码树的 hotspot/src/share/vm/oops 中。 oop 的整体继承层次结构是这样的:

  • oop(抽象基类)
  • instanceOop(实例对象)
  • methodOop(方法的表示)
  • arrayOop(数组抽象基类)
  • symbolOop(内部符号/字符串类)
  • klassOop(klass头部,只存在于Java 7和更早的版本中)
  • markOop

使用 oop 结构来表示运行时的对象,用一个指针来指向类级的元数据,另一个指针来指向实例级的元数据,这种用法并不少见。其他许多 JVM 和执行环境也使用了相关机制。比如, Apple 的 iOS 就使用了类似的模式来表示对象。

⚾️2.2 GC根和Arena

关于 HotSpot 的文章和博客经常引用 GC 根。这些是内存的“锚点”,本质上是已知的指针,处于它感兴趣的内存池之外,并指向这个内存池。它们是外部指针,和内部指针截然不同。内部指针是处于一个内存池内,并指向当前内存池内的另一个位置。

不过我们还将看到其他类型的 GC 根,包括:

• 栈帧

• JNI

• 寄存器(用于变量提升情况)

• 代码根(来自 JVM 代码缓存)

• 全局变量

• 来自已加载类的元数据

如果感觉这个定义比较复杂,那么最简单的 GC 根的例子就是引用类型的局部变量,它总是指向堆中的一个对象(假设它不为 null)。

HotSpot 垃圾收集器是以内存区域的方式工作的,这里的内存区域被称为 Arena。这是一个非常底层的机制, Java 开发人员通常不需要这么详细地考虑内存系统的运行机制。然而,性能专家有时需要深入研究 JVM 的内部,因此熟悉相关文献中使用的概念和术语是有帮助的。

有一个重要的事实需要记住,即 HotSpot 不使用系统调用来管理 Java 堆。相反, HotSpot 通过用户空间代码来管理堆的大小,所以我们可以使用简单的观测对象来判断垃圾收集子系统是否正在引发某些类型的性能问题。

⚽️3. 分配与生命周期

一个 Java 应用程序的垃圾收集行为主要有两个驱动因素:

• 分配率

• 对象生命周期

分配率是新创建的对象在一个时间段内所使用的内存量(通常以 MB/s 为单位)。它并不是由 JVM 直接记录下来的,但这个值很容易估算,并且也可以使用 Censum 这样的工具来精确测定。

相比之下,对象的生命周期通常很难测量(甚至估算)。事实上,反对使用手动内存管理的主要论据之一,就是要真正理解实际应用程序中的对象生命周期过于复杂。因此,对象生命周期甚至比分配率更重要。

对象被创建出来,存活一段时间,之后用于存储其状态的内存可以被回收,这个理念至关重要;没有这个理念,垃圾收集将无法工作。正如我们将在第 7 章中看到的,垃圾收集器必须多方权衡,而其中最重要的一些权衡就是由生命周期和分配问题所驱动的。

⚾️3.1 弱分代假说

JVM 内存管理的一个关键部分依赖于一个观察到的软件系统在运行时的表现,即弱分代假说(weak generational hypothesis):

在 JVM 和类似的软件系统中,对象生命周期的表现为双峰分布——大部分对象

寿命很短,次一级对象的寿命长于预期。

这个假说实际上是从实验中观察到的关于面向对象工作负载行为的一个经验法则,由此得出一个明显的结论:垃圾收集堆应该以这样的方式组织,即能够方便快速地收集寿命短的对象,并且最好让寿命长的对象可以与寿命短的隔离开来。

HotSpot 使用了下面几种机制来尝试利用弱分代假说:

• 它会跟踪每个对象的“年龄”(或者说代数,即该对象到目前为止熬过的垃圾收集次数);

• 除了大对象外,它在“Eden”空间(也叫 Nursery)中创建新对象,并且期望移动存活的对象;

• 它维护着一个单独的内存区域(Old Generation 或者 Tenured Generation)来保存那些已经存活足够长而且很有可能继续存活下去的对象。

这种方法引出了如下视图,其中存活了一定数量的垃圾收集周期的对象被提升到了老年代。注意区域是连续的。

为了进行分代收集而将内存划分成不同区域,会给 HotSpot 如何实现标记和清除收集带来额外的影响。一项重要的技术涉及跟踪记录从外部指向新生代内的指针。这使得垃圾收集周期不必遍历整个对象图来确定仍然活着的新生代对象。

为方便处理, HotSpot 维护了一个叫作卡表(card table)的结构,以帮助记录哪些老年代对象可能指向新生代对象。卡表本质上是一个由 JVM 管理的字节数组,该数组的每个元素对应于老年代中一个 512 字节的区域。

其核心思想是,当老年代中对象 o 中的一个引用类型的字段被修改时,包含与 o 相对应的instanceOop 的卡表项被标记为“脏”(dirty)。当更新引用字段时, HotSpot 通过一个简单的写屏障(write barrier)来实现这一点。它本质上可以归结为在字段存储后执行了这样一段代码:

cards[*instanceOop >> 9] = 1;

注意卡片的脏值为 1,右移 9 位后得到卡表的大小为 512 字节。

最后,需要注意的是,在历史上,用新生代和老年代来描述堆是 Java 收集器管理内存的方式。随着 Java 8u40 的到来,一种新的收集器(garbage first, G1)达到了生产质量。 G1 选择了不同的堆布局方法,这将在后面介绍。关于堆管理的这种新的思维方式将变得越来越重要,因为 Oracle 的意图是让 G1 成为 Java 9 以后的默认收集器。

👬 交友小贴士:

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

目录
相关文章
|
11月前
|
存储 算法 Java
JVM垃圾回收相关及堆分代原理
JVM垃圾回收相关及堆分代原理
54 0
|
2月前
|
存储 算法 Java
技术笔记:JVM的垃圾回收机制总结(垃圾收集、回收算法、垃圾回收器)
技术笔记:JVM的垃圾回收机制总结(垃圾收集、回收算法、垃圾回收器)
31 1
|
2月前
|
算法 Java 云计算
JVM垃圾回收的历史演进:从GC算法到垃圾回收器选择
JVM垃圾回收的历史演进:从GC算法到垃圾回收器选择
|
3月前
|
存储 Java
软件体系结构 - 垃圾收集器(1)分代ZGC
【4月更文挑战第22天】软件体系结构 - 垃圾收集器(1)分代ZGC
63 2
|
3月前
|
算法 Java
JVM中如何实现垃圾收集
JVM中如何实现垃圾收集
|
存储 缓存 安全
【JVM】12. 垃圾回收相关概念(1)
12.1. System.gc()的理解 在默认情况下,通过system.gc()或者Runtime.getRuntime().gc() 的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。 然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效) 循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
|
11月前
|
机器学习/深度学习 算法 Java
大牛用十年功力带你彻底理解JVM垃圾回收器:ZGC,回收设计
ZGC的并发回收算法采用的也是“目的空间不变性”的设计,关于目的空间不变性的更多内容可以参考第7章。 在第7章中提到,Shenandoah从JDK 13开始也采用“目的空间不变性”的设计。但是ZGC与Shenandoah相比,还是有不少细节并不相同,如表8-3所示。
|
12月前
|
缓存 算法 Java
一文吃透JVM分代回收机制
一文吃透JVM分代回收机制
114 0
|
存储 缓存 算法
【JVM】12. 垃圾回收相关概念(2)
12.6. 再谈引用:强引用 我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
|
存储 缓存 安全
jvm之垃圾回收相关概念解读
jvm之垃圾回收相关概念解读