【笔记二】GC 回收机制和分代回收策略

垃圾回收(Garbage Collection,简写为: GC)是 Java 开发者最关注的一块知识点。Java 语言开发者比 C 语言开发者幸福的地方就在于,不需要手动释放对象的内存,JVM 中的垃圾回收器(Garbage Collector)会为我们自动回收。但是也是又对应的代价的:一旦这种自动化机制出错,就必须区深入理解 GC 回收机制,甚至需要对这些 “自动化” 的技术实施必要的监控和调节。

Java 内存运行时区域中程序计数器虚拟机栈本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。

方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

1. 什么是垃圾

所谓垃圾就是内存中已经没有用的对象。既然是“垃圾回收”,那就需要知道哪些对象是垃圾。Java 虚拟机中使用一种叫做 “可达性分析” 的算法来决定对象是否可以被回收。

1.1 可达性分析

可达性分析算法是从离散数学图论引入的,JVM 把内存中对象的引用关系看做一张图,从一组叫做 “GC Root” 的对象作为起始点,从这些结点开始向下搜索,搜索走过的路径成为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如下图所示:

img

如上图所示,对象 A、B、C、D、E 都与 “GC Root” 之间存在一条直接或者间接的引用链,这代表它们与 “GC Root” 之间是可达的,因此他们是不能被 GC 回收掉的。而对象 K 和对象 M 都被对象 J 所引用到,但是并不存在一条引用链连接他们和 “GC Root” ,所以当 GC 进行垃圾回收时,只要遍历到 J、K 、M 这三个对象,就会将他们回收掉。

注意:上图中圆形图标虽然标记的是对象,但实际上代表该对象在内存中的引用。包括 “GC Root” 也是一组引用而并非对象。

GC Root

在 Java 中,有一下几种对象可以作为 GC Root:

  • Java 虚拟机栈(局部变量表)中引用的对象;
  • 方法区中静态引用指向的对象;
  • 仍处于存货状态中的对象;
  • Native 中 JNI 引用的对象。

2. 什么时候回收

不同的虚拟机有着不同的 GC 实现机制,但是一般情况下每一种 GC 的实现都会再以下两种情况触发垃圾回收。

  1. Allocation Failure:再堆内存分配时,如果因为剩余可用空间不足导致对象内存分配失败,这时候系统会触发依次 GC。
  2. System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求依次 GC 。

3. 代码验证 GC Root 的几种情况

现在我们了解了 Java 中的 GC Root ,以及何时触发 GC 。接下来就通过几个案例来验证 GC Root 的几种情况。在看代码之前,先了解一下执行 Java 命令时的参数。

-Xms 初始分配 JVM 运行时的内存大小,如果不指定,默认为物理内存的 1/64。

比如我们运行如下命令执行 HelloWord 程序,从物理内存中分配出 200M 空间分配给 JVM 内存。

java -Xms200m HelloWorld

3.1 验证虚拟机栈(栈帧中局部变量)中引用对象作为 GC Root

运行如下代码:

