深入浅出 JVM 之垃圾回收-垃圾回收概述与算法

深入浅出 JVM 之垃圾回收-垃圾回收概述与算法

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。

系统环境:

  • JDK 1.8

参考地址:

深入浅出 JVM 系列文章

博文中大部分概念都是基于 HotSpot 虚拟机为基础的相关概念。

一、垃圾回收概述

现代编程语言广泛采用了垃圾回收 (Garbage Collection, GC) 机制作为一种自动化的内存管理方案。这一机制旨在识别并释放不再被程序使用的内存空间,以供其它对象重新利用。

在早期的编程语言 C/C++ 中,程序开发者需要自己手动申请和释放内存空间,但是在编程的过程中很多时候由于疏忽,往往会经常忘记释放那些不再使用的内存空间,从而造成内存泄漏,导致垃圾对象永远无法被清除,最终引发程序崩溃。而垃圾回收机制诞生的初衷就是为了实现内存的自动回收,以此来减少程序开发者因手动管理内存而犯错的机会,从而提升开发效率。

其实,早在 1960 年,诞生于麻省理工学院的 LISP 语言已经采用内存动态分配和垃圾收集技术,并且在 LISP 语言还没诞生时,其作者 John McCarthy 就思考过垃圾收集需要完成的三件事情,分别是:

  • 哪些内存需要回收?
  • 什么时候进行回收?
  • 该怎么样进行回收?

随着时间的推移和技术的进步,内存管理和垃圾回收技术得到了极大的发展和完善,许多高级编程语言如 PHP、Java、C#、Erlang 以及 Go 等,都内置了高效的垃圾回收机制,通过自动化处理不再使用的内存资源,有效地降低了内存管理的复杂性,使得开发人员能够更专注于业务逻辑而非底层的内存管理细节。

二、垃圾回收的是什么

在计算机系统中的应用程序,在运行时为了保证性能,很多时候都会将数据加载到内存中进行操作,有些数据在使用完成后可能就不会再使用,这些数据如果没有被及时释放掉就会长久驻足在内存中,既占用着内存空间,又不会被使用,我们常称这些数据为垃圾数据。

而内存垃圾回收是一种回收内存垃圾的一种机制,其可以将内存中无用的数据回收掉,从而释放内存空间,保证内存空间的充足,避免内存溢出。

在 Java 语言中,占用内存最多的是堆 (Heap),Java 中的大部分对象都存储堆中,所以对象是主要占用堆内存空间最多的数据。而堆中的对象又会被栈 (Stack) 和方法区 (Method Area) 中的变量/对象所引用,其中:

  • 当一个对象拥有一个有效的引用时,则说明该对象是一个有效对象,也称为存活对象;
  • 当一个对象没有任何引用时,则说明该对象是一个无效对象,也可以称为是死亡对象;

垃圾回收的主要目的就是清理这些已经死亡的对象,从而释放它们所占用的内存空间,保证内存的有效利用。

三、垃圾回收重点关注的区域

在 Java 虚拟机中,垃圾回收机制主要关注于运行时数据区的 "堆空间" 中的数据,其次关注的是 "方法区" 中的数据。

在 Java 8 及后版本中,堆空间又可以划分为 新生代老年代 区域,而且移除了 永久代,使用 元空间 作为 "方法区" 的实现,并且在 Java 7 及以后版本中 字符串常量池 也移到了 "堆空间" 中进行存储,所以从垃圾回收频率上讲,新生代老年代字符串常量池元空间 都是垃圾回收的重点关注区域。

四、如何进行垃圾回收

无论做什么事情,都需要制定一套流程,只有按照流程进行才能实现目标。如果给垃圾回收制定一个执行流程的话,那么从常规的执行流程角度思考,一般情况下首先要做的肯定就是先确定哪些对象是待回收的垃圾对象,哪些是仍然有效的对象,然后再依次对有效的对象进行标记,最后再找一个合适的时机,在不影响应用运行的情况下,将没有被标记的垃圾对象给清除掉。

有了上面这个初步的想法,就可以制定出一个大体流程,如果对流程定义几个阶段的话,那么大概可以两个阶段,分别是:

  • ① 标记阶段:判断对象是否存活,并对存活的对象进行标记,表示这些对象仍然是有效的。
  • ② 清除阶段:将未被标记的对象视为垃圾对象,并予以清除,从而释放内存空间。

其中在 标记阶段 中,需要判断内存中的哪些对象是存活的,哪些对象是死亡的,并对存活的对象进行标记。这一过程通常采用两种主流算法,分别是 引用计数法可达性分析算法

等待标记完成后就会进入到 清除阶段,这一阶段的主要任务是清除未被标记的对象,从而释放内存空间。常用的清除算法有三种,分别是 标记清除算法复制算法标记整理算法

五、垃圾回收相关算法

5.1 标记阶段-引用计数法

■ 什么是引用计数法

引用计数法就是在对象中添加一个引用计数器,每当有其它对象引用该对象时,就使计数器的值加 1,当引用失效时,就使计数器值减 1,当计数器的值为 0 时,就说明该对象没有被引用,该对象可以被回收掉。

