JVM 系列(6)吊打面试官:为什么 finalize() 方法只会执行一次?

简介: JVM 系列(6)吊打面试官:为什么 finalize() 方法只会执行一次?

前言


Java Finalizer 机制提供了一个在对象被回收之前释放占用资源的时机,但是都说 Finalizer 机制是不稳定且危险的,不推荐使用,这是为什么呢?今天我们来深入理解这个问题。


学习路线图:


image.png


1. 认识 Finalizer 机制


1.1 为什么要使用 Finalizer 机制?


Java 的 Finalizer 机制的作用在一定程度上是跟 C/C++ 析构函数类似的机制。当一个对象的生命周期即将终结时,也就是即将被垃圾收集器回收之前,虚拟机就会调用对象的 finalize() 方法,从而提供了一个释放资源的时机。


1.2 Finalizer 存在的问题


虽然 Java Finalizer 机制是起到与 C/C++ 析构函数类似的作用,但两者的定位是有差异的。C/C++ 析构函数是回收对象资源的正常方式,与构造函数是一一对应的,而 Java Finalizer 机制是不稳定且危险的,不被推荐使用的,因为 Finalizer 机制存在以下 3 个问题:


  • 问题 1 - Finalizer 机制执行时机不及时: 由于执行 Finalizer 机制的线程是一个守护线程,它的执行优先级是比用户线程低的,所以当一个对象变为不可达对象后,不能保证一定及时执行它的 finalize() 方法。因此,当大量不可达对象的 Finalizer 机制没有及时执行时,就有可能造成大量资源来不及释放,最终耗尽资源;
  • 问题 2 - Finalizer 机制不保证执行: 除了执行时机不稳定,甚至不能保证 Finalizer 机制一定会执行。当程序结束后,不可达对象上的 Finalizer 机制有可能还没有运行。假设程序依赖于 Finalizer 机制来更新持久化状态,例如释放数据库的锁,就有可能使得整个分布式系统陷入死锁;
  • 问题 3 - Finalizer 机制只会执行一次: 如果不可达对象在 finalize() 方法中被重新启用为可达对象,那么在它下次变为不可达对象后,不会再次执行 finalize() 方法。这与 Finalizer 机制的实现原理有关,后文我们将深入虚拟机源码,从源码层面深入理解。


1.3 什么时候使用 Finalizer 机制?


由于 Finalizer 机制存在不稳定性,因此不应该将 Finalizer 机制作为释放资源的主要策略,而应该作为释放资源的兜底策略。程序应该在不使用资源时主动释放资源,或者实现 AutoCloseable 接口并通过 try-with-resources 语法确保在有异常的情况下依然会释放资源。而 Finalizer 机制作为兜底策略,虽然不稳定但也好过忘记释放资源。

不过,Finalizer 机制已经被标记为过时,使用 Cleaner 机制作为释放资源的兜底策略(本质上是 PhantomReference 虚引用)是相对更好的选择。虽然 Cleaner 机制也存在相同的不稳定性,但总体上比 Finalizer 机制更好。


2. Finalizer 机制原理分析


从这一节开始,我们来深入分析 Java Finalizer 机制的实现原理,相关源码基于 Android 9.0 ART 虚拟机。


2.1 引用实现原理回顾


在上一篇文章中,我们分析过 Java 引用类型的实现原理,Java Finalizer 机制也是其中的一个环节,我们先对整个过程做一次简单回顾。

image.png

2.2 创建 FinalizerReference 引用对象


我们都知道 Java 有四大引用类型,除此之外,虚拟机内部还设计了 @hideFinalizerReference 类型来支持 Finalizer 机制。Reference 引用对象是用来实现更加灵活的对象生命周期管理而设计的对象包装类,Finalizer 机制也与对象的生命周期有关,因此存在这样 “第 5 种引用类型” 也能理解。


在虚拟机执行类加载的过程中,会将重写了 Object#finalize() 方法的类型标记为 finalizable 类型。每次在创建标记为 finalizable 的对象时,虚拟机内部会同时创建一个关联的 FinalizerReference 引用对象,并将其暂存到一个全局的链表中 (如果不存在全局的变量中,没有强引用持有的 FinalizerReference 本身在下次 GC 直接就被回收了)。

