GC回收机制与分代回收策略

1,489 阅读17分钟

GC回收机制

一、前言

垃圾回收Garbage Collection,简写 GCJVM 中的垃圾回收器会自动回收无用的对象。

但是 GC 自动回收的代价是:当这种自动化机制出错,我们就需要深入理解 GC 回收机制,甚至需要对这些 自动化 的技术实施必要的监控与调节。

在虚拟机中,程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而执行着出栈和入栈操作。所以这几个区域不需要考虑回收的问题。

而在 堆和方法区 中,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样。这部分的只有在程序运行期间才会知道需要创建哪些对象,这部分的内存的创建和回收是动态的,也是垃圾回收器重点关注的地方。


二、什么是垃圾

垃圾 就是 内存中已经没有用的对象。既然是 垃圾回收,就必须知道哪些对象是垃圾。

Java 虚拟机中使用了一种叫做 可达性分析 的算法 来决定对象是否可以被回收

GCRoot示意图

上图中 A、B、C、D、E 与 GCRoot 直接或间接产生引用链,所以 GC 扫描到这些对象时,并不会执行回收操作;J、K、M虽然之间有引用链,但是并没有与 GCRoot 存在引用链,所以当 GC 扫描到他们时会将他们回收。

注意的是,上图中所有的对象,包括 GCRoot,都是内存中的引用。

作为 GCRoot 的几种对象
  1. Java虚拟机栈(局部变量表)中的引用的对象;
  2. 方法区中静态引用指向的对象;
  3. 仍处于存活状态中的线程对象;
  4. Native方法中 JNI 引用的对象;

三、什么时候回收

不同的虚拟机实现有着不同的 GC 实现机制,但一般情况下都会存在下面两种情况:

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

四、验证GCRoot的几种情况

在验证之前,先了解Java命令时的参数。

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

举个小例子

// 表示从物理内存中分配出 200M 空间给 JVM 内存
java -Xms200m HelloWorld
1.验证虚拟机栈(栈帧中的局部变量)中引用的对象作为 GCRoot
// 验证代码
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 gc = new GCRootLocalVariable();
        System.gc();
        System.out.println("第一次GC完成");
        printMemory();
    }

    // 打印出当前JVM剩余空间和总的空间大小
    public static void printMemory() {
        long freeMemory = Runtime.getRuntime().freeMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();
        System.out.println("剩余空间:" + freeMemory / 1024 / 1024 + "M");
        System.out.println("总共空间:" + totalMemory / 1024 / 1024 + "M");
    }
}
// 打印日志:
开始时:
剩余空间:119M
总共空间:123M
第一次GC完成
剩余空间:40M
总共空间:123M
第二次GC完成
剩余空间:120M
总共空间:123M

从上述代码中可以看到:

第一次打印内存信息,分别为 119M 和 123M;

第二次打印内存信息,分别为 40M 和 123M;剩余空间小了 80M,是因为在 method() 方法中创建了局部变量 gc(位于栈帧中的局部变量),并且这个 gc 对象会被作为 GCRoot。虽然创建的对象未被使用并且调用了 System.gc(),但是因为该方法未结束,所以创建的对象不能被回收。

第三次打印内存信息,分别为 120M 和 123M;method() 方法已经结束,创建的对象 gc 也随方法消失,不再有引用类型指向该 80M 对象。

【值得注意的是】

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

上面 2 行代码是必须的,如果去掉,那么 3 次打印结果将会一致,idea 也会出现Instantiation of utility class 警告信息,说这个类只存在静态方法,没必要创建这个对象。

这也就是说为什么创建 GCRootLocalVariable() 会需要 80M 的大小,是因为 GCRootLocalVariable 在创建时就会为其内部变量 memory 确定 80M 的大小。

2.验证方法区中的静态变量引用的对象作为 GCRoot
public class GCRootStaticVariable {
    private static int _10M = 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(2 * _10M);
        g.staticVariable = new GCRootStaticVariable(4 * _10M);
        // 将g设置为null,调用GC时可以回收此对象内存
        g = null;
        System.gc();
        System.out.println("GC完成");
        printMemory();
    }

    // 打印JVM剩余空间和总空间
    private static void printMemory() {
        long freeMemory = Runtime.getRuntime().freeMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();
        System.out.println("剩余空间" + freeMemory/1024/1024 + "M");
        System.out.println("总共空间" + totalMemory/1024/1024 + "M");
    }
}

