读书笔记 | JVM 的垃圾回收机制

一、概述

本篇博客是基于《深入理解Java虚拟机》一书的读书笔记,主要记录的是关于 GC 方面的相关知识,脉络如下:

  • 什么是引用
  • 引用的四种基本类型
  • 判断对象已死
  • 垃圾收集算法
  • HotSpot 的算法实现
  • 对象内存分配与回收策略
  • 思维导图

二、什么是引用

引用可用于判断对象是否存活,所以想要了解 GC,那么对于引用的了解必不可少,要知道什么是对象,我们还得先了解一下什么是对象?

我们都知道,在 Java 中万物皆对象,每个对象都是某个类(Class)的一个实例(Instance),例如人类是一个类,而具体到人类当中的每个人,例如张三,就是这个类的一个实例了。在了解了什么是对象之后,我们接下来看看什么是引用?

在《Java编程思想》中有这么一段话:

每种编程语言都有自己的数据处理方式。有些时候,程序员必须注意将要处理的数据是什么类型。你是直接操纵元素,还是用某种基于特殊语法的间接表示(例如C/C++里的指针)来操作对象。所有这些在 Java 里都得到了简化,一切都被视为对象。因此,我们可采用一种统一的语法。尽管将一切都“看作”对象,但操纵的标识符实际是指向一个对象的“引用”(reference)。

举一个例子:

Person person = new Person("张三");

这行代码中,person 指的是一个引用(具体来说是强引用,后面会介绍到),那么对象在哪里呢?对象其实指的就是 new Person("张三") 这一部分。这个过程我们可以理解为,Java 在堆区中创建了一个 Person 对象(张三),然后将 person 指向了该对象,示意图如下所示:
在这里插入图片描述


三、引用的四种基本类型

在知道了何为引用之后,接下来介绍引用的基本类型,它可分为以下 4 种类型:

1. 强引用(Strong Reference)

  • 在程序代码中普遍存在,形如 Object o = new Object() 这类的引用即为强引用。
  • 只要强引用还存在,垃圾回收器永远都不会去回收掉被引用的对象
  • 从上面第 2 点可以得知,当出现内存不足的情况时,JVM 宁愿抛出 OOM 也不愿意回收强引用对象。

2. 软引用(Soft Reference)

  • 用于描述一些有用但非必须的对象。
  • 使用方式:SoftReference<String> sr = new SoftReference<String>(str);
  • 只具有软应用的对象会在内存空间不足的时候进行 GC,如果在 GC 过后内存空间仍然不足才会抛出内存溢出异常。
  • 可用于实现内存敏感的高速缓存。

3. 弱引用(Weak Reference)

  • 弱引用和软引用一样用于描述有用但非必须的对象,但它的强度比软引用还弱。
  • 在 GC 的时候只具有弱引用的对象必定会被回收掉。
  • 使用方式:WeakReference<String> sr = new WeakReference<String>(str);
  • 适用于引用偶尔被使用且不影响垃圾收集的对象。

4. 虚引用(Phantom Reference)

  • 最弱的一种引用关系,不会决定对象的生命周期。
  • 任何时候都可能被垃圾回收器回收。
  • 跟踪对象被垃圾回收器回收的活动,起哨兵作用。
  • 必须和引用队列 ReferenceQueue 联合使用,其中 ReferenceQueue 是用于存储被回收的对象。

这 4 种引用类型的强弱关系即为 强引用 > 软引用 > 弱引用 > 虚引用


四、判断对象已死

对于垃圾回收机制,我们需要明晰两个问题:

  • 垃圾回收机制回收的是什么样的对象?
  • 该对象如何被判定为垃圾?

第一个问题很简单,垃圾回收机制回收的就是堆区上的垃圾对象。
第二个问题,如何判定对象已死,就需要介绍接下来的两种算法:引用计数算法和可达性算法。

1. 引用计数算法

该算法的思想是:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1,当引用失效时,计数器值减 1。任何时刻计数器为 0 的对象就是不可能再被使用的了,也就是说此时对象为垃圾对象。

