再谈Java中的四种引用

Java强、弱、软、虚四种引用一文大体介绍了Java的四种引用,但是比较简单。这篇文章我们通过实际的例子来展示各种引用的用法。

强引用

强引用就是我们平时用来指向new创建的对象的最普通的引用:Object o = new Object()

graph LR
A((o)) -->B(Object)

如果一个对象有强引用指着,那么垃圾回收器是无论如何都不能回收它的。如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了。

为了更好地描述,我们准备一个M对象,并复写根对象Object中的finalize方法。如下所示:

public class M {
    @Override
    protected void finalize() throws Throwable {
        System.out.println("finalize");
    }
}

finalize方法会在垃圾回收器回收该对象之前被调用,所以我们可以在它里面打印日志来追踪对象是否被被回收。它早期的主要设计目的是在对象从内存中删除之前释放它们使用的资源。但是在实际中,gc什么时候调用finalizer取决于JVM的具体实现和系统的具体情况,甚至不一定会被调用,所以我们不能依赖于该方法来清理资源,它适合做的关闭资源等工作try-catch-finally也能做,这也是该方法为什么被标记为@Deprecated的原因。

接下来我们就可以测试强引用的回收了:

public class NormalReferenceTest {
    public static void main(String[] args) throws IOException {
        M m = new M();
        m = null;
        System.gc();
        System.in.read();
    }
}

运行该程序就能看到控制台打印finalize.

该程序先把M对象new出来用引用m指向它,然后把m=null之后,对象M就失去了引用,也就是说可以被垃圾回收了。为了”立即”进行垃圾回收,我们调用了System.gc()。并且为了看到打印结果,我们在最后写了一个System.in.read()用来阻塞main线程,不让其结束,因为回收垃圾不是在main线程里面进行的,而是在单独的线程中, 所以需要这样一个方法来等待垃圾回收线程运行,才能看到结果的打印。

在实际应用中,坚决不要显式调用System.gc(),原因如下:

  1. 其开销很大
  2. 它并不能立即触发垃圾回收,只是个JVM一个垃圾回收的提示
  3. 对于何时需要调用GC,其实JVM自己更清楚

所以在jvm参数中有-XX:+DisableExplicitGC,这个参数作用是禁止代码中显式调用System.gc(),使得代码中调用System.gc()没有任何效果。

软引用

只有在空间不够的时候,软引用指向的对象才会被回收。下面用demo掩饰一下:

public class SoftReferenceTest {
    public static void main(String[] args) {
        SoftReference<byte[]> m = new SoftReference<>(new byte[1024 * 1024 * 10]); // 1
        System.out.println(m.get());

        System.gc();
        try { // 2
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(m.get());

        byte[] b = new byte[1024 * 1024 * 10]; // 3
        System.out.println(m.get());
    }
}
  1. 首先给软引用指向的byte数组分配10M空间
  2. 调用System.gc()并给gc一点回收时间
  3. 再分配一个15M的强引用数组

用jvm参数-Xmx20M启动该程序之后输出如下:

[B@7a0ac6e3
[B@7a0ac6e3
null

可以看到前两次都能正常获取到软引用指向的对象,但是当分配了另一个强应用byte数组之后,heap将装不下,这时候系统会进行垃圾回收,先回收一次,如果不够,会把软引用干掉。那么最后再去获取的时候就是null了。

软应用这种特性通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。比如大对象的缓存。在著名的guava缓存框架中,提供了softValues作为软引用的相关实现:

@Test
public void whenSoftValue_thenRemoveFromCache() {
    CacheLoader<String, String> loader;
    loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return key.toUpperCase();
        }
    };

    LoadingCache<String, String> cache;
    cache = CacheBuilder.newBuilder().softValues().build(loader);
}

弱引用

弱引用就是在遇到GC的时候直接被回收的。我们可以很容易的模拟出它这种特性,运行如下代码:

public class WeakReferenceTest {
    public static void main(String[] args) {
        WeakReference<M> m = new WeakReference<>(new M());
        System.out.println(m.get());
        System.gc();
        System.out.println(m.get());
    }
}

输出:

site.pengcheng.jvm.reference.M@71be98f5
null
finalize

可以看到GC之后就不能拿到原对象了。

这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择。

弱引用在Threadlocal中的应用

在jdk中有一个很重要的地方用到了WeakReference,那就是ThreadLocalThreadLocal在调用set方法保存value的时候会取出当前线程中的ThreadLocal.ThreadLocalMap对象,然后把自己(this,ThreadLocal对象本身)作为key,保存的数据作为value,封装成一个ThreadLocal.ThreadLocalMap.Entry对象,而这个Entry类就是一个WeakReference的子类:

static class Entry extends WeakReference<ThreadLocal<?>>

之所以使用WeakReference,是为了防止内存泄露(具体原因请查阅相关资料)。

虚引用

虚引用又被称为幻象引用,通过该引用不能得到被其包装的原对象(总是返回null)。虚引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。这个机制需要通过ReferenceQueue来配合实现,比如原对象被回收之后,发送一个通知到ReferenceQueue中,然后消费这个队列的用户就能知道虚引用的对象被回收了,然后可以做一些相应的事情。

考虑如下代码:

public class PhantomReferenceTest {
    private static final List<Object> LIST = new LinkedList<>();
    private static final ReferenceQueue<M> QUEUE = new ReferenceQueue<>();

    public static void main(String[] args) {
        PhantomReference<M> phantomReference = new PhantomReference<>(new M(), QUEUE);
        System.out.println(phantomReference.get());

        new Thread(() -> {
            while (true) {
                LIST.add(new byte[1014 * 1024 * 1]);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    Thread.currentThread().interrupt();
                }
                System.out.println(phantomReference.get());
            }
        }).start();

        // 模拟监控线程,监控对象是否被会后
        new Thread(() -> {
            while (true) {
                Reference<? extends M> poll = QUEUE.poll();
                if (poll != null) {
                    System.out.println("虚引用被jvm回收了" + poll);
                }
            }
        }).start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
            Thread.currentThread().interrupt();
        }
    }

使用-Xmx10M参数运行这段代码输出:

null
null
null
null
null
finalize
null
虚引用被jvm回收了java.lang.ref.PhantomReference@2cf96e41
null
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space

我们先生成一个包装了M对象的虚引用,然后疯狂生成byte数组来挤占对空间;当内存不够的时候,M对象被回收,然后另一个通过ReferenceQueue·队列监控M对象是否被回收的线程就收到了通知并通过打印展示了出来。

虚引用在实际中一个重要的应用就是管理堆外内存

原来在读取外部文件的时候数据需要通过操作系统内核,然后到操作系统管理的内存,然后再被拷贝到jvm管理的内存中,这个拷贝过程会影响程序性能。所以Java引入了堆外内存,可以用一个jvm中的引用直接指向堆外的内存,减少数据拷贝,这个技术称为零拷贝(zero copy)DirectByteBuffer就使用了堆外内存:

ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024);

这句代码表示用一个堆内的引用bb指向一个堆外的、大小为1M的内存。

但是堆外内存不归GC管,也就是不归jvm管(而是归Hotspot的c++程序管),所以就没办法知道什么时候该回收。这种时候虚引用就派上了用场:用虚引用包装DirectByteBuffer对象,当这个对象被回收的时候ReferenceQueue会收到通知,告诉jvm有堆外内存需要被处理。

总结

我们知道GC是通过可达性分析判断一个对象是否应该回收的,而上述的各种引用在可达性分析中扮演了十分重要的角色。

上述的各种引用SoftReferenceWeakReferencePhantomReference都是java.lang.ref.Reference的子类。它们有两个重要的特征:

  1. 提供public T get()方法获取原有对象(虚引用该方法返回null)
  2. 都有一个需要ReferenceQueue作为参数的构造方法

其中1意味着,利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态!

在虚引用中我们已经使用过了ReferenceQueue,因为它不能get出原对象,那么对ReferenceQueue的使用就成了其唯一的使用方式。事实上软引用和弱引用也可以关联到ReferenceQueue,从而实现这样的逻辑:JVM 会在特定时机将引用 enqueue 到队列里,我们可以从队列里获取引用(remove 方法在这里实际是有获取的意思)进行相关后续逻辑:

Object counter = new Object();
ReferenceQueue refQueue = new ReferenceQueue<>();
PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
counter = null;
System.gc();
try {
    // Remove是一个阻塞方法,可以指定timeout,或者选择一直阻塞
    Reference<Object> ref = refQueue.remove(1000L);
    if (ref != null) {
        // do something
    }
} catch (InterruptedException e) {
    // Handle it
}

本文相关代码可以在这里找到。


文章作者: 木白
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 木白 !
  目录