深入理解 JVM 垃圾回收机制 - 何为垃圾?

随着编程语言的发展,GC 的功能不断增强,性能也不断提高,作为语言背后的无名英雄,GC 离我们的工作似乎越来越远。作为 Java 程序员,对这一点也许会有更深的体会,我们不需要了解太多与 GC 相关的知识,就能很好的完成工作。那还有必要深入了解 GC 吗?学习 GC 的意义在哪儿?

何为垃圾题图

不管性能提高到何种程度,GC 都需要花费一定的时间,对于实时性要求较高的场景,就必须尽量压低 GC 导致的最大暂停时间 (GC 会导致应用线程处于暂停状态),举两个例子:

  • 实时对战游戏:如果因为 GC 导致玩家频繁卡顿,任谁都会想摔手机吧。
  • 金融交易:在某些对价格非常敏感的交易(比如:外汇)场景下,如果因为 GC 导致没有按照投资者指定的价格进行交易,相信我,这些投资者非生吃了你。

但也有许多场景,GC 的最大暂停时间没那么重要,比如,离线分析、视频网站等等。因此,知道 这个 GC 算法有这样的特征,所以它适合这个场景,对程序员来说非常有价值,这也是我们学习 GC 最重要的意义。

接下来,我们将一步步走进 GC 的世界。

从诞生之初,人们就在思考 GC 需要完成的 3 件事情:何为垃圾?何时回收?如何回收?垃圾收集器在对内存进行回收前,第一件事就是要确定这些对象之中哪些还”活着“,哪些已经”死去“,而这些”死去“的对象,也就是我们所说的垃圾。

引用计数法

判断对象是否存活,其中的一种方法是给对象添加一个引用计数器,每当有一个地方引用它,计数器的值就加 1,当引用失效时,计数器的值减 1,任一时刻,如果对象的计数器值为 0,那么这个对象就不会再被使用,这种方法被称为引用计数法。

在整个回收过程中,引用计数器的值会以极快的速度更新,因而计数值的更新任务变得繁重,而且需要给计数器预留足够大的内存空间,以确保它不会溢出。引用计数法的算法很简单,但在实际运用中要考虑非常多的因素,所以它的实现往往比较复杂,更为重要的是它不能解决对象之间的循环引用问题。

举个例子,下面的代码片段展示了为什么引用计数法无法解决循环引用的问题。

public class GcDemo {
    public static void main(String[] args) {
        // 在栈中分配内存空间给obj1,然后在堆中创建GcObject对象实例A
        // 将obj1指向A实例,这时A的引用计数值 = 1
        GcObject obj1 = new GcObject();
        // 同理,GcObject实例B的引用计数值 = 1
        GcObject obj2 = new GcObject();
        // GcObject实例2被引用,所以B引用计数值 = 2
        obj1.instance = obj2;
        // 同理A的引用计数值 = 2
        obj2.instance = obj1;
        // 栈中的obj1不再指向堆中A,这时A的计数值减1,变成1
        obj1 = null;
        // 栈中的obj2不再指向堆中B,这时B的计数值减1,变成1
        obj2 = null;
    }
}

class GcObject {
    public Object instance = null;
}

仔细阅读代码中的注释,并结合下面的内存结构示意图,应该可以很好的理解其中的原因:如果 JVM 垃圾收集器采用引用计数法,当 obj1 和 obj2 不再指向堆中的实例 A、B 时,虽然 A、B 已经不可能再被访问,但彼此间相互引用导致计数器的值不为 0,最终导致无法回收 A 和 B。
引用计数示例

可达性分析

引用计数法有一个致命的问题,即无法释放有循环引用的垃圾。因此,主流的 Java 虚拟机都没有使用引用计数法来管理内存,而是通过可达性分析(Reachability Analysis)来判定对象是否存活。

可达性分析的基本思路是找到一系列被称为GC Roots的对象引用 (Reference) 作为起始节点,通过引用关系向下搜索,能被遍历到的(可到达的)对象就被判定为存活,其余对象(也就是没有被遍历到的)自然被判定为死亡。这里需要着重理解的是:可达性分析本质是找出活的对象来把其余空间判定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间,简略的示意图如下所示。
可达性分析示意图
从图中可以看出,经过可达性分析后,有不少对象没有在 GC Roots 的引用链条上,其中还包含一些相互引用的对象,这些对象在不久以后都会被垃圾收集器回收。由此可见,可达性分析算法可以有效解决引用计数法存在的致命问题。

在实际应用中,首次被标记的对象并一定会被回收,它还有自救的机会。一个对象真正的死亡至少需要经历两次标记过程:

  • 标记所有不可达对象,并进行筛选,筛选的标准是该对象覆盖了finalize()方法且finalize()方法没有被虚拟机调用过,选出的对象将被放置在一个即将被回收的队列中。稍后虚拟机会创建一个低优先级的 Finalizer 线程去遍历队列中的所有对象并执行finalize()方法
  • 对队列中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么这个对象将被移除队列,这时候还留在队列中的对象,就会被回收了。

要正确的实现可达性分析算法,就必须完整地枚举出所有的 GC Roots,否则就有可能会漏掉本应存活的对象,如果垃圾收集器错误的回收了这些被漏掉的活对象,将会造成严重的 bug。GC Roots 作为垃圾回收的起点,必须是一系列活的引用(Reference)集合,那这个集合中究竟包含哪些引用?为什么这些引用可以作为 GC Roots?要回答好这两个问题,需要对 Java 对象在内存中布局有一些初步的了解,所以,在下节会对相关知识进行补充说明。

拓展阅读

尽管引用计数法存在比较严重的问题,但仍有不少主流语言采用这一方法来管理内存,比如 Objective-C。那它们是如何解决循环依赖问题的?

在 OC 中有两个方法可以解决:

  • 开发者明确的知道某些地方存在循环引用,在合理的位置主动断开循环中的一个引用,使得对象可以被回收。
  • 使用弱引用。弱引用虽然持有对象,但并不增加引用计数,这就避免了循环引用的产生。

对于前者,需要开发者手动显式地控制,相当于回到了谁申请谁释放的年代。因此,这种方法并不常用。对于后者,貌似解决了循环引用的问题,但又引入了新的问题:弱引用什么时候被回收呢?

其实,系统对于每一个有弱引用的对象,都维护一个表来记录它所有的弱引用的指针地址。这样,当一个对象的引用计数为 0 时,系统就通过这张表,找到所有的弱引用指针,继而把它们都置成 nil。

除此之外,Xcode 还提供循环引用检测工具来帮助开发者检查代码。

你会发现,在 OC 中,即使是弱引用,仍然需要开发者关注语言层面的内存管理问题,至少你得知道什么时候使用弱引用吧。在我看来,这对开发者仍是一种负担。

最后,Java、Python 等语言都是采用可达性分析的方法来进行垃圾回收,而 Objective-C、Swift 等还是采用引用计数法。需要注意的是,不同语言都有自己的发展路径,采用何种方法来管理内存,并不存在孰优孰劣的问题。

深入理解 JVM 系列的第 8 篇,完整目录请移步:深入理解 JVM 系列
本站暂时未接入任何评论系统,如对本文有任何疑问,可移步到微信公众号评论
在微信客户端内打开右侧链接即可评论:深入理解 JVM 垃圾回收机制 - 何为垃圾?
不遗漏重要文章,欢迎关注本站公众号,手机扫描下方二维码或微信搜索 橙子成绩好
微信公众号二维码

回到首页