打印结果:
程序开始:
剩余空间119M
总共空间123M
GC完成
剩余空间81M
总共空间123M

通过上述打印结果可知:

  1. 程序刚开始时打印结果为 119M;
  2. 当创建 g 对象时分配 20M 内存,又为静态变量 staticVariable 分配 40M 内存;
  3. 当调用 gc 回收时,非静态变量 memory 分配的 20M 内存被回收;
  4. 但是作为 GCRoot 的静态变量 staticVariable 不会被回收,所以最终打印结果少了 40M 内存。
3.验证活跃线程作为GCRoot
public class GCRootThread {

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

    public static void main(String[] args) throws InterruptedException {
        System.out.println("程序开始:");
        printMemory();
        AsyncTask asyncTask = new AsyncTask(new GCRootThread());
        Thread thread = new Thread(asyncTask);
        thread.start();
        System.gc();
        System.out.println("main方法执行完成,执行gc");
        printMemory();
        thread.join();
        asyncTask = null;
        System.gc();
        System.out.println("线程代码执行完成,执行gc");
        printMemory();
    }

    private static void printMemory() {
        long freeMemory = Runtime.getRuntime().freeMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();
        System.out.println("剩余内存:" + freeMemory / 1024 / 1024 + "M");
        System.out.println("总共内存:" + 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 (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
打印结果:
程序开始:
剩余内存:119M
总共内存:123M
main方法执行完成,执行gc
剩余内存:41M
总共内存:123M
线程代码执行完成,执行gc
剩余内存:120M
总共内存:123M

通过上述打印结果可知:

  1. 程序刚开始时可用内存为 119M;
  2. 第一次调用 gc 时,线程并没有执行结束,并且它作为 GCRoot ,所以它所引用的 80M 内存不会被 GC 回收掉;
  3. thread.join() 保证线程结束后再调用后续代码,所以当第二次调用 GC 时,线程已经执行完毕并被置为 null;
  4. 这时线程已经销毁,所以该线程所引用的 80M 内存被 GC 回收掉。
4.测试成员变量是否可作为GCRoot
public class GCRootClassVariable {
    private static int _10M = 10 * 1024 * 1024;
    private byte[] memory;
    private GCRootClassVariable gcRootClassVariable;

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

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

    private static void printMemory() {
        long freeMemory = Runtime.getRuntime().freeMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();
        System.out.println("剩余内存:" + freeMemory / 1024 / 1024 + "M");
        System.out.println("总共内存:" + totalMemory / 1024 / 1024 + "M");
    }
}
打印结果:
程序开始:
剩余内存:119M
总共内存:123M
GC完成
剩余内存:121M
总共内存:123M

上述打印结果可知:

  1. 第一次打印结果与第二次打印结果一致:全局变量 gcRootClassVariable 随着 g=null 后被销毁。
  2. 所以全局变量并不能作为 GCRoot。

五、如何回收垃圾(常见的几种垃圾回收算法)

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

从 “GCRoots” 集合开始,将内存整个遍历一次,保留所有可以被 GCRoots 直接或间接引用到的对象,而剩下的对象都当做垃圾对待并回收。

上述整个过程分为两步:

  1. Mark标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或间接相连则标记为灰色(存活对象),否则标记为黑色(垃圾对象)。
  2. Sweep清楚阶段:当遍历完所有的 GC Root 之后,则将标记为垃圾的对象直接清楚。

标记清除算法示意图

标记清除算法优缺点

【优点】

实现简单,不需要将对象进行移动。

【缺点】

需要中断进程内其他组件的执行,并且可能产生内存碎片,提高了垃圾回收的频率。

2.复制算法(Copying)
  1. 将现有的内存空间分为两块,每次只使用其中一块;
  2. 在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中;
  3. 之后清除正在使用的内存块中的所有对象;
  4. 交换两个内存的角色,完成垃圾回收(目前使用A,B是空闲,算法完成后A为空闲,设置B为使用状态)。

复制算法复制前示意图

复制算法复制后示意图

复制算法优缺点

【优点】

按顺序分配内存即可;实现简单、运行高效,不用考虑内存碎片问题。

【缺点】

可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

3.标记压缩算法(Mark-Compact)
  1. 需要先从根节点开始对所有可达对象做一次标记;
  2. 之后并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端;
  3. 最后清理边界外所有的空间。

所有,标记压缩也分为两步完成:

  1. Mark标记阶段:找到内存中的所有 GC Root 对象,只要和 GC Root 对象直接或间接相连则标记为灰色(存活对象),否则标记为黑色(垃圾对象)
  2. Compact压缩阶段:将剩余存活对象按顺序压缩到内存的某一端

标记压缩算法示意图

标记压缩算法优缺点

【优点】

避免了碎片产生,又不需要两块相同的内存空间,性价比较高。

【缺点】

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




分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为 新生代老年代,这就是 JVM 的内存分代策略。

注意:在 HotSpot 中除了 新生代老年代,还有 永久代

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


一、年轻代

新生成的对象优先存放在新生代中,存活率很低

新生代中,常规应用进行一次垃圾收集一般可以回收 70% ~ 95% 的空间,回收效率很高。所以一般采用的 GC 回收算法是 复制算法

新生代也可细分3部分:Eden、Survivor0(简称 S0)、Survivor1(简称 S1),这 3 部分按照 8:1:1 的比例来划分新生代。

新生代老年代示意图

新生成的对象会存放在 Eden 区。

新生代老年代示意图

当 Eden 区满时,会触发垃圾回收,回收掉垃圾之后,将剩下存活的对象存放到 S0 区。当下一次 Eden 区满时,再次触发垃圾回收,这时会将 Eden 区 和 S0 区存活的对象全部复制到 S1 区,并清空 Eden 区和 S0 区。

新生代老年代示意图

上述步骤重复 15 次之后,依然存活下来的对象存放到 老年区


二、老年代

一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。

老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。

可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小。

因为老年代对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

【注意的是】

有这么一种情况,老年代中的对象会引用新生代中的对象,这时如果要执行新生代的 GC,则可能要查询整个老年代引用新生代的情况,这种效率是极低的。所以老年代中维护了一个 512byte 的 table,所有老年代对象引用新生代对象的引用都记录在这里。这样新生代 GC 时只需要查询这个表即可。


三、GC log分析

为了让上层应用开发人员更加方便调试 Java 程序,JVM 提供了相应的 GC 日志,在 GC 执行垃圾回收事件中,会有各种相应的 log 被打印出来。

新生代和老年代打印的日志是有区别的:

【新生代GC:轻GC】这一区域的 GC 叫做 Minor GC。因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度比较快。

【老年代GC:重GC】发生在这一区域的 GC 叫做 Major GC 或者 Full GC,当出现 Major GC,经常会伴随至少一次 Minor GC

Major GCFull GC 在有些虚拟机中还是有区别的:前者是仅回收老年代中的垃圾对象,后者是回收整个堆中的垃圾对象。

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

添加 VM Options 参数:分配堆内存 20M,10M给新生代,10M给老年代

// VM args: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
public class MinorGCTest {

    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {
        byte[] a, b, c, d;
        a = new byte[2 * _1M];
        b = new byte[2 * _1M];
        c = new byte[2 * _1M];
        d = new byte[_1M];
    }
}
打印结果:(这里测试是第二次修改后的运行效果)
[GC (Allocation Failure) [PSYoungGen: 7820K->840K(9216K)] 7820K->6992K(19456K), 0.0072302 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 840K->0K(9216K)] [ParOldGen: 6152K->6759K(10240K)] 6992K->6759K(19456K), [Metaspace: 3198K->3198K(1056768K)], 0.0087734 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 9216K, used 1190K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)  eden space 8192K, 14% used [0x00000000ff600000,0x00000000ff7298d8,0x00000000ffe00000)  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000) ParOldGen       total 10240K, used 6759K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)  object space 10240K, 66% used [0x00000000fec00000,0x00000000ff299cd0,0x00000000ff600000) Metaspace       used 3205K, capacity 4496K, committed 4864K, reserved 1056768K  class space    used 351K, capacity 388K, committed 512K, reserved 1048576K

上述字段意思代表如下:

字段代表含义
PSYoungGen新生代
eden新生代中的 Eden 区
from新生代中的 S0 区
to新生代中的 S1 区
ParOldGen老年代
  1. 第一次运行效果后,因为 Eden 区 8M,S0 和 S1 各 1M。所以 a、b、c、d 共有 7M 空间都会在 Eden 区。
  2. 修改 d = new byte[2 * _1M],再次运行;
  3. JVM 会将 a/b/c 存放到 Eden 区,Eden 占有 6M 空间,无法再分配 2M 空间给 d;
  4. 因此会执行一次轻 GC,并尝试将 a/b/c 复制到 S1 区;
  5. 但是因为 S1 区只有 1M 空间,所以没办法存储 a/b/c 三者任一对象。
  6. 这种情况下,JVM 将 a/b/c 转移到老年代,将 d 保存在 Eden 区。

【最终结果】

Eden区 占用 2M 空间(d),老年代占用 6M 空间(a,b,c)


四、引用

通过 GC Roots 的引用可达性来判断对象是否存活,JVM 中的引入关系有以下四种:

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

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