引用技术算法的优点是实现简单,效率很高,但是它的缺点却也非常明显,非常难以解决对象之间循环引用的问题,如下例子所示:

public class Person {
    public Person person;

    public static void main(String[] args) {
        Person a = new Person();
        Person b = new Person();
        a.person = b;
        b.person = a;
    }
}

它们的关系如下图所示:
在这里插入图片描述
可以看出 A 引用了 B,而 B 也引用了 A,即所谓的“你中有我,我中有你”。此时 A、B 的计数器值均为 1,即使它们已经确定不再被使用了,但是也无法被回收掉。

基于该缺点,目前主流的虚拟机(例如 HotSpot)没有选用引用计数法来管理内存。

2. 可达性分析算法

该算法的思想是:通过一系列的 “GC Roots” 对象作为起点,从这些结点向下搜索,搜索所走过的路径叫引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,证明此对象是不可用的。如下图所示:

在这里插入图片描述
在 Java 中,可作为 GC Roots 的对象有以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI 引用的对象。

需要注意的是,在可达性算法中不可达的对象,也并非是非死不可的,要真正宣告一个对象死亡,至少需要两次标记的过程:当对象被发现没有与 GC Roots 相连接的引用链,那么它会被第一次标记并进行筛选,帅选的条件是此对象是否有必要执行 finalize 方法,如果发现没有必要执行,才会被“宣判死刑”。

判定是否有必要执行 finalize 方法的条件有如下两点:

  • 对象所在的类必须重写 finalize 方法
  • 虚拟机此时没有调用过对象的 finalize 方法

满足这两个条件的对象(注意此时它已经被标记过一次了)此时会被放置在一个叫做 F-Queue 的队列中,稍后由一个虚拟机自行创建的、低优先级的 Finalizer 线程执行它,此时对象可以在 finalize 方法中尝试自救,获取与 GC Roots 之间的联系,从而逃脱死亡的命运。如果对象在 finalize 中成功拯救了自己,那么它在第二次标记中就会被移除“即将回收”的集合。

但是需要注意一点的是 finalize 方法并不保证一定会执行完,在对象执行该方法到一半的过程中第二次标记也有可能已经完成了,此时对象仍然无法逃脱死亡的命运。


五、垃圾收集算法

在标记出垃圾对象之后,接下来就需要执行垃圾回收了,垃圾回收算法主要有以下 4 种:

1. 标记-清除算法

标记-清除(Mark-Sweep)算法可分为“标记”和“清除”两个阶段,标记阶段其实就是前面所说的如何判断对象已死,在标记完成之后,统一回收所有被标记的对象,如下图所示:
在这里插入图片描述
它的不足有如下 2 点:

  • 效率问题,标记和清除两个过程的效率都不高。
  • 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致分配大对象的时候无法找到足够的连续空间而不得不提前触发另一次 GC。

2. 复制算法

复制(Copying)算法的思想是,将内存划分为大小相等的两块,然后每次只使用其中的一块。当一块内存用完之后,就直接将还存活的对象复制到另一块上面去,然后再将已使用过的内存空间清理掉,周而复始。如下图所示:
在这里插入图片描述
它的优点有如下 2 点:

  • 内存分配不存在内存碎片的情况
  • 实现简单,运行高效

它的缺点如下:

  • 将内存缩小为原来的一半,代价有些太高。

3. 标记-整理算法

标记-整理(Mark-Compact)算法的标记阶段与标记-整理算法是一致的,在回收阶段会先将所有存活的对象向一端移动,然后直接清理掉边界以外的内存,示意图如下所示:

在这里插入图片描述
相比于标记-清除算法,它的优势是在回收内存过后不会存在内存碎片,但是劣势也是很显而易见的,那就是它的操作成本比标记-清除算法要高。

4. 分代算法

