Java中的强引用、软引用、弱引用、虚引用与引用队列 通俗举例实战详解

本次实验Java版本:JDK 1.8.0_152_release

1. 基本概念

本节先了解一下概念,看不懂没关系,后续会详解。

Java中的引用可以按强弱程度分为四种,JVM对不同程度的引用回收策略不同:

强引用(Strong Reference):我们平时用的都是强引用。例如:MyObject myObj = new MyObject();

  • 回收:只要有引用,就不会被回收。

软引用(Soft Reference):使用SoftReference显式声明。

  • 回收:当JVM内存不够时,会对软引用对象进行回收。
  • 应用场景:做资源类缓存。
  • 使用样例:
     MyObject myObject = new MyObject("Amy");  // 从数据库中获取数据
     SoftReference<MyObject> reference = new SoftReference<>(myObject);  // 增添软引用
    
     // do something ...
    
     myObject = reference.get(); // 尝试获取myObject对象
    
     if (myObject == null) {
         // 没获取到,说明已经被JVM回收了
         myObject = new MyObject("Amy");  // 重新从数据库中获取数据
     } else {
         // 没有被JVM回收
     }
    

弱引用(Weak Reference):使用WeakReference显式声明。

  • 回收:当JVM下次执行垃圾回收时,就会立刻回收。
  • 应用场景:做临时行为类数据缓存。
  • 使用样例:和上面SoftReference一样,把SoftReference改成WeakReference即可。