■ 引用计数法缺点

引用计数法的缺点很明显,就是无法解决循环引用问题。比如说,存在 AB 两个对象,对象 A 引用对象 B,对象 B 也引用对象 A,这时候相互引用,这时候这两个对象计数器的值都不为 0,导致两个对象都无法正常被回收掉。

5.2 标记阶段-可达性分析算法

■ 什么是可达性分析

可达性分析算法的基本思想就是定义一些 GC Roots 对象,以这些对象作为起点,向下搜索与该对象关联的其它对象节点,搜索过程中经过的路径被称之为引用链。

GC Roots 对象和其它对象之间存在引用链时,则说明该对象是可达的,而不存在引用链时,则说明该对象是不可达。

不可达的对象一般至少需要经过两次 GC 扫描后,才能确定该对象是否可以被回收,如果对象经过两次 GC 扫描后仍然没有被标记,则说明该对象是一个不可达的对象,会被被垃圾回收器给回收掉。

■ 什么是 GC Roots

GC Roots (根对象集合) 是一组活跃的引用,可以通过组中的引用找到与之关联的对象。如果一个对象没有与 GC Roots 中的任何引用建立关联,则说明该对象是不可达的,即是一个已经死亡的对象。

■ 可以作为 GC root 的对象

在 Java 虚拟机中,常见的 GC Roots 包括但不限于以下几种:

  • 堆中的静态变量引用的对象;
  • 方法区中的常量引用的对象;
  • 虚拟机栈中变量引用的对象 (栈帧中的局部变量表,比如各个线程被调用的方法中使用到的参数、局部变量等);
  • 本地方法栈中的变量引用的对象 (JNI Native 方法);
  • 所有被同步锁 synchronized 所持有的对象;
  • Java 虚拟机内系统类加载器对象;
  • Java 虚拟机内基本数据类型对应的 Class 对象;
  • Java 虚拟机内一些常驻的异常对象 (如 NullPointerException、OutOfMemoryError);

■ 为什么不可达对象需要两次标记

在介绍可达性分析时提到,不可达的对象一般至少需要经过两次 GC 标记后才能确定是否可以被回收。这主要与对象的回收流程有关,整个流程可以分为两个阶段:

第一个阶段:

在这个阶段,垃圾回收器从 GC Roots 开始扫描,标记所有不可达的对象。但是对象被标记后并不会被立马回收,这主要是因为在 Java 语言中提供了对象终止机制 (finalization),允许开发人员配置对象被回收前执行指定逻辑,即重写 finalize() 方法,所以 GC 在回收对象前不确定该对象是否需要执行 finalize() 方法清理资源。

于是就对不可达的对象进行判断,判断其是否配置了 finalize() 方法并且没有执行过,如果该对象配置了 finalize() 方法并且没有执行过,则就将该对象加入到 F-Queue 队列中,然后创建一个优先级低的 finalizer 线程去执行对象的 finalize() 方法。

第二个阶段:

在这个阶段,垃圾回收器会对 F-Queue 队列中的对象再次进行标记。

如果对象在执行 finalize() 方法的过程中,重新与引用链中的任意对象建立了关联,即实现了 "自救",那么该对象就会从 F-Queue 队列中移除,从而避免被回收。相反,如果对象没有从 F-Queue 队列中移除,那么它将被垃圾回收器清理掉。

通过这两个阶段,垃圾回收机制确保了对象在被最终回收之前,有机会执行必要的清理工作。

5.3 标记阶段-三色标记法

■ 三色标记法是什么

在使用可达性分析并标记对象过程中,会触发 Stop The World 使用户线程停顿,这无疑会对性能造成很大的影响,尤其是在当今运行的应用程序普遍具有较大的内存空间的情况下,如果对象数量又非常多的话,那么触发 Stop The World 后,进行扫描与标记对象的过程会非常慢。所以,就提出了三色标记法,用于解决在并发环境下尽可能减少触发用户线程停顿,从而提高性能。

三色标记法听名字就知道,是使用 三种颜色,从 GC roots 出发对 JVM 内存中的对象进行扫描标记,等扫描标记完成后再通过一些手段解决那些 漏标多标 的对象,然后再将没有被标记的对象给清除掉。

■ 黑灰白三种颜色的定义

在三色标记法中,将对象标记分为 三种颜色,不同颜色的标记有不同的定义,如下:

  • 黑色: 对象和对象的引用都已经被 GC 扫描过。
  • 灰色: 对象以及被 GC 扫描过,但是对象的引用还没有。
  • 白色: 对象和对象的引用都没有被 GC 扫描过,可能是一个不可达的对象。

■ 三色标记算法缺陷

使用三色标记法标记对象的主要目的就是避免触发 Stop The World 造成用户线程停顿,使 GC 线程和用户线程可以并行执行,但是也正是因为在标记对象期间 GC线程用户线程 并行执行,这就有可能会导致发生 多标漏标 的问题。

(1) 什么是多标问题?

例如,存在一个之前已经被扫描并且标记为 "黑色" 的对象。在 GC 线程扫描标记对象的过程中,用户线程也在执行,并且用户线程执行过程中将该 "黑色" 标记对象的所有的引用给删除了,这就使得该对象变成了没有任何引用的对象。

正常情况下,该没有任何引用的对象不应该被标记为 "黑色",而应该是一个 "白色" 标记的对象。但实际上该对象已经被标记为 "黑色",这就导致 GC 不会回收该对象,从而发生了 多标 问题。

(2) 什么是漏标问题?

例如,存在一个没有被标记的 "白色" 对象。在 GC 线程扫描标记对象的过程中,用户线程也在执行,并且用户线程执行过程中使一个 "黑色" 标记的对象引用了该 "白色" 标记的对象。这样一来,有其它对象对该 "白色" 标记的对象进行了引用,使其变成了一个可达的对象。

但是,由于这个 "黑色" 标记的对象已经被扫描并标记过了,因此 GC 线程不会再次对该 "黑色" 标记的对象进行扫描标记,自然也不会对该 "白色" 标记的对象进行扫描标记,导致这个存活的对象被 GC 回收,从而发生了 漏标 问题。

(3) 多标和漏标问题会造成什么影响?

一般来说 多标 问题会导致 GC 线程不会将这个已经不可达的对象回收。不过这个问题影响不大,大不了下次再执行 GC 扫描时,不对该对象进行标记,将其定义为一个不可达的对象,使 GC 将其回收即可。

但是 漏标 问题的影响就比较大了,会直接影响程序的正常运行。本来是可达的存活对象,因为漏标导致其被定义为一个不可达的对象,使其被 GC 回收,所造成的影响是不可接受的,这也是使用三色标记法实现的 GC 重点需要解决的问题。

■ 三色标记法缺陷的解决办法

上面也提到过三色标记法重点需要解决的就是 "漏标" 问题,并且在经过研究表明,只有同时满足两个条件才会发生漏标问题:

  • 插入了一条或者多条黑色到白色对象的引用;
  • 删除了全部从灰色到白色对象的直接或间接引用;

为了解决该问题,Java 团队使用了 读/写屏障 结合 增量更新原始快照 两种方式来解漏标该问题,比如 CMS 垃圾回收器采用的就是增量更新方案,而 G1 垃圾回收器采用的就是原始快照方案。这里所说的几个概念,下面给简单介绍一下:

  • 读屏障: 在读取对象前,进行一些操作,类似于 AOP 的前置增强 (Advice Before);
  • 写屏障: 在对象进行写操作前,以及对象进行写操作后,进行一些操作,类似于 AOP 的环绕增强 (Advice Around);
  • 增量更新: 在黑色对象要新增一个白色对象的引用时,将这个黑色对象记录到集合中,等到 GC 扫描完成后,以黑色对象作为 GC Root,再继续扫描记录在集合中的对象 (这个操作会触发 stop-the-world,造成用户线程停顿);
  • 原始快照: 在灰色对象要删除一个白色对象的引用时,将这个灰色对象记录到集合中,等到 GC 扫描完成后,以灰色对象作为 GC Root,再继续扫描记录在集合中的对象 (这个操作会触发 stop-the-world,造成用户线程停顿);

现代的大部分的垃圾回收器,几乎都借鉴了 三色标记法 的思想来实现,采用 读/写屏障增量更新原始快照 方式来解决,以 Java HotSpot 虚拟机中的 CMSG1ZGC 为例,其并发标记时对漏标的处理方案如下:

  • CMS: 写屏障 + 增量更新
  • G1: 写屏障 + SATB(原始快照)
  • ZGC: 读屏障

5.4 清除阶段-标记清除算法

标记清除算法 (Mark-Sweep) 是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并应用于 LISP 语言。标记清除算法算法可以分为 "标记" 和 "清除" 俩个阶段:

  • 标记阶段: 使用可达性分析对存活的对象,对其进行标记。
  • 清除阶段: 使用垃圾回收器清除掉没有被标记的对象。

优点
缺点
◆ 比较容易实现;
 
◆ 执行效率一般,在进行垃圾回收时需要停止整个应用程序;
◆ 执行完成后会产生大量不连续的内存空间碎片;

5.5 清除阶段-复制算法

标记清除算法的垃圾回收效率较低,并且回收完成后会产生内存空间碎片。为了解决这些问题,M.L.Minsky 在 1963 年发表了著名论文《CA LISP Garbage Collector Algorithm Using Serial Secondary Storage》,即使用双存储区的 Lisp 语言垃圾回收器。

在这篇论文中,M.L.Minsky 描述了一种后来被称为“复制算法”的方法,并成功地将其引入到了 LISP 语言的一个实现版本中。

复制算法 (Copying) 的核心思想是将内存空间划分为大小相等的两块,每次只使用其中一块内存空间存储对象。当这一块内存空间使用完后,将还存活的对象复制到另一块内存空间中,并将分配对象的指针指向另一块内存空间,然后清理掉这块内存空间中的对象。两块内存空间相互交换,从而完成垃圾回收任务。

优点
缺点
◆ 执行完成后不会产生内存空间碎片;
◆ 每次只需要清理一半内存空间,执行速度快;
◆ 每次只使用一半的内存空间区域存储对象,比较浪费内存空间;
 

5.6 清除阶段-标记整理算法

复制算法适用于存活对象较少的场景,这样可以避免大量复制对象,保持回收的高效性。因此,在分代算法中,复制算法更适用于新生代,而不适合存在大量存活对象的老年代。

而标记清除算法则比较适合老年代,不过该算法不仅回收效率低下,而且每次回收完成后会产生大量内存碎片。为了改进标记清除算法,在 1970 年前后 G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者提出了 "标记压缩算法"。

标记整理算法 (Mark Compact) 与标记清除算法类似,只不过该算法不直接清除没有被标记的对象,而是让所有被标记的对象向一端移动,紧邻排列,然后以没有被标记的对象作为边界,将边界以外的内存空间直接清理掉。这种算法在执行过程中会移动对象,因此执行效率相对较低,但不会产生内存空间碎片。

优点
缺点
◆ 执行完成后不会产生内存空间碎片; ◆ 执行过程中会移动对象,并更新对象的引用地址,执行效率比较低;

5.7 分代收集算法

■ 什么是分代收集

上面所介绍的 标记清除算法复制算法标记整理算法,它们各自都具有自己独特的优势和特点,每种算法都有各自相适应的场景,没有一种算法可以完全替代另一种算法。

在这样的背景下 分代收集算法 孕育而生,由于每个对象的生命周期各不相同,有的对象可以长期存活,有的对象朝生夕灭。因此,针对不同生命周期的对象,可以采取不同的回收方式来提高回收效率。

在一般情况下,市面上大多数 Java 虚拟机中的 GC 都采用分代收集,即将 堆空间 划分为 新生代老年代,不同生命周期的对象放到不同的区域中进行存储,并且针对不同的区域采用不同的回收策略,这就是我们常说的 分代收集 (Generational Collection)。

■ 强/弱分代假说

当前商业虚拟机的垃圾回收器,大多数都遵循了 分代收集 的理论进行设计,分代收集名为理论,但实质上是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说 (Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的。
  • 强分代假说 (Strong Generational Hypothesis): 熬过越多次垃圾回收过程的对象就越难以消亡。

基于这两个分代假说,奠定了市面上常用的垃圾回收器的一致设计原则:

  • 分代处理: 垃圾回收器应该将堆空间划分出不同的区域,然后将回收对象依据其年龄 (年龄即对象熬过垃圾回收过程的次数,一般情况下每经理一次回收年龄+1) 分配到不同的区域之中存储。
  • 处理新生代: 如果一个区域中大多数对象都是朝生夕灭,那么每次回收时只须关注如何保留少量存活的对象,就能以较低代价回收到大量的空间。
  • 处理老年代: 如果一个区域中大多数对象都是难以消亡的,那么虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾回收的时间开销和内存的空间有效利用。

在堆空间划分出不同的区域之后:

  • 回收类型的划分: 垃圾回收器才可以每次只回收其中某一个或者某些部分的区域,因而才有了 Minor GCFull GC 这样的回收类型的划分;
  • 针对性的垃圾回收算法: 针对不同的区域使用不同的垃圾回收算法,因而发展出了 标记-复制算法标记-清除算法标记-整理算法 等有针对性的垃圾回收算法;

把分代收集理论具体放到现在的常用的 Java 虚拟机中,其设计者一般常会将 Java 堆空间 划分为 新生代老年代 两个区域。也就是说,在新生代中每次进行垃圾回收后,都将会有大批对象死亡,其中仍然存活的对象是相对较少的,这些存活对象的岁数会逐步累加,当达到一定岁数后就会转移到老年代中进行存储。

■ 跨代引用假说

在不同区域的对象不是孤立的,对象之间会存在跨代引用。比如,新生代中的对象完全有可能被老年代中的对象引用,所以如果只需要对新生代进行一次垃圾回收的话,为确保可达性分析的正确性,还需要遍历整个老年代中的所有对象,反之也一样。但遍历所有的老年代对象的方案会带来很大的性能负担,为了解决这个问题,就需要使用第三条经验法则:

  • 跨代引用假说 (Intergenerational Reference Hypothesis): 跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,该结构被称为 记忆集 (Remembered Set)。

这个结构把老年代划分成若干小块,标识出老年代中的哪一块内存会存在跨代引用。此后,当发生 Minor GC (新生代的垃圾回收) 时,只有包含了跨代引用小块内存中的对象,才会被加入到 GC Roots 中进行扫描。虽然这种方法需要在对象改变引用关系时维护记录数据的正确性,导致增加一些运行时的开销,但比起垃圾回收时扫描整个老年代来说,仍然是比较划算的。

为避免产生混淆,这里统一定义一下回收类型的划分:

  • 部分收集(Partial GC): 指回收的目标并不是整个堆空间,而是只回收部分。其中又可以划分为:
    • 新生代收集(Minor GC/Young GC): 指回收的目标只是新生代空间;
    • 老年代收集(Major GC/Old GC): 指回收的目标只是老年代空间;
  • 混合收集 (Mixed GC): 指回收的目标是新生代以及部分老年代。目前只有 G1 回收器会有这种行为。
  • 整堆收集 (Full GC): 指回收整个堆空间和方法区。

目前只有 CMS 回收器会有单独收集老年代的行为,另外请注意 Major GC 这个说法在不同资料上常有不同所指,需按上下文区分到底是指老年代的收集还是整堆收集。

5.8 增量收集算法

■ 为什么需要增量收集

在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态,在这种状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。

如果垃圾回收时间过长,应用程序会被挂起很久,这将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了 增量收集 (Incremental Collecting) 算法的诞生。

■ 什么是增量收集

增量式收集算法 顾名思义,就是将一个整体回收过程拆分为多个小批次执行,每次造成的停顿时间都非常短,从而避免回收任务长时间阻塞应用运行,达到近似实时回收的效果。

直白来说就是,如果一次性处理所有垃圾会导致系统长时间停顿,那么就可以让垃圾收集线程和应用程序线程交替执行,每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,如此反复,直到垃圾收集完成。

其实总的来说,增量收集算法 的基础仍然是传统的 标记清除复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记与清理或者复制工作。

■ 增量收集所带来的问题

使用 增量收集 方式进行垃圾回收,虽然可以在回收过程中间断性地执行应用程序代码,从而减少系统的 Stop The World 停顿时间,但是因为线程切换和上下文的转换也会带来一定的性能消耗,使得垃圾回收的总体成本上升,从而造成系统吞吐量下降。

5.9 分区收集算法

■ 为什么需要分区收集

一般来说,在相同条件下,如果需要执行回收的空间越大,意味着待执行回收扫描的空间也越大,这样会导致一次垃圾回收所需的时间越长,执行垃圾回收时的 Stop The World 停顿时间也会越长。

为了更好地控制垃圾回收的 Stop The World 停顿时间,一个好的办法是将一块大的内存区域分割成多个小的区域,然后根据目标的停顿时间制定回收计划。根据制定的计划,每次只回收部分区域中的对象,而不是对整个堆空间进行回收,从而减少一次垃圾回收所产生的 Stop The World 停顿时间。

■ 什么是分区收集

分区算法 是指,将整个堆空间划分成连续的不同的小区域,每一个小区域都独立使用,独立回收,就如下图所示:

采用分区进行垃圾回收的最大好处在于,当遇到大空间回收较为耗时的情况时,可以控制每次回收多少个小区域,从而逐步进行回收。这种方法可以在减少垃圾回收耗时的同时,提升回收效率。

六、其它相关概念

6.1 内存泄漏与内存溢出

内存泄漏

内存泄漏 (Memory Leak) 是指程序中已动态分配的堆内存,因未能及时释放或失去释放条件,导致这部分内存无法被回收,从而造成了资源浪费。这种情况会导致程序运行速度减缓,甚至可能引起系统崩溃。

在 Java 中,内存泄漏通常表现为应用程序创建的对象不再使用后,这些对象本应被垃圾回收器 GC 回收掉,但由于某些对象仍被其它对象间接引用,使得 GC 无法正常回收它们。这种持续的内存占用如果不加以处理,最终可能会导致内存溢出,造成 Java 应用程序的崩溃。

内存溢出

内存溢出 (Out Of Memory,简称 OOM) 是指应用系统中存在无法回收的内存或消耗内存过多,以至于所需的内存超过了系统所能提供的最大值,从而导致程序无法继续运行而崩溃。

在 Java 中,内存溢出具体表现为,应用程序在运行时创建了过多的对象或加载了大量数据,导致堆或方法区 (如类信息、常量、静态变量等存放区域) 的内存使用超出限制。当应用程序尝试分配额外内存而无法获得时,就会抛出 OutOfMemoryError 异常,进而导致 Java 应用程序停止运行。

6.2 停顿 Stop The World

■ 什么是 Stop The World

Stop The World (简称 STW),是指在垃圾回收过程中,为了执行垃圾回收操作,必须暂时暂停所有应用程序线程的一种情况。在这段时间内,整个应用程序将处于停滞状态,无法对用户的操作做出响应,类似于电脑画面卡机一样。这种暂停即被称为 STW 暂停。

■ 为什么需要 Stop The World

在垃圾回收过程中,执行 Stop The World 主要是因为垃圾回收器需要对不可达的对象进行分析和标记。这一过程要求内存中对象的状态保持一致,不允许有变动,以便准确地确定哪些对象是可回收的。因此,为了保证这种一致性,所有用户线程必须暂停,防止在垃圾回收期间数据结构被修改,从而影响回收的正确性和系统的稳定性。

■ 触发 Stop The World 时引发的问题

Stop The World (STW) 事件发生时,所有应用程序线程都会被暂停,此时应用程序将无法对外部请求作出响应。如果这种停顿时间过长,则会导致应用吞吐量下降。频繁的中断会让用户感觉如同网络延迟导致的视频播放卡顿,极大地影响用户体验。因此,在执行垃圾回收时,应当尽可能减少 STW 事件的发生,并缩短其持续时间。

需要注意的是,STW 事件的发生与具体的垃圾回收器类型无关,也就是说,即使是较为先进的垃圾回收器 (如 ZGC 或 G1),也依然会有 STW 的情况出现。然而,这些现代垃圾回收器的设计目标之一就是提高回收效率,并尽可能缩短 STW 的时间,以减轻对应用性能的影响。

6.3 安全点与安全区域

■ 安全点

安全点(Safe Point) 指的是程序执行时,并非任意代码位置都可以立即暂停以开始执行垃圾回收 (GC) 任务。应用程序只有在某些特定的位置 (执行点),才能安全地暂停下来以便进行 GC,这些点就称为安全点。

安全点的选择至关重要。如果安全点设置得太少,可能会导致垃圾回收等待的时间过长,影响回收的及时性;而如果设置得过于频繁,则可能干扰程序的正常执行流程,引起性能问题。大多数指令的执行时间都很短,因此一般会选择那些可能会导致程序长时间执行的位置作为安全点。例如,方法调用、循环体内的分支跳转以及异常处理路径等,都是常见的安全点位置。

■ 抢先式中断与主动式中断

确保在垃圾回收 (GC) 过程中,所有线程能够安全地停顿至最近的安全点的办法主要有两种:

  • 抢先式中断: 在抢先式中断模式中,当需要执行 GC 时,系统会尝试立即中断所有正在运行的线程。如果某个线程在非安全点被中断,那么它会被暂时恢复,以便它可以运行到下一个安全点后再停顿。
  • 主动式中断: 在主动式中断模式中,会设置一个中断标志。每个线程在到达安全点时会主动检查这个标志。如果发现中断标志已被设置,那么线程将会把自己置于挂起状态,等待 GC 完成。这种方式减少了不必要的线程上下文切换,提高了效率。

■ 安全区域

安全点机制解决了在应用程序执行过程中,从何处开始执行垃圾回收 (GC) 的问题。然而,在实际应用中,情况可能会更加复杂。例如,如果一个线程调用了 sleep() 方法而进入了阻塞状态,那么该线程将无法响应 JVM 的中断请求,也无法达到安全点。在这种情况下,JVM 不可能无限期地等待该线程被唤醒。为了解决这类问题,引入了 安全区域 (Safe Region) 的概念。

安全区域指的是在一段代码执行期间,其中对象的引用关系不会发生变化。这意味着在此区域内,无论何时开始执行 GC 都是安全的。换句话说,可以将安全区域视为一种扩展的安全点,在这个区域内,线程可以安全地进入一个不会改变对象引用关系的状态,从而使 GC 能够顺利进行。这样,即使线程处于长时间的阻塞状态,也能确保 GC 的顺利执行,而不必等待线程自然醒来。

■ 实际执行时情况

当线程执行到包含安全区域的代码段时,它会首先标记自身为已进入安全区域。如果在此期间发生垃圾回收 (GC),JVM 会忽略那些已经被标记为处于安全区域内的线程。

此外,当线程准备离开安全区域时,它会检查 JVM 是否已完成垃圾回收。如果 GC 已经完成,线程可以继续执行;如果 GC 尚未完成,则线程必须等待,直到接收到可以安全离开安全区域的信号为止。这样可以确保在 GC 过程中,线程不会因为在非安全状态下被中断而导致的数据不一致问题。

6.4 垃圾回收的并行与并发

■ 并发概念

并发 (Concurrent) 指的是多个程序可以同时运行的现象,两个或多个事件在同一时间间隔发生。

我们的计算机在绝大部分时间都运行很多的进程与线程,所以 CPU 并发执行并切换分配 CPU 时间片资源是一种常态,只是 CPU 的执行速度实在是太快了,快到绝大部分情况下你都无法感知执行线程的切换。所以,并发不是真正意义上的同时进行,只是 CPU 把一个时间段划分成几个时间片段 (时间区间),然后在这几个时间区间之间来回切换,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。比如一边播放音乐,一边运行浏览器,一边运行其它软件。

如下图所示,在指定时间段内可以有多个线程并发执行:

■ 并行概念

并行 (Parallel) 指的是在同一时刻,有多条指令在多个处理器上同时执行,所以无论从微观还是从宏观来看,二者都是一起执行的。

当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行。其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。

如下图所示,在指定时间段内可以有多个线程并行执行:

■ 垃圾回收中的串行/并发/并行

串行、并行和并发,在谈论垃圾回收器的上下文语境中,它们可以解释如下:

串行 (Serial)

垃圾回收线程和用户线程交替执行,但是垃圾回收线程是单线程的,在执行垃圾回收线程时需要暂停用户线程,出现 Stop The World 暂停 (GC 线程是单线程的并非说明环境是单 CPU 下,在多核 CPU 下进行 GC 的时候只会使用单核 CPU)。如 SerialSerial Old 等垃圾回收器都是串行回收器。

并行 (Parallel)

并行是多条垃圾回收线程并行工作,这里肯定是在多核 CPU 环境下,多条垃圾回收线程同时执行,此时用户线程处于暂停。如 ParNewParallel ScavengeParallel Old 等垃圾回收器都是并行回收器;

并发 (Concurrent)

并发是垃圾回收线程和用户线程同时执行,也是在多核 CPU 环境下,垃圾回收线程和用户线程并发执行,也就是同一个时刻 CPU0 上执行用户线程,CPU1 上有可能执行垃圾回收线程。如 CMSG1 等垃圾回收器都是并发回收器;

6.5 记忆集与卡表

■ 回收器引入记忆集的缘由

现在大部分 Java 虚拟机中的垃圾回收设计方案都是基于分代理论,将对象存放在不同的分代区域中。比如,将新创建的对象存放于新生代,将长期存活的对象放置到老年代。采用分代理论设计的垃圾回收方案可以极大的提升回收效率,不过这种方案也存在着一些问题,比如对象跨代引用问题。

跨代引用问题简单来说,就是在新生代中的一些对象,其可能引用了老年代中的对象,这就导致 GC 在进行垃圾回收时,为了确认老年代中的对象是否仍然存活,最笨的办法就是遍历整个老年代,找出存在跨带引用的对象。不过这种做法存在极大的性能浪费,仅仅为了找到一些存在跨代引用的一小部分对象是不值得这么做的,所以为了避免这种性能开销,通常常见的分代垃圾回收器会引入一种称为 记忆集 的技术。

■ 什么是记忆集

"记忆集" (Remembered Set) 是一种用于记录从非回收区域指向回收区域指针集合的数据结构。如果我们不考虑效率和成本的话,记忆集数据结构最简单的实现,可以用非回收区域中所有含跨代引用对象的数组来实现,如下代码所示:

1Class RememberedSet {
2    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
3}

在代码中创建了一个存储对象的数组,用于记录全部含跨代引用对象,这种实现方案的实现非常简单,但是这种方案所占用的空间及维护成本,都相当高昂。

而且,在实际情况中垃圾回收的场景下,回收器只需要通过记忆集判断出某一块非回收区域是否存在有指向了回收区域的指针,就可以判断是否存在跨代引用,并不需要了解这些跨代指针的具体细节。那设计者在实现记忆集的时候,便可以选择更为粗粒度的记录方式来实现,以便节省记忆集的存储空间和维护成本。

■ 记忆集的多种实现方案

记忆集有多种不同粗细粒度的实现方案,比如:

  • 字长精度: 每个记录精确到一个机器字长,该字包含跨代指针。
  • 对象精度: 每个记录精确到对象,该对象里有字段含跨代指针。
  • 卡精度: 每个记录精确到一块内存区域,该内存区域有对象含有跨代指针。

■ 记忆集和卡表的关系

在上面提到过,记忆集中实现方案大致可以分为 字长精度对象精度卡精度 三种,其中的第三种 卡精度 所指的是用一种称为 卡表 (Card Table) 的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

前面介绍记忆集的时候提到过,记忆集其实是一种抽象的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度与堆内存的映射关系等。两者关系简单理解来说就像 Java 中的 Map 与 HashMap 的关系,Map 是接口定义,而 HashMap 是具体实现。

■ 什么是卡表

卡表是一种特殊的记忆集,最简单的实现可以使用一个字节数组来记录那些存在跨带引用的对象,如在 HotSpot 虚拟机中就是使用字节数组来实现的卡表,示例代码如下:

1CARD_TABLE [this address >> 9] = 0

在示例代码中,字节数组 CARD_TABLE 中的每一个元素,都对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称作为 卡页 (Card Page)

一般来说卡页大小都是 2N 次幂的字节数,通上面的 HotSpot VM 中的代码可以看出,其使用的卡页是 29 次幂,即 512 字节 (地址右移 9 位,相当于用地址除以 512)。所以,如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 012 号元素分别对应了地址范围为 0x0000~0x01FF0x0200~0x03FF0x0400~0x05FF 的卡页内存块,如下图所示:

一个卡页的内存中通常包含着多个对象,只要其中一个对象中的字段存在着跨代指针,那就将对象所在卡页对应的卡表中的数组元素值标识为 1,表示这个元素变脏 (Dirty),如果元素对应的卡页中不存在跨代指针的对象,则将该卡表元素值标识为 0

在垃圾回收进行时,回收器只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含着跨代指针,只需要将内存块中的对象加入 GC Roots 中一并扫描,就可以使用较低的成本解决跨代引用问题。

6.6 写屏障

■ 回收器为什么需要写屏障

在上面提到过可以使用卡表以较低的成本解决跨代引用问题,但其中并没有提及卡表元素如何维护问题,比如说卡表中的元素何时变脏、谁来把它们变脏等。

关于卡表元素什么时候变脏这个问题,在介绍卡表时已经提到过,只要其它分代区域中的对象引用了本区域中对象时,对应的卡表元素就应该被变脏 (Dirty),变脏的时间点原则上应该和发生变动对象中引用类型字段赋值的时刻一致,但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表?

假如是在解释执行的字节码中,那相对比较好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间,但如果在编译执行的场景中如何处理呢?因为经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段来解决,把维护卡表的动作放到每一个赋值操作之中方可,于是就必须提及 写屏障 (Write Barrier) 这个概念,并且在 HotSpot 虚拟机中就是使用的写屏障来维护的卡表状态的。

■ 什么是写屏障

写屏障其实可以看作是在虚拟机层面中对引用类型字段前后赋值的动作,就类似于 Spring 框架中的 AOP 的 Around 类型 Advice 一样,可以在代码前后嵌入指定动作。也就是说,可以在对字段赋值的代码前后,指定统一动作来维护卡表中元素的状态。

而且 "写屏障" 也分为 写前屏障 (Pre-Write Barrier)写后屏障 (Post-Write Barrier),在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。在 G1 回收器出现之前的回收器都只用到了写后屏障,只有在 G1 及以后的 ZGC 等回收器使用到了写前和写后俩个屏障。

"屏障" 这个词汇在很多地方都有这个概念,比如延迟回收器中会提到的 "读屏障",解决并发乱序执行问题中的 "内存屏障" 等,这里的写屏障的概念要和它们区分开来,避免混淆。

下面是一段更新卡表状态的写后屏障的简化代码逻辑:

1void oop_field_store(oop* field, oop new_value) {
2    // 引用字段赋值操作
3    *field = new_value;
4    // 写后屏障,在这里完成卡表状态更新
5    post_write_barrier(field, new_value);
6}

虚拟机在应用写屏障后,就会为所有赋值操作生成相应的指令。不过,一旦回收器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次都会对引用进行更新,这样做就会造成一些额外的性能开销,不过这个开销与 Minor GC 执行时扫描整个老年代的代价相比较,还是低得多的。

■ 写屏障与伪共享问题

使用卡表除了写屏障的开销外,在高并发场景下还面临着 伪共享 (False Sharing) 的问题。伪共享是处理并发底层细节时经常需要考虑的问题,现代中央处理器的缓存系统中是以 缓存行 (Cache Line) 为单位存储的,当多个线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此间产生影响而导致性能降低 (如写回、无效化或者同步) ,这就是伪共享问题。

假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32KB (64×512字节),也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域中,就会导致更新卡表时正好写入到了同一个缓存行,从而影响性能。

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

1if (CARD_TABLE [this address >> 9] != 0) {
2    CARD_TABLE [this address >> 9] = 0;
3}

在 JDK 7 版本之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark 来控制是否开启卡表更新,开启会增加一次额外判断的开销但能够避免伪共享问题,但是会增加一些性能损耗,所以是否打开要根据应用实际运行情况来进行测试权衡。

6.7 虚拟机中的四种引用

在 Java 虚拟机中的对象如果按照引用关系划分,大概可以分为四种,分别是 强引用软引用弱引用虚引用,不同引用类型的对象在经过 GC 时表现出不同的行为,分别为:

  • 强引用: 在日常开发中创建的对象大都是强引用对象,一般进行垃圾回收时,垃圾回收器不会对强引用对象进行回收,当虚拟机内存不足时宁愿抛出 OutOfMemoryError 错误,使程序终止也不会随意回收强引用对象 (比如 Object obj = new Object() 这种,就是强引用);
  • 软引用: 软引用对象一般在垃圾回收时不会被回收,只有在虚拟机内存不足时,才会将软引用对象对象进行回收 (使用 SoftReference 实现弱引用);
  • 弱引用: 弱引用对象一般只会活过一次 GC 垃圾回收,再经历第二次 GC 垃圾回收时会被回收掉 (使用 WeakReference 实现弱引用);
  • 虚引用: 顾名思义,虚引用就像如同图虚设一样没有任何引用对象,在任何时候都可能被垃圾回收器回收掉 (使用 PhantomReference 实现虚引用);

七、垃圾回收相关问题

7.1 复制算法应用于新生代还是老年代?

复制算法应用再新生代中,比如 JVM 中的 Survivor 区,就是使用的复制算法。

7.2 标记清除算法应用于新生代还是老年代?

标记清除算法应用于老年代中,比如 CMS 垃圾回收器中,就是使用的标记清除算法。

7.3 标记整理算法应用于新生代还是老年代?

标记整理算法应用于老年代中,比如 Serial Old、Parallel Old 垃圾回收器中,就是使用的标记整理算法。

7.4 执行 System.gc() 方法一定会执行 GC 吗?

执行 System.gc() 方法 JVM 虚拟机不一定进行垃圾回收操作,执行该方法只是将 GC 请求记录下来,具体什么时候执行取决于具体虚拟机,不同的虚拟机有不同的策略。

---END---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。