heap.cc


void Heap::AddFinalizerReference(Thread* self, ObjPtr<mirror::Object>* object) {
    ScopedObjectAccess soa(self);
    ScopedLocalRef<jobject> arg(self->GetJniEnv(), soa.AddLocalReference<jobject>(*object));
    jvalue args[1];
    args[0].l = arg.get();
    // 调用 Java 层静态方法 FinalizerReference#add
    InvokeWithJValues(soa, nullptr, WellKnownClasses::java_lang_ref_FinalizerReference_add, args);
    *object = soa.Decode<mirror::Object>(arg.get());
}
复制代码

FinalizerReference.java

// 关联的引用队列
public static final ReferenceQueue<Object> queue = new ReferenceQueue<Object>();
// 全局链表头指针(使用一个双向链表持有 FinalizerReference,否则没有强引用的话引用对象本身直接就被回收了)
private static FinalizerReference<?> head = null;
private FinalizerReference<?> prev;
private FinalizerReference<?> next;
// 从 Native 层调用
public static void add(Object referent) {
    // 创建 FinalizerReference 引用对象,并关联引用队列
    FinalizerReference<?> reference = new FinalizerReference<Object>(referent, queue);
    synchronized (LIST_LOCK) {
        // 头插法加入全局单链表
        reference.prev = null;
        reference.next = head;
        if (head != null) {
            head.prev = reference;
        }
        head = reference;
    }
}
public static void remove(FinalizerReference<?> reference) {
    // 从双向链表中移除,代码略
}
复制代码

2.3 在哪里执行 finalize() 方法?


根据我们对引用队列的理解,当我们在创建引用对象时关联引用队列,可以实现感知对象回收时机的作用。当引用指向的实际对象被垃圾回收后,引用对象会被加入引用队列。那么,是谁在消费这个引用队列呢?


在虚拟机启动时,会启动一系列守护线程,其中除了处理引用入队的 ReferenceQueueDaemon 线程,还包括执行 Finalizer 机制的 FinalizerDaemon 线程。FinalizerDaemon 线程会轮询观察引用队列,并执行实际对象上的 finalize() 方法。


提示: FinalizerDaemon 是一个守护线程,因此 finalize() 的执行优先级低。

Daemons.java


public static void start() {
    // 启动四个守护线程
    ReferenceQueueDaemon.INSTANCE.start();
    FinalizerDaemon.INSTANCE.start();
    FinalizerWatchdogDaemon.INSTANCE.start();
    HeapTaskDaemon.INSTANCE.start();
}
// 已简化
private static class FinalizerDaemon extends Daemon {
    private static final FinalizerDaemon INSTANCE = new FinalizerDaemon();
    // 这个队列就是 FinalizerReference 关联的引用队列
    private final ReferenceQueue<Object> queue = FinalizerReference.queue;
    FinalizerDaemon() {
        super("FinalizerDaemon");
    }
    @Override public void runInternal() {
        while (isRunning()) {
            // 1、从引用队列中取出引用
            FinalizerReference<?> finalizingReference = (FinalizerReference<?>)queue.poll();
            // 2、执行引用所指向对象 Object#finalize()
            doFinalize(finalizingReference);
            // 提示:poll() 是非阻塞的,FinalizerDaemon 是与 FinalizerWatchDogDaemon 配合实现等待唤醒机制的
        }
    @FindBugsSuppressWarnings("FI_EXPLICIT_INVOCATION")
    private void doFinalize(FinalizerReference<?> reference) {
        // 2.1 移除 FinalizerReference 对象
        FinalizerReference.remove(reference);
        // 2.2 取出引用所指向的对象(不可思议,为什么不为空呢?)
        Object object = reference.get();
        // 2.3 解除关联关系
        reference.clear();
        // 2.4 调用 Object#finalize()
        object.finalize();
    }
}
复制代码


这里你有发现问题吗,当普通的引用对象在进入引用队列时,虚拟机已经解除了引用对象与实际对象的关联,此时调用 Reference#get() 应该返回 null 才对。 但 FinalizerReference#get() 居然还能拿到实际对象,实际对象不是已经被回收了吗!? 这只能从源码中寻找答案。


2.4 FinalizerReference 引用对象入队过程


由于标记为 finalizable 的对象在被回收之前需要调用 finalize() 方法,因此这一类对象的回收过程比较特殊,会经历两次 GC 过程。我将整个过程概括为 3 个阶段:

