JVM 垃圾回收

本文将介绍 JVM 的垃圾回收相关知识。

一、为什么要学习垃圾回收?

现如今,Java 的垃圾回收技术已经非常成熟,一切都可以由虚拟机自动完成。那么,为什么要学习垃圾回收?

原因是:当需要排查各种内存溢出、内存泄露问题时,当垃圾回收成为系统达到更高并发量的瓶颈时,我们就必须对这些 “自动化” 的技术实施必要的监控和调节。

二、垃圾回收针对的区域

在前面的文章中,我们了解了 JVM 中内存的各个区域。

具体请看:

JVM 内存区域

其中,虚拟机栈、本地方法帧、程序计数器与线程执行有关,不能也不应回收。

而堆和方法区具有显著的不确定性,其原因是:一个接口的多个实现类需要的内存大小可能不相同,一个方法所执行的不同条件分支所需的内存大小也可能不相同。需要创建多少个对象,这些对象需要占据多大的空间,只有真正运行时才能确定。为了在内存空间有限的情况下保证程序的正常运行,垃圾回收器需要针对这两个区域做处理。

三、堆是否回收判断

1. 引用计数算法

其做法是:在对象中添加一个引用计数器,每当新增加一个对象引用时,将计数递增;当有对象引用失效时,将计数递减。当引用计数为 0 时,即代表对象不再被使用,可以回收。

在 Java 领域中并没有使用这一算法,原因是这个算法过于简单,一个简单的循环引用便可以使它无法正确工作。

1
2
3
4
5
6
7
8
9
10
// 类Obj
class Obj {
Obj instance;
}

// 存在两个Obj的实例,这两个实例分别持有对方
Obj objA = new Obj();
Obj objB = new Obj();
objA.instance = objB;
objB.instance = objA;

即使栈中已经没有了 objA 和 objB 的引用,这两个实例已经不可能再被访问到,但由于两个实例分别持有了对方的引用,这两个实例将永远不会被判为需要回收。

2. 可达性分析算法

(1) 可达性分析做法

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判断对象是否应该回收的。

其做法是:以一系列 GC Roots 为根节点,根据引用关系向下搜索,只有被搜索到的对象才应该继续存活。

(2) GC Roots

在 Java 中,GC Roots 对象固定包括以下几种:

  • 在虚拟机栈中引用的对象

  • 在本地方法栈中引用的对象

  • 在方法区中类静态属性所引用的对象

  • 在方法区中常量引用的对象

  • JVM 内部的引用

    例如:基本数据类型对应的 Class 对象、常驻的异常对象(NollPointException、OutOfMemoryError 等)、系统类加载器

  • 所有锁对应的对象

  • 反映 JVM 内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

  • 考虑跨代引用而选中的额外对象

    除了这些固定的 GC Roots 以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象也可以作为 GC Roots。譬如垃圾回收和局部回收,如果只针对堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 中去。为避免 GC Roots 过多,各个垃圾收集器也在实现上做了各种优化处理。

(3) 回收前操作

当对象被判定为应该回收后,会依次进行以下回收前操作:

  • 回收前收尾:

    • 首先判断是否需要执行 finalize() 方法,当 finalize()已被重写 && finalize() 未执行过 时,认为需要执行

      一个对象的 finalize() 方法只会被执行一次

    • 若需要执行,将对象放置于 F-Queue 队列中,由虚拟机自动创建的、低调度优先级的 Finalizer 线程依次执行队列中对象的 finalize() 方法

      需要注意的是,Finalizer 只保证执行,并不保证执行完成。

      因为如果承诺执行完成,假如某个对象的 finalize() 方法执行缓慢甚至死循环,很可能导致队列中其它对象永久等待,甚至导致整个内存回收子系统的崩溃

  • 二次检查:

    • JVM 会对进行了收尾工作的对象进行第二次可达性分析,若第二次检查时对象被判断为不需要回收,则不再回收

      对象可以在 finalize() 方法中 “拯救” 自己,例如将自己(this)赋值给其它地方

  • 回收

四、方法区是否回收判断

1. 方法区是否需要垃圾回收?

《Java 虚拟机规范》不要求虚拟机对方法区垃圾回收。

并且,方法区垃圾回收的性价比也较低,往往回收不到多少内存空间。

2. 常量回收

常量的回收与堆中对象的回收类似。

以字符串常量为例:假如一个字符串 “java” 曾经进入常量池,但是当前系统中没有任何一个字符串对象的值是 “java”,如果在此时发生垃圾回收,且垃圾收集器判断有必要的话,这个 “java” 常量就会被清理。

常量池中类的符号引用、方法的符号引用、字段的符号引用也与此类似。

3. 类的回收

当以下三个条件均满足时,

  • 类的所有实例都已回收

  • 加载该类的类加载器已回收

    这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是难以达成的

  • 类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

类型被视为不再被使用,JVM 方才允许进行回收。

五、垃圾收集算法

1. 是否回收判断

详见上文 “三、堆中对象是否回收判断”。

2. 分代

(1) 分代假说

有两条公认的分代假说如下:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

大多数垃圾收集器都遵循分代假说,将堆划分出不同的区域,对象依据其年龄(即对象熬过几个垃圾收集过程)被分配到不同的区域中存储。

显然,这样做有两个好处:

  • 可以针对不同的区域设定不同的垃圾回收频率

  • 可以针对不同的区域设定不同的垃圾回收策略

  • 可以针对不同的区域设定不同的是否回收区分方法

    • 在收集 “低年龄区域” 时,可以只标记不被回收的对象
    • 在收集 “高年龄区域” 时,可以只标记被回收的对象

(2) 跨代引用问题

对象与对象之间可能会存在跨代引用。如果考虑这个情况,就不能只针对某个区域做垃圾收集。

对象 A 无法由 GC Roots 通过引用关系访问到,但 “老年代” 中的对象 B 持有它的引用,那么它就不应该被回收,否则对象 B 不合法。

因此,除了可达性分析之外,还得遍历其它区域以排除被其它区域引用的对象。这种方案虽然可行,但会给垃圾回收带来较大的性能开销。

为了解决这个问题,引入了第三条经验法则:

  • 跨代引用假说:存在互相引用关系的两个对象往往倾向于同时生存或消亡,跨代引用相对于同代引用来说仅占少数

依据这个经验法则,通常在新生代上建立一个全局的记忆集,用于标识老年代中哪些部分存在跨代引用,当进行垃圾收集时,将这些区域中的对象也作为 GC Roots,进行可达性分析。通过这种方式,避免了跨代引用时的错误清理,也节省了性能开销。

3. 标记 - 清除算法

先标记,后回收。标记所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;或者,标记所有不需要回收的对象,在标记完成后,统一回收掉所有未被标记的对象。

它的主要缺点有两个:

  • 如果对象较多,必须要进行大量的标记和清除工作
  • 标记、清除后会产生大量不连续的内存碎片

4. 标记 - 复制算法

(1) 半区复制

1969 年 Fenichel 提出了一种称为 “半区复制” 的垃圾收集算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当需要进行垃圾收集时,将存活对象复制到另外一块中,将原有块做一次性清理,切换以使用另外一块内存。

其优点是:

  • 如果大多数对象都是可回收的,只需要复制少量对象
  • 复制时可以将对象 “整齐放置”,避免空间碎片

其缺点是:

  • 空间利用率较低,每次只能用到空间的一半

(2) Appel 式回收

大多数对象都熬不过第一轮收集,因此没有必要设置一块同等大小的区域用于放置存活对象。

将空间分为一个大区和两个小区,同一时刻只使用大区和其中一块小区。当进行垃圾回收时,将存活对象复制到另外一块小区中,将大区和原小区做一次性清理,切换以使用大区和另一块小区。

其优点是:

  • 空间利用率更高

    HotSpot 虚拟机默认的大区和小区 的大小比例为 8 : 1,也就是说,用到空间为总空间的 90%

其缺点是:

  • 假如新生代中存活的数量较多,小区可能无法完全装下

    如果小区不足以容纳所有对象,就需要依赖其它区域进行分配担保

5. 标记 - 整理算法

先标记,后整理。

标记所有不需要回收的对象,在标记完成后,将所有不需要回收的对象 “整齐放置”,然后一次性清理掉边界以外的内存空间。

其优点是:

  • 不会产生空间碎片

其缺点是:

  • 假如存活对象较多,移动存活对象将会是一个大动作,往往需要暂停用户应用程序才能,这种暂停又被称作 “Stop The World”

参考

  • 深入理解 Java 虚拟机