[TOC]

1. 如果判断对象可以回收?

1.1 引用计数算法

定义: 在对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值就加一; 当引用失效时, 计数器值就减一; 任何时刻计数器为零的对象就是不可能再被使用的

主流的Java虚拟机里面都没有选用引用计数算法来管理内存, 主要原因是有些场景无法准确标记, 譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

微软COM(Component Object Model) 技术、 使用ActionScript 3的FlashPlayer、 Python语言以及Squirrel中都使用了引用计数算法进行内存管理

1.2 可达性分析算法

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为“引用链”(Reference Chain) , 如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时, 则证明此对象是不可能再被使用的

image-20230903231937920

在Java技术体系里面, 固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表) 中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的参数、 局部变量、 临时变量等。
  • 在方法区中类静态属性引用的对象, 譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象, 譬如字符串常量池(String Table) 里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法) 引用的对象。
  • Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如NullPointExcepiton、 OutOfMemoryError) 等, 还有系统类加载器。
  • 所有被同步锁(synchronized关键字) 持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等。

除了这些固定的GC Roots集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入, 共同构成完整GC Roots集合。 譬如分代收集和局部回收(Partial GC) , 如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集) , 必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的, 更不是孤立封闭的), 所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用, 这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去, 才能保证可达性分析的正确性

1.3 标记后执行finalize()

即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程: 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。 没有必要执行了就直接回收了

假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。

所以基本所有的情况都是直接回收了, 没人会在业务代码里重写finalize()方法

如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的队列之中, 并在稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize()方法

这里所说的“执行”是指虚拟机会触发这个方法开始运行, 但并不承诺一定会等待它运行结束。这样做的原因是, 如果某个对象的finalize()方法执行缓慢, 或者更极端地发生了死循环, 将很可能导致F-Queue队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃

finalize()方法是对象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己(this关键字) 赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集合; 如果对象这时候还没有逃脱, 那基本上它就真的要被回收了

总结: 当对象被判断为不可达时, 如果还需要执行finalize()方法, 且在finalize()方法中拯救了自己, 那这次就不会回收这个对象了

例如:

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次, 因为一个对象的finalize()方法最多只会被系统自动调用一次
 *
 * @author zzm
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        SAVE_HOOK = null;
        //对象第一次成功拯救自己, 成功了
        System.gc();
// 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        } 
        // 下面这段代码与上面的完全相同, 但是这次自救却失败了SAVE_HOOK = null;
        System.gc();
// 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}


结果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(

另外一个值得注意的地方就是, 代码中有两段完全一样的代码片段, 执行结果却是一次逃脱成功, 一次失败了。 这是因为任何一个对象的finalize()方法都只会被系统自动调用一次, 如果对象面临下一次回收, 它的finalize()方法不会被再次执行, 因此第二段代码的自救行动失败了

这是因为判断需要是否执行finalize(), 其中有个条件是,已经执行后就不再执行了

finalize()这个方法已经被弃用了, 当初写这个方法也是为了使C/C++程序猿的一种妥协,因为C/C++中有析构函数

1.4 三色标记法

我们要进行垃圾回收,就需要弄明白哪些对象是需要回收的,哪些对象是不需要回收的。

三色标记算法: 三色标记算法指的是将所有对象分为白色、黑色和灰色三种类型。黑色表示从 GCRoots 开始,已扫描过它全部引用的对象,灰色指的是扫描过对象本身,还没完全扫描过它全部引用的对象,白色指的是还没扫描过的对象。

  • 白色表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

因为是并发标记, 则会有两个问题:一个是错标,标记过不是垃圾的,变成了垃圾(也叫浮动垃圾);第二个是错杀, 本来已经当做垃圾了,但是又有新的引用指向它。

错标不怕, 影响不是很大,可能就是暂时的浪费一点内存,它肯定抗不过下一轮GC。

错杀问题很大 , 把要用的 对象清除了, 程序就挂了

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。(删除了多个灰色对象指向白色对象的引用)

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别 产生了三种解决方案:写屏障+增量更新(Incremental Update)和写屏障+原始快照(Snapshot At The Beginning, SATB)和 读屏障

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

读屏障, 当读取成员变量时,一律记录下来, 在条件一中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的G1、Shenandoah则是用原始快照来实现, ZGC:读屏障

三色标记法与读写屏障 - 简书 (jianshu.com)

JVM 三色标记算法,原来是这么回事!_肥肥技术宅的博客-CSDN博客

2. 引用类型

无论是通过引用计数算法判断对象的引用数量, 还是通过可达性分析算法判断对象是否引用链可达, 判定对象是否存活都和“引用”离不开关系。

引用分为强引用(Strongly Re-ference) 、 软引用(Soft Reference) 、 弱引用(Weak Reference) 和虚引用(Phantom Reference) 4种, 这4种引用强度依次逐渐减弱

2.1 强引用

强引用是最传统的“引用”的定义, 是指在程序代码之中普遍存在的引用赋值, 即类似Object obj=new Object()这种引用关系。 无论任何情况下, 只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象。

2.2 软引用

软引用是用来描述一些还有用, 但非必须的对象。 只被软引用关联着的对象, 在系统将要发生内存溢出异常前, 会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存,才会抛出内存溢出异常。 在JDK 1.2版之后提供了SoftReference类来实现软引用。

软引用可用来实现内存敏感的(非必须的对象)高速缓存

内存不够时, 发生垃圾回收, 才会回收软引用

2.3 弱引用

弱引用也是用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用

只要是垃圾回收,就会回收弱引用

2.4 虚引用

虚引用也称为“幽灵引用”或者“幻影引用”, 它是最弱的一种引用关系。 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例。 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。 在JDK 1.2版之后提供了PhantomReference类来实现虚引用。 通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

public class PhantomReference<T> extends Reference<T> {
    /**
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * <code>null</code>.
     *
     * @return  <code>null</code>
     */
    public T get() {
        return null;
    }
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

2.5 引用队列(ReferenceQueue)

引用队列可以与软引用、弱引用以及虚引用一起配合使用,当垃圾回收器准备回收一个对象时,如果发现它还有引用,那么就会在回收对象之前,把这个引用加入到与之关联的引用队列中去。程序可以通过判断引用队列中是否已经加入了引用,来判断被引用的对象是否将要被垃圾回收,这样就可以在对象被回收之前采取一些必要的措施。

与软引用、弱引用不同,虚引用必须和引用队列一起使用。

2.6 多引用类型的可达性判断

比较容易理解的是 Java 垃圾回收器会优先清理可达强度低的对象。 那现在问题来了, 若一个对象的引用类型有多个, 那到底如何判断它的可 达性呢? 其实规则如下: (“单弱多强” )

  1. 单条引用链的可达性以最弱的一个引用类型来决定;
  2. 多条引用链的可达性以最强的一个引用类型来决定;

image-20230903232039912

我们假设图 2 中引用①和③为强引用, ⑤为软引用, ⑦为弱引用, 对于对象 5 按照这两个判断原则, 路径①-⑤取最弱的引用⑤, 因此该路径对对象 5 的引用为软引用。 同样, ③-⑦为弱引用。 在这两条路径之间取最强的引用, 于是对象 5 是一个软可及对象。

2.7 总结

img

java四种引用类型以及使用场景详解 - 朱子威 - 博客园 (cnblogs.com)

深入理解Java的四种引用类型强引用(StrongReference)软引用(SoftReference)弱引用(WeakReference)虚引用(PhantomReference)多引用类型的可达 - 云+社区 - 腾讯云 (tencent.com)

Carson带你学Java:深入解析引用类型-强、软、弱、虚 - 简书 (jianshu.com)