public class GCRootLocalVariable {
    private int _10MB = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10MB];

    public static void main(String[] args){
        System.out.println("开始时:");
        printMemory();
        method();
        System.gc();
        System.out.println("第二次GC完成");
        printMemory();
    }

    public static void method() {
        GCRootLocalVariable g = new GCRootLocalVariable();
        System.gc();
        System.out.println("第一次GC完成");
        printMemory();
    }

    /**
     * 打印出当前JVM剩余空间和总的空间大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

日志如下:

开始时:
free is 242 M, total is 245 M,
第一次GC完成
free is 163 M, total is 245 M,
第二次GC完成
free is 243 M, total is 245 M,

分析:

  1. 当第一次 GC 时,g 作为局部变量(new 出来的80M对象)的引用,并且它作为 GC Root ,在调用 GC 后,并不会被 GC 回收
  2. 当第二次 GC 时,method() 方法执行完成后,局部变量 g 跟随方法消失,不再有引用类型执行该 80M 对象,所以第二次 GC 时会被回收。

注意:上面的日志包括后面的实例中,因为有中间变量,所以会有1M 左右的误差,不影响进行 GC 分析。

3.2 验证方法区中的静态变量引用的对象作为 GC Root

运行如下代码:

public class GCRootStaticVariable{
    private static int _10MB = 10 * 1024 * 1024;
    private byte[] memory;
    private static GCRootStaticVariable staticVariable;

    public GCRootStaticVariable(int size) {
        memory = new byte[size];
    }

    public static void main(String[] args){
        System.out.println("程序开始:");
        printMemory();
        GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
        g.staticVariable = new GCRootStaticVariable(8 * _10MB);
        // 将g置为null, 调用GC时可以回收此对象内存
        g = null;
        System.gc();
        System.out.println("GC完成");
        printMemory();
    }

    /**
     * 打印出当前JVM剩余空间和总的空间大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

打印结果如下:

程序开始:
free is 242 M, total is 245 M,
GC完成
free is 163 M, total is 245 M,

分析:

程序刚开始运行时内存为 242M ,并分别创建了 g 对象(40M),初始化了 g 对象内部的静态变量 staticVariable 对象(80M)。当调用 GC 时,g 对象引用为置为 null , 该40M 对象被 GC 回收,而 staticVariable 对象(80M)作为 GC Root ,它引用的 80M 对应并未被回收。

3.3 验证活跃线程作为 GC Root

运行如下代码

public class GCRootThread{

    private int _10MB = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10MB];

    public static void main(String[] args) throws Exception {
        System.out.println("开始前内存情况:");
        printMemory();
        AsyncTask at = new AsyncTask(new GCRootThread());
        Thread thread = new Thread(at);
        thread.start();
        System.gc();
        System.out.println("main方法执行完毕,完成GC");
        printMemory();

        thread.join();
        at = null;
        System.gc();
        System.out.println("线程代码执行完毕,完成GC");
        printMemory();
    }

    /**
     * 打印出当前JVM剩余空间和总的空间大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }

    private static class AsyncTask implements Runnable {
        private GCRootThread gcRootThread;

        public AsyncTask(GCRootThread gcRootThread){
            this.gcRootThread = gcRootThread;
        }

        @Override
        public void run() {
            try{
                Thread.sleep(500);
            } catch(Exception e){}
        }
    }
}

打印日志如下:

开始前内存情况:
free is 242 M, total is 245 M,
main方法执行完毕,完成GC
free is 163 M, total is 245 M,
线程代码执行完毕,完成GC
free is 243 M, total is 245 M,

分析:

程序刚开始时是 242M 内存,当调用第一次 GC 时线程并没有执行结束,并且它作为 GC Root,所以它所引用的 80M 内存并不会被 GC 回收掉。 thread.join() 保证线程结束再调用后续代码,所以当调用第二次 GC 时,线程已经执行完毕并被置为 null,这时线程已经被销毁,所以之前它所引用的 80M 此时会被 GC 回收掉。

3.4 测试成员变量是否可以作为 GC Root

运行如下代码:

public class GCRootClassVariable{

    private static int _10MB = 10 * 1024 * 1024;
    private byte[] memory;
    private GCRootClassVariable classVariable;

    public GCRootClassVariable(int size){
        memory = new byte[size];
    }

    public static void main(String[] args){
        System.out.println("程序开始:");
        printMemory();
        GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
        g.classVariable = new GCRootClassVariable(8 * _10MB);
        g = null;
        System.gc();
        System.out.println("GC完成");
        printMemory();
    }

    /**
     * 打印出当前JVM剩余空间和总的空间大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

打印日志如下:

程序开始:
free is 242 M, total is 245 M,
GC完成
free is 243 M, total is 245 M,

分析:

从上面日志中可以看出当调用 GC 时,因为 g 已经置为 null,因此 g 中的全局变量 classVariable 此时也不再被 GC Root 所引用。所以最后 g(40M) 和 classVariable(80M) 都会被回收掉。这也表明全局变量同静态变量不同,它不会被当作 GC Root

上面演示的这几种情况往往也是内存泄漏发生的场景,设想一下我们将各个 Test 类换成 Android 中的 Activity 的话将导致 Activity 无法被系统回收,而一个 Activity 中的数据往往是较大的,因此内存泄漏导致 Activity 无法回收还是比较致命的。

4. 如何进行垃圾回收

由于垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各不相同,下面介绍几种算法的思想以及优缺点。

4.1 标记清除算法(Mark and Sweep GC)

从 GC Roots 集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都作为垃圾对待并回收,可以分为以下两步:

  1. Mark 标记阶段:找到内存中所以 GC Root 对象,将和 GC Root 直接或间接引用的对象标记为灰色(存活对象),否则标记位黑色(垃圾对象)。
  2. Sweep 清除阶段:遍历完内存中所以对象后,直接将标记为垃圾的对象清除。
img

优缺点分析:

优点:实现简单,不需要将对象进行移动

缺点:这个算法需要中断进程内其他组件的执行(stop the world),并可能产生内存碎片,提高了垃圾回收的频率。

4.2 复制算法(Copying)

将现有的内存分为两块,每次只使用其中一块;在垃圾回收时,将正在使用的内存块中存活的对象复制到未被使用的内存块中;之后将正在使用的内存块中的所有对象进行清除,交换两个内存块的角色,完成垃圾回收。

  1. 复制算法之前,内存分为 A/B 两块,并且当前只使用内存 A,内存的状况如下图所示:

    img

  2. 标记完之后,所有可达对象都被按次序复制到内存 B 中,并设置 B 为当前使用中的内存。内存状况如下图所示:

    img

优缺点分析:

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

4.3 标记-压缩算法(Mark-Compact)

需要从根节点开始对所有可达对象做一次标记,之后,它不简单清理未标记对象,而是将所有存活对象压缩到内存的一端。最后,清理边界外所有空间。因此,标记压缩算法也分两步进行:

  1. Mark 标记阶段:找到内存中所以 GC Root 对象,只要是和 GC Root 对象直接或间接相连的标记位灰色(存活对象),否则标记位黑色(回收垃圾对象)。

  2. Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。

    img

优缺点分析:

优点:这种方法既避免了内存碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高

缺点:所谓压缩操作,需要对局部对象进行移动,所以,一定程度上还是降低了效率。

4.4 JVM 分代回收策略

Java 虚拟机根据内存中对象的存活周期的不同,把内存划分为几块;一般分为新生代、老年代,这就是 JVM 的分代回收策略。在 HotSpot 中,除了新生代和老年代还有永久代。

分代回收的中心思想就是:对于新创建的对象会在新生代区域分配内存,此区域的对象生命周期一般比较短。如果经过多次回收仍然存活下来则将它们转移到老年代区域中。

新生代(年轻代 Young Generation)

新生成的对象优先存放在新生代区域中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾回收一般可以回收70%~95% 的空间,回收率很高。新生代区域中需要一些复制操作,所以在新生代区域中使用的垃圾回收算法是复制算法。

新生代区域又可以继续细分为3部分:Eden、Survivor0(简称 S0)、Survivor1(简称 S1)。这三部分按照8 : 1 : 1 的比例来划分。这3块内存区域的分配如下:

绝大多数刚刚被创建的对象会存放在 Eden 区。如图所示:

img

Eden 区第一次满的时候,会进行垃圾回收。首先将 Eden 区的垃圾对象回收清除,并将存活的对象复制到 S0 ,此时 S1 时空的。如图所示:

img

下一次 Eden 区满时,在进行一次垃圾回收。此时将 Eden 区和 S0 区中的垃圾对象进行回收清除,并将存活对象复制到 S1 区,此时 S0 是空的。如图所示:

img

如此反复在 S0S1 切换几次(默认15次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将他们转移到老年代中。如图所示:

img

老年代

一个对象在如果在新生代区域存活了足够长时间没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(字符串或大数组),并且新生代剩余空间不足,则这个大对象会被直接分配到老年代。

我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代对象的大小,大于这个值的对象会直接分配到老年代区域。老年代因为生命周期比较长,不需要过多的复制操作,,所以一般采用标记-压缩的回收算法。

注意:对于老年代,可能存在这么一种情况,老年代中的对象有时候会引用到新生代中的对象。如果这时候要执行 GC ,则可能需要查询整个老年代上可能存在引用新生代中的对象的情况,这显然是低效的。所以,老年代中维护了一个 512 byte 的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代 GC 发生时,只需要检查这个 card table 即可,大大提高了性能。

5. GC log 分析

为了让上层应用开发人员更方便的调试 Java 程序,JVM 提供了相应的 GC 日志。在 GC 执行垃圾回收时间过程中,会有各种相应的 log 被打印出来。其中新生代和老年代打印出来的 log 日志是有区别的。

  • 新生代 GC :这一区域的 GC 叫做 Minor GC。因为 Java 对象大都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC:发生在这一区域的 GC 叫做 Major GC 或者 Full GC。当出现了 Major GC,经常会伴随至少一次的 Minor GC。

注意:在有些虚拟机实现中,Major GC 和 Full GC 还是有一定的区别的。Major GC 只是代表回收老年代区域的内存,Full GC 代表回收整个堆中的内存,也就是新生代+老年代。

首先我们需要先理解几个 Java 命令参数

命令参数 功能描述
-verbose:gc 显示 GC 的操作内容
-Xms20M 初始化堆大小为20M
-Xmx20M 设置堆最大分配内存20M
-Xmn10M 设置新生代内存大小为10M
-XX:+printGCDetails 打印 GC 的详细 log 日志
-XX:SurvivorRatio=8 新生代中的 Eden 区域与Survivor 区域的大小比值为 8:1:1

使用如下代码,在内存中创建 4 个 byte 类型数组来演示内存分配与 GC 的详细过程。代码如下:

/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;

    public static void testAllocation() {
        byte[] a1, a2, a3, z4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[1 * _1MB];
    }

    public static void main(String[] agrs) {
        testAllocation();
    }
}

通过上面的参数可以看出堆内存总大小为 20M,其中新生代占 10M,剩下的10M 会自动分配给老年代。执行上面代码打印日志如下:

Heap
PSYoungGen      total 9216K, used 8003K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdd0ed8,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
  to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen       total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace       used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

日志中的各字段代表意义如下:

字段 含义
PSYoungGen 新生代
eden 新生代中的 Eden 区
from 新生代中的 S0 区
to 新生代中的 S1 区
ParOldGen 老年代

从日志中可以看出:程序执行完之后,a1,a2,a3,a4 四个对象都被分配在了新生代的 Eden 区。

如果我们将测试代码中的 a4 初始化改为 ``a4 = new byte[2*_1M],则打印日志如下:

[GC (Allocation Failure) [PSYoungGen: 6815K->480K(9216K)] 6815K->6632K(19456K), 0.0067344 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
Heap
PSYoungGen      total 9216K, used 2130K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 26% used [0x00000007bf600000,0x00000007bf814930,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
  to   space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen       total 10240K, used 6420K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 62% used [0x00000007bec00000,0x00000007bf2450d0,0x00000007bf600000)
Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

这是因为在给 a4 分配内存之前,Eden 区已经被占用6M。已经无法再分配出 2M 来存储 a4 对象。因此会执行一次 Minor GC,并尝试将存活的 a1,a2,a3 对象复制到 S1 区。但是 S1区只有1M 空间,所以没有办法存储a1、a2、a3 任意一个对象。在这种情况下,a1、a2、a3 被转移到老年代,最后将 a4 存储在 Eden 区。所以最终的结果就是:Eden 区占用2M(a4),老年代占用6M(a1,a2,a3)。

6. 引用

上面说过,判断对象是否存活是通过 GC Roots 的引用可达性来判断的。但 JVM 中的引用关系不止一种,而是有四种,根据引用由强到弱,他们分别是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。

四种引用的简单对比:

引用 GC 回收时机 使用示例
强引用 如果一个对象具有强引用,那垃圾回收器绝不会回收它 Object obj = new Object();
软引用 内存实在不足时,会对软引用进行回收 SoftReference softObj = new SoftReference();
弱引用 第一次垃圾回收时,如果垃圾回收器遍历到此弱引用,则将其回收 WeakReference weakObj = new WeakReference();
虚引用 一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来获取一个对象的实例 不会使用

平时项目中,尤其是Android项目,因为有大量的图像(Bitmap)对象,使用软引用的场景较多。所以重点看下软引用SoftReference的使用,不当的使用软引用有时也会导致系统异常。

软引用常规使用

常规使用代码如下

public class SoftReferenceNormal {
  static class SoftObject {
    byte[] data = new byte[120 * 1024 * 1024];//120M
  }

  public static void main(String[] args) throws InterruptedException {
    SoftReference<SoftObject> cacheRef = new SoftReference<>(new SoftObject());

    System.out.println("第一次 GC 前,软引用:"+cacheRef.get());
    System.out.println("第一次 GC 后,软引用:"+cacheRef.get());

    //再分配一个120M 对象
    SoftObject newSo = new SoftObject();
    System.out.println("再次分配120M 强引用对象后,软引用:"+cacheRef.get());
  }
}

执行上述代码,打印日志如下:

$ java -Xmx200m SoftReferenceNormal
第一次 GC 前,软引用:SoftReferenceNormal$SoftObject@5c647e05
第一次 GC 后,软引用:SoftReferenceNormal$SoftObject@5c647e05
再次分配120M 强引用对象后,软引用:null

首先通过-Xmx将堆最大内存设置为200M。从日志中可以看出,当第一次GC时,内存中还有剩余可用内存,所以软引用并不会被GC回收。但是当我们再次创建一个120M的强引用时,JVM可用内存已经不够,所以会尝试将软引用给回收掉。

软引用隐藏问题

需要注意的是,被软引用对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会自动被垃圾回收器回收掉。比如如下代码:

import java.lang.ref.SoftReference;
import java.util.Set;
import java.util.HashSet;

public class SoftReferenceTest {
    static class SoftObject {
        byte[] data = new byte[1024];//1KB
      }

  public static final int CACHE_INITIAL_CAPACITY = 100 * 1024;//100M
  //静态集合保存软引用对象,会导致这些软引用对象本身不能被垃圾回收器回收
  public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);

  public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < CACHE_INITIAL_CAPACITY ; i++) {
        SoftObject obj = new SoftObject();
        cache.add(new SoftReference<>(obj));
        if(i % 10000 == 0){
             System.out.println("size of cache:"+cache.size());
        }
    }

    System.out.println("The End!");
    }
}

执行上述代码,打印日志如下:

$java -Xms4M -Xmx4m -Xmn2M SoftReferenceTest
size of cache:1
size of cache:10001
size of cache:20001
size of cache:30001
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
    at SoftReferenceTest$SoftObject.<init>(SoftReferenceTest.java:7)
    at SoftReferenceTest.main(SoftReferenceTest.java:16)

限制堆内存大小为4M,最终程序崩溃,但是异常的原因并不是普通的堆内存溢出,而是”GC overhead”。之所以会抛出这个错误,是由于虚拟机一直在不断回收软引用,回收进行的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。

这里需要做优化,合适的处理方式是注册一个引用队列,每次循环之后将引用队列中出现的软引用对象从cache中移除。如下所示:

import java.lang.ref.SoftReference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.Reference;
import java.util.Set;
import java.util.HashSet;

public class SoftReferenceTest {
    static class SoftObject {
        byte[] data = new byte[1024];//1KB
      }

      public static int removedSoftRefs = 0;
      public static final int CACHE_INITIAL_CAPACITY = 100 * 1024;//100M
      //静态集合保存软引用对象,会导致这些软引用对象本身不能被垃圾回收器回收
      public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
      public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();

      public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < CACHE_INITIAL_CAPACITY ; i++) {
            SoftObject obj = new SoftObject();
            cache.add(new SoftReference<>(obj,referenceQueue));
            clearUselessReference();
            if(i % 10000 == 0){
                 System.out.println("size of cache:"+cache.size());
            }
        }

        System.out.println("The End!");
    }


    public static void clearUselessReference() {
        Reference<? extends SoftObject> ref = referenceQueue.poll();
        while (ref != null){
            if(cache.remove(ref)){
                removedSoftRefs++;
            }
            ref = referenceQueue.poll();
        }

    }
}

再次运行修改后的代码,结果如下:

$java -Xms4M -Xmx4m -Xmn2M SoftReferenceTest
size of cache:1
size of cache:1700
size of cache:1310
size of cache:920
size of cache:530
size of cache:2218
size of cache:1828
size of cache:1310
size of cache:708
size of cache:280
size of cache:1940
The End! removes soft Reference 100657

可以看出优化后,程序可以正常执行完。并且在执行过程中会动态的将集合中的软引用删除。

总结

虚拟机垃圾回收机制很多时候都是影响系统性能、并发能力的主要因素之一。尤其是对于从事 Android 开发的工程师来说,有时候垃圾回收会很大程度上影响 UI 线程,并造成界面卡顿现象。因此理解垃圾回收机制并学会分析 GC Log 也是一项必不可少的技能。