深入Java虚拟机(五)垃圾收集

561 阅读36分钟

Java 虚拟机的堆里存放着程序运行中所创建的所有对象。虚拟机可以使用newnewarrayanewarraymultianewarray指令来创建对象,但是没有明确的代码来释放它们。垃圾收集就是自动释放不再被程序所使用的对象的过程。

本篇文章并不是要描述正式的 Java 垃圾收集器,因为根本不存在这样一个正式的描述。前面说过,Java 虚拟机规范不要求任何特定的垃圾收集技术,这根本不是必需的。但是在发明可以无限使用的内存前,大部分的 Java 虚拟机都会附带垃圾收集功能

为什么要使用垃圾收集

垃圾收集这个名字暗示着程序不再需要的对象就是垃圾,可以被丢弃。更准确地说,应该被叫做内存回收。当一个对象不再被程序所引用时,它所使用的堆空间可以被回收,一遍后续的新对象所使用。

垃圾收集器必须能够确认哪些对象是不再被引用的,并且能够把它们所占据的堆空间释放出来。在释放对象的过程中,垃圾收集器还要运行将要被释放对象的终结方法(finalizer)。

除了释放不再被引用的对象,垃圾收集器还要处理堆碎块。堆碎块是在程序运行过程中产生的。在请求分配新对象的内存空间时,可能不得不增大堆空间的大小,虽然可以使用的总空闲空间是足够的。这是因为堆中的空闲空间并不连续,无法放下一个新的对象。

把内存回收的任务交给虚拟机有几个好处:

  • 提高生产效率。在一个不具备垃圾收集机制的语言下编程时,你可能需要花费好多时间来查找难以琢磨的内存问题(C/C++用得少,没机会体验。。。。。)
  • 保持程序的完整性。垃圾回收技术是Java安全策略的一个重要部分,Java程序员不可能因为错误释放内存而导致Java虚拟机的崩溃

但是使用垃圾收集,有一个潜在的缺陷就是加大了程序的负担,可能影响程序性能。Java虚拟机必须追踪哪些对象正在使用,哪些对象需要释放。和明确释放内存比起来,内存释放过程还会需要更多的CPU时间片。

垃圾收集算法

任何垃圾收集算法必须做两件事:

  • 检测垃圾对象
  • 回收垃圾对象所使用的堆空间并还给程序

根对象(Root)

垃圾检测通常通过建立一个根对象的集合并且检查从这些跟对象开始的可触及性来实现。如果正在执行的程序可以访问到跟对象和某个对象之间存在引用路径,这个对象就是可触及的。对于程序来说,跟对象总是可以访问的。从这些根对象开始,任何可以被触及的对象都被认为是活动的对象。无法被触及的对象被认为是垃圾,它们不再被程序使用到。

任何根对象引用的对象都是可触及的,从而是活动的。另外,任何被活动的对象引用对象都是可触及的。程序可以访问任何可触及的对象,所以这些对象必须保存在堆里面。任何不可触及的对象都可以被收集,因为程序被办法访问它们。

来源

Java 虚拟机的根对象集合根据实现方式各有不同,但是总会包含局部变量中的对象引用和栈帧的操作数栈(以及类变量中的对象引用)。关于根对象的来源大概有这几种:

  • 一个来源是用类的常量池中的对象引用,比如字符串。被加载的类的常量池可能指向保存在堆中的字符串,比如类名字、超类的名字、字段名、字段特征签名、方法名或者方法特征签名。
  • 还有一个来源是传递到本地方法中的,没有被本地方法释放的对象引用(根据本地方法接口,本地方法可以通过简单地返回来释放引用;或者显示地调用一个回调函数来释放传递来的引用,或者是这两者的结合)
  • 再一个潜在的对象来源是Java虚拟机的运行时数据区中从具有垃圾收集功能的堆中分配的部分。举例来说,在某些实现中,方法区中的类数据本身可能被存放在使用垃圾收集器的堆中,以便使用和对象同样的垃圾回收算法检测和卸载不再使用的类。

说实话这三条咋这么抽象嘞,先往下看看把

基本类型的干扰

在 Java 虚拟机的实现中,有些垃圾收集器可以区分真正的对象引用和看上去很像合法对象引用的基本类型(比如一个 int 变量)之间的差别。(例如一个 int 整数,如果被解释是一个本地指针,可能指向堆中的一个对象)可是某些垃圾收集器仍然选择不区分真正的对象引用和伪装品,这种垃圾收集器被称为保守的,因为它们可能做不到释放掉每一个不再引用的对象。对于保守的收集器,有时候垃圾对象也被错误的判断为活动的,因为有一个看上去像是对象引用的基本类型"引用"了对象。这种保守的垃圾收集器是垃圾回收速度提高了,因为有一些垃圾被遗忘了。

基础算法分类

区分活动对象和垃圾对象的两个基本方法是引用计数和跟踪。

  • 引用计数垃圾收集器通过堆中的每一个对象保存一个计数来区分活动对象和垃圾对象。这个计数记录下了对象被引用的次数。
  • 跟踪垃圾收集器是追踪从根节点开始的引用图。在追踪过程中遇上的对象以某种方式打上标记,当追踪结束,没有被打上标记的对象就被判定为不可触及的,可以被回收。

引用计数收集器

引用计数是垃圾回收的早期策略。在这种方法中,堆中每一个对象都有一个引用计数。

规则

规则包括:

  • 当一个对象被创建,并且将指向该对象的引用分配给一个变量,这个对象的引用计数被置为1。
  • 当任何其他变量被赋值为这个对象的引用时,计数加1。
  • 当一个对象的引用超过了生存期或者被置为一个新的值时,对象的引用计数减1。
  • 任何计数为0的对象都可以被当做垃圾回收。
  • 当一个对象被回收时,它所引用的对象的计数也要减1。

在这种方法中,一个对象被垃圾收集后可能会触发后续其他对象的垃圾收集行动。

弊端

引用计数无法检测出循环引用(即两个或者更多对象之间的相互引用)

循环引用的例子如

class A{
  public B b;
}
class B{
  public A a;
}
public class Main{
    public static void main(String[] args){
        A a = new A();
        B b = new B();
        a.b=b;
        b.a=a;
        a=null;
        b=null;
    }
}

a 和 b 虽然置为了 null,但是按照引用计数的规则永远不会被收集,因为 a 和 b 分别持有各自的引用。

跟踪收集器

跟踪收集器是追踪从根节点开始的对象引用图。在追踪过程中遇到的对象以某种方式打上标记。标记时,要么在对象本身设置标记,要么用一个独立的位图来设置标记。当追踪结束时,未被标记的对象就知道是无法触及的,从而可以被收集。

基本的追踪算法被称作标记-清除算法。这个名字指出了垃圾收集过程的两个阶段:

  • 标记阶段:垃圾收集遍历引用树,标记每一个遇到的对象。
  • 清除阶段:释放未被标记对象所占用的内存。这个阶段就要触发对象的终结方法。

压缩收集器

Java虚拟机的垃圾收集器可能有对付堆碎块的策略。标记-清除收集器通常使用的两种策略是压缩和拷贝。这两种方式都是通过快速地移动对象来减少堆碎块。

压缩收集器把活动的对象越过空闲区移动到堆的另一端,这样堆的另一端会出现一个大的连续空间。此后,所有被移动的对象的引用也会被更新,指向新的内存地址。

更新被移动对象的引用有时候会通过一个间接对象引用层,不直接引用堆中的对象,对象的引用实际上指向一个对象句柄表。对象句柄才是真正指向堆中对象的实际位置。当对象被移动了,只需要更新句柄表就可以。不过在对象的访问上,因为增加了一个句柄表,性能有所损失。

拷贝收集器

拷贝垃圾收集器把所有的活动对象都移动到一个新的区域。在拷贝的过程中,它们被紧挨着布置,所以可以消除原本它们在旧区域的空隙。而原有的区域被认为是空闲区。

这种方法的好处就是从根对象开始遍历的过程中,一旦发现对象就进行拷贝,不再有标记和清除的区分。对象被快速拷贝到新区域,同时转向指针仍然留在原来的位置。转向指针可以让垃圾收集器发现已经被转移对象的引用。然后垃圾收集器把和这个对象有关的引用设置为转向指针的值。

一般的拷贝收集器算法被称为停止-拷贝。方案如下:

  • 堆被分为两个区域,任何时候都只是用其中的一个区域。
  • 对象在同一个区域分配,直到这个区域被耗尽。
  • 此时,程序执行被终止,堆开始遍历,遍历时遇到的对象被拷贝到另外的一个区域。
  • 停止-拷贝过程结束后,程序恢复执行。
  • 内存将从新的堆区域开始分配,直到它也被用尽。这时程序再次中止,重复上面的步骤。

这种方法的代价就是,对于指定大小的堆来说,实际上需要两倍的内存来运行。

图形描述如下:

image
上图一共是9张堆内存快照:

  • 快照1中,堆的下半部分没有被使用,上半部分零散的被对象填充(包含对象的部分用橘黄色表示)
  • 快照2中,随着程序的运行,上半部分逐渐被对象填充。
  • 快照3中,对象已经把上半部分填满了。
  • 快照4中,因为快照3已经把上半部分填满了,此时,垃圾收集器停止程序执行,从根节点开始追踪活动对象图。当遇到活动的对象时就拷贝到堆的下半部分,并且每一个对象都紧挨着。
  • 快照5中,垃圾收集刚刚结束,程序已经恢复运行。上半部分被清理完成,并且作为未被使用部分。之前存活的对象移动到了下半部分。
  • 快照6中,随着程序的运行,下半部分逐渐被对象填充。
  • 快照7中,对象已经把下半部分填满了。
  • 快照8中,垃圾收集器再次中止了程序,追踪活动对象。这次它把遇到的活动对象都拷贝到堆的上半部分。
  • 快照9中,垃圾收集完成,下半部分垃圾对象被清理,变为了未使用区。之前存活的对象移动到了上半部分。

在程序执行中,这个上述过程一次有一次地重复。

按代收集的收集器

简单的停止-拷贝收集器的缺点是,每一次收集时,所有的活动对象都必须被拷贝。大部分语言的大多数程序都有一下特点,如果我们全面考虑这些,拷贝算法是可以改进的。

  • 大部分程序创建的大部分对象都具有很短的生命期。
  • 大多数程序都会创建一些具有非常长生命周期的对象。

简单的停止-拷贝收集器浪费效率的一个主要原因就是,它们每次把这些生命周期很长的对象来回拷贝,消耗大量的时间

按代收集的收集器通过把对象按照寿命来分组解决停止-拷贝效率低下的问题,更多地收集那些短暂出现的年幼对象,而非寿命比较长的对象。逻辑如下:

  • 堆被划分成两个或者更多的子堆
  • 每一个堆为一代的对象服务
  • 最年幼的那一代进行最频繁的垃圾收集。因为大多数对象都是短暂出现的,只有很少一部分年幼对象在经历第一次收集后还存活
  • 如果一个最年幼的对象在经历了好几次垃圾收集后仍然存活,那么这个对象就成长为寿命更到的一代:转移到另外一个子堆中
  • 年龄更高的每一代的收集都没有年轻的那一代来的频繁
  • 每当对象在它所属的年龄层中变得成熟(逃过了多次垃圾收集)之后,它们就会被转移到更高的年龄层中

按代收集技术除了可以应用于停止-拷贝垃圾收集算法,也可以用于标记-清除垃圾回收算法。不管哪种情况下,把堆按照年龄层分解都可以提高最基本的垃圾收集算法的性能。

自适应收集器

自适应收集器算法李永乐如下事实:在某种情况下某些垃圾收集算法工作的更好,而另外一些收集算法在另外的情况下工作得更好

自适应算法监视堆中的情形,并对应的调整为合适的垃圾收集技术。

使用自适应方法,Java虚拟机的实现者不需要只选择一种特定的算法。可以使用多种技术,以便在最擅长的场合使用它们。

火车算法(train GC

火车算法最早是有Richard HudsonEliot Moss提出的,目的是为了在成熟对象空间提供限定时间的渐进收集,最早用于Sun公司的Hotspot虚拟机。该算法详细的说明了按代收集的垃圾收集器的成熟对象空间的组织。

唏嘘的是到Sun JDK 6的时候就已经彻底不包含train GC了,不过更重要的是思想,还是看一看吧

以往收集算法存在的问题

垃圾收集算法和主动释放对象内存比起来有一个潜在的缺点,即垃圾收集算法中程序员对安排 CPU 时间进行内存回收的过程缺乏控制。

要精确的预测出何时进行垃圾收集、收集需要多长时间基本上是不可能。因为垃圾收集一般会中止整个程序来查找和收集垃圾对象,它们可能在程序执行的任意时刻触发垃圾收集,并且中止的时间也无法确定。这种垃圾收集的暂停有时候长得让用户注意到了。

而当一种垃圾收集算法可能导致用户可察觉到的停顿或者使得程序无法满足实时系统的要求,这种算法被称作破坏性的。

渐进式收集算法

达到非破坏性垃圾收集的方法是使用渐进式垃圾收集算法。

渐进式垃圾收集器就是不试图一次性发现并回收所有的垃圾对象,而是每次发现并回收一部分。因此每次都只有堆的一部分执行垃圾收集,因此理论上说的每一次收集会持续更短的时间。

如果能够保证每次收集不超过一个最大时间长度,就可以让Java虚拟机适合实时环境,并且也可以消除用户可察觉的停顿。

通常渐进式收集器都是按代收集的收集器。

车厢、火车和火车站

火车算法把成熟的对象空间划分为固定长度的内存块,算法每次在一个块中单独执行。规则如下:

  • 每个内存块属于某一个集合,并在集合中有序排列。
  • 集合与集合之间有序排列。

在原始论文中,内存块叫做车厢;集合叫做火车。成熟对象的内存空间叫做火车站

算法组织图如下:

image

命名计划

火车按照它们创建时的顺序分配号码。
因此,假设我们将第一列火车(最先进入该年龄层的对象内存)被拉进轨道1,称为火车1。到达的第二辆火车被拉到轨道二,称为火车2。下一列到达的火车被拉到轨道3。
依次类推,按照这样的计划,号码较小的火车总是更早出现的火车。

在火车内部,车厢(内存块)总是附加到火车的尾部。
附加的第一节车厢被称为车厢1,这列车附加的下一节车厢被称为车厢2。 因此,在列车内部,较小的数字总能表示更早出现的车厢。

这个命名计划给出了成熟对象空间中内存块的总体顺序。

上图中显示了三列车,标记为火车1火车2火车3

  • 火车1拥有四节车厢,标记为 1.1-1.4
  • 火车2拥有三节车厢,标记为 2.1-2.3
  • 火车3拥有五节车厢,标记为 3.1-3.5

而对于加入的顺序为:

  • 车厢1.1在车厢1.2的前面,车厢1.2在车厢1.4的前面,以此类推。
  • 火车1的最后一节车厢总是在火车2的第一节车厢前面,所以车厢1.4在车厢2.1之前。同理,车厢2.1在车厢3.1之前。

火车算法每一次执行的时候,只会对一个块(号码最低的块)执行垃圾收集。对于上图,它会收集车厢1.1,下次执行时会收集车厢1.2。当它收集了火车1的最后一个车厢,算法在下一次执行时收集火车2的车厢2.1。(从这部分看,在收集完一个车厢后,算法应该是要把收集过的车厢移走)。

对象从更年轻的年龄层的子堆进入成熟对象空间,不管何时进入,它们都会被附加到任何已经存在的火车中(最小数字的火车除外),或者专为容纳它们而建立的一列或多列火车中。也就是说,对象有两种方法到达火车站:

  • 打包成车厢,挂接在==最小数字的火车==之外的火车尾部
  • 作为一列新的火车开进火车站

==最小数字的火车除外==是为什么呢?
因为算法始终检测的是最小数字的火车,或者最小数字的火车最小数字的车厢。这列火车不会直接存放刚刚进入火车站的对象。看下面的车厢收集就明白啦!

车厢收集

每一次算法被执行的时候,它要么收集最小数字火车中的最小数字车厢,要么收集整列最小数字火车。思路如下:

  • 首先检查指向最小数字火车中任何车厢的引用,如果不存在任何来自最小数字火车外的引用指向最小数字火车内部的对象,那么整列火车包含的都是垃圾对象,可以抛弃。
  • 如果最小数字火车并不都是垃圾,那么算法把它的注意力放到火车的最小数字车厢上。在这个过程中,算法将检测到的被引用的对象转移到其他车厢,然后任何保留着车厢里的对象都是可回收的。

我们知道有一种循环引用的问题,而对于火车算法来说,保证整列火车中没有循环的数据结构的关键是算法如何移动对象,包括下面几个规则:

  • 如果正在被收集的==车厢==(这个车厢其实就是最小数字火车最小数字车厢啦)中有一个对象存在来自火车站外的引用,这个对象就被转移到正在被收集的火车之外的其他车厢中去。
  • 如果对象被火车站中的其他火车引用,对象就被转移到引用它的火车中去。
    • 然后扫描被转移过去的对象,把它在原车厢所引用的的对象都转移到引用它们的车厢,这个过程不断重复,直到没有任何来自其他火车的引用指向正在被收集的那节车厢。
    • 如果接受对象的火车车厢没有空间了,那么算法会创建新的车厢,并附加到火车的尾部。
  • 一旦没有火车站外的引用,也没有火车站内其他火车的引用,那么这节正在被收集的车厢剩余的外部引用都是来自同一列火车的其他车厢。
    • 算法把这样的对象移动到最小数字火车的最后一个车厢去。
    • 然后扫描新移动过去的对象,查找是否有引用指向被收集车厢中的对象。
      • 任何新发现的对象也转移到最小数字火车的最后一个车厢中
      • 然后继续扫描新对象,整个过程不断重复
      • 直到没有任何形式的引用指向被收集的车厢
    • 然后算法归还整个最小数字车厢占据的空间,释放所有仍然留在车厢中的对象,并且返回

因此,在每次执行时,火车算法或者收集最小数字火车的最小数字车厢,或者手机整列最小数字火车。而将对应移动到引用它们的火车中,相关的对象会变得集中。最后,称为垃圾的循环数据结构中的所有对象,不管有多大,会放置到同一列火车中去。而增大循环数据结构只会增大最终组成同一列火车的车厢数。前面已经说明,火车算法会先检查最小数字火车是否完全就是垃圾,而对于循环数据结构这种内部引用,它完全可以完成收集

记忆集合

火车算法的目标是为了给按代收集的垃圾收集器提供限定时间内的渐进式收集。

对于车厢来说,分配时可以指定一个最大的内存size,并且每次执行只收集一个车厢,所以大部分情况下,火车算法可以保证每次的执行时间在某个最长时间限度内,不过不能确保每一次都是,因为算法执行的过程中不仅仅是拷贝对象。

为了优化收集过程,火车算法使用了记忆集合。一个记忆集合是一个数据结构,它包含了对一节车厢或者一列火车的外部引用。算法为火车站(成熟对象空间)内的每节车厢和每列火车都维护了一个记忆集合。所以一节特定车厢的记忆集合记录了指向车厢内对象的所有引用。一个空的记忆集合显示车厢或者火车中的对象已经不再被车厢或者火车外的任何变量引用(被遗忘了)。被遗忘的就是不可触及的,可以被回收。

记忆集合的优势

记忆集合是一种可以帮助火车算法更有效地完成工作的技术。当回车算法发现一节车厢或者一列火车的记忆集合是空的时,它就知道车厢里面全是垃圾,可以释放回收这部分占用的内存。
并且在移动一个对象到另一节车厢是,记忆集合中的信息有助于它高效的更新所有指向被移动对象的引用。

限制

我们可以通过限制一个车厢的大小来控制每次字节拷贝的上限,但是当移动一个很受欢迎的对象(有很多外部连接)时,所需要的工作几乎是不可能限制的,每次算法移动一个对象时,它必须遍历对象的记忆集合,更新每一个连接,以便于使连接指向新的地址。因为指向一个对象的连接数是无法限定的,所以更新一个被移动对象的所有连接所需要的的时间也无法限定。

也就是说,在特定条件下,火车算法仍然可能是破坏性的。不过除了这种受欢迎的清下不太实用外,火车算法大部分情况工作的很好。

再次强调下到Sun JDK 6的时候就已经彻底不包含train GC了,不过后续的GC策略能和这个差别有多大呢?对吧

终结

Java语言里,一个对象可以拥有终结方法:这个方法是垃圾收集器在释放对象前必须要运行的。而这个可能存在的终结方法使得任何Java虚拟机的垃圾收集器要完成的工作更加复杂。

终结方法

给一个类加上终结方法,只需要这样:

public class FinalizerTest {
    @Override
    protected void finalize() throws Throwable {
        //do something 
        super.finalize();
    }
}

垃圾收集器必须检查它所发现的不再被引用的对象是否存在finalize()方法。

因为,存在终结方法时,Java虚拟机的垃圾收集器必须每次在收集时执行一些额外的步骤:

  • 首先,垃圾收集器必须使用某种方法检测出不再被引用的对象(称为第一遍扫描)。
  • 然后,它必须检查它检测出的不再被应用的对象是否声明了终结方法。(如果时间允许的话,可能在这个时候垃圾收集器就着手处理这些存在的终结方法)。
  • 当执行了所有的终结方法后,垃圾收集器必须从根节点开始再次检测不再被引用的对象(称为第二遍扫描)。这个步骤是必要的,因为终结方法可能复活了某些不在引用的对象,使它们再次被引用了。
  • 最后垃圾收集器才能释放那些在第一次和第二次扫描中发现没有被引用的对象。

为了减少释放内存的时间,在扫描到某些对象拥有终结方法和运行终结方法之间,垃圾收集器可以有选择地插入一个步骤:

  • 一旦垃圾收集器执行了第一遍扫描,并且找到了一些不再被引用的对象需要执行终结方法时,它可以运行一次小型的追踪。从需要执行终结方法的对象开始(而非根节点),执行逻辑如下:
    • 任何满足从根节点开始不可触及&&从将要被终结的对象开始不可触及这些对象不可能在执行终结方法时复活,它们可以被立即释放。
    • 请注意上条标注的两个条件,是的关系

如果一个带有终结方法的对象不再被引用,并且它的总结方法已经执行过了,垃圾收集器必须使用某种方法记住这一点,而不能再次执行这个对象的终结方法。

如果这个对应已经被自己的终结方法或者其他对象的终结方法复活了,稍后再次不再被引用,垃圾收集器必须像对待一个没有终结方法的对象一样对待它(也就是finalize()只会执行一次的原因)。

使用Java编程时请记住,是垃圾收集器运行对象的终结方法。因为无法预测垃圾收集何时触发,所以我们也无法预测对象的终结方法何时执行。

对象可触及性的生命周期

在版本1.2之前,在垃圾收集器看来,堆中的每一个对象都有三种状态:

  • 可触及的:垃圾收集器通过根节点可以追踪到的对象
  • 可复活的:一旦程序释放了所有该对象的引用(从根节点追踪图中不可触及),这个对象就变成了可复活状态。关于可复活,请注意:
    • 不仅仅是声明了finalize()方法的对象,而是所有的对象都会经过可复活状态。
    • 由于可以自定义对象的finalize()方法(再次引用一个对象),任何处于可复活状态的对象都可能再次复活
    • 垃圾收集器会在保证所有可复活对象执行过finalize()(如果声明了的话)后,再把可复活对象的状态或者转化为可触及,或者转化为不可触及。
  • 不可触及的:对象不再被触及,并且对象不可能通过任何终结方法复活。不可触及对象不再对程序执行产生影响,可以自由回收。

在版本1.2中,对可触及状态延伸扩充了三个新状态:软可触及弱可触及影子可触及。而原来的可触及状态变成了强可触及。(其实就是我们编程用到的弱引用、强引用啥的吧)

任何从根节点开始的任何直接引用,比如一个局部变量,是强可触及。同理,任何由强可触及对象所引用到的对象也是强可触及

引用对象(Reference

Java提供了java.lang.rf.Reference类用来管理对象连接,包含SoftReferenceWeakReferencePhantomReference三个实现类,继承图如下:

image

  • SoftReference:封装对引用目标的软引用
  • WeakReference:封装对引用目标的弱引用
  • PhantomReference:封装对引用目标的影子引用

强引用和上述三种引用的区别是,强引用禁止引用目标被垃圾收集,而软引用、弱引用、影子引用不禁止。

当需要创建一个Reference的对象时,简单的把强引用传递到对应的Reference实现类的构造方法中去就可以。以SoftReference为例:

public class ReferenceTest{
    public static void main(String[] args) {
        Cow c = new Cow();
        SoftReference<Cow> softReference = new SoftReference<Cow>(c);
        c = null;
    }
}
class Cow{}

我们通过维护softReference来维护关于Cow实例对象的软引用。引用示意图如下:

image

SoftReference对象封装了一个Cow对象的软引用。SoftReference对象被一个局部变量softReference强引用,==和所有的局部变量一样,对于与垃圾收集器来说这是一个根节点==(这部分存疑哈)。

一旦一个引用对象创建后,它将一直维持到它的引用目标的软引用,直到它被程序或者垃圾收集器清除。要清楚一个引用对象,程序或者垃圾收集器只需调用Referece对象的clear()方法。

可触及状态的变化

前面讲到,引用对象的目的是为了能够指向某些对象,使这些对象可以随时被垃圾收集器收集。换个说法就是,垃圾收集器可以随意改变不是强可触及对象的可触及状态。

如果想监听这种状态的变化,我们可以使用java.lang.rf.ReferenceQueue<T>类。怎么用呢,我们看下Reference的构造方法:

public abstract class Reference<T> {
    Reference(T referent) {
        this(referent, null);
    }
    Reference(T referent, ReferenceQueue<? super T> queue) {
        this.referent = referent;
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }
}

而对于Reference的整体结构,如下图:

image

==说实话,Reference的体系之前没怎接触,只是在Android中简单使用WeakReferenceget方法,等结束本篇垃圾收集,单独撩拨一下==

那我们就可以这样写:

class ReferenceTest {
    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<Cow> referenceQueue = new ReferenceQueue<>();
        Cow c = new Cow();
        WeakReference<Cow> softReference = new WeakReference<Cow>(c, referenceQueue);
        //把对cow的强引用置为空
        c = null;
        
        softReference.clear();
        System.out.println("clear Reference");

        System.out.println("获取软引用下的Cow = " + softReference.get());

        //加入队列,这一步虚拟机会去做,但是因为时间上的问题,我们手动触发一下
        softReference.enqueue();
        
        Reference<? extends Cow> cow = referenceQueue.remove();
        System.out.println("从释放队列中获取Cow = " + cow);

    }
}
class Cow{}

当垃圾收集器决定收集弱可触及对象的时候,它会清除WeakReference对象中引用的Cow对象(通过clear()方法)。然后可能立即把这个WeakReference对象立即加入它的引用队列中,也可能在稍后的某个时间加入。

为了把引用对象加入它所关联的队列中,垃圾收集器会执行它的enqueue()方法。只有在创建引用对象时关联了一个队列,并且当且仅当该对象第一次执行enqueue()方法时,才把引用对象加入这个队列中。

在不同的情况下,垃圾收集器把软引用、弱引用、影子引用对象加入队列表示三种不同的可触及性状态的转变。这一共表示了6中可触及状态,情况如下:

  • 强可触及:对象可以从根节点不通过任何引用对象搜索到。对象生命周期从强可触及状态开始,并且只要有根节点或者另外一个强可触及对象引用它,就保持强可触及状态。垃圾收集器不会试图回收该状态下的对象。
  • 软可触及:对象不是强可触及,但是可以从根节点开始通过一个或者多个软引用对象触及。垃圾收集器==可能==回收软可触及对象。如果发生了,它会清除所有到此软可触及对象的软引用。当垃圾收集器清除一个和引用队列有关联的软引用对象时,它会把该软引用对象加入队列。
  • 弱可触及:对象既不是强可触及也不是软可触及,但是从根节点开始可以通过一个或多个弱引用对象触及。垃圾收集器==必须==回收弱可触及对象占用的内存。垃圾收集器回收时,它会清除所有到此弱可触及对象的弱引用,并将弱引用对象加入队列(如果有关联的话)
  • 可复活的:对象既不是强可触及、软可触及、也不是弱可触及,但是仍然可能通过某些终结方法复活到这几个状态之一。
  • 影子可触及:对象既不是强可触及、软可触及、也不是弱可触及,并且已经断定不会被任何终结方法复活(如果对象定义了终结方法。此时终结方法已经被执行过了),并且它可以从根节点开始通过一个或多个影子引用对象触及。一个某个影子引用的对象变成影子可触及状态,垃圾收集器立刻把该引用对象加入队列。==垃圾收集器不会清除一个影子引用,所有的影子引用必须由程序明确地清除==
  • 不可触及:一个对象不是强可触及、软可触及、弱可触及,也不是影子可触及,并且他不可复活。不可触及对象已经准备好被回收了。

请注意,垃圾收集器再把软引用和弱引用对象加入关联队列时,是在他们的引用目标离开相应的可触及状态时(调用clear
而影子引用对象加入队列是在引用目标进入相应状态时(也就是构造一个影子引用对象,并执行enqueue()后)。
也就是说垃圾收集器把软引用或者弱引用对象加入队列标志着引用对象刚刚离开了软可触及或者弱可触及状态;而垃圾收集器把影子引用加入队列标志着引用目标已经进入了影子可触及状态。==影子可触及对象会保持影子可触及状态,直到程序显式地清除了引用对象==。

不同类型引用的用法

垃圾收集器对待软、弱和影子对象的方法不同,是因为每一种都是被设计成为程序提供不同的服务。

  • 软引用可以创建内存中的缓存,它与程序的整体内存需求有关。
  • 弱引用可以创建规范映射,比如哈希表,它的关键字和值在没有其他引用时可以从映射表中清除。
  • 影子引用可以实现除终结方法以外的更加复杂的临终清理政策。

影子引用的一些注意事项

请注意,要使用软引用或者弱引用的引用目标,可以调用对象的get()方法。如果引用目标没有被清除,则返回被引用的对象;如果被清除了,则返回null

但是对于影子引用对象的get()方法,始终返回null;我们看下PhantomReference的源码

public class PhantomReference<T> extends Reference<T> {
    public T get() {
        return null;
    }
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

真滴是简洁啊。。。。==为什么要这样呢?==
我们前面描述了6个状态,而对于影子可触及状态来说,它表示对象是不可复活的。如果影子引用的get()方法有返回对象的话,那么这个规则就要被打破了。
请记住==如果一个对象达到了影子可触及状态,它不能再复活。==

不过虚拟机设计的真的这么严谨么?
我们看下面的代码:

    public static void main(String[] args) {
        ReferenceQueue<Cow> referenceQueue = new ReferenceQueue<>();
        Cow c = new Cow();
        PhantomReference<Cow> softReference = new PhantomReference<Cow>(c, referenceQueue);
        //把对cow的强引用置为空
        c = null;

        System.out.println("get()获取影子引用下的Cow = " + softReference.get());

        //加入队列,这一步虚拟机会去做,但是因为时间上的问题,我们手动触发一下
        softReference.enqueue();
        
        //remove 后就可以取得影子引用对象了
        Reference<? extends Cow> cow = referenceQueue.remove();
        
        //上面的get()没取到对象,那我们反射试一下
        Field field = Reference.class.getDeclaredField("referent");
        field.setAccessible(true);
        Object obj = field.get(cow);
        System.out.println("从释放队列中获取Cow = " + obj);
        
        //手动释放一下
        cow.clear();
        
        //再反射获取一下
        obj = field.get(cow);
        System.out.println("从释放队列中获取Cow = " + obj);
    }

输出如下:

获取影子引用下的Cow = null
从释放队列中获取Cow = hua.lee.jvm.Cow@60e53b93
从释放队列中获取Cow = null

我们看到,==影子可触及状态的对象也是可以被拿出来的嘛==
另外,有一点需要注意的是==影子可触及状态的对象是不会被垃圾收集器给回收的,我们需要像上面的示例一样手动clear()来释放对象==

软引用的常用场景

虚拟机的实现需要在抛出OOM之前清除掉软引用,但在其他情况下可以自行选择清理的时间或者是否清除。实现最好是只在内存不足的情况下才去清除软引用,清除的时候先清除老的而不是新的,清除长期未用的而不是最近使用的。

软引用可以让你在内存中缓存那些需要从外部费时获取的数据,比如文件中、数据库里或者网络上的数据。
只要虚拟机有足够的内存,可以在堆中保存所有的强引用数据和软引用数据。
如果内存紧张,垃圾收集器会决定清除软引用,回收被软引用的数据所占用的空间。下一次程序需要使用这个数据时,可能不得不再次从外部数据源进行加载。

弱引用的常用场景

弱引用类似于软引用,但不同的是:

  • 垃圾收集器可以自行决定是否清除处于软可触及状态的对象的软引用。
  • 而对于弱可触及状态的对象,垃圾收集器会立即清除相关的弱引用。

弱引用的这种特性使得我们可以用关键字和值来创建规范映射。java.lang.WeakHashMap类就是用弱引用提供这样的规范映射。
可以通过put()方法加入键值对到WeakHashMap的实例。不过和HashMap不同的是,在WeakHashMap中,关键字对象是通过一个关联到引用队列的弱引用实现的。如果垃圾收集器检测到关键字对象时弱可触及的,它会清除引用并且把弱引用到该对象的引用对象加入到各自的队列。下次WeakHashMap被访问的时候,它会从引用队列中拉出所有的被垃圾收集器存放的弱引用对象,并清除和其有关的映射关系。