深入理解 Java 引用类型:强壮、柔软、脆弱、虚无的力量

在这里插入图片描述

🔭 大家好,我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者
📖 推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代
🌲文章所在专栏:JVM
🤔 我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识
💬 向我询问任何您想要的东西,ID:vnjohn
🔥觉得博主文章写的还 OK,能够帮助到您的,感谢三连支持博客🙏
😄 代词: vnjohn
⚡ 有趣的事实:音乐、跑步、电影、游戏

目录

前言

引用是用于访问对象的变量,它是内存中的一个指针,指向堆中的对象实例;通过 reference 类型数据代表某块内存、某个对象的引用

若内存空间还足够时,仍然能保存在内存中,若内存空间在进行垃圾收集以后非常紧张下,就可以抛弃一些引用对象,因此,引出了四种不同的引用,分别是:强、软、弱、虚

Object obj = new Object();

一般在工作中都是使用如上所示的强引用,这种引用只有在内存空间不足时才会回收,并且该引用通过引用计数算法(Reference Count)或根可达算法(Root Searching)找不到与之相关联的引用情况下,随即在发生 GC 时这种强引用对象才会被回收

对象自我拯救

当引用计数为 0 或根不可达时,对象是在垃圾收集阶段被回收的,如何可以让对象可以自我完成救赎继续使用呢?可以通过重写的 finalize 方法来完成对象逃脱死亡的最后一次机会

若对象被判断为确实有必要执行 finalize 方法,那么该对象将会被放置在一个名为 F-Queue 队列之中,并会在后面由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize 方法(所有类指向父类都是 Object,它承担了 finalize 方法定义)
低调度优先级线程去“执行”,当触发该方法运行时,但并不一定会等待它执行结束,例如:当 finalize 方法执行缓慢或更极端情况下发生了死循环!

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC ESCAPE_HOOK = null;

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

    public static void main(String[] args) throws InterruptedException {
        ESCAPE_HOOK = new FinalizeEscapeGC();
        ESCAPE_HOOK = null;
        System.gc();
        // 因为 finalize 方法优先级很低,暂停 0.5 s,以等待它
        TimeUnit.MILLISECONDS.sleep(500);
        // alive 拯救成功
        if (null != ESCAPE_HOOK) {
            System.out.println("FinalizeGC is alive!");
        } else {
            System.out.println("FinalizeGC is dead!");
        }
        System.gc();
        ESCAPE_HOOK = null;
        System.gc();
        // 因为 finalize 方法优先级很低,暂停 0.5 s,以等待它
        TimeUnit.MILLISECONDS.sleep(500);
        // dead 拯救失败
        if (null != ESCAPE_HOOK) {
            System.out.println("FinalizeGC is alive!");
        } else {
            System.out.println("FinalizeGC is dead!");
        }
        System.gc();
    }
}

以上代码演示了两点,如下:

  1. 对象可以在被 GC 时自我拯救
  2. 自救的机会只有一次,在 finalize 方法重新指向引用
  3. 一个对象的 finalize 方法最多只会被系统自动调用一次,如以上代码,第一次可以拯救成功,但第二次就无法拯救成功了

注意:实际工作中,-XX:+DisableExplicitGC 参数是开启的,禁用 System.gc() 调用,手动回收垃圾不会生效

finalize 出现,只是为了 Java 刚诞生时 C++ 程序员更容易接受 Java 所做出的一项妥协(C++ 需要手动 delete 删除引用指针、数组指针),而且它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,若要作关闭外部资源之类的工作时,完全可以使用 try/finally 块来完成,在 finally 代码块完成资源的释放工作

引用

接下来介绍不同引用之间的区别及联系

前置工作

/**
 * @author vnjohn
 * @since 2023/6/27
 */
public class FinalizeGC {

    @Override
    protected void finalize() throws Throwable {
        System.out.println("finalize method invoke");
    }
}

先提前准备一个资源类型,来判断是否发生了垃圾回收,若发生了会输出 finalize method invoke

强引用

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class StrongReference {
    public static void main(String[] args) throws Exception {
        FinalizeGC finalizeGC = new FinalizeGC();
        // finalizeGC = null;
        System.gc();
        // 因为 finalize 方法优先级很低,暂停 0.5 s,以等待它
        TimeUnit.MILLISECONDS.sleep(500);
    }
}

如上代码,定义实现了一个强引用对象,当我们调用 System.gc() 并不会进行回收,强引用对象只有在发生 GC 时并且该对象没有任何强引用关系链存在,就会被回收

System.gc() 不建议手动调用,垃圾回收的工作还是交由给专门负责这项工作的 JVM
通过:-XX:+DisableExplicitGC 参数来关闭手动 GC,默认是 - 开启的

软引用

软引用是用来描述一些还有用、非必须的对象,只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入到回收范围之中,进行第二次的回收,若这次回收过后仍没有足够的内存,就会抛出内存溢出异常

在 Java 中,软引用通过 java.lang.ref.SoftReference 声明

private static final Integer MAX_BYTE = 1024 * 1024 * 10;
java.lang.ref.SoftReference<byte[]> softReference = new java.lang.ref.SoftReference<>(new byte[MAX_BYTE]);

软引用就是将对象使用 SoftReference 进行一层包装,当我们需要从软引用中获取到包装的对象时,直接调用 get 方法即可

private static final Integer MAX_BYTE = 1024 * 1024 * 10;
java.lang.ref.SoftReference<byte[]> softReference = new java.lang.ref.SoftReference<>(new byte[MAX_BYTE]);
System.out.println(softReference.get());

软引用特征:当内存不足时,会触发 GC 回收,若 GC 后内存还是不足够,就会回收掉软引用中包装的对象,也就是当 JVM 内存不足以使用时,才会回收该引用

通过代码来演示,当触发 GC 回收后,获取软引用包装的对象时是否会为空,如下:

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class SoftReference {
    private static final Integer MAX_BYTE = 1024 * 1024 * 10;
    private static final Integer PLUS_BYTE = 1024 * 1024 * 5;


    public static void main(String[] args) {
        // 实例化一个 10M 的类型为 SoftReference 的 m 对象
        java.lang.ref.SoftReference<byte[]> softReference = new java.lang.ref.SoftReference<>(new byte[MAX_BYTE]);
        System.gc();
        System.out.println(softReference.get());
        byte[] bytes = new byte[MAX_BYTE + PLUS_BYTE];
        System.out.println(softReference.get());
    }
}

先创建一个软引用包装的 10 M 字节数组对象,再创建一个 15 M 字节数组对象,若不对 JVM 参数作任何配置的话,运行是看不到任何效果的,先调整 JVM Options 参数如下:

# 最小、最大堆内存为 25 M
-Xms25M -Xmx25M

运行主 main 方法,执行结果如下:

[B@45ee12a7
null

基于以上结果,可以很清楚的看到通过手动 GC 方式回收时,软引用所包装的 byte 字节数组对象还存活好好的,但当我们又创建了一个 15 M 字节数组强引用对象后,堆内存不够了,所以就会将软引用包装的 byte 字节数组给回收掉了

软引用使用场景:非常适合用作缓存,当内存足够,可以正常的拿到缓存;当内存不够时,就会先干掉软引用缓存,不至于马上抛出 OOM 异常

弱引用

弱引用是用来描述哪些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只会生存到下一次垃圾收集发生为止;当垃圾收集开始工作,无论当前内存是否足够,都会回收掉哪些只被弱引用关联的对象

WeakReference

在 Java 中,弱引用通过 java.lang.ref.WeakReference 声明

弱引用的特点在于,无论内存是否足够,都会被回收,代码如下:

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class WeakReference {
    private static final Integer MAX_BYTE = 1024 * 1024 * 10;
    private static final Integer PLUS_BYTE = 1024 * 1024 * 5;

    public static void main(String[] args) {
        java.lang.ref.WeakReference<byte[]> weakReference = new java.lang.ref.WeakReference<>(new byte[MAX_BYTE]);
        System.out.println(weakReference.get());
        System.gc();
        System.out.println(weakReference.get());
        byte[] bytes = new byte[MAX_BYTE + PLUS_BYTE];
        System.out.println(weakReference.get());
    }
}

运行主 main 方法,执行结果如下:

[B@45ee12a7
null
null

基于以上结果,在未调用 System.gc() 方法手动触发垃圾收集时,弱引用包装的对象仍然是可见的,一旦触发了该方法调用,所有的弱引用包装对象都会被回收,无论你堆内存配置多大,弱引用包装的对象都会被当成垃圾回收掉,这是弱引用本身的特性

ThreadLocal

在使用弱引用时,有一个非常重要的类会在工作中经常使用 > ThreadLocal,大家应该都知道,它提供了一种方式,为每个线程能够拥有一份自己的变量副本,元素由内部类 ThreadMap 存储, ThreadMap 内部又有 Entry 类来作为数组存放变量值,Entry 继承至 WeakReference 弱引用

在这里插入图片描述

在 ThreadLocal 中,提供了三个常用的方法,如下:

public T get();
public void set(T value);
public void remove()

使用 ThreadLocal 时,要注意的是,remove 方法要配合 set 方法一起使用,因为在 Entry 结构中 Key 为弱引用所修饰,它会在垃圾收集时进行回收,但 Value 它为 Object 强引用会一直存放在内存中,即使发生垃圾收集也不会被回收;若这种情况下在流量比较大的时候,一直堆积这种强引用的无用对象,会造成内存泄漏,最终也有可能会发生 OOM 异常,导致系统不可用!!

基于以上这种要注意的情况,可以通过以下的方式去进行测试

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class ThreadLocalDemo {
    private static final Integer MAX_BYTE = 1024 * 1024 * 10;
    private static final Integer PLUS_BYTE = 1024 * 1024 * 5;

    static class ThreadLocalObject {
        private final byte[] bytes;

        public ThreadLocalObject(byte[] bytes) {
            this.bytes = bytes;
        }

        public byte[] getBytes() {
            return bytes;
        }
    }

    private static final ThreadLocal<ThreadLocalObject> CURRENT_THREAD_MAP = new ThreadLocal<>();

    public static void main(String[] args) {
        new Thread(() -> {
            ThreadLocalObject currentThreadObj = new ThreadLocalObject(new byte[MAX_BYTE]);
            CURRENT_THREAD_MAP.set(currentThreadObj);
            ThreadLocalObject threadLocalObject = CURRENT_THREAD_MAP.get();
            System.out.println(Thread.currentThread().getName() + ":" + threadLocalObject.getBytes());
        }, "Thread-A").start();

        new Thread(() -> {
            ThreadLocalObject currentThreadObj = new ThreadLocalObject(new byte[MAX_BYTE + PLUS_BYTE]);
            CURRENT_THREAD_MAP.set(currentThreadObj);
            ThreadLocalObject threadLocalObject = CURRENT_THREAD_MAP.get();
            System.out.println(Thread.currentThread().getName() + ":" + threadLocalObject.getBytes());
        }, "Thread-B").start();
    }
}

在 VM Options 配置好最小/最大堆内存大小,再进行测试:

# 最小、最大堆内存为 25 M
-Xms25M -Xmx25M

执行结果如下:

Exception in thread "Thread-B" java.lang.OutOfMemoryError: Java heap space
	at org.vnjohn.jvm.ThreadLocalDemo.lambda$main$1(ThreadLocalDemo.java:35)
	at org.vnjohn.jvm.ThreadLocalDemo$$Lambda$2/1989780873.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:750)
Thread-A[B@31c12e10

在这里插入图片描述

在 IDEA 工具开启阿里编码规范扫描时,就会提示我们 ThreadLocal 应该调用 remove 方法,所以在日常工作开发中,记得 set/remove 方法要配合使用,在 try/finally 块结合起来!!

InheritableThreadLocal

在这里,既然说到了 ThreadLocal,那么就再提出一个更有意思的知识点,如何在 Java 中子线程可以获取到父线程变量的值?

ThreadLocal 有一个子类 InheritableThreadLocal,通过它来实现可以获取父线程变量副本的值,示例代码如下:

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class InheritableThreadLocalDemo {

    public static void main(String[] args) {
        ThreadLocal<String> threadLocal = new ThreadLocal<>();
        threadLocal.set("Hello World");
        new Thread(() -> System.out.println(Thread.currentThread().getName() + ":" + threadLocal.get()), "Thread-A").start();

        InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
        inheritableThreadLocal.set("Hello Vnjohn");
        new Thread(() -> System.out.println(Thread.currentThread().getName() + ":" + inheritableThreadLocal.get()), "Thread-B").start();
    }
}

以上代码,在线程:Thread-A,是获取不到 ThreadLocal 类设置的变量值的;只有在线程:Thread-B,通过 InheritableThreadLocal 设置,然后才可以获取到具体的变量值

InheritableThreadLocal 关于该 ThreadLocal 子类共享父线程变量副本的实现原理在后续文章分析,敬请期待!

虚引用

虚引用也称为 “幽灵引用” 或者 “幻影引用”,它是最弱的一种引用关系;一个对象是否有弱引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例;为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被垃圾收集器回收时收到一个系统通知

在 Java 中,虚引用通过 java.lang.ref.PhantomReference 声明

接下来,来看看虚引用如何使用,如下:

/**
 * @author vnjohn
 * @since 2023/6/28
 */
public class PhantomReference {
    public static void main(String[] args) throws IOException {
        ReferenceQueue<FinalizeGC> queue = new ReferenceQueue<>();
        List<Object> objects = new ArrayList<>();
        java.lang.ref.PhantomReference<FinalizeGC> phantomReference = new java.lang.ref.PhantomReference<>(new FinalizeGC(), queue);

        // 第一个线程:一直往集合中塞入数据,直至堆内存大小不足,会触发虚引用回收
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                objects.add(new byte[1024 * 1024]);
            }
            System.out.println(phantomReference.get());
        }, "Thread-A").start();

        // 线程 Thread-B:死循环从 queue 队列里面取数据,若取出的数据不为空就打印出来后退出循环
        new Thread(() -> {
            while (true) {
                Reference<? extends FinalizeGC> poll = queue.poll();
                if (null != poll) {
                    System.out.println("虚引用被回收了:" + poll);
                    break;
                }
            }
        }, "Thread-B").start();

        System.in.read();
    }
}

在 VM Options 设置最小/最大堆内存,以便我们能看到虚引用回收时的效果

-Xms25M -Xmx25M

然后再运行代码,结果会如下:

finalize method invoke
Exception in thread "Thread-A" java.lang.OutOfMemoryError: Java heap space
	at org.vnjohn.jvm.PhantomReference.lambda$main$0(PhantomReference.java:22)
	at org.vnjohn.jvm.PhantomReference$$Lambda$1/2093631819.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:750)
虚引用被回收了:java.lang.ref.PhantomReference@618c65e9

基于以上结果,简要分析,线程 Thread-A 往集合中塞数据,随着数据越来越多达到堆内存阈值,肯定就会触发 GC;线程 Thread-B 死循环,从队列 queue 中获取数据,若数据不为空,就打印出来

从执行结果来看,当发生 GC,虚引用就会被回收,并会把回收的引用放入到 ReferenceQueue 中.

虚引用的使用与软引用、弱引用的区别还是挺大的,虚引用的特点如下:

  1. 无法通过虚引用来获取对象的真实引用,PhantomReference#get 方法返回的数据是直接返回 null,不管包裹的对象是什么,都是直接返回 null

创建虚引用对象,除了把包装传入的对象,同时还需要传 ReferenceQueue 参数,从名字来看它代表引用队列

  1. 虚引用必须与 ReferenceQueue 一起使用,当 GC 准备回收一个对象时,若发现它还有虚引用,就会 GC 回收之前,将该虚引用加入到与之关联的引用队列 ReferenceQueue 中

虚引用使用场景:NIO 会使用虚引用来管理堆外内存信息

总结

该篇博文介绍了经典四大引用门将:强软弱虚,以及如何在对象被 GC 回收前重新完成一次自我救赎代码演示,强引用:当引用无关联其他引用时,根不可达时,该引用会被回收;软引用:内存不足时触发 GC 才会回收,适合于作缓存;弱引用:无论内存是否足够,只要触发 GC 都会被回收,ThreadLocal、WeakHashMap 经典案例;虚引用:在内存不足产生 GC 时,会将虚引用进行回收,回收的结果会放入到 ReferenceQueue 引用队列中,希望这块的知识能够对你有许些帮助,感谢支持三连!后续会分析如何判断对象是否已死(其实前提就是说明引用计数、根可达算法之间的应用及区别)?

参考文献:《深入理解 Java 虚拟机》周志明著

博文放在 JVM 专栏里,欢迎订阅,会持续更新!

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!

推荐专栏:Spring、MySQL,订阅一波不再迷路

大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!

  • 37
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 47
    评论
评论 47
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

vnjohn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值