分代算法其实就是根据对象周期的不同将内存划分为几块。一般 Java 把堆划分为新生代和老年代。

  • 新生代:在新生代中的对象大部分具有“朝生夕灭”的特性,即每次垃圾收集的时候都会发现有大部分的对象已经死去,只有少量存活,在这部分可选用复制算法。
  • 老年代:老年代的对象基本上都是经历过多次 GC 之后存活下来的对象,它们的存活率比较高,并且也没有额外的空间进行担保,就必须使用“标记-清除”或者“标记-整理”算法进行对象的清理。

这里老年代没有额外的空间担保是相对于新生代所说的,而新生代的额外空间担保其实指的就是老年代,详细内容会在对象内存分配与回收策略部分进行解答。


六、HotSpot 的算法实现

上面关于垃圾回收的算法都是基于理论上的,我们在实际中接下来看看 HotSpot 是怎么做的,以保证虚拟机的高效运行。

1. 枚举根节点

不直接使用可达性分析算法的原因:

可达性分析算法的缺陷

  • GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,如果 GC Roots 的数量过多,那么在逐个检查它们的引用的时候会消耗很多时间。
  • 由于可达性分析必须停顿所有的 Java 线程(这个事件成为 Stop The World),这样做的目的是防止对象的引用关系不断变化,从而保证分析的准确性。

而目前主流的 Java 虚拟机使用的都是准确式 GC,当系统停顿之后,并不需要一个不漏地检查完所有全局性的引用和执行上下文。那么虚拟机是如何知道哪些地方存放着对象的直接引用的呢?

HotSpot 是通过使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,会计算出对象某偏移量上某类型数据,JIT 编译时会在特定的位置记录栈和寄存器中引用的位置。这样 GC 在扫描时就可直接得知这些信息,并快速准确地完成 GC Roots 的枚举。

2. 安全点(Safepoint)

安全点的定义和选定标准

  • 上述“特定的位置”即为安全点,程序执行过程中只有在安全点才能暂停进行 GC。
  • 选定标准:具有让程序长时间执行的特征,即 Safepoint 不能太少以至于让 GC 的等待时间过长,也不能太频繁以至于过分增大运行时负荷。

GC 时所有线程位于安全点附近暂停下来的方案

  • 抢先式中断(Preemptive Suspension):在 GC 发生时中断所有线程,如果线程中断位置不位于安全点上,恢复线程让其运行到安全点位置。
  • 主动式中断(Voluntary Suspension):在 GC 发生的时候设置一个与安全点重合的标志,各个线程执行时主动去轮询这个标志,发现中断标志为真就将自己挂起。

3. 安全区域(Safe Region)

出现原因:为了解决程序不执行的时候导致线程无法响应 JVM 中断请求的情况,程序不执行即没有分配 CPU 时间,典型的就是处于 Sleep 或 Blocked 状态的线程。这种情况下安全点设置的方式就会失效,解决的方式是采用安全区域。

安全区域:在一段代码片中,引用关系不会发生变化。从它的任意地方开始 GC 都是安全的,也可以将 Safe Region 当作是扩展了的 Safepoint。

在线程执行到 Safe Region 中的代码时就对自己进行标记,如果这时线程要发起 GC 就不用管该标记下的线程。在线程要离开 Safe Region 时要检查系统是否已经完成根节点枚举,完成了就继续执行线程,否则就需要等待可以安全离开 Safe Region 的信号方可继续。


4. 垃圾收集器

到目前为止说的都是 HotSpot 如何发起内存回收,具体的回收动作由 GC 所采用的垃圾收集器所决定。在垃圾收集器中以下两个名词的意思是:

  • 并行(Parallel):多条垃圾收集线程并行工作,用户线程处于等待状态。
  • 并发(Concurrent):垃圾线程与用户线程在一段时间内同时工作(交替执行)。

接下来对 6 种垃圾收集器进行介绍:

4.1 Serial 收集器

  • 单线程收集器,只会使用一个 CPU 或一条线程去完成垃圾收集工作,在进行垃圾回收时必须暂停其它线程(Stop The World,STW)。
  • 使用的算法是复制算法,是 JVM Client 模式下的默认新生代收集器。
  • 优点是简单而高效,在限定单个 CPU 的环境下没有线程交互的开销;缺点是执行时会 STW。