虚引用(Phantom Reference):也称为“幽灵引用”、“幻影引用”等。单纯的将其标记一下,配合引用队列(ReferenceQueue)进行回收前的一些操作

  • 回收:当JVM执行垃圾回收时,就会立刻回收
  • 应用场景:资源释放(用来代替finalize方法)
  • 特殊点:虚引用的reference.get()方法一定会返回null(源码就是直接return null,这也是为什么叫虚引用的原因。
  • 使用样例:建后续引用队列。

引用队列:在定义软/弱/虚引用时,可以传个引用队列(虚引用必须传),这样对象在被回收之前会进入引用队列,可以显式的对其进行一些操作。

  • 作用:可以在该对象被回收时主动收到通知,来对其进行一些操作。
  • 应用场景:资源释放

2. 代码演示

本节是对软/弱/虚引用和引用队列的作用机制代码演示。

2.1 软引用代码演示

import java.lang.ref.Reference;
import java.lang.ref.SoftReference;

public class SoftReferenceTest {

    public static void main(String[] args) {
        // 这里定义了obj对象,为正常的强引用
        String obj = new String("myObj");

        // 为该对象增加软引用
        // 此时它同时拥有两种引用,即obj同时拥有强引用和软引用
        Reference<String> softReference = new SoftReference<>(obj);

        // 解除obj的强引用,此时obj只剩下了软引用
        obj = null;

        // 调用GC进行垃圾回收。(只是通知,并不一定真的做垃圾回收,这里假设做了)
        System.gc();

        // 尝试从软引用中获取obj对象
        // 若没有获取到,说明该对象已经被JVM释放
        // 若获取到了,说明还没有被释放
        String tryObj = softReference.get();
        System.out.println("try obj:" + tryObj);
    }

}

输出结果:

try obj:myObj

解释:

上述样例中,我们为obj对象绑定了软引用,在解除了强引用后尝试进行GC。可以看到,GC并没有回收obj对象。这是因为软引用的回收规则为:当JVM内存不够时,才会进行回收,上面的简单例子中,JVM内存显然不会不够用,因此我们可以通过reference对象拿到obj对象。

2.2 弱引用代码演示

import java.lang.ref.Reference;
import java.lang.ref.WeakReference;

public class WeakReferenceTest {

    public static void main(String[] args) {
        // 这里定义了obj和obj2对象,为正常的强引用
        String obj1 = new String("myObj1");
        String obj2 = new String("myObj2");

        // 为它们增加弱引用
        // 此时它们同时拥有两种引用,即obj同时拥有强引用和弱引用
        Reference<String> reference1 = new WeakReference<>(obj1);
        Reference<String> reference2 = new WeakReference<>(obj2);

        // 解除obj1的强引用,然后执行GC
        obj1 = null;
        System.gc();

        // 解除obj2的强引用,不执行GC
        obj2 = null;

        /**
         * 尝试从若引用中获取obj1对象
         * 若没有获取到,说明该对象已经被JVM释放
         * 若获取到了,说明还没有被释放
         */
        String tryObj1 = reference1.get();
        // 这里tryObj1将会返回null,因为obj解除强引用后执行了GC操作
        // 因此obj1被释放了,所以获取不到
        System.out.println("try obj1:" + tryObj1);

        String tryObj2 = reference2.get();
        // 这里obj2不是null,因为obj2解除了强引用后并没有执行GC,
        // 因此obj2并没有被释放,所以可以获取到
        System.out.println("try obj2:" + tryObj2);
    }

}

输出结果:

try obj1:null
try obj2:myObj2

解释:弱引用的释放规则为“下次执行GC时,会将对象进行释放”。在上述代码中,obj1执行了GC,而obj2没有执行GC,因此obj1null,而obj2不为null

2.3 弱引用+引用队列代码演示

import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;

public class WeakReference2Test {

    public static void main(String[] args) throws InterruptedException {
        // 定义引用队列
        ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();

        // 这里定义了obj为正常的强引用
        String obj1 = new String("myObj1");

        // 为它增加弱引用
        // 此时它同时拥有两种引用,即obj同时拥有强引用和弱引用
        // 在为obj对象绑定弱引用时,同时传入了引用队列。
        // 这样“obj对象”在被回收之前,其对应的“引用对象”就会进入引用队列
        Reference<String> reference1 = new WeakReference<>(obj1, referenceQueue);  // 这里传入引用队列
        System.out.println("reference1: " + reference1);

        // 解除obj1强引用,然后执行GC
        obj1 = null;

        // 尝试从引用队列中获取引用对象。
        Reference<String> pollRef1 = (Reference<String>) referenceQueue.poll();
        // 这里会返回null,因为obj1虽然没有了强引用,但是还没有被标记为“可回收”
        // 因此,“obj1”的引用对象并没有进入到引用队列中
        System.out.println("pollRef1: " + pollRef1);

        // 执行GC操作
        System.gc();

        // 给些时间让reference对象进入引用队列
        Thread.sleep(100);

        // 再次尝试从引用队列中获取“引用对象”
        Reference<String> pollRef2 = (Reference<String>) referenceQueue.poll();
        // 这次就不为null了,因为`obj1`已经被标记为“可回收”,
        // 因此其对应的“引用对象”就会进入引用队列
        System.out.println("pollRef2: " + pollRef2);
        
        // 尝试从引用对象中获取`obj`对象
        // 这里两种可能:①null;② `myObj1`
        // 因为当`obj1`被标记为“可回收”时,其引用对象就会进入引用队列,但此时obj1是否被回收并不知道
        // ① 若obj1已经被回收了,这里就会返回null。(也是最常见的)
        // ② 若Obj1暂时还没被回收,这里就会返回“myObj1”(这个很难复现)
        System.out.println("pollRef2 obj: " + pollRef2.get());
    }

}

输出

reference1: java.lang.ref.WeakReference@49c2faae
pollRef1: null
pollRef2: java.lang.ref.WeakReference@49c2faae
pollRef2 obj: null

上述代码中一共进行了两次从引用队列中获取“引用对象”。第一次,由于还没有执行GC操作,obj1没有被标记为“可回收”状态,所以其对应的引用对象reference1也就没有进入引用队列。第二次,由于执行了GC操作,obj1被标记为了“可回收”,因此可以拿到reference1对象,但由于obj1已经被回收了,因此使用reference1.get()却拿不到obj1对象了。

这里再额外说明几点大家可能疑惑的点:

  1. 将“①对象标记为是否可回收”和“②释放对象”两个动作基本上是同时发生的,执行完动作①就会执行动作②。所以一般我们都只能从引用队列中拿到“引用对象”,但是再用引用对象去拿对象就拿不到了,因为已经被释放了。
  2. 只要对象被标记为“可释放”,那么该对象的“引用对象”就会进入引用队列,后续该对象随时可能会被释放。因此有可能会出现以下情况:
    Reference<String> pollRef1 = (Reference<String>) referenceQueue.poll();
    // 不为null,因为这个时候还没被释放
    System.out.println("pollRef1: " + pollRef1.get()); 
    // 为null,被释放了
    System.out.println("pollRef1: " + pollRef1.get());
    

2.4 虚引用代码演示

虚引用必须要配合引用队列,要不然没有任何意义:

import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceTest {

    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<>();

        // 这里定义了obj对象,为正常的强引用
        String obj = new String("myObj");

        // 为该对象增加虚引用
        // 此时它同时拥有两种引用,即obj同时拥有强引用和虚引用
        // 虚引用构造方法要求必须要传引用队列
        Reference<String> phantomReference = new PhantomReference<>(obj, queue);

        // obj的强引用还在,因此其一定不会被释放
        // 尝试使用虚引用获取obj对象
        String tryObj = phantomReference.get();
        // tryObj为null,因为phantomReference.get()的实现就是`return null;`
        System.out.println("try obj:" + tryObj);
    }

}

