> 文章列表 > Java ~ Reference【总结】

Java ~ Reference【总结】

Java ~ Reference【总结】

一 概述


 简介

    在JDK1.2之前,Java中引用的定义是十分传统的:如果引用类型的变量中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。在这种定义之下,一个对象只有被引用和没有被引用两种状态。

    实际上,我们还希望存在这样的一类对象:当内存空间还足够的时候,这些对象能够保留在内存空间中;如果当内存空间在进行了垃圾收集之后还是非常紧张,则可以抛弃这些对象。基于这种特性,可以满足很多系统缓存功能的使用场景。

    从JDK1.2起,Java对引用概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)及终引用(Final Reference)。其中强引用就是JDK1.2之前的引用,日常代码中绝大多数引用都是强引用。而其它几种引用则是JDK1.2中引入的,这些引用有着各自的特性及作用,例如软引用就代表了上述需求的“将对象在内存空闲时保留,不足时舍弃”的功能。这些引用概念存在着相应的类实现,Java通过Java类与底层机制相结合的方式实现了这些概念,而重点是这些类又全都继承自同一个父类,也是本文真正的核心 —— Referecne(引用)抽象类(下文简称引用)。

    引用抽象类是强引用之外引用(下文简称特殊引用)概念的实质化产物,其作用在于定义并实现了特殊引用的生命周期及运行流程,使得特殊引用不再是一个虚幻的设想,而是实际独立于强/无引用之外的第三存在,为软、弱等具体特殊引用实现提供了实质性基础,该知识点会在下文详述。

 特殊引用实现思想

    事实上,作为API层面上的实现,单纯依靠引用抽象类是无法实现特殊引用的,需要JVM层面(GC)的配合。因此,与其说引用抽象类实现了特殊引用,倒不如说其为GC分辨特殊引用提供了判断依据更加合适,具体如下图所示:

Java ~ Reference【总结】

    由上图可知,通过在对象的强引用关系之间插入引用的方式,GC可以判断得出对象A/B之间存在特殊引用关系,由此就可以避开强引用的执行逻辑转而进行对特殊引用对象的特殊处理,从而实现不同于强引用的奇妙回收效果,例如“在剩余内存充裕时保留,紧张时回收”及“对象实际可达但依然被回收”等。

二 使用


 初始化

    引用抽象类由于是抽象类,因此构造方法自然是无法直接执行的,只能在子类的构造方法中调用。

  • Reference(T referent) —— 初始化指定所指对象但未注册ReferenceQueue(引用队列)类对象(下文简称引用队列)的引用。

  • Reference(T referent, ReferenceQueue<? super T> queue) —— 初始化指定所指对象且注册引用队列的引用。

 方法

  • public T get() —— 获取 —— 获取当前引用的所指对象,当所指对象不存在时返回null。所指对象初始是必然存在的,但可以在后期被清除。

  • public void clear() —— 清除 —— 清除当前引用的所指对象(即断开两者的引用关系),并不会将当前引用加入到注册引用队列中。该方法专为开发者提供,GC线程不会调用该方法断开引用与所指对象的关联。

  • public boolean isEnqueued() —— 是否入队 —— 判断当前引用是否已加入注册引用队列,是则返回true;否则返回false。引用加入注册引用队列时会将注册引用队列替换为“入队”引用队列,这是一个在引用队列类内部创建的全局静态引用队列,被作为引用加入注册引用队列的标志位来使用。因此判断当前引用是否加入注册引用队列无需遍历注册引用队列,直接判断注册引用队列是否是“入队”引用队列即可。

  • public boolean enqueue() —— 入队 —— 将当前引用加入注册引用队列中,成功返回true;否则返回false。该方法底层调用引用队列类的enqueue(Reference<? extends T> r) 方法实现。该方法专为开发者提供,“引用处理器”线程不会调用该方法将当前引用加入注册引用队列。

三 实现


Java ~ Reference【总结】

 所指对象

    所谓的所指对象即是被引用所持有的对象,每个引用在创建时必须且只能持有一个对象,这个对象将因为引用的原因被GC认为存在特殊引用,从而受到特殊处理。根据引用类型的不同(软、弱及虚等),其收到的特殊处理也不尽相同。

 引用队列

    引用在创建时可以设置/注册引用队列,当引用的所指对象被GC回收时,引用会被安排加入注册引用队列中。因此可知,注册引用队列的实际作用是追踪所指对象的GC状态,通过判断引用是否处于注册引用队列中的方式开发者可以知道其所指对象是否已被GC回收,并以此为契机来执行某些自定义操作,例如回收资源等。

    当然,引用队列的注册并非是强制的,引用抽象类提供了构造方法Reference(T referent)来创建不注册引用队列的引用。此时,引用中用于持有注册引用队列的queue(队列)字段会被默认赋值为“空”引用队列。“空”引用队列与“入队”引用队列一样,是在引用队列类内部创建的全局静态引用队列,被作为“引用未注册引用队列”及“引用退出注册引用队列”的标志位来使用(两种状态的具体区分还需要搭配其它字段,该知识点会在下文详述)。

    如果引用未注册引用队列,则其自然就无法被加入到注册引用队列中,自然而然也就无法通过该方式来判断其所指对象是否被GC回收。但话虽如此,开发者依然可以通过调用get()方法的方式来进行判断。如果引用抽象类的子类没有重写该方法或在重写时没有添加额外的限制,则可以通过判断get()方法返回值是否为null的方式来判断所指对象是否被GC回收。事实上,由于将引用加入注册引用队列需要一定的程序与步骤,因此使用get()方法进行判断通常具有更高的实时性。

 待定列表

    待定列表是全局唯一的引用列表,以全局静态变量的方式保存在引用抽象类中,即用于持有待定列表引用的pending(待定)字段是一个静态变量。实际上,待定列表是逻辑列表并非真正的列表,即pending(待定)字段持有的实际是其头节点而非类似于ArrayList(数组列表)类对象的对象。由于引用抽象类在设计上兼容了节点(即组合了用于持有后继引用的discovered(发现)字段,当引用为尾节点时该字段值为自身,即自引用),因此可以通过遍历的方式来访问整个列表,从而只要持有了头节点就相当于持有了整个列表。如此设计的原因是因为待定列表本质上被作为堆栈来使用,节点/引用的插入/移除都会在列表的头部执行(头插法/头移法),因此并没有遍历整个列表的需求,故而无需使用功能如此完善的封装类。并且因为相同的原因,我们可以知道引用在待定列表中的移除是非公平的,即晚插入的引用反而会被先移除。

    待定列表被作为引用加入其注册引用队列之前的临时存放点。我们已知的是如果引用在创建时注册了引用队列,则当其所指对象被GC回收时其会被安排加入注册引用队列中,该任务会由“引用处理器”线程来执行。“引用处理器”类是引用抽象类自定义的静态内部类,同时也是Thread(线程)类的子类,被专门设计用来作为执行“将引用加入引入队列”的任务。“引用处理器”线程在引用抽象类的静态块中创建,整个系统中仅有一条,是优先级为10(最高)的守护线程,这令其能够获得更多的CPU资源来执行任务。但所指对象的回收显然是由GC线程负责的,两者的执行速率未必一致,如此当“引用处理器”线程无法及时将每个相关引用加入注册引用队列时将会造成GC线程等待(或者其它问题,该知识点会在下文详述)。为了避免这一点,自然就需要一块空间来暂存相关引用以充当两者间的缓冲区域,待定列表起的就是这个作用。

四 Reference(引用)机制


    所谓的Reference(引用)机制指的是从引用断开与其所指对象的连接到将引用从注册引用队列移除的完整执行流程,是在引用抽象类中的静态块中实现的。在这个流程中有着GC线程、API线程及用户线程的三方参与,引用在不同阶段会处于不同的状态,具体的流程及讲解如下所示:

Java ~ Reference【总结】

    首先,引用初始会被统一保存在发现列表中。发现列表是一个由GC维护的引用列表,关于其的资料很少,源码中仅有几句描述,是属于JVM层面而非API层面的列表,目前唯一确定的是发现列表会保存所有引用,个人猜测这应该是GC为了快速检索及操作引用而采用的设计。引用位于发现列表中时处于活跃状态,由于活跃状态是引用的初始状态,并且初始状态下引用必然持有发现列表中的后继引用(发现列表与待定列表都使用discovered(发现)字段来持有后继引用,但区别是如果引用为尾节点则为null),因此可知引用再被创建时就被加入发现列表。

    发现列表中的引用会收到GC的特殊处理,或者说活跃状态的引用会收到GC的特殊处理。所谓的特殊处理,是指当引用的所指对象被GC判断为可回收时(判断条件会根据具体的引用抽象类子类的类型而有所差异),GC并不会像强引用一样直接将之回收,而是会先断开其与引用的连接(不通过引用的clear()方法实现)后再回收,并安排引用加入到注册引用队列中。GC线程首先会判断引用是否注册了引用队列,即判断queue(队列)字段持有的是否是“空”引用队列。如果是,说明未注册引用队列,直接将之从发现列表中移除,此时引用将直接变为最终的怠惰状态,意味着其已经完成了整个流程;否则意味着引用注册了引用队列,在将之从发现列表移除之后还需要以头插法的方式将之加入到待定列表中,并转变为待定状态,等待被“引用处理器”线程移除并加入注册引用队列中。因此可以知道的是,待定队列中保存的引用都是注册了引用队列的。之前我们说过,待定列表的存在是为了避免“引用处理器”线程无法及时将每个相关引用加入注册引用队列而造成的GC线程等待问题,事实上也可能是长遍历问题,即GC线程不等待“引用处理器”线程取走当前与所指对象断开连接的引用而直接向后遍历,等重遍历时在再由空闲的“引用处理器”线程将断连引用取走。但这么做必然会使得断连引用在发现列表中保留,从而在GC重遍历发现列表时需要遍历更长的长度,并且该方法也无法保证重遍历时“引用处理器”线程是空闲的。

    “引用处理器”线程会从待定列表的头部取出引用并将之加入注册引用队列中。关于“引用处理器”线程,上文已经详述过,此处就不再提及了。“引用处理器”线程所属的引用处理器类重写了run()方法,会以死循环的方式不断地从待定列表的头部取出引用并加入注册引用队列中。当待定列表中没有引用时,“引用处理器”线程会无限等待,直至待定列表存在引用后被GC线程唤醒。可以发现的是:待定列表虽然名为列表,但实际是作为堆栈来使用的,节点/引用的插入/移除都会在头部发生,这一点在上文也有所提及。如此一来造成的结果就是此处会存在线程安全问题,因为节点/引用的插入/移除分别由不同的线程执行,为此引用抽象类在此使用了类锁,即使用synchronized关键字并搭配静态变量的方式来保证线程安全。引用被从待定列表中移除后,理论上会直接加入注册引用队列中,但实际上此处还存在一个特殊判断,即判断引用是否是Cleaner(清洁工)类型(引用抽象类的孙类)。如果不是,则按正常流程将引用以头插法(队列…头插法…)加入注册引用队列中,并将引用转变为入队状态;而如果是,则该引用不会被加入注册引用队列,而是会在调用clean()方法(来自于Cleaner(清洁工)类)执行内部自定义操作后直接结束流程。该特殊操作是Reference(引用)机制的对外衍生,目的是取代不可靠的Finalization(终结)机制(但实际上两者的效果大同小异),具体内容会在Cleaner(清洁工)类/Finalizer(终结者)类中详述。

    引用加入注册引用队列中后,其注册引用队列将变为“入队”引用队列,以作为判断引用已入队的标志位。需要注意的是:引用使用next(下个)字段来持有注册引用队列的后继引用(如果引用为尾节点则自引用),这与发现/待定列表是不同的。为何如此设计的原因暂且未知,单单从API层面似乎看不出什么问题,我想可能与JVM层面的实现有关。我一开始猜测是不是因为引用可以同时存在于待定列表及发现队列中,后来发现并不能共存…后来我又猜测是因为next(下个)字段与queue(队列)字段组合共同表示引用状态的缘故,但转念一想如果继续使用discovered(发现)字段来持有注册引用队列的后继引用,则next(下个)字段完全可以替换为state(状态)字段来记录状态,这不仅不会增多字段的总数,并且状态的记录也比目前更加方便直观,因此我又否定了这个猜想。如果有知道具体原因的童鞋还望不吝赐教,鄙人不胜感激!

    加入注册引用队列的引用可作为其所指对象已被GC回收的判断依据,开发者可以此为契机执行某些自定义操作。这似乎是非常理所当然的事情,因为安排引用加入注册引用队列是所指对象被GC判定为可回收之后才会触发的行为。但如此就能判定所指对象一定已被GC回收吗?个人认为是否定的。因为Reference(引用)机制中的GC线程所做的仅仅是断开所指对象与引用的连接,真正的回收会由其它GC线程完成…那也就是说,“引用加入注册引用队列”与“所指对象被具体回收”这两者实质上是异步行为,没有任何实质证据证明引用加入注册引用队列时其所指对象一定已被GC回收。因此与其说“引用加入注册引用队列意味着其所指对象已被GC回收”,倒不如说“引用加入注册引用队列意味着其所指对象会被GC回收”更加合适…当然…这都是我个人的猜想而已。相比通过get()方法来判断所指对象是否已/会被GC回收,“引用入队”方案显然是存在延时的。上文的所有内容都在证明引用加入注册引用队列是程序性行为,这在实时性上与直接判断get()方法返回值是否为null的行为是无法比拟的,因为该判断在GC线程断开引用与所指对象的连接时就会成立…当然,这一切都建立在get()方法未被子类重写或重写时没有添加额外限制的基础上。

    开发者可以通过调用引用的isEnqueued()方法或从注册引用队列中取出引用的方式判断引用是否位于注册引用队列中,引用会从注册引用队列的头部取出,因此可知引用队列与待定列表一样,本质都被作为堆栈使用…可能大佬都是这个样子的…名字什么的差不多就可以了。当引用被用户线程从注册引用队列中取出时,意味着对于该引用而言,整个流程已彻底宣告结束。此时的引用将变为怠惰状态,表示其已经没有任何作用,只能等待着被GC回收。在这里某些童鞋可能已经发现了,如果开发者始终不将引用从注册引用队列中取出,是否意味着引用永远都不会变为怠惰状态呢?事实确实如此,虽说怠惰是引用的最终状态,但并不意味着引用一定会以怠惰的状态结束,并且上文的流程详述中也存在该类情况,在此我将对引用在Reference(引用)机制中的所有状态变化情况做一个总结,具体如下:

  • 活动 ——> 怠惰:引用未注册引用队列;
  • 活动 ——> 待定:引用注册了引用队列,但本身是Cleaner(清洁工)类型;
  • 活动 ——> 待定 ——> 入队:引用注册了引用队列,且本身不是Cleaner(清洁工)类型,但直至注册引用队列被GC回收都未曾出队;
  • 活动 ——> 待定 ——> 入队 ——> 怠惰:引用注册了引用队列,且本身不是Cleaner(清洁工)类型,并在注册引用队列被GC回收前出队。

 状态

    文中多次提及了状态,但始终没有整体性介绍这个概念,故而此处我将所有状态列举出来,具体如下所示。在引用抽象类中状态是一个很神奇的东西:一是因为其没有组合具体的字段来记录,而是通过queue(队列)字段与next(下个)字段组合表示的;二是在源码中也没有发现任何将之作为判断条件的地方…其似乎就是个单纯的概念,专门用于帮助开发者更加清晰的了解整个Reference(引用)机制…可能我还是有没有学透的地方。

    active(活动):活动状态是引用的初始状态,该状态下的引用会受到GC的“特殊照顾”。当所指对象被判定为可回收后,如果引用注册了引用队列,GC线程会将之加入到待定队列中,相当于将状态修改为了待定。而如果引用没有注册引用队列的话则会直接将其状态变为怠惰。当引用的状态为活动时,queue(队列)字段及next(下个)字段的值如下:
        queue(队列):注册引用队列或默认的"空"引用队列(如果未注册引用队列,会赋予一个默认的"空"引用队列表示未注册);
        next(下个):null(活动是唯一一个next(下个)字段为null的状态,因此可以直接通过判断next(下个)字段是否为null来判断其是否为活动状态)。

    pending(待定):当引用被GC加入待定列表时,即表示其状态变为了待定状态,该状态下的引用正等待着被“引用处理器”线程加入到注册引用队列中。由于加入待定列表是将引用加入到注册引用队列的前置步骤,因此可知只有注册了引用队列的引用才能够转变为这个状态。当引用的状态为待定时,queue(队列)字段及next(下个)字段的值如下:
        queue(队列):注册引用队列(待定状态下的引用必然注册了引用队列);
        next(下个):this(即自引用)。

    enqueue(入队):当引用被“引用处理器”线程加入注册引用队列时,即表示其状态变为了入队状态,这个状态下的引用正等待着被用户线程从注册引用队列中取出。当入队状态下的引用被用户线程从注册引用队列移除时,意味着其变为了怠惰状态,同时也意味着一个引用可能永远都不会变为怠惰…如果其永远都没有出队的话。当引用的状态为入队时,queue(队列)字段及next(下个)字段的值如下:
        queue(队列):“入队”引用队列(一个与“空”引用队列相同的预定值,表示当前引用已经加入的注册引用队列,可用作引用是否入队的判断条件);
        next(下个):注册引用队列中的后继引用,如果当前引用为尾节点则为this(即自引用)。

    inactive(怠惰):引用转变为怠惰状态有两种方式:一是因为没有注册引用队列而被GC线程设置;二是其从注册引用队列中出队时被用户线程设置。一个状态已经被转变为怠惰的引用意味着其已经没有什么事情可以做了,处于生命周期的尾声,存在的唯一意义就是等待被GC回收。当引用的状态为怠惰时,queue(队列)字段及next(下个)字段的值如下:
        queue(队列):"空"引用队列("空"引用队列即是引用未注册引用队列的默认值,也是其从注册引用队列中出队时会被赋予的终结值,因此"空"引用队列有着双重含义);
        next(下个):this(即自引用)。

五 相关系列


  • 《Java ~ Reference【目录】》
  • 《Java ~ Reference【源码】》