Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

前言

上篇文章已经给大家介绍了 JVM 的架构和运行时数据区 (内存区域),本篇文章将给大家介绍 JVM 的重点内容——垃圾收集。众所周知,相比 C / C++ 等语言,Java 可以省去手动管理内存的繁琐操作,很大程度上解放了 Java 程序员的生产力,而这正是得益于 JVM 的垃圾收集机制和内存分配策略。我们平时写程序时并感知不到这一点,但是如果是在生产环境中,JVM 的不同配置对于服务器性能的影响是非常大的,所以掌握 JVM 调优是高级 Java 工程师的必备技能。正所谓“基础不牢,地动山摇”,在这之前我们先来了解一下底层的 JVM 垃圾收集机制。

既然要介绍垃圾收集机制,就要搞清楚以下几个问题:

  1. 哪些内存区域需要进行垃圾收集?
  2. 如何判断对象是否可回收?
  3. 新的对象是如何进行内存分配的?
  4. 如何进行垃圾收集?

本文将按以下行文结构展开,对上述问题一一解答。

  1. 需要进行垃圾收集的内存区域;
  2. 判断对象是否可回收的方法;
  3. 主流的垃圾收集算法介绍;
  4. JVM 的内存分配与垃圾收集机制。

下面开始正文,还是图文并茂的老配方,走起。

一、需要进行垃圾收集的内存区域

先来回顾一下 JVM 的运行时数据区:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

JVM 运行时数据区

其中程序计数器、Java 虚拟机栈和本地方法栈都是线程私有的,与其对应的线程是共生关系,随线程而生,随线程而灭,栈中的栈帧也随着方法的进入和退出井然有序地进行入栈和出栈操作。所以这几个区域的内存分配和回收都是有很大确定性的,在方法结束或线程结束时,内存也会随之释放,因此也就不需要考虑这几个区域的内存回收问题了。

而堆和方法区就不一样了,Java 的对象几乎都是在堆上创建出来的,方法区则存储了被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,方法区中的运行时常量池则存放了各种字面量与符号引用,上述的这些数据大部分都是在运行时才能确定的,所以需要进行动态的内存管理。

还要说明一点,JVM 中的垃圾收集器的最主要的关注对象是 Java 堆,因为这里进行垃圾收集的“性价比”是最高的,尤其是在新生代 (后文对分代算法进行介绍) 中的垃圾收集,一次就可以回收 70% - 99% 的内存。而方法区由于垃圾收集判定条件,尤其是类型卸载的判定条件相当苛刻,其回收性价比是非常低的,因此有些垃圾收集器就干脆不支持或不完全支持方法区的垃圾收集,比如 JDK 11 中的 ZGC 收集器就不支持类型卸载。

二、判断对象是否可回收的方法

2.1 引用计数法

引用计数法的实现很简单,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。大部分情况下这个方法是可以发挥作用的,但是在存在循环引用的情况下,引用计数法就无能为力了。比如下面这种情况:

public class Student {
// friend 字段
public Student friend = null; public static void test() {
Student a = new Student();
Student b = new Student();
a.friend = b;
b.friend = a;
a = null;
b = null;
System.gc();
}
}

上述代码创建了 a 和 b 两个 Student 实例,并把它们各自的 friend 字段赋值为对方,除此之外,这两个对象再无任何引用,然后将它们都赋值为 null,在这种情况下,这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。如下图所示:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

循环引用

但是在 Java 程序中,a 和 b 是可以被回收的,因为 JVM 并没有使用引用计数法判定对象是否可回收,而是采用了可达性分析法。

2.2 可达性分析法

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集 (GC Root Set),从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链” (Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,则说明此对象不再被使用,也就可以被回收了。要进行可达性分析就需要先枚举根节点 (GC Roots),在枚举根节点过程中,为防止对象的引用关系发生变化,需要暂停所有用户线程 (垃圾收集之外的线程),这种暂停全部用户线程的行为被称为 (Stop The World)。可达性分析法如下图所示:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

可达性分析法

图中绿色的都是位于 GC Root Set 中的 GC Roots,所有与其有关联的对象都是可达的,被标记为蓝色,而所有与其没有任何关联的对象都是不可达的,被标记为灰色。即使是不可达对象,也并非一定会被回收,如果该对象同时满足以下几个条件,那么它仍有“逃生”的可能:

  1. 该对象有重写的 `finalize()`方法 (Object 类中的方法);
2. `finalize()`方法中将其自身链接到了引用链上;
3. JVM 此前没有调用过该对象的`finalize()`方法 (因为 JVM 在收集可回收对象时会调用且仅调用一次该对象的`finalize()`方法)。

不过由于finalize()方法的运行代价高昂,不确定性大,且无法保证各个对象的调用顺序,所以并不推荐使用。那么 GC Roots 又是何方神圣呢?在 Java 语言中,固定可作为GC Roots的对象包括以下几种:

  1. 在虚拟机栈 (栈帧中的本地变量表) 中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
2. 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
3. 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用。
4. 在本地方法栈中JNI (即通常所说的Native方法) 引用的对象。
5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象 (比如
NullPointExcepiton、OutOfMemoryError) 等,还有系统类加载器。
6. 所有被同步锁 (synchronized关键字) 持有的对象。
7. 反映Java虚拟机内部情况的 JM XBean、JVM TI 中注册的回调、本地代码缓存等。

三、垃圾收集算法介绍

3.1 标记-清除算法

标记-清除算法的思想很简单,顾名思义,该算法的过程分为标记和清除两个阶段:首先标记出所有需要回收的对象,其中标记过程就是使用可达性分析法判断对象是否属于垃圾的过程。在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。示意图如下:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

标记清除算法

这个算法虽然很简单,但是有两个明显的缺点:

  1. 执行效率不稳定。如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 导致内存空间碎片化。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作,非常影响程序运行效率。

3.2 标记-复制算法

标记-复制算法常简称复制算法,这一算法正好解决了标记-清除算法在面对大量可回收对象时执行效率低下的问题。其实现方法也很易懂:在可用内存中划分出两块大小相同的区域,每次只使用其中一块,另一块保持空闲状态,第一块用完的时候,就把存活的对象全部复制到第二块区域,然后把第一块全部清空。如下图所示:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

标记-复制算法

这个算法很适合用于对象存活率低的情况,因为它只关注存活对象而无需理会可回收对象,所以 JVM 中新生代的垃圾收集正是采用的这一算法。但是其缺点也很明显,每次都要浪费一半的内存,未免太过奢侈,不过新生代有更精细的内存划分,比较好地解决了这个问题,见下文。

3.3 标记-整理算法

这个算法完美解决了标记-清除算法的空间碎片化问题,其标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

标记整理算法

这个算法虽然可以很好地解决空间碎片化问题,但是每次垃圾回收都要移动存活的对象,还要对引用这些对象的地方进行更新,对象移动的操作也需要全程暂停用户线程 (Stop The World)。

3.4 分代收集算法

与其说是算法,不如说是理论。如今大多数虚拟机的实现版本都遵循了“分代收集”的理论进行设计,这个理论可以看作是经验之谈,因为开发人员在开发过程中发现了 JVM 中存活对象的数量和它们的年龄之间有着某种规律,如下图:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

JVM 中存活对象数量与年龄之间的关系

在此基础上,人们得出了以下假说:

  1. 绝大多数对象都是朝生夕灭的。
  2. 熬过越多次垃圾收集过程的对象就越难以消亡。

根据这两个假说,可以把 JVM 的堆内存大致分为新生代和老年代,新生代对象大多存活时间短,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间,所以这一区域一般采用标记-复制算法进行垃圾收集,频率比较高。而老年代则是一些难以消亡的对象,可以采用标记-清除和标记整理算法进行垃圾收集,频率可以低一些。

按照 Hotspot 虚拟机的实现,针对新生代和老年代的垃圾收集又分为不同的类型,也有不同的名词,如下:

  1. 部分收集 (Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

    • 新生代收集 (Minor GC / Young GC):指目标只是新生代的垃圾收集。

    • 老年代收集 (Major GC / Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器的并发收集阶段是单独收集老年代的行为。

    • 混合收集 (Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。

  2. 整堆收集 (Full GC):收集整个Java堆和方法区的垃圾收集。

人们经常会混淆 Major GC 和 Full GC,不过这也有情可原,因为这两种 GC 行为都包含了老年代的垃圾收集,而单独的老年代收集 (Major GC) 又比较少见,大多数情况下只要包含老年代收集,就会是整堆收集 (Full GC),不过还是分得清楚一点比较好哈。

四、JVM 的内存分配和垃圾收集机制

经过前面的铺垫,现在终于可以一窥 JVM 的内存分配和垃圾收集机制的真面目了。

4.1 JVM 堆内存的划分

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

JVM 堆内存划分

Java 堆是 JVM 所管理的内存中最大的一块,也是垃圾收集器的管理区域。大多数垃圾收集器都会将堆内存划分为上图所示的几个区域,整体分为新生代和老年代,比例为 1 : 2,新生代又进一步分为 Eden、From Survivor 和 To Survivor,默认比例为 8 : 1 : 1,可通过 SurvivorRatio 参数进行设置。请注意,从 JDK 8 开始,JVM 中已经不再有永久代的概念了。Java 堆上的无论哪个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

4.2 分代收集原理

4.2.1 新生代中对象的分配与回收

大多数情况下,对象优先在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。Eden、From Survivor 和 To Survivor 的比例为 8 : 1 : 1,之所以按这个比例是因为绝大多数对象都是朝生夕灭的,垃圾收集时 Eden 存活的对象数量不会太多,Survivor 空间小一点也足以容纳,每次新生代中可用内存空间为整个新生代容量的90% (Eden 的 80% 加上 To Survivor 的 10%),只有From Survivor 空间,即 10% 的新生代是会被“浪费”的。不会像原始的标记-复制算法那样浪费一半的内存空间。From Survivor 和 To Survivor 的空间并不是固定的,而是在 S0 和 S1 之间动态转换的,第一次 Minor GC 时会选择 S1 作为 To Survivor,并将 Eden 中存活的对象复制到其中,并将对象的年龄加1,注意新生代使用的垃圾收集算法是标记-复制算法的改良版。下面是示意图,请注意其中第一步的变色是为了醒目,虚拟机只做了标记存活对象的操作。

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

第一次 Minor GC 示意图

在后续的 Minor GC 中,S0 和 S1会交替转化为 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活对象会复制到 To Survivor 中,并将年龄加 1。如下图所示:

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

后续 Minor GC 示意图

4.2.2 对象晋升老年代

在以下这些情况下,对象会晋升到老年代。

  1. 长期存活对象将进入老年代

    对象在 Survivor 区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解

长期存活对象晋升老年代示意图

  1. 大对象可以直接进入老年代

    对于大对象,尤其是很长的字符串,或者元素数量很多的数组,如果分配在 Eden 中,会很容易过早占满 Eden 空间导致 Minor GC,而且大对象在 Eden 和两个 Survivor 之间的来回复制也还会有很大的内存复制开销。所以我们可以通过设置 -XX:PretenureSizeThreshold 的虚拟机参数让大对象直接进入老年代。

  2. 动态对象年龄判断

    为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。

  3. 空间分配担保 (Handle Promotion)

    当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域 (实际上大多数情况下就是老年代) 进行分配担保。在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 - XX:HandlePromotionFailure 参数的设置值是否允许担保失败 (Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次 Full GC。

总结

本文介绍了 JVM 的垃圾收集机制,并用大量图片和动图来帮助大家理解,如有错误,欢迎指正。后续文章会继续介绍 JVM 中的各种垃圾收集器,包括最前沿的 ZGC 和 Shenandoah 收集器,是 JVM 领域的最新科技成果,敬请期待。

最后是参考文章:

上一篇:java中枚举(enum)小例子。之前学过枚举但是一直没用,这里有个枚举类帮你我理解下(很肤浅)


下一篇:15行python代码,帮你理解令牌桶算法