Android“引用们”的底层原理

本文分析基于Android 15

相信绝大多数开发者对WeakReference、SoftReference这种引用类都不会感到陌生。面试官们喜欢问:WeakReference和SoftReference有什么区别啊?一般适用于什么场景啊?但再往下就不会深究了。因为对应用开发而言,"会用"已经满足99%的需求,再多就事倍功半了。所以这种底层原理的文章大家就当饭后消遣,看不懂也没啥损失,因为它可能会有点难度。

本文会针对四种引用做底层剖析,它们分别是WeakReference、SoftReference、PhantomReference以及FinalizerReference。FinalizerReference估计没什么人听过,因为它并非Java SE中的基本类,而是ART虚拟机为了管理finalize()所人为增加的类,且这种类只会被系统使用。

在正文开始之前,有必要针对这些引用做个简单的介绍。

  • WeakReference:可以通过get()获取到referent(被引用者)对象;当referent仅被它引用时,该对象在下次GC时会被回收。

  • SoftReference:和WeakReference唯一的区别在于referent的回收时机。当referent仅被它引用时,该对象可以一直存活,直到堆内存真的被耗尽以至于马上要发生OOM时,referent才会被回收。

  • PhantomReference:和SoftReference和WeakReference相比,它无法通过get()获取到referent对象。它只会在referent回收时触发一些事件。

  • FinalizerReference:非Java SE中的基本类,创造出来的唯一目的就是管理对象的finalize()方法,因为finalize()被广泛用于对象销毁时的资源释放,虽然已被弃用,但仍大面积存在。

WeakReference

下面我们从最简单的WeakReference入手。

[Use Case]

java 复制代码
Object referent = new Object();
WeakReference weakReference = new WeakReference<>(referent);
Object value = weakReference.get();
if (value != null) {
    // GC hasn't removed the referent yet
} else {
    // GC has cleared the referent
}

用例可以引出两个问题:

  1. WeakReference构造时传入了referent对象,且Reference类中确实存在一个名为referent的引用字段。既然如此,referent对象又是如何逃脱GC标记的呢?
  2. WeakReference.get()中如何判定referent已经被回收?那如果referent正在被回收呢?

带着这两个疑问,我们来看看ART虚拟机到底是如何应对的。

首先Reference类中确实存在referent字段,但ART里做了一个特殊处理,它欺骗了GC。欺骗的方法是将Reference类的instance reference fields count减一,由于字段在内存空间里是按照字母顺序排列的,因此referent字段(r开头)恰好是Reference类最后一个实例引用字段。将instance reference fields count减一后,GC遍历时就会认为referent字段只是一个基本类型,而非引用类型,因此不会标记。

来到第二个问题,直觉告诉我Reference的get()方法绝对不会是简单地返回referent字段,因为GC中存在大量的并发,这会让处理变得复杂。事实也确实如此,Java中的get()方法最终会通过@FastNative的JNI方法调用Native层的ReferenceProcessor::GetReferent()方法,在那里会有不同情况的逻辑处理。

选择什么样的处理逻辑取决于GC的状态。绝大多数情况下,get()都是将referent字段返回(fast path),只在一个很小的时间窗口中才会走复杂逻辑(slow path)。

CMC(Concurrent Mark Compact)Collector的处理流程如上所示,其中两个标黄的圆圈(Marking Pause和Compaction Pause)代表需要暂停其他线程,也即人们常说的Stop The World,剩下的都是并发阶段。Reclaim Phase自然也属于并发阶段,它会清理没有标记过的对象,不过在正式清理之前会通过ProcessReferences对所有引用先进行处理。

ProcessReferences运行的这段时间内,其他线程通过get()获取referent就需要走slow path。其内部也分为三个阶段,分别是kStarting、kInitMarkingDone和kInitClearingDone。在kStarting阶段,其他线程在get()里需要等待,等待GC线程完成kStarting阶段,原因是这个阶段会进行"起死回生"的标记操作,它不做完,整个堆的标记状态无法最终确定。在kInitMarkingDone阶段,由于所有存活的对象都已被标记,因此可以通过referent->IsMarked来判断对象是否存活。这个阶段会遍历weak_reference_queue_soft_reference_queue_,如果发现referent没有被标记,则会将对应的Reference对象里的referent字段置为null。因此来到kInitClearingDone阶段后,get()可以直接返回referent。

SoftReference

说完WeakReference,再来说说SoftReference,它和前者唯一的区别在于referent可以穿越GC周期。既然可以穿越GC周期,那么不免就会有两个疑问:

  1. 什么时候referent必须被回收?
  2. 多数时候如何保证referent不被回收?

第一个问题可以参见如下注释。它发生在对象申请的过程中,当对象申请实在从堆中挤不出空间时,这一次的GC就要回收SoftReference了。

c++ 复制代码
// Most allocations should have succeeded by now, so the heap is really full, really fragmented,
// or the requested size is really big. Do another GC, collecting SoftReferences this time. The
// VM spec requires that all SoftReferences have been collected and cleared before throwing
// OOME.

除此之外,还有两种情况也会回收SoftReference。一种是Zygote进程,每次GC都会回收SoftReference;另一种是dex2oat,过程中也会有回收SoftReference的动作。

第二个问题其实前文回答了一部分,即ProcessReferences里对soft_reference_queue_里的referent进行标记,这样他们便可以安全度过此次GC。除了这里的标记外,Marking Phase结束前也会对soft_reference_queue_里的referent进行标记。为什么需要做两次?因为Marking Phase中完成了大部分的标记任务,而ProcessReferences发生在Marking Pause之后,soft_reference_queue_里又会有新增的对象,因此ProcessReferences需要对这些新增的小批量对象再做标记。

PhantomReference

前面所说的两种Reference都可以通过get()来获取referent对象,但PhantomReference不可以。正如其名"Phantom"(幻象)所言,它仿佛就像退化版本的Reference。如果一个Reference连指向的引用对象都无法获取,那它还有什么价值?别着急,它还有一个用处。这种用法WeakReference和SoftReference也具备,所以PhantomReference说是退化版本一点不假,它有人有,人有它无。

java 复制代码
Reference(T referent, ReferenceQueue<? super T> queue) {
    this.referent = referent;
    this.queue = queue;
}

再来看看Reference的构造方法,它支持传入一个ReferenceQueue对象,而这个queue就是那个用处。

当HeapTaskDaemon线程结束它的GC操作后,它还需要再做一件事才能去休息。这件事就是处理GC过程中所有referent被清空的Reference。本着专事专办的原则,这件事交由ReferenceQueueDaemon来做,HeapTaskDaemon只是把这些Reference加入到一个全局列表,然后唤醒ReferenceQueueDaemon。等候多时的ReferenceQueueDaemon终于来活了,它从这个列表中不断读取Reference,根据Reference的特点分三种情况处理:

  1. 如果这个Reference的类别为sun.misc.Cleaner,那么将会调用该对象的clean()方法,我之前的文章提过这种用法。
  2. 如果Reference的queue字段为空,那么不做任何处理。
  3. 其他情况下,将Reference对象加入queue字段指向的队列中,并通过queue.lock.notify来唤醒其他等待处理的工作线程。至于工作线程是谁,那就看开发者怎么写了。

对于PhantomReference而言,我们当然可以在构造时传入一个空的queue,但这样的PhantomReference就变成了废物,没有任何用处。因此但凡用的上PhantomReference的地方,一定会传一个有效的queue,用它来接收referent销毁事件。另外由于ReferenceQueueDaemon处理时GC已经结束,因此可以肯定的是,工作线程处理时referent对象已经被销毁了。

FinalizerReference

紧赶慢赶,故事终于来到最后一个章节。finalize()方法由于其使用上的便利性,一直深受开发者的喜爱。其机制上的诸多缺陷暂且按下不表(否则也不会被弃用),先来看看虚拟机是如何在对象销毁时调用finalize()的。

当一个类override finalize()方法后,那么它就被打上了kAccClassIsFinalizable的标签。这种类在创建对象时有一个额外的动作,即创建一个FinalizerReference指向它,并把FinalizerReference加入到一个全局列表中。这些FinalizerReference创建时会传入同一个queue,而等待这个queue的线程就是FinalizerDaemon,相信看过ANR trace的朋友对这个线程名不会感到陌生。

在PhantomReference那一章节,我们已经通过图解的方式展示了Reference的处理逻辑。当FinalizerDaemon被ReferenceQueueDaemon唤醒后,它便会执行referent的finalize()方法。

可是如果finalize()方法中使用了this对象怎么办?它会不会访问到已经被清理的对象?答案是不会。因为GC的ProcessReferences阶段会针对FinalizerReference将未被引用的referent重新标记。单标记还不够,因为总不能每次GC都标记吧,那样被FinalizerReference指向的对象将永远得不到回收。因此需要把FinalizerReference的referent字段移动到zombie字段,然后referent字段清空。这么做有两个目的,一是zombie字段的指向可以保证referent在未被FinalizerDaemon处理完之前不会被回收;二是referent字段清空可以让下次ProcessReferences跳过此FinalizerReference。

当FinalizerDaemon处理该对象时,除了会调用finalize()方法以外,它还会将对应的FinalizerReference从全局列表中清除,这样系统为对象自动生成的FinalizerReference也会在下次GC被清理掉。

因此我们可以说,在FinalizerDaemon未处理referent之前,referent不会被清理,因此我们可以放心地在finalize()方法中使用this对象。但假设我们在finalize()中又将this对象赋值给其他人,那么之后的referent将变成一个普通的对象,因为对应的FinalizerReference已被释放。换言之,一个对象的finalize()方法只会执行一次,不论你耍什么样的小把戏。

此外,由于finalize()方法的运行时间不可控,为了保证FinalizerDaemon不被某个任务阻塞住,系统又增加了一个名为FinalizerWatchdogDaemon的线程来监控它,当某个finalize()方法运行超过10秒,系统便会杀死进程。除了能监控FinalizerDaemon,FinalizerWatchdogDaemon还可以监控ReferenceQueueDaemon,这主要是因为ReferenceQueueDaemon中会执行Cleaner对象的clean()方法,也属于时间不可控的方法,监控的超时时间同样设置为10秒。

后记

故事的起因只是因为我接到了一个FinalizerWatchdogDaemon的报错,在解决的过程中顺带把整个引用机制研究了一下。这种以点带面的学习方法确实很好,既解决了问题,也总结了知识,不至于落入"书呆子"式死学习的陷阱。这让我想起前段时间看到的一个账号,名叫"文科生学编程",里面的视频都是对着某本编程书背诵的片段。这种账号自然是为了流量,但里面讽刺的画面也确实给人警醒。难道不明就里的抄代码就比背诵编程书好多少了么?

相关推荐
qq_429856571 分钟前
idea启动服务报错Application run failed
java·ide·intellij-idea
瑞雨溪3 分钟前
java中的this关键字
java·开发语言
Dnelic-7 分钟前
解决 Android 单元测试 No tests found for given includes:
android·junit·单元测试·问题记录·自学笔记
J不A秃V头A11 分钟前
Redisson 中开启看门狗(watchdog)机制
java·分布式锁·看门狗
草字14 分钟前
uniapp input限制输入负数,以及保留小数点两位.
java·前端·uni-app
李迟14 分钟前
某Linux发行版本无法使用nodejs程序重命名文件问题的研究
java·linux·服务器
MapleLea1f27 分钟前
26届JAVA 学习日记——Day14
java·开发语言·学习·tcp/ip·程序人生·学习方法
没有黑科技33 分钟前
基于web的音乐网站(Java+SpringBoot+Mysql)
java·前端·spring boot
爪哇学长34 分钟前
解锁API的无限潜力:RESTful、SOAP、GraphQL和Webhooks的应用前景
java·开发语言·后端·restful·graphql
佛系小嘟嘟38 分钟前
Android Studio不显示需要的tag日志解决办法《All logs entries are hidden by the filter》
android·ide·android studio