4.2 Serial Old 收集器

  • Serial 收集器的老年代版本,同样是一个单线程收集器。
  • 主要给 JVM Cient 模式下使用,使用的是“标记-整理”算法。
  • 在 JVM Server 模式下,一种用途是是在jdk1.5 之前与 Parallel Scavenge 收集器搭配使用,另一用途是作为 CMS 收集器的后备方案。

Serial / Serial Old 的示意图如下:
在这里插入图片描述

4.3 ParNew 收集器

  • Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为和 Serial 一样。
  • 在单 CPU 的环境中由于线程切换的开销效率不如 Serial 收集器,在多 CPU 的时候比较有优势。

ParNew / Serial Old 的示意图如下:
在这里插入图片描述

4.4 Parallel Scavenge 收集器

  • 新生代收集器,复制算法思想,多线程收集器。
  • 关注点是达到一个可控制的吞吐量(Throughput)。

吞吐量指的是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。
结算公式为:吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)

可以使用两个参数控制吞吐量:

  • -XX:MaxGCPauseMillis:允许值为一个大于 0 的毫秒数,收集器尽可能保证内存回收花费的时候不超过该设定值,它是通过牺牲吞吐量和新生代空间来达到效果的。
  • -XX:GCTimeRatio:设定一个大于 0 小于 100 的整数,表示垃圾收集时间占总时间的比率。

4.5 Parallel Old 收集器

  • Parallel Scavenge 的老年代版本,使用的算法是“标记-整理”算法。

Parallel Scavenge / Parallel Old 的示意图如下:
在这里插入图片描述