  • 阶段 1 - 首次 GC 过程: 当垃圾收集器发现对象变成不可达对象时,会解绑实际对象与引用对象的关联关系。当实际对象被清除后,会将引用对象加入关联的引用队列(这个部分我们在上一篇文章中分析过了)。然而,finalizable 对象还需要调用 finalize() 方法,所以首次 GC 时还不能回收实际对象。为此,垃圾收集器会主动将原本不可达的实际对象重新标记为可达对象,使其从本次垃圾收集中豁免,并且将实际对象临时保存到 FinalizerReference 的 zombie 字段中。实际对象与 FinalizerReference 的关联关系依然会解除,否则会陷入死循环永远无法回收;
  • 阶段 2 - FinalizerDaemon 执行 finalize() 方法: FinalizerDaemon 守护线程消费引用队列时,调用 ReferenceQueue#get() 只是返回暂存在 zombie 字段中的实际对象而已,其实此时关联关系早就解除了(这就是为什么 FinalizerReference#get() 还可以获得实际对象)。
  • 阶段 3 - 二次 GC: 由于实际对象和 FinalizerReference 已经没有关联关系了,第二次回收过程跟普通对象相同。前提是 finalize() 中将实际对象重新变成可达对象,那么二次 GC 不会那么快执行,要等到它重新变为不可达状态。

提示: 这就是为什么 finalize() 方法只会执行一次,因为执行 finalize() 时实际对象和 FinalizerReference 已经解除关联了,后续的垃圾回收跟普通的非 finalizable 对象一样。


源码摘要如下:


垃圾收集器清理过程:

方法调用链:ReclaimPhase→ProcessReferences→ReferenceProcessor::ProcessReferences→ReferenceQueue::EnqueueFinalizerReferences

reference_queue.cc


void ReferenceQueue::EnqueueFinalizerReferences(ReferenceQueue* cleared_references, collector::GarbageCollector* collector) {
    while (!IsEmpty()) {
        ObjPtr<mirror::FinalizerReference> ref = DequeuePendingReference()->AsFinalizerReference();
        mirror::HeapReference<mirror::Object>* referent_addr = ref->GetReferentReferenceAddr();
        // IsNullOrMarkedHeapReference:判断引用指向的实际对象是否被标记
        if (!collector->IsNullOrMarkedHeapReference(referent_addr, /*do_atomic_update*/false)) {
            // MarkObject:重新标记位可达对象
            ObjPtr<mirror::Object> forward_address = collector->MarkObject(referent_addr->AsMirrorPtr());
            // 将实际对象暂存到 zombie 字段
            ref->SetZombie<false>(forward_address);
            // 解除关联关系(普通引用对象亦有此操作)
            ref->ClearReferent<false>();
            // 将引用对象加入 cleared_references 队列(普通引用对象亦有此操作)
            cleared_references->EnqueueReference(ref);
        }
        DisableReadBarrierForReference(ref->AsReference());
    }
}
复制代码


实际对象暂存在 zombie 字段中:

FinalizerReference.java

// 由虚拟机维护,用于暂存实际对象
private T zombie;
// 2.2 取出引用所指向的对象(其实是取 zombie 字段)
@Override public T get() {
    return zombie;
}
// 2.3 解除关联关系,实际上虚拟机内部早就解除关联关系了,这里只是返回暂存在 zombie 中的实际对象
@Override public void clear() {
    zombie = null;
}
复制代码


至此,Finalizer 机制实现原理分析完毕。

使用一张示意图概括整个过程:


image.png


3. 总结


总结一下 Finalizer 机制最主要的环节:


