探秘 JVM:垃圾回收机制

JVM 中的程序计数器、java 虚拟机栈、本地方法区属于线程私有的,其生命周期控制在线程生命周期范围内,并且 java 虚拟机栈和本地方法区则随着栈帧的出栈入栈由生到灭,所以这些区域的内存使用是确定性的(编译期已知),而 java 堆和方法区是线程共享的,对象创建也是动态的,所以垃圾回收的主战场在 java 堆和方法区。

一. 引用类型与可达性分析

1.1 Java 中的引用类型

我们一般对于引用的定义是存放另外一个对象地址的对象,而 java 对于引用的定义则更加细化,分为强引用、软引用、弱引用,以及虚引用 4 种,强度逐级由强到弱。

  • 强引用:通常在程序中看到的引用都是强引用,比如 Object obj = new Object();,这类引用的特点是只要存在,垃圾收集器就不会对被引用的对象执行回收操作。
  • 软引用:通过继承 SoftReference 类实现,描述一些还有用但非必须的对象,JVM 会在发生内存溢出异常之前将这些对象列进回收范围之中进行第二次回收,如果这部分对象回收这还仍然内存不足,才会抛出内存溢出异常。
  • 弱引用:通过继承 WeakReference 类实现,描述一些非必须的对象,被弱引用的对象会被下一次 GC 操作所回收,而不管当前是否内存足够,典型的应用场景就是 ThreadLocal,ThreadLocal 维护了一个线程私有的内存数据库来记录线程私有的对象,而对象的 key 是一个弱引用的对象。
  • 虚引用:通过继承 PhantomReference 类实现,虚引用的唯一目的在于能在被引用的对象被回收时收到一个系统通知,一个对象的生命周期完全不受虚引用的影响,我们也无法通过虚引用来得到一个对象的实例。

所有的引用类型都继承自 java.lang.ref.Reference 抽象类,它定义了一个 Reference#get 方法用于获取当前引用对应的原有对象,但是幻象引用除外,因为其 get 方法永远返回 null。

1.2 对象可回收性判断

一个对象只有在无效的前提下才可以被回收,如何判定一个对象是无效的,主要有两种思路:引用计数和可达性分析。

1.2.1 引用计数法

引用计数法是比较简单的一种判定对象无效的策略,通过为每一个对象设置一个计数器,当一个对象被引用则计数加 1,反之则减 1,当一个对象的引用计数是 0 时,我们可以将其视为无效并回收。但是,该策略存在循环引用的问题,当两个对象互相引用时,尽管没有被其他对象所引用,那么这两个对象的引用计数也至少是 1,无法被回收。

1.2.2 可达性分析

可达性分析采用连通图的策略,我们可以把每个对象都看作是图上的一个结点,而引用则可以看作图上的边,在这个图中存在一些特殊的结点,被称为 GC Roots(可以简单将其理解为由堆外指向堆内的引用),如果某个对象与任何一个 GC Roots 之间不连通,则视为该对象无效,可以被回收。

在 JVM 中,可以被视为 “GC Roots” 的结点包括下面几种:

  1. 虚拟机栈(局部变量表)中引用的对象。
  2. 方法区中类静态(static)变量引用的对象。
  3. 方法区中 final 常量引用的对象。
  4. 本地方法栈中 native 方法引用的对象。

通过可达性分析判定的对象无效实际上是给对象判定了一个死缓,并没有立即执行回收,一些对象还可以在后续通过良好表现而无刑释放。真正需要对一个对象执行死刑需要经过 两次标记 过程:第 1 次标记被判定为无效的对象将经过一次筛选,筛选出那些有必要执行 finalize 方法的对象(有必要是指该对象覆盖了 finalize 方法,且该方法还没有被 JVM 调用过,并且一个对象的 finalize 方法只能被系统调用一次),这些方法被筛选出来之后就被放进 F-Queue 队列中,稍后由一个 JVM 自动创建的、低优先级的 Finalizer 线程去依次调用队列中对象的 finalize 方法,所以我们可以认为 finalize 方法是对象最后一个改过自新的地方,如果对象在这里重新让自己被引用则复活,否则就几乎只有等死了,JVM 依次执行队列中的 finalize 方法的过程可以看作是第 2 次标记。下面是一个验证上述过程的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class FinalizeDemo {

public static FinalizeDemo saveHook;

@Override
protected void finalize() throws Throwable {
System.out.println("do finalize method");
super.finalize();
saveHook = this;
}

private static void isAlive() {
System.out.println(null == saveHook ? "Sorry, I'm died!" : "yes, I'm still alive!");
}

public static void main(String[] args) throws Exception {
saveHook = new FinalizeDemo();

/* 第一次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();

/* 第二次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法,因为finalize只会被调用一次,所以本次不会调用finalize,自救失败
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();
}

}

执行结果:

1
2
3
do finalize method
yes, I'm still alive!
Sorry, I'm died!

上述示例在第一次 GC 时触发调用了对象的 finalize 方法,我们在该方法中为 saveHook 变量带来了第二春(即 saveHook = this),所以该对象从死缓中被保释了出来,但是这货不老实,放出来之后又被抓进去了(即 saveHook = null),这一次就没有那么幸运了,因为一个对象的 finalize 方法只能被系统调用一次,所以只能坐以待毙了。

注意:一般不推荐在 finalize 方法中添加自定义逻辑,因为该方法的执行是不确定的,推荐将这些逻辑写到 finally 块中。

虽然可达性分析能够解决引用计数存在的循环引用问题,但在具体实现时仍然存在一些其它需要解决的问题。例如,在多线程环境下线程可能会并发更新对象的引用,从而可能导致将对象设置为 null 造成误报,或者将引用设置为未被访问过的对象造成漏报。

传统的 JVM 垃圾回收算法解决这一问题,采用的是一种简单粗暴的方式,即停止所有非垃圾回收线程的工作直到垃圾回收操作完成,这也就是臭名昭著的 Stop-The-World(简称 STW)。JVM 中的 STW 是通过安全点(safepoint)机制来实现的,当 JVM 收到 STW 请求,便会等待所有的线程都到达安全点,然后允许请求 STW 的线程进行独占的工作。

二. 垃圾回收算法

垃圾收集的过程会中断正常业务逻辑的执行来查找和回收垃圾对象,因为回收时机的不确定性,如果收集的过程耗时较长,会引起系统的卡顿,影响用户体验,所以一般收集过程不会一次性彻底执行,而是采用渐进式回收策略,将一次收集过程分摊到多次执行,控制每次执行的时间长度,以不让用户感觉到这一过程。

在 JVM 实现中并没有采取单一的 GC 算法,而是设计了分代回收的策略。JVM 将 java 堆可以分为新生代和老年代(一些 JVM 实现还会划分永久代,用于描述 java 方法区),并针对不同的区域(代)采取不同的 GC 算法,比如新生代中对象生命周期较短,适合采用复制算法,而老年代中对象大部分时间都是处于存活状态,而且很多都是大对象,所以比较适合采用标记算法。

新生代可以细分为 Eden 和两个 Survivor 区域(From Survivor 和 To Survivor),默认情况下 JVM 采取动态分配的策略,根据对象的生成速率和 Survivor 区域的使用情况动态调整 Eden 区和 Survivor 区的比例。同时,JVM 也支持通过参数 -XX:SurvivorRatio 来固定这个比例。

当发生 Minor GC 时,Eden 和 from 指针指向的 Survivor 区域的存活对象(即不应该被 GC 的对象)会被复制到 to 指针所指向的 Survivor 区域,然后交换 from 和 to 指针,所以任何时候总有一个 Survivor 区域是空闲的。如果一个对象在两个 Survivor 区域之间来回复制的次数超过指定阈值(默认为 15,可以通过 -XX:+MaxTenuringThreshold 参数指定),那么该对象将会被移入老年代。此外,如果单个 Survivor 区域使用率超过 50%(对应 -XX:TargetSurvivorRatio 配置),则复制次数较多的对象也会被移入老年代。

2.1 标记清除法

标记清除算法是最基础的垃圾回收算法,它的执行过程可以分为标记和清除 2 个阶段,算法在第 1 阶段先标记出所有可以回收的对象,然后在第 2 阶段对这些对象进行统一回收。

标记清除算法的缺点主要分为 2 个方面:

  1. 标记和清除过程的效率都不高。
  2. 清除操作会产生大量不连续的内存碎片,影响后续分配的效率。

2.2 标记整理法

标记整理算法与标记清除算法在第 1 阶段的操作相同,区别在于第 2 阶段,标记整理会将所有存活的对象移动到内存的一端,然后对剩余的内存进行清理。

2.3 复制算法

复制算法解决了标记算法在第 1 阶段标记过程效率不高的问题,常规的复制算法将内存区域分为大小 1:1 的两份,每次仅在其中一块上分配,当需要 GC 时就将当前存活的对象全部移动到另外一块上去,然后对之前已使用的那一块内存进行一次性清理,其缺点就是内存利用率不高,只有 50%。

如前面所述,实际应用中往往将新生代大小分为 3 块,一块大的 Eden 区域和两块小的 Survivor 区域(From Survivor 和 To Survivor),每次都使用 Eden 和其中一块 Survivor 进行内存分配,当需要执行 Minor GC 时就将所有存活的对象全部移动到另外一块 Survivor 上去,然后清理掉之前的 Eden 和 Survivor。实际中可能会出现一块 Survivor 不够用的情况,这个时候就需要通过空间分配担保机制将多的对象送入老年代。

针对空间分配担保机制的说明如下:

JVM 在执行 Minor GC 前会检查老年代最大连续空间是否大于新生代所有对象的总空间,如果大于则可以确保 Minor GC 可以安全执行,否则就会继续查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许则继续检查老年代最大连续空间是否大于历次升级到老年代对象的平均大小,如果大于则尝试执行 Minor GC,如果小于或者 HandlePromotionFailure 设置值不允许,则执行一次 Full GC。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(老年代最大连续空间 > 新生代所有对象大小之和) {
执行 Minor GC
} else {
if(HandlePromotionFailure == 允许担保失败) {
if(老年代最大连续空间 > 历次升级到老年代对象的平均大小) {
尝试执行Minor GC
} else {
执行Full GC
}
} else {
执行Full GC
}
}

Minor GC 不用对整个堆进行垃圾回收,但是如果老年代的对象引用了新生代中的对象,则不能对这些(新生代)对象进行回收,此时我们需要依赖某种机制进行发现。最简单的方法就是对整个老年代进行全表扫描,但是这样效率较低。HotSpot 引入了一种被称为卡表(Card Table)的技术,将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表以记录每张卡的一个标识位(用于标识对应的卡是否存在指向新生代对象引用的可能)。这样在执行 Minor GC 时则无需扫描整个老年代,只需要扫描卡表即可。

三. 内存分配与回收策略

3.1 对象优先在 Eden 上进行分配

如前面所介绍,一般情况下对象会首先在新生代 Eden 区域进行分配,当 Eden 空间不足时,JVM 将发起一次 Minor GC。Minor GC 和 Full GC 的区别如下:

  • Minor GC:新生代上的 GC,频繁发生且速度较快。
  • Full GC:也称 Major GC,是发生在老年代上的 GC,一般情况下都会伴随发生至少一次 Minor GC,Full GC 的速度一般比 Minor GC 慢 10 倍。

3.2 大对象直接进入老年代

对于大对象(需要大量连续内存空间的对象),JVM 采取的策略是直接进入老年代,因为新生代中的对象只有在一定的 GC 次数之后仍然存活才能进入老年代,而在此之前大对象会频繁在两个 Survivor 块之间复制,这样会降低效率,但是对于一个朝生夕灭的大对象,直接进入老年代也不是好事,实际上就算分配在新生代上也不是好事,所以编程中应该尽量避免这种朝生夕灭的大对象。

3.3 长期存活的对象进入老年代

因为采用分代存储,所以 JVM 需要知道哪些对象应该放在新生代,哪些对象应该放在老年代,JVM 一般会根据对象熬过的 GC 次数作为判定依据,并且会为该对象设置一个对象年龄计数器,对于一个对象首先在 Eden 区域进行分配,如果在第 1 次 Minor GC 后仍然存活,并且能够被 Survivor 区域容纳,则进入 Survivor 区域,同时设置对象年龄计数器为 1,后面每熬过 1 次 Minor GC 则对象年龄计数器加 1,当年龄达到一定值(默认为 15)则进入老年代。

JVM 并非完全死板的执行上诉策略,而是会进行动态对象年龄判定,如果 Survivor 空间中相同年龄的所有对象大小之和大于 Survivor 空间的一半,则年龄大于或等于这一年龄的对象可以直接进入老年代。

四. 垃圾收集器

新生代专属的垃圾收集器包括 3 个:Serial、Parallel Scavenge,以及 Parallel New。其中,Serial 是一个单线程的垃圾收集器;Parallel New 可以看做是 Serial 的多线程版本;Parallel Scavenge 与 Parallel New 类似,但更加注重吞吐率。这 3 个垃圾收集器均采用复制算法。

老年代专属的垃圾收集器也包括 3 个:Serial Old、Parallel Old,以及 CMS。其中,Serial Old 是一个单线程的垃圾收集器;Parallel Old 可以看做是 Serial Old 的多线程版本;CMS 除了少数几个操作需要 STW 之外,可以在应用程序运行过程中进行垃圾回收。Serial Old 和 Parallel Old 均采用标记整理算法,而 CMS 采用标记清除算法。此外,Parallel Scavenge 垃圾收集器不能与 CMS 一起使用。

G1(Garbage First)垃是一个横跨新生代和老年代的垃圾收集器,它并不区分新生代和老年代,而是直接将堆分成多个区域,每个区域都可以充当 Eden、Survivor,或者老年代中的一个,并能够针对每个区域进行垃圾回收。G1 采用的是标记整理算法,而且和 CMS 垃圾收集器一样都能够在应用程序运行过程中并发地执行 GC。由于 G1 的出现,CMS 在 java 9 中已被废弃。

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 极客时间:深入拆解 java 虚拟机

转载声明 : 版权所有,商业转载请联系作者,非商业转载请注明出处
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议
Powered by hexo & Theme by hiero   Copyright © 2015-2019 浙ICP备 16010916  号,指 · 间 All Rights Reserved.