Java开发大厂面试第24讲:如何判断一个对象是否“死亡”?垃圾回收的算法有哪些?

73 篇文章 0 订阅

说到 Java 虚拟机不得不提的一个词就是 “垃圾回收”(GC,Garbage Collection),而垃圾回收的执行速度则影响着整个程序的执行效率,所以我们需要知道更多关于垃圾回收的具体执行细节,以便为我们选择合适的垃圾回收器提供理论支持。

我们今天分享的面试题是,如何判断一个对象是否“死亡”?垃圾回收的算法有哪些?

判断一个对象是否“死亡”主要有两种方法:

  1. 引用计数法

    • 原理:每一个对象都添加一个计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1。
    • 判断:如果对象的计数器为0,说明这个对象再也没有被引用,就可以判断为“死亡”或“可回收”。
    • 优缺点:这种方法实现简单,判定高效,但在Java中并未被采用,因为存在循环引用的问题,即两个对象相互引用,导致它们的计数器永远不为0,但实际上这两个对象都不再被外部使用。
  2. 可达性分析算法

    • 原理:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。
    • 判断:如果一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的,可以判断为“死亡”或“可回收”。
    • 应用:在Java中,这种算法是通过判断对象的引用链是否可达来判断对象是否可回收的。

垃圾回收的算法主要有以下几种:

  1. 标记-清除算法(Mark-Sweep)

    • 分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。
    • 缺点:效率较低,标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
  2. 复制算法(Copying)

    • 将内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。
    • 优点:实现简单,运行高效,不会存在内存碎片问题。
    • 缺点:内存使用率不高,只有一半的内存可用。
  3. 标记-整理算法(Mark-Compact)

    • 标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
    • 优点:解决了“标记-清除”算法的空间碎片问题。
  4. 分代收集算法(Generational Collection)

    • 根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
    • 新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
    • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。

典型回答

垃圾回收器首先要做的就是,判断一个对象是存活状态还是死亡状态,死亡的对象将会被标识为垃圾数据并等待收集器进行清除。

判断一个对象是否为死亡状态的常用算法有两个:引用计数器算法和可达性分析算法。

引用计数算法(Reference Counting) 属于垃圾收集器最早的实现算法了,它是指在创建对象时关联一个与之相对应的计数器,当此对象被使用时加 1,相反销毁时 -1。当此计数器为 0 时,则表示此对象未使用,可以被垃圾收集器回收。

引用计数算法的优缺点很明显,其优点是垃圾回收比较及时,实时性比较高,只要对象计数器为 0,则可以直接进行回收操作;而缺点是无法解决循环引用的问题,比如以下代码:

class CustomOne {
    private CustomTwo two;
    public CustomTwo getCustomTwo() {
        return two;
    }
    public void setCustomTwo(CustomTwo two) {
        this.two = two;
    }
}
class CustomTwo {
    private CustomOne one;
    public CustomOne getCustomOne() {
        return one;
    }
    public void setCustomOne(CustomOne one) {
        this.one = one;
    }
}
public class RefCountingTest {
    public static void main(String[] args) {
        CustomOne one = new CustomOne();
        CustomTwo two = new CustomTwo();
        one.setCustomTwo(two);
        two.setCustomOne(one);
        one = null;
        two = null;
    }
}

即使 one 和 two 都为 null,但因为循环引用的问题,两个对象都不能被垃圾收集器所回收。

可达性分析算法(Reachability Analysis) 是目前商业系统中所采用的判断对象死亡的常用算法,它是指从对象的起点(GC Roots)开始向下搜索,如果对象到 GC Roots 没有任何引用链相连时,也就是说此对象到 GC Roots 不可达时,则表示此对象可以被垃圾回收器所回收,如下图所示:

image (4).png

当确定了对象的状态之后(存活还是死亡)接下来就是进行垃圾回收了,垃圾回收的常见算法有以下几个:

  • 标记-清除算法;

  • 标记-复制算法;

  • 标记-整理算法。

标记-清除(Mark-Sweep)算法属于最早的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。

标记-清除算法的执行流程如下图所示:

image (5).png

从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。

标记-复制算法是标记-清除算法的一个升级,使用它可以有效地解决内存碎片化的问题。它是指将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。这样就不会产生内存碎片的问题了,其执行流程如下图所示:

image (6).png

标记-复制的算法虽然可以解决内存碎片的问题,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。

标记-整理算法的诞生晚于标记-清除算法和标记-复制算法,它也是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:

image (7).png

考点分析

本题目考察的是关于垃圾收集的一些理论算法问题,都属于概念性的问题,只要深入理解之后还是挺容易记忆的。和此知识点相关的面试题还有这些:

  • Java 中可作为 GC Roots 的对象有哪些?

  • 说一下死亡对象的判断细节?

知识扩展

GC Roots

在 Java 中可以作为 GC Roots 的对象,主要包含以下几个:

  • 所有被同步锁持有的对象,比如被 synchronize 持有的对象;

  • 字符串常量池里的引用(String Table);

  • 类型为引用类型的静态变量;

  • 虚拟机栈中引用对象;

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

死亡对象判断

当使用可达性分析判断一个对象不可达时,并不会直接标识这个对象为死亡状态,而是先将它标记为“待死亡”状态再进行一次校验。校验的内容就是此对象是否重写了 finalize() 方法,如果该对象重写了 finalize() 方法,那么这个对象将会被存入到 F-Queue 队列中,等待 JVM 的 Finalizer 线程去执行重写的 finalize() 方法,在这个方法中如果此对象将自己赋值给某个类变量时,则表示此对象已经被引用了。因此不能被标识为死亡状态,其他情况则会被标识为死亡状态。

以上流程对应的示例代码如下:

public class FinalizeTest {
    // 需要状态判断的对象
    public static FinalizeTest Hook = null;
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("执行了 finalize 方法");
        FinalizeTest.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeTest();
        // 卸载对象,第一次执行 finalize()
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 执行
        if (Hook != null) {
            System.out.println("存活状态");
        } else {
            System.out.println("死亡状态");
        }
        // 卸载对象,与上一次代码完全相同
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 执行
        if (Hook != null) {
            System.out.println("存活状态");
        } else {
            System.out.println("死亡状态");
        }
    }
}

上述代码的执行结果为:

执行了 finalize 方法
存活状态
死亡状态

从结果可以看出,卸载了两次对象,第一次执行了 finalize() 方法,成功地把自己从待死亡状态拉了回来;而第二次同样的代码却没有执行 finalize() 方法,从而被确认为了死亡状态,这是因为任何对象的 finalize() 方法都只会被系统调用一次

虽然可以从 finalize() 方法中把自己从死亡状态“拯救”出来,但是不建议这样做,因为所有对象的 finalize() 方法只会执行一次。因此同样的代码可能产生的结果是不同的,这样就给程序的执行带来了很大的不确定性。

最后

今天我们分享了对象状态判断的两种算法:引用计数算法和可达性分析算法。其中引用计数算法无法解决循环引用的问题,因此对于绝大多数的商业系统来说使用的都是可达性分析算法;同时还讲了垃圾回收的三种算法:标记-清除算法、标记-复制算法、标记-整理算法,其中,标记-清除算法会带来内存碎片的问题,而标记-复制算法会降低内存的利用率。所以,标记-整理算法算是一个不错的方案。


  • 26
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值