输出:

try obj:null

上面的代码中,虽然obj没有被释放,但用虚引用依然无法获取obj对象。这也是为什么人们说“虚引用是虚幻的,必须要配合引用队列一起用,要不然就没意义了”

2.5 虚引用+引用队列代码演示

import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceTest2 {

    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<>();

        // 这里定义了obj对象,为正常的强引用
        String obj = new String("myObj");

        // 为该对象增加虚引用
        // 此时它同时拥有两种引用,即obj同时拥有强引用和虚引用
        // 虚引用构造方法要求必须要传引用队列
        Reference<String> phantomReference = new PhantomReference<>(obj, queue);
        System.out.println("phantomReference:" + phantomReference);

        // 解除obj的强引用
        obj = null;

        System.gc();

        // 从引用队列中获取引用对象。
        // 由于obj已经是“可释放或已释放”状态,因此可以获取到`phantomReference`
        System.out.println("referenceQueue:" + queue.poll());
    }

}

3. 实战样例

3.1 利用软引用实现资源对象缓存

场景:假设我们有一个资源类Resource,它占用100M内存。加载过程很慢,我们既不想因为导致OOM,又想让它缓存在JVM里,我们就可以用软引用实现。

import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

public class SoftReferenceAppTest {

    static class Resource {

        private byte[] data = new byte[1024 * 1024 * 100]; // 该资源类占用100M内存

        private static int num = 0;

        private static SoftReference<Resource> resourceHolder = null;

        public static Resource getInstance() {
            if (resourceHolder != null) {
                // 从缓存中获取
                Resource resource = resourceHolder.get();
                if (resource != null) {
                    return resource;
                }
            }

            // 缓存中没有,重新new一个
            System.out.println("从数据库中获取资源类:" + num);
            num ++;

            Resource resource = new Resource();
            resourceHolder = new SoftReference<>(resource);
            return resource;
        }

        public String getData() {
            return "data";
        }
    }

    public static void main(String[] args) {
        // 使用资源类
        System.out.println(Resource.getInstance().getData());

        // 执行各种各样的事情,最终jvm内存满了,然后回收了resource。
        int i = 0;
        List<byte[]> list = new ArrayList<>();
        while(true) {
            list.add(new byte[1024*1024*50]);
            i++;

            // 使用资源类
            Resource.getInstance().getData();

            if (i > 10000) {
                break;
            }
        }
    }

}

输出:

从数据库中获取资源类:0
data
从数据库中获取资源类:1
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at jvm.SoftReferenceAppTest$Resource.<init>(SoftReferenceAppTest.java:11)
	at jvm.SoftReferenceAppTest$Resource.getInstance(SoftReferenceAppTest.java:30)
	at jvm.SoftReferenceAppTest.main(SoftReferenceAppTest.java:52)

上述例子中,我们的Resource类使用了软引用SoftReference来进行缓存。

之后在模拟中,执行过程如下:

  1. 首先获取了资源类对象,并将这个对象绑定了软引用,但并不存在强引用
  2. 使用资源类,正常从软引用中获取缓存的资源类对象
  3. 不断的往JVM中添加数据list.add(new byte[1024*1024*50]);,最终导致内存不够用
  4. 由于内存不够用,因此GC回收时回收了资源类对象,因此重新生成了资源类对象
  5. 最后,JVM内存不够了,最终OOM

3.2 利用弱引用实现临时行为数据缓存

抽象场景:在某些场景中,用户的操作会产生一些后台数据,而这些数据用户可能会在短时间内再次使用,但也可能不使用。这种时候就可以使用弱引用WeakReference进行缓存。

样例场景:后台提供了一个word转PDF的功能,但由于某些用户可能会连点,或由于网络不好导致短时间内再次对同一文档进行转换。而转换过程又比较耗资源,因此使用WeakReference进行缓存。

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;

public class WeakReferenceAppTest {

    // 缓存对象
    static Map<String, WeakReference<String>> cacheMap = new HashMap<>();

    // 将word数据转为pdf数据,不使用缓存
    public static String wordToPDF(String wordData) {
        return "pdf " + wordData;
    }