    public static void main(String[] args) {
        SoftReference<SoftObject> softObj = new SoftReference<>(new SoftObject());
        System.out.println("第一次GC前,软引用:" + softObj.get());
        System.gc();
        System.out.println("第一次GC后,软引用:" + softObj.get());
        SoftObject obj = new SoftObject();
        System.out.println("分配100M强引用,软引用:" + softObj.get());
    }
}

添加 VM Option 参数:-Xmx200M 给堆内存分配最大200M内存

第一次 GC 前,软引用:SoftReferenceNormal$SoftObject@1b6d3586
第一次 GC 后,软引用:SoftReferenceNormal$SoftObject@1b6d3586
分配 100M 强引用,软引用:null
  1. 添加参数后位堆内存分配最大 200M 空间,分配给 softObj 对象 120M。
  2. 第一次 GC 后,因为剩余内存任然够,所以软引用并没有被回收。
  3. 当分配 100M 强引用后,堆内存空间不够,会触发GC回收,回收掉软引用。

软引用隐藏的问题

【注意】

被软引用对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会自动被垃圾回收器回收掉。

public class SoftReferenceTest {

    static class SoftObject {
        byte[] data = new byte[1024]; // 占用1k空间
    }

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

    public static void main(String[] args) {
        for (int i = 0; i < _100K; i++) {
            SoftObject obj = new SoftObject();
            cache.add(new SoftReference(obj));
            if (i * 10000 == 0) {
                System.out.println("cache size is " + cache.size());
            }
        }
        System.out.println("END");
    }
}