  • 1、为了实现对象的 Finalizer 机制,虚拟机设计了 FinalizerReference 引用类型。重写了 Object#finalize() 方法的类型在类加载过程中会被标记位 finalizable 类型,每次创建对象时会同步创建关联的 FinalizerReference 引用对象;
  • 2、不可达对象在即将被垃圾收集器回收时,虚拟机会解除实际对象与引用对象的关联关系,并将引用对象加入关联的引用队列中。然而,由于 finalizable 对象还需要执行 finalize() 方法,因此垃圾收集器会主动将对象标记为可达对象,并将实际对象暂存到 FinalizerReference 的 zombie 字段中;
  • 3、守护线程 ReferenceQueueDaemon 会轮询全局临时队列 unenqueued 队列,将引用对象分别投递到关联的引用队列中
  • 4、守护线程 FinalizerDaemon 会轮询观察引用队列,并执行实际对象上的 finalize() 方法。
目录
相关文章
|
3天前
|
SQL 缓存 监控
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
本文详细解析了数据库、缓存、异步处理和Web性能优化四大策略,系统性能优化必知必备,大厂面试高频。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
|
4天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
8天前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
41 4
|
28天前
|
存储 监控 算法
美团面试:说说 G1垃圾回收 底层原理?说说你 JVM 调优的过程 ?
尼恩提示: G1垃圾回收 原理非常重要, 是面试的重点, 大家一定要好好掌握
美团面试:说说 G1垃圾回收 底层原理?说说你 JVM 调优的过程  ?
|
2月前
|
安全 Java 应用服务中间件
JVM常见面试题(三):类加载器,双亲委派模型,类装载的执行过程
什么是类加载器,类加载器有哪些;什么是双亲委派模型,JVM为什么采用双亲委派机制,打破双亲委派机制;类装载的执行过程
JVM常见面试题(三):类加载器,双亲委派模型,类装载的执行过程
|
1月前
|
Java 应用服务中间件 程序员
JVM知识体系学习八:OOM的案例(承接上篇博文,可以作为面试中的案例)
这篇文章通过多个案例深入探讨了Java虚拟机(JVM)中的内存溢出问题,涵盖了堆内存、方法区、直接内存和栈内存溢出的原因、诊断方法和解决方案,并讨论了不同JDK版本垃圾回收器的变化。
26 4
|
1月前
|
Java API 对象存储
JVM进阶调优系列(2)字节面试:JVM内存区域怎么划分,分别有什么用?
本文详细解析了JVM类加载过程的关键步骤,包括加载验证、准备、解析和初始化等阶段,并介绍了元数据区、程序计数器、虚拟机栈、堆内存及本地方法栈的作用。通过本文,读者可以深入了解JVM的工作原理,理解类加载器的类型及其机制,并掌握类加载过程中各阶段的具体操作。
|
1月前
|
存储 缓存 JavaScript
JVM面试真题总结(一)
JVM面试真题总结(一)
|
28天前
|
存储 Kubernetes 架构师
阿里面试:JVM 锁内存 是怎么变化的? JVM 锁的膨胀过程 ?
尼恩,一位经验丰富的40岁老架构师,通过其读者交流群分享了一系列关于JVM锁的深度解析,包括偏向锁、轻量级锁、自旋锁和重量级锁的概念、内存结构变化及锁膨胀流程。这些内容不仅帮助群内的小伙伴们顺利通过了多家一线互联网企业的面试,还整理成了《尼恩Java面试宝典》等技术资料,助力更多开发者提升技术水平,实现职业逆袭。尼恩强调,掌握这些核心知识点不仅能提高面试成功率,还能在实际工作中更好地应对高并发场景下的性能优化问题。
|
2月前
|
ARouter 测试技术 API
Android经典面试题之组件化原理、优缺点、实现方法?
本文介绍了组件化在Android开发中的应用,详细阐述了其原理、优缺点及实现方式,包括模块化、接口编程、依赖注入、路由机制等内容,并提供了具体代码示例。
44 2