4.6 CMS 收集器

  • CMS(Concurrent Mark-Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
  • CMS 是一款真正意义上的并发收集器,采用的算法是“标记-清除”算法。

它的运行过程分为以下 4 个步骤:

  • 初始标记(CMS initial mark):需要进行 STW,但是速度很快,仅仅标记一下 GC Roots 能直接关联到的对象。
  • 并发标记(CMS concurrent mark):进行 GC Roots 的 Tracing 过程,该过程耗时最长,但能与用户线程一起工作。
  • 重新标记(CMS remark):修正并发标记期间用户程序继续运作而导致标记产生变动的对象的标记记录,这个过程同样需要 STW,消耗时间比初始标记长,但远比并发标记短。
  • 并发清除(CMS concurrent sweep):清除被标记的对象,该过程的耗时较长,但是它能与用户线程一起工作。

运行示意图如下所示:
在这里插入图片描述
CMS 有以下 3 个主要缺陷:

  • 对 CPU 资源非常敏感,在并发阶段不会发生停顿所付出的代价是它需要占用一部分 CPU 资源从而导致程序变慢。CMS 的默认回收线程数是(CPU数量+3)/ 4。
  • 无法处理浮动垃圾(Floating Garbage),由于 CMS 并发清理的时候用户线程还在运行,伴随着程序运行就会有新垃圾产生,这些垃圾只能留待下一次 GC 时才能清理,这部分垃圾就叫“浮动垃圾”。
  • 由于 CMS 采用的是“标记-清除”算法,不可避免的会产生内存碎片,但碎片过多时就会给分配大对象带来麻烦,不得不提前触发一次 Full GC。

最后上一张图表示这些垃圾收集器之间的关系,其中被连线的两个收集器表示可以搭配使用:
在这里插入图片描述


七、对象内存分配与回收策略

对象的分配广义上是指在堆上分配,如果启动了本地线程分配缓存,将按线程优先在 TLAB 上分配。对象主要分配在新生代的 Eden区,少数情况下会直接分配到老年代中(往往是大对象),分配的规则具体取决于当前使用的是哪一种收集器集合以及虚拟机中与内存相关的设置。

1. 新生代区

新生代区具体又可划分为 Eden区、To Survivor区和 From Survivor区,它们的比例默认为 8:1:1, 对象大多数情况下分配在 Eden区中,当 Eden 区空间不足的时候,虚拟机将触发一次 Minor GC。新生代有如下注意事项:

  • 新生代区的总可用空间为 Eden区 + 一个Survivor区的总容量。
  • 两个 Survivor 区哪个为 To 哪个为 From 并不是固定的,它们之间会随着 Minor GC 进行替换。
  • 每触发一次 Minor GC,存活下来的对象年龄就会加 1,默认为 15 时就会进入老年代。

详细例子如下:

在这个例子中,我们先做如下假设:

  • 每个对象的大小相等。
  • Eden区最大可存储 4 个对象。
  • Survivor 区最大可存储 3 个对象。

首先,假设 Eden区已经存满,此时仅有一个对象存活,触发 Minor GC,示意图如下
在这里插入图片描述
此时假设 S0 为 From Survivor区,S1 为 To Survivor区,将存活的对象复制到 From Survivor区,并将它的年龄加 1,然后清空 Eden区和 To Survivor区。

接着,假设 Eden区再次存满,此时有两个对象存活,触发 Minor GC,示意图如下
在这里插入图片描述
在这次 Minor GC 中,S1为 From区,S0 为 To区,此时将 Eden区和 S0区还存活的对象都复制到 From区,然后年龄加 1,并且清理 Eden区和 To区。

最后,假设 Eden区又一次存满了,此时存活了一个对象,而 From Survivor区中也有一个对象死去了,此时触发 Minor GC,示意图如下
在这里插入图片描述
在这次 GC 中,同样 From Survivor区和 To Survivor区又做了一次互换,将存活的对象都复制到 S0 区之后,然后存活对象年龄加 1,并清空 Eden区和 To Survivor区。


通过上面的示例,对新生代中分配对象内存的方式应该有所了解了,那么对象什么时候会晋升为老年代呢?

2. 老年代区

对象晋升为老年代的 3 种方式

  • 经历一定 Minor GC 次数仍然存活的对象,每经历一次 Minor GC 对象年龄就会加 1,默认年龄为 15 时对象就会进入老年代。
  • Survivor 区中放不下的对象会直接放置到老年代中。
  • 新生成的大对象。

老年代 GC 通常叫做 Major GC,因为 Major GC 的时候通常也会伴随一次 Minor GC,所以也称老年代的 GC 为 Full GC。而每次 Major GC 速度一般比 Minor GC 慢10倍以上。

由于老年代中的对象大都是经历过多次 Minor GC 之后仍然存活的对象,所以在老年代中,每次标记所需清理的无用对象一般都不多,所以老年代中更多的是使用“标记-清除”或者“标记-整理”算法进行垃圾的回收。

3. 空间分配担保

在每次进行 Minor GC 时,虚拟机都会先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果是那么 Minor GC 就是安全的;如果不是虚拟机接着会查看 HandlePromotionFailure 设置值是否允许失败。若允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;若大于,将尝试进行一次 Minor GC,若小于或者不允许担保失败,将改为进行一次 Full GC。

在每次进行 Minor GC 前需要这么做的原因是:

在新生代区中使用的是复制算法,但考虑到新生代区的对象大都是“朝生夕死”,所以为了提高内存利用率,只使用了一个 Survivor区作为轮换备份(默认情况下它只有新生代 10% 的空间)。因此如果在某次 Minor GC 过后如果大部分的对象都存活了下来(注意这种情况相当极端),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代中。但前提是老年代本身还有容纳这些对象的剩余空间,由于在完成内存回收之前无法预知实际存活对象,只好取之前每次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,从而决定是否进行Full GC来让老年代腾出更多空间。

思维导图

最后上一张思维导图串联一下虚拟机的垃圾回收机制的知识:

在这里插入图片描述
点击可查看大图。

参考

《深入Java虚拟机第二版》 第3章 垃圾收集器和内存分配策略

浅谈Java中的对象和引用

要点提炼| 理解JVM之GC&内存分配


希望本篇学习笔记能对您有所帮助~如果发现任何疑问和错误可以在评论区下方留言或者私信我,欢迎相互探讨问题。

猜你喜欢

转载自blog.csdn.net/qq_38182125/article/details/88724438