添加 VM Option 参数:-Xms4m -Xmx4m -Xmn2m

// 打印结果:
cache size is 1
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at SoftReferenceTest$SoftObject.<init>(SoftReferenceTest.java:8)
	at SoftReferenceTest.main(SoftReferenceTest.java:17)

程序崩溃,崩溃的原因并不是堆内存溢出,而是超出了 GC 开销限制。

这里错误的原因是:JVM 不停的回收软引用中的对象,回收次数过快,回收内存较小,占用资源过高了。

【解决方案】注册一个引用队列,将这个对象从 Set 中移除掉。

public class SoftReferenceTest {

    static class SoftObject {
        byte[] data = new byte[1024]; // 占用1k空间
    }

    private static final int _100K = 100 * 1024;
    // 静态集合保存软引用,会导致这些软引用对象本身无法被垃圾回收器回收
    private static Set<SoftReference<SoftObject>> cache = new HashSet<>(_100K);
    // 解决方案:注册一个引用队列,将要移除的对象从中删除
    private static ReferenceQueue<SoftObject> queue = new ReferenceQueue<>();
    // 记录清空次数
    private static int removeReferenceIndex = 0;

    public static void main(String[] args) {
        for (int i = 0; i < _100K; i++) {
            SoftObject obj = new SoftObject();
            cache.add(new SoftReference(obj, queue));
            // 清除掉软引用
            removeSoft();
            if (i * 10000 == 0) {
                System.out.println("cache size is " + cache.size());
            }
        }
        System.out.println("END removeReferenceIndex: " + removeReferenceIndex);
    }

    private static void removeSoft() {
        Reference<? extends SoftObject> poll = queue.poll();
        while (poll != null) {
            if (cache.remove(poll)) {
                removeReferenceIndex++;
            }
            poll = queue.poll();
        }
    }
}
// 打印结果:
cache size is 1
END removeReferenceIndex: 101745