深入浅出 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 线程和用户线程并行执行,这就可能会导致发生一些 多标漏标 问题,比如:

  • 之前已经过扫描并且被标记的 "黑色标记的对象",由于用户线程继续执行,并且用户线程执行过程中,将该对象的全部引用给删除掉了,这就相当于该对象变成了一个没有任何引用的对象,正常来说该对象不应该被标记,但是实际上已经被标记了,这就是造成了 多标 的问题;
  • 之前没有被标记的 "白色标记的对象",由于用户线程继续执行,并且用户线程执行过程中,使一个 "黑色标记的对象" 引用了该对象,这就相当于有其它对象对其进行了引用,使其变成了一个可达的对象,但是由于这个 "黑色标记的对象" 已经被扫描过了,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 GCMajor GCFull GC 这样的回收类型的划分;
  • 针对不同的区域使用相匹配的垃圾回收算法,因而发展出了 标记-复制算法标记-清除算法标记-整理算法 等针对性的垃圾回收算法;

把分代收集理论具体放到现在的常用的 Java 虚拟机中,其设计者一般常会将 Java堆划分为新生代 (Young Generation) 和老年代 (Old Generation) 两个区域。也就是说,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

值得注意的是,分代收集理论也有其缺陷,最新出现的几款垃圾回收器都展现出了面向全区域收集设计的思想,或者可以支持全区域不分代的收集的工作模式。

■ 跨代引用假说

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

  • 跨代引用假说 (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 中的内存泄漏指的是,Java 应用程序中创建的对象不再使用后,这些对象本应被垃圾回收器 GC 回收掉,但是由于某种原因,该对象一直被其它对象所引用,导致 GC 无法回收该对象,造成其一直占用堆内存空间而无法回收释放。如果长时间维持内存泄漏状态而没有处理,就很可能会引发内存泄漏,导致 Java 应用程序崩溃。

内存溢出

内存溢出 (Out Of Memory,简称 OOM) 是指,应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存,造成程序崩溃。

而 Java 中的内存溢出指的是,Java 应用程序运行过程中,由于创建对象过多或者加载大容量数据,导致占用堆或者方法区中的内存空间过大,从而造成无法申请到足够的内存,导致出现 OutOfMemoryError 错误。错误发生时会导致正在运行的 Java 应用程序崩溃终止。

6.2 停顿 Stop The World

■ 什么是 Stop The World

Stop The World (简称 STW),指的是垃圾回收执行过程中使应用程序线程停顿的一种情况,停顿产生时整个应用程序线程都会被暂停,这时候将不会进行任何响应,就像电脑画面卡机了一样,这个停顿称为 STW。

■ 为什么需要 Stop The World

垃圾回收过程中之所以需要执进行 Stop The Wolrd,这主要是因为垃圾回收需要对死亡对象进行分析标记,这个分析过程工作必须在一个能确保一致性的快照中进行;

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

Stop The Wolrd 触发时会暂停应用进行线程,这时候应用程序不会进行任何响应,如果停顿时间过长就会使应应用吞吐量降低,繁中断会让用户感觉像是网速不快造成电影画面卡顿一样,会给用户体验造成极大的影响,因此在执行垃圾回收时应尽可能减少 Stop The World 的发生。

Stop The Wolrd 事件和使用哪个 GC 垃圾回收器无关,所有的 GC 垃圾回收器都回触发这个事件,就算最新的 ZGCG1 垃圾回收器也会触发 Stop The World,不过越新的 GC 垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。 回收效率越来越高,尽可能地缩短了暂停时间。

6.3 安全点与安全区域

■ 安全点

安全点 (Safe Point) 指的是程序执行时,并非在所有地方都能停顿下来开始执行 GC 任务,只有在特定的位置才能停顿下来开始 GC,这些位置称为安全点。

安全点的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据 "是否具有让程序长时间执行的特征" 为标准。比如,选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

■ 抢先式/主动式中断

如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来有两种办法,分别是:

  • 抢先式中断: 抢先式中断执行时会中断所有线程,这时如果还有线程不在安全点,就暂时恢复线程,让线程运行到安全点;
  • 主动式中断: 主动式中断执行时会设置一个中断标志,各个线程运行到安全点的时候会主动轮询这个标志,如果中断标志为真则将自己进行中断挂起;

■ 安全区域

安全点机制保证应用程序运行时从哪个地方开始执行 GC 的问题,但是实际应用运行过程中情况复杂,如果应用程序长时间不执行,比如线程调用 sleep() 方法进入堵塞状态,这时候应用程序无法响应 JVM 的中断请求,也就无法到达安全点,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域 (Safe Region) 来解决这个问题。

安全区域 (Safe Region) 是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始执行 GC 都是安全的,从另一个角度来看,我们也可以把安全区域看做是被扩展了的安全点。

■ 实际执行时情况

当线程运行到安全区域的代码时,首先标识已经进入了安全区域,如果这段时间内发生 GC,JVM 会忽略标识为安全区域状态的线程。

而且,当线程即将离开安全区域时,会检查 JVM 是否已经完成 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---


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