    // 将word数据转为pdf数据,使用缓存
    public static String wordToPDFWithCache(String wordData) {
        // 先看缓存里有没有
        if (cacheMap.keySet().contains(wordData)) {
            String pdfData = cacheMap.get(wordData).get();
            if (pdfData != null) {
                System.out.println("使用缓存数据");
                return pdfData;
            }
        }

        String pdfData = "pdf " + wordData;
        System.out.println("无缓存,执行转换!");
        cacheMap.put(wordData, new WeakReference<>(pdfData));

        return pdfData;
    }

    public static void main(String[] args) throws InterruptedException {
        // 程序A,不使用WeakReference做缓存
        {
            System.out.println("---程序A(不用缓存)----");

            // 用户A将word1转为了pdf
            System.out.println(wordToPDF("word1"));

            // 用户A因为手快连点了两次,由于没有进行缓存,因此又执行了一遍wordToPDF
            System.out.println(wordToPDF("word1"));
        }

        System.out.println();

        // 程序B,使用WeakReference做缓存
        {
            System.out.println("---程序B(使用缓存)----");

            // 用户A将word1转为了pdf
            System.out.println(wordToPDFWithCache("word1"));

            // 用户A因为手快连点了两次,由于有缓存,直接返回
            System.out.println(wordToPDFWithCache("word1"));

            // 过了若干时间
            System.gc();
            Thread.sleep(1000);

            // 用户A再次对word1进行转pdf
            System.out.println(wordToPDFWithCache("word1"));
        }
    }

}

输出:

---程序A(不用缓存)----
pdf word1
pdf word1

---程序B(使用缓存)----
无缓存,执行转换!
pdf word1
使用缓存数据
pdf word1
无缓存,执行转换!
pdf word1

3.3 利用虚引用+引用队列实现资源释放

应用场景:替代finalize,因为其有很多缺点:

finalize的缺点:

(1)无法保证什么时间执行。
(2)无法保证执行该方法的线程优先级。
(3)无法保证一定会执行。
(4)如果在终结方法中抛出了异常,并且该异常未捕获处理,则当前对象的终结过程会终止,且该对象处于破坏状态。
(5)影响GC的效率,特别是在finalize方法中执行耗时较长的逻辑。
(6)有安全问题,可以进行终结方法攻击。

最优解决方案:使用try-with-resource。

其次解决方案:使用虚引用+引用队列。

最好两种同时用。

样例场景:我们现在有一个比较耗资源的Resource类(也可以是IO等),我们没有办法保证try-with-resourcefinalize能正确释放。因此我们还要启一个后台线程来监控目前是否有未释放的资源。如果有,则释放资源。若释放失败,则发送通知消息。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
import java.util.HashMap;
import java.util.Map;

public class PhantomReferenceAppTest {

    // 引用队列,专门负责对Resource对象的释放
    static ReferenceQueue<Resource> referenceQueue = new ReferenceQueue<>();
    static Map<PhantomReference, String /*资源ID*/> referenceIdMap = new HashMap<>();

    static class Resource {

        public static Resource getInstance(String resourceID) {
            Resource resource = new Resource();
            // 将resource与一个虚引用绑定,并传入公共引用队列
            // 这样当resource资源不再使用时,就可以释放资源。
            PhantomReference phantomReference = new PhantomReference<>(resource, referenceQueue);
            referenceIdMap.put(phantomReference, resourceID); // 记录这个引用对象对应的资源ID,用于后续释放资源

            return resource;
        }

        // 注意:这个必须是静态方法。因为PhantomReference根本拿不到Resource的实例。
        // 假设使用软/弱引用,也不一定能拿到Resource,因为Resource对象很有可能已经被JVM释放了
        public static void realise(String resourceID) {
            System.out.println("释放资源:" + resourceID);
        }

    }

    public static void main(String[] args) throws InterruptedException {
        // 定义一个专门用于资源释放的线程
        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                while (true) {
                    Thread.sleep(100);

                    // 尝试从引用队列中获取引用对象
                    PhantomReference phantomReference = (PhantomReference) referenceQueue.poll();

                    if (phantomReference == null) {
                        continue;
                    }

                    String resourceId = referenceIdMap.get(phantomReference); // 获取resourceID

                    Resource.realise(resourceId);

                    referenceIdMap.remove(phantomReference);
                }
            }
        }).start();


        Resource resource1 = Resource.getInstance("res1");
        Resource resource2 = Resource.getInstance("res2");
        Resource resource3 = Resource.getInstance("res3");

        resource3 = null; // resource3使用完毕,执行了GC
        System.gc();

        Thread.sleep(1000);
        resource1 = null; // resource1使用完毕,没有执行GC
        resource2 = null; // resource2使用完毕,执行GC
        System.gc();
    }
}

输出:

释放资源:res3
释放资源:res2
释放资源:res1