本文是对《深入理解 Java 虚拟机》中第 3 章前半部分的回顾与总结,这部分也是 JVM 的重点。

垃圾收集(Garbage Collection,GC)主要围绕以下三点进行展开:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

对象存活

垃圾收集器在对堆进行回收的之前,需要知道哪些对象还存在,哪些对象不可能再通过任何途径进行使用,即判断对象是否存活。

判断对象是否存活的方法

引用计数法(Reference Counting)

描述:给对象添加一个引用计数器,每当该对象被引用的时候,计数器就加 1;当引用失效时,计数器就减 1,直到计数器为 0 时,该对象不再被引用。

优点:实现简单,效率高。

缺点:难以解决对象之间相互循环引用的问题。

可达性分析法(Reachability Analysis)

该方法类似于树结构,通常将称为 GC Roots 的对象作为起始点,从这些点开始向下搜索,搜索的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连接时,则说明此对象是不可用的。

如下图1所示,虽然 Object5Object6Object7 之间是互相有关联的,但是它们到 GC Roots 是不可达的,所以它们被判定为可回收对象。

此外,可作为 GC Roots 对象的有:

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

四种不同的引用方式

以上两种方法都和 引用 有关,在 JDK 1.2 之前,如果 reference 类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。但我们希望当内存还足够时,对象能保留在内存中;如果内存空间在进行垃圾收集后还是紧张的话,则可以抛弃这些对象。所以在 JDK 1.2 之后就引入了如下四种不同的引用类型:

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

强引用 即平时使用的引用,如 Person p = new Person();,该类引用只要还存在的话,垃圾收集器就不会回收掉被引用的对象。

软引用 说的是 一些有用但非必需的对象。对于一个软引用的对象,在系统将要发生内存溢出之前,将会把这些对象列入回收范围中进行第二次回收。如果回收后还没有足够的内存,则会抛出内存溢出异常。

弱引用软引用 还要弱一点,它描述的也是非必需的对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。也就是说,当垃圾收集器工作的时候,无论当前内存是否足够,被弱引用关联的对象都会被回收。

虚引用 也称为 幽灵引用幻影引用,是这四者中最弱的一种引用关系。如果一个对象关联了虚引用,则它仅仅是为了能够在这个对象被收集器回收时收到一个系统通知,因此也无法通过一个虚引用获得一个对象实例。

finalize() 方法

可达性分析法 在判断一个对象是否存活的时候,至少需要经过两次标记和筛选的过程:

第一次标记和筛选:如果对象在进行 可达性分析法 后发现没有与 GC Roots 相连接的引用链,即对象不可达,那它将会被第一次标记并进行筛选。此时,如果进行筛选,则将对象从 即将回收 的集合中取出;如果不进行筛选的话,对象就继续留在 即将回收 的集合里,等待被回收。

筛选的条件是该对象是否有必要执行 finalize() 方法。

  1. 如果有必要执行的话,则筛选出来,进入第二次标记和筛选阶段。
  2. 如果没有必要执行的话,则不筛选,判定该对象死亡,并等待回收。

当对象没有覆盖 finalize() 方法的时候,或 finalize() 方法已经被虚拟机调用过了,则虚拟机将这两种情况视为没有必要去执行。

第二次标记和筛选:当该对象被判定为有必要执行 finalize() 方法的时候,就会被放在一个叫 F-Queue 的队列里,并在稍后会被一个由虚拟机自动建立、低优先级的 Finalizer 线程去执行它。如果在这时 对象在 finalize() 过程中重新与引用链上的任何一个对象建立关联了(即:与 GC Roots 直接关联或者间接关联) ,则该对象就会被移出 即将回收 的集合,也就判断为该对象是 存活 状态。

整个过程如下图2所示:(点击图片看大图)

image.png

值得注意的是,任何一个对象的 finalize() 方法只会被系统自动调用一次,如果对象面临下一次回收,则它的 finalize() 方法不会再次被执行。

方法区的回收

方法区(或 HotSpot 虚拟机中的永久代)的垃圾收集效率是比较低的,其主要回收 废弃常量无用的类

如何判断一个常量是不是 废弃常量

假如一个字符串 abc 已经进入到常量池中,如果当前没有任何一个 String 对象引用常量池中的 abc,同时其它地方也没有引用这个字面量 abc,则再进行内存回收的时候,就判定该常量是 废弃常量,即 abc 就会被系统清理出常量池。常量池中的其它 类(接口)方法字段 的符号引用也是类似的。

如果判断一个类是不是 无用的类

如果一个类是 无用的类,则需要同时满足以下 3 个条件:

  • Java 堆中不存在该类的任何实例,即该类的实例都已经被回收了。
  • 加载该类的类加载器(ClassLoader)已经被回收了。
  • 该类所所对应的 java.lang.Class 对象没有在任何地方被引用,也就是说,无法在任何地方通过反射的方式访问该类的方法。

针对类来说,并不是满足以上三个条件就会被回收,也并不是像 对象 一样不使用了就必然会回收,而是说仅仅是处于 可被回收的状态。具体回不回收,可以通过配置虚拟机参数的方式进行。

垃圾收集算法

标记-清除算法

标记-清除算法(Mark-Sweep)由 标记清除 两个步骤组成:

  • 标记出所有需要回收的对象(标记方法如上面所示)
  • 在标记完成后统一清除(回收)所有被标记过的对象

处理过程如下图3所示:

缺点:

  • 效率问题:标记和清除这两个过程的效率都不高。
  • 空间问题:标记清除后会产生大量不连续的内存碎片,这会导致以后再程序运行的过程中,如果需要分配较大内存的时候,无法找到足够的连续内存而不得不提前触发另外一次的垃圾收集动作。

复制算法

为了解决上面出现的 效率空间 问题,提出了一种 复制算法, 具体步骤如下:

  • 首先将 可用内存 按容量划分为大小相等的两块,每次只使用其中的一块;
  • 然后当这一块的内存用完了之后,就将还存活着的对象复制到另一块上面;
  • 最后在把已使用过的内存空间一次性清理掉。

如下图3所示:

image.png

该方法每次需要对整个半区进行内存回收,无须考虑内存碎片的问题,只需要移动堆顶指针,按顺序分配内存即可。

由于该方法导致使用的内存变为原来的一半,在对象存活率较高时需要进行多次的复制操作,效率会变低。所以适用于对象存活率较低以及需要频繁进行垃圾回收的区域,如 新生代 区域。

在上一篇文章 Java 内存区域概述 中,我们知道 新生代 包括 Eden SpaceSurvivor 0 Space 以及 Survivor 0 Space

当进行内存划分的时候,每次使用 Eden Space其中一块 Survivor 区域,当进行回收的时候,将 Eden SpaceSurvivor还存活的对象 一次性复制到另一块 Survivor 区域,最后再清理掉 Eden Space 和刚才用过的 Survivor 区域。

假如在 复制到另一块 Survivor 区域 的过程中该空间不够了,则需要依赖 老年代 来进行 分配担保(Handle Promotion)。即将 另一块 Survivor 区域 没有足够空间存放上一次新生代收集下来的存活对象,通过 分配担保机制 存储到 老年代

标记-整理算法

标记-整理(Mark-Compact)算法适用于 老年代,具体步骤如下:

  • 标记出所有需要回收的对象
  • 将所有存活的对象都向一端移动
  • 清理掉端边界以外的内存

如下图3所示:

image.png

该算法解决了 标记-清除 算法中存在的效率低、内存碎片问题。由于 将所有存活的对象都向一端移动,所以就减少了内存碎片,保留了较大内存空间;而通过 直接清理掉边界以外的区域 的方式实现了较高的效率。

分代收集算法

分代收集算法(Generational Collection)就是根据 Java 堆中的不同区域的特点来使用适当的收集算法。

针对 新生代 每次垃圾收集时都会有大量对象死去,少量对象存活的特点,适合采用 复制算法。因为只有少量的对象存活,所以其复制成本很低。

针对 老年代 存活率高、没有额外空间进行分配担保的特点,则采用 标记-清理标记-整理 的算法。


  1. JVM 判断对象是否存活 ↩︎

  2. JVM: 判断一个Java对象是否存活 ↩︎

  3. 垃圾收集算法 ↩︎