JVM学习之垃圾回收和垃圾回收器

目录

背景

概述

垃圾定义

为何需要GC

早期垃圾回收

Java的垃圾回收机制

相关算法

标记阶段:引用计数算法

标记阶段:可达性分析算法

对象的finalization机制

使用MAT进行GC Roots溯源

清除阶段:标记-清除算法

清除阶段:复制算法

清除阶段:标记-压缩算法

小结

分代收集算法

增量收集算法、分区算法

增量收集算法

分区算法

相关概念

System.gc()的理解

内存溢出与内存泄漏

内存溢出(OOM)

内存泄漏

Stop The World

垃圾回收的并行与并发

安全点与安全区域

安全点

安全区域

实际执行

强引用

软引用

弱引用

虚引用

终结器引用

垃圾回收器

GC分类与性能指标

分类

性能指标

吞吐量

暂停时间

不同的垃圾回收器概述

Serial回收器:串行回收

ParNew回收器:并行回收

Parallel回收器:吞吐量优先

CMS回收器:低延迟

G1回收器:区域化分代式

分区算法的特点

参数设置

区域Region:化整为零

简要回收过程

记忆集与写屏障

详细回收过程

优化建议

垃圾回收器总结

GC日志分析

垃圾回收器的新发展

EpsilonGC

Shenadoah GC

ZGC

其他GC

结语


背景

迟迟不开学,赋闲多日,学习新东西以打发时间,现在整理下JVM的最后一部分笔记——垃圾回收和垃圾回收器

概述

垃圾定义

进程中没有任何指针指向的对象,是为垃圾

为何需要GC

为了方便JVM整理出内存分配给新的对象,不进行GC的话,内存迟早要被消耗完。

早期垃圾回收

C阶段,使用malloc、realloc、calloc函数申请内存,使用free函数释放内存

C++阶段,使用new关键字申请内存,使用delete关键字释放内存

Java的垃圾回收机制

Java的自动内存管理,可以降低内存泄漏和溢出的风险,让程序员更专注业务开发

GC作用区域是堆和方法区,频繁回收新生代,较少收集老年代,基本不动方法区

相关算法

垃圾回收分为标记-清除两阶段,第一阶段标记需要回收的对象,第二阶段对这些对象进行回收

标记阶段:区分出哪些对象是存活对象,哪些是死亡的对象,死亡的定义是不被任何对象继续引用

标记阶段:引用计数算法

对每个对象都保存一个整型的引用计数器属性,记录对象被引用的情况

如果任何一个对象引用了对象A,则A的引用计数器+1,如果引用失效了,计数器就-1

如果A的引用计数器值为0,则A不可能再被使用,可进行回收

优点:

1)、实现简单,垃圾对象便于标识

2)、判断效率高,回收没有延迟

缺点:

1)、需要单独的字段存储计数器增加了存储空间开销;

2)、每次赋值都要更新计数器,伴随着加法减法操作,增加了时间开销;

3)、无法处理循环引用的情况,这一点直接导致java的垃圾回收器里没有使用这种算法

循环引用:

对于以下循环链表,p指针指向头结点,尾结点的next对象指向头结点,这种情况就是循环引用

将p置为null,头结点的计数器-1,值为1,导致不能被回收,从而发生内存泄漏

虽然java没有使用引用计数,但python用了,但也对其进行了优化:

1)、手动解除:在合适的时机,解除引用关系

2)、使用弱引用weakref,这是python提供的标准库,旨在解决循环引用

标记阶段:可达性分析算法

基本思路:

1)、可达性分析是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标是否可达

2)、使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称之为引用链

3)、如果目标对象没有任何引用链相连,则是不可达的,意味着此对象已经死亡,可以被标记为垃圾对象

4)、在可达性分析算法中,只有能被根对象集合直接或间接连接的对象才是存活对象

GC Roots包含以下几类元素:

1)、栈中引用的对象(局部变量表),虚拟机栈或本地方法栈

2)、方法区中类的静态属性引用的对象

3)、方法区中常量引用的对象

4)、同步锁syncronized持有的对象

5)、JVM内部的引用:Class对象、常驻异常对象、系统类加载器

6)、反应JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

如果一个指针指向了堆内存(元空间外)的对象,而自己又不在堆内存(元空间外)里面,那他就是一个GC Root

除了以上常规的GC Roots集合外,根据用户所选的垃圾回收器以及当前回收的内存区域的不同(方法区),还可以将其他对象临时性加入,共同构成完整的根对象集合。比如分代收集和局部回收。

如果只针对堆中某一块区域进行垃圾回收(比如只针对新生代),就必须考虑内存区域的实现细节,因为这个区域的对象完全有可能被其他区域的对象所引用,这是就需要一并将关联区域的对象也加入根结点集合,才能保证可达性分析的准确性。

注意,如果使用可达性分析来判断内存是否可回收,就必须在一个能保证一致性的快照中进行分析工作,这样才能保证分析结果的准确性,所以GC时必须Stop The World暂停用户线程

对象的finalization机制

在回收某对象之前,总会先调用这个对象的finalize()方法,我们可以覆写此方法,用于在对象被回收时进行资源释放等收尾工作

永远不要主动调用某对象的finalize()方法,原因有三:

1)、在finalize()时,可能会导致对象复活

2)、finalize()方法执行时间是没有保障的,完全由GC线程决定。一个极端条件下,则此方法没有执行机会

3)、如果finalize()方法写的不好,会严重影响GC性能

由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态:

1)、可触及的:从根结点开始,可以到达这个对象

2)、可复活的:对象的所有引用都被释放,但是对象有可能在finalize()方法中复活

3)、不可触及的:对象的finalize()方法被调用,且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()方法只会被调用一次

判断一个对象objA是否可回收,至少要经历两次标记过程:

1)、如果objA到根结点集合没有引用链,则进行第一次标记

2)、进行筛选,看此objA是否有必要执行finalize()方法

    a.如果此对象没有覆写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为没有必要执行,objA被判定为不可触及的

    b.如果对象覆写了finalize()方法,且没有被执行过,那么objA会被插入到F-Queue队列中。这是一个由虚拟机自动创建的、低优先级的Finalizer线程,此线程会触发objA的finalize()方法去执行

    c.finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么第二次标记时,objA会被移出“即将回收”集合。之后,如若对象再次出现没有引用存在的情况,此时,finalize()方法不会再被调用,对象会直接变成不可触及的对象,换言之,finalize()方法只会被调用一次

所以只有在对象不可触及时才会被回收

对于对象在finalize()方法中复活的代码演示

package jvm.gcDemos;

public class RebornObj {
    public static RebornObj obj;


    public static void main(String[] args) throws InterruptedException {
        obj = new RebornObj(); // 在堆空间中new一个RebornObj对象,并让元空间的obj指针指向它
        obj = null;
        System.gc(); // 第一次gc,堆中对象的finalize()方法被调用
        System.out.println("第一次gc");


        Thread.sleep(2000);


        if (obj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }


        obj = null;
        System.gc(); // 第二次gc,堆中对象的finalize()方法不会被调用
        System.out.println("第二次gc"); 


        if (obj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }


    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("待回收对象的finalize方法被调用");
        obj = this;
    }
}

控制台输出

待回收对象的finalize方法被调用
第一次gc
obj is still alive
第二次gc
obj is dead


Process finished with exit code 0

使用MAT进行GC Roots溯源

1)、首先下载MAT,可访问官网https://www.eclipse.org/mat/

下载后直接解压,打开里面的MemoryAnalyzer.exe就能用

2)、编写样例代码

package jvm.gcDemos;


import java.util.ArrayList;
import java.util.Date;
import java.util.Scanner;


public class GCRoots {
    public static void main(String[] args) {
        ArrayList<String> strings = new ArrayList<String>();
        Date date = new Date();


        for (int i = 0; i < 100; i++) {
            strings.add(String.valueOf(i));
            try {
                Thread.sleep(30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }


        System.out.println("添加数据结束,请指示:");
        new Scanner(System.in).next();


        strings = null;
        date = null;


        System.out.println("数据已置空,请结束:");
        new Scanner(System.in).next();
        System.out.println("结束");


    }
}

3)、运行以上代码,会阻塞在第一次控制台输入,此时打开jvisualvm,对此进程的堆进行快照。关于jvisualvm的简单使用,可以参见文章JVM学习笔记之堆的核心概述部分

会在左侧出现新建好的快照

右击它,选择保存的目录,点击确定即可

然后在控制台输入指令,让程序释放strings和date引用,此时会阻塞在第二次控制台输入,然后重复上面操作,生成第二个快照文件并保存,最后在控制台随便输入文字,让程序结束即可

关闭jvisualvm后,在我们指定的目录下,会有生成的两个快照文件

其他文件都是我之前用mat分析快照文件时生成的

4)、用mat打开比较老的hprof文件,点击中间窗口中类似数据库的图标,点击Java Basics->GC Roots

在新出来的GC Roots界面,点击线程选项,可以看到java.lang.Thread,也可以看到传说中的FinalizerThread。我们点击java.lang.Thread,找到主线程,里面可以看到我们的ArrayList和Date引用,是一个根结点

同样的方法打开新的hprof文件,这是把strings和date对象都置空后的快照,用上面的方法查看主线程的根结点集合,会发现已经没有了ArrayList和Date

根据线程下面的统计(21->19),也可以证实对strings和date进行了GC

至此,使用mat溯源根结点就完成了

清除阶段:标记-清除算法

当堆中有效内存耗尽时,就会停止所有用户线程(Stop The World),然后进行标记-清除

标记:收集器从根结点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。关于对象的头部信息等内存布局,可以参见文章JVM学习之对象的实例化、内存布局与访问定位中对象的内存布局部分

清除:收集器对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其清除。此处的清除不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次要加载新的对象时,判断垃圾的位置空间是否够,如果够,就直接用新的对象覆盖原有的垃圾对象。

标记-清除算法过程举例如下图所示,绿色对象都是可达对象,在其Header中进行标记;在清除阶段,Header中没有记录可达标记的黑色对象,就都被回收了

缺点:

1)、效率不高,要遍历整个堆空间

2)、进行GC时,需要暂停整个应用程序,导致用户体验差

3)、会产生内存碎片,要维护一个空闲链表,以便实例化新对象时分配内存

关于空闲链表法分配内存,请参见文章JVM学习之对象的实例化、内存布局与访问定位中对象实例化的步骤部分

清除阶段:复制算法

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

复制算法举例如下图所示,把内存分为A、B两块,两块内存大小相同。对A进行GC时,遍历根结点集合,把所有可达对象复制到未被使用的B区域中,不对对象添加任何标记,然后把A清空,完成GC。对B进行GC时,交换AB角色,如法炮制。

在现有JVM中,AB对应的是新生代里的幸存者0区和幸存者1区,所以幸存者区中的垃圾回收用的就是复制算法

优点:

1)、没有标记和清除过程,实现简单,运行高效

2)、复制过去后保证空间的连续性,为新对象分配内存时可采用指针碰撞的方式分配

关于指针碰撞法分配内存,请参见文章JVM学习之对象的实例化、内存布局与访问定位中对象实例化的步骤部分

缺点:

1)、需要两倍的内存空间

2)、对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC要维护region之间对象的引用关系,不管是内存占用还是时间开销,都不算小

3)、如果系统中垃圾对象很少,则复制算法需要复制的存活对象数量很大,效果就不会很理想

不过对于新生代中的对象绝大多数都是朝生夕死,那么幸存者区使用复制算法就很合适。但老年代就不适合用这个算法了。

清除阶段:标记-压缩算法

标记:此阶段和标记-清除算法一样,从根结点开始标记所有被引用的对象

压缩:把所有存活对象压缩到内存的一端,按序排放,并清理边界外的所有空间

标记-压缩算法举例如下图所示

标记-压缩算法和标记-清除算法的区别是:前者是移动性算法,后者是非移动性算法。移动存活对象是一个优缺点并存的风险决策。

优点:

1)、消除了内存碎片,可以使用指针碰撞为新对象分配内存

2)、充分利用内存空间

缺点:

1)、效率不如复制算法

2)、移动对象时,如果对象被其他对象引用,则还需调整引用地址

3)、STW时间长

小结

三种经典GC算法对比如下表所示

经典GC算法的对比
  标记-清除 标记-压缩 复制
速率 最慢 最快
空间开销 少,但会堆积碎片 少,且不会堆积碎片 需要存活对象2倍大小的空间,但不堆积碎片
移动对象

复制算法的效率最高,但浪费了太多内存,用空间换时间

综合三个指标,标记-压缩算法相对更平滑,但效率很低,比复制算法多了一个标记阶段,比标记-清除算法多了一个整理内存阶段

没有最优的算法,只有最合适的算法,物尽其值

分代收集算法

分代收集算法是融合了上面三种算法的新算法,它基于对象的生命周期的不同采用不同的收集方式,以便提高回收效率。关于堆中的分代,可以参见文章JVM学习笔记之堆中年轻代与老年代部分

目前几乎所有GC都采用的是分代收集来进行GC的

对于年轻代,对象生命周期短,存活率低,回收频繁,而且空间比老年代小,所以这种情况采用复制算法最合适。HotSpot中的两个幸存者区可以缓解复制算法对空间的浪费

对于老年代,对象生命周期长,存活率高,回收频率比年轻代低,空间比较大,一般是由标记-清除和标记-压缩算法混合实现。标记阶段的时间开销和存活对象数量成正比,清除阶段的时间开销和所管理内存大小成正比,压缩阶段时间开销和存活对象大小成正比

以HotSpot中的CMS回收器为例,CMS是基于标记-清除算法实现的,回收效率高。对于碎片问题,CMS使用基于标记-整理的Serial Old回收器进行补偿;当内存回收效果不佳(内存碎片导致的Concurrent Mode Failure)时,将采用Serial Old执行Full GC达到对老年代内存的整理

增量收集算法、分区算法

这两种算法都是为了减少STW时间,提高用户体验

增量收集算法

让GC线程和用户线程交替执行,GC每次执行时,只收集一小片区域的内存空间。

增量收集算法通过对线程间冲突的妥善处理,允许GC线程以分阶段的方式完成标记、清理或复制工作

缺点:因为线程的切换和上下文转换的消耗,会造成GC的总体成本上升,造成系统吞吐量的下降

分区算法

将整个堆空间划分成连续的不同小区间(分代算法是按照对象生命周期长短,把堆区分成两个部分),每个小区间独立使用,独立回收,可以存放伊甸园区、幸存区、老年代等区域的对象或者大对象

这种算法的好处是可以控制一次性回收多少个块

相关概念

System.gc()的理解

默认情况下,通过System.gc()或Runtime.getRuntime().gc()的调用可以显示触发Full GC

然而,System.gc()的调用附带一个免责声明,无法保证对垃圾收集器的立刻调用

所以,一般情况下,无需手动触发GC,否则就太麻烦了

如果一定要手动gc,可以同时调用System.gc()和System.runFinalization()两个方法

针对System.gc()方法和局部变量表的关系,请看以下代码,先使能输出详细的GC信息-XX:+PrintGCDetails

package jvm.gcDemos;


public class HandGC {


    public void localVarGc() {
        {
            byte[] bytes = new byte[10 * 1024 * 1024];
        }
        System.gc();
        // 此时bytes还占用着索引为1的局部变量表槽,所以不会被回收
    }


    public void localVarGc1() {
        {
            byte[] bytes = new byte[10 * 1024 * 1024];
        }
        int i = 0;
        // 此时bytes所占的索引为1的局部变量表槽,已经被i占据,所以bytes会被回收
        System.gc();
    }


    public static void main(String[] args) {
        HandGC test = new HandGC();
        test.localVarGc();
    }
}

调用方法localVarGc()时,输出的GC日志如下

[GC (System.gc()) [PSYoungGen: 15442K->10720K(75776K)] 15442K->11190K(249344K), 0.0201163 secs] [Times: user=0.00 sys=0.00, real=0.03 secs]
[Full GC (System.gc()) [PSYoungGen: 10720K->0K(75776K)] [ParOldGen: 470K->11095K(173568K)] 11190K->11095K(249344K), [Metaspace: 3233K->3233K(1056768K)], 0.0064126 secs] [Times: user=0.09 sys=0.00, real=0.01 secs]
...

System.gc()触发Full GC,Full GC会先触发新生代GC,所以第一行是PSYoungGen,PS表示Parallel System,用的是并发回收器

这次Full GC后,年轻代占用的空间直接清零,但老年代占用的空间却涨到了11095K,说明我们代码块里的10M的字节数组对象没有被回收掉,而是转到了老年代。

对于GC日志的解读,也可以参见文章JVM学习笔记之堆;关于GC日志参数的设置,可以参见本文的GC日志分析部分

查看localVarGC的局部变量表

会发现只有一个this指针,但局部变量表最大长度却是2

说明方法结束时,bytes占用的槽被清空,但调用System.gc()时方法没有结束,因此bytes还占着一个局部变量槽,因此它引用的对象不会被回收

关于局部变量槽与局部变量表,也可以参见文章JVM学习笔记上(概述-本地方法栈)

但调用localVarGc1()时,日志输出如下

[GC (System.gc()) [PSYoungGen: 15442K->968K(75776K)] 15442K->976K(249344K), 0.0007605 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 968K->0K(75776K)] [ParOldGen: 8K->829K(173568K)] 976K->829K(249344K), [Metaspace: 3200K->3200K(1056768K)], 0.0046999 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
...

年轻代照样被清空,老年代也只有829K,盛不下10M的bytes,因此可见bytes被回收了。原因是代码块执行完后,System.gc()执行前,我又声明了一个局部变量i,此时方法的局部变量表如下所示

并且局部变量表长度还是2

因此此时,调用System.gc()时,bytes占用的局部变量槽被i占用,因此引用消失,字节数组对象得以被回收

内存溢出与内存泄漏

内存溢出(OOM)

没有空闲内存,并且垃圾回收器回收后也不能提供更多内存

原因有二:

1)、JVM的堆内存设置不够,可通过-Xms、-Xmx来设置

2)、代码中创建了大量大对象,并且长时间不能被垃圾回收器回收

内存泄漏

对象不会再被程序使用,但GC又不能回收它们的情况

如上图所示,如果红色箭头表示的引用不消失,蓝色框内的对象就都不能被回收,而这些对象如果在程序中都不再使用的话,就发生了内存泄漏

举例:

1)、单例模式:单例的生命周期和应用程序一样长,所以在单例程序中,如果持有对外部对象的引用的话,这个外部对象是不能被回收的,就会导致内存泄漏

2)、一些提供close()的外部资源没有关闭

Stop The World

Stop The World(STW)指的是GC事件发生时,会造成用户线程的停顿,整个应用程序会被暂停,没有任何响应

这么做的原因,是因为GC时要首先找到所有根结点,如果分析过程时对象引用关系还在变化,那么分析结果的准确性将无法保证

GC完成后Stop The World就会结束,所有的GC都有这个事件,不可能被完全消除

垃圾回收的并行与并发

并行垃圾回收:多条收集线程并行工作,用户线程等待

并发垃圾回收:用户线程与收集线程同时进行,但依旧有STW

安全点与安全区域

安全点

能够停下来进行GC的程序位置,称之为安全点。

安全点太少,可能导致GC等待时间过长;安全点太多,导致GC频繁会影响程序性能。安全点的选择标准为能让程序长时间执行

常见的安全点有方法调用、循环跳转、异常跳转等

发生GC时,在安全点设置一个中断标志,各个线程运行到安全点时轮询这个标志,如果这个中断标志为真,则将自己进行中断挂起。这种方式称为主动中断方式。

安全区域

指的是在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的

实际执行

1)、当线程运行到安全区域的代码时,首先标识已经进入安全区域。如果这段时间内发生了GC,JVM会忽略标识为进入安全区域的线程

2)、当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了就继续运行;否则线程必须等待直到收到可以安全离开安全区域的信号为止

强引用

类似Object obj = new Object()这种引用关系,obj就是new Object()对象的强引用。

只要强引用关系还存在,GC就永远不会回收被引用的对象

特点:永远不回收

强引用是默认的引用类型,强引用的对象是可触及的,GC永远不会回收被强引用引用的对象,所以强引用是造成OOM的主要原因之一

软引用

系统要发生OOM之前,将会把只被软引用引用的对象放入回收范围之内进行二次回收。如果回收完之后还没有足够的内存,才抛出OOM。

特点:内存不足就回收

软引用通常用来实现内存敏感的缓存,比如高速缓存里就用到了软引用。被软引用关联的对象是软可触及的,当GC发生时,JVM会把软引用引用的对象放入软引用队列中,在内存不够、决定清除软可触及对象时,清空这个队列的元素及其引用的对象即可。

如果二次回收后内存还不够,那就只能报OOM了

举例

HandGC test = new HandGC();
SoftReference<HandGC> softReference = new SoftReference<HandGC>(test); // 建立对test引用的对象的软引用
test = null; // 断开强引用

// 以上三行代码等价于
// SoftReference<HandGC> softReference = new SoftReference<HandGC>(new HandGC());

test = softReference.get(); // 使用软引用对象
if (test!= null) {
    test.localVarGc();
}

弱引用

弱引用对象只能存活到下一次GC之前,GC时,不管内存空间是否足够,都会将其回收。

特点:发现就回收

只被弱引用引用的对象只能生存到下一次GC前,由于GC线程优先级很低,所以弱引用对象也可以存在很长时间。

软引用弱引用都很适合保存那些可有可无的缓存数据

一个安卓端使用弱引用优化Handler的例子如下所示

public class WeakHandler extends Handler {
    private WeakReference<IHandler> mWeakHandler;


    public WeakHandler(IHandler handler) {
        mWeakHandler = new WeakReference<>(handler);
    }


    @Override
    public void handleMessage(Message msg) {
        IHandler handler = mWeakHandler.get();
        if (handler != null) {
            handler.handleMessage(msg);
        }
    }
}

IHandler是接口,实现类通常就是Activity。Activity在覆写onCreate()方法时new一个WeakHandler,把自己传进去即可

当activity要被销毁时,WeakHandler里的mWeakHandler持有activity的弱引用,所以不影响activity对象的回收,因此避免了内存泄漏

虚引用

虚引用存在的唯一目的,就是在此对象被回收时,收到一个系统通知

如果一个对象仅持有虚引用,那它和没有引用基本是一样的,如果要使用虚引用的get()方法获取对象时,结果总是null

Object obj = new Object();
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> phantomReference = new PhantomReference<Object>(obj, phantomQueue);

obj = null;

特点:回收时通知

以下是一个检测虚引用是否被回收的例子

1)、定义静态属性obj,强引用一个堆中对象

private static HandGC obj = new HandGC();

2)、覆写finalize()方法,使其复活一次

@Override
protected void finalize() throws Throwable {
    super.finalize();
    System.out.println("finalize方法被调用");
    obj = this;
}

3)、在main()方法里,构造引用队列和虚引用,并断开强引用

final ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> phantomReference = new PhantomReference<Object>(obj, phantomQueue);

System.out.println(phantomReference.get());
obj = null;

4)、创建检测线程,检查引用队列是否为空,并将此线程设置为守护线程

Thread checkThread = new Thread(new Runnable() {
    public void run() {
        while (true) {
            if (phantomQueue != null) {
                PhantomReference<Object> reference = null;
                try {
                    reference = (PhantomReference<Object>) phantomQueue.remove(); // 如果虚引用没有被回收,则一直阻塞
                } catch (Exception e) {
                    e.printStackTrace();
                }


                if (reference != null) {
                    System.out.println("追踪GC,HandGC对象被回收了");
                }
            }
        }
    }
});
checkThread.setDaemon(true);
checkThread.start();

5)、在主线程里执行两次gc,并打印日志

try {
    System.out.println(phantomReference.get());


    obj = null;
    System.gc();


    Thread.sleep(1000);
    if (obj == null) {
        System.out.println("obj is null");
    } else {
        System.out.println("obj可用");
    }


    System.out.println("第二次gc");


    obj = null;
    System.gc();


    Thread.sleep(1000);
    if (obj == null) {
        System.out.println("obj is null");
    } else {
        System.out.println("obj可用");
    }
} catch (Exception e) {
    e.printStackTrace();
}

完整代码如下

package jvm.gcDemos;


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


public class HandGC {
    private static HandGC obj = new HandGC();

    public static void main(String[] args) {
        final ReferenceQueue phantomQueue = new ReferenceQueue();
        PhantomReference<Object> phantomReference = new PhantomReference<Object>(obj, phantomQueue);


        Thread checkThread = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    if (phantomQueue != null) {
                        PhantomReference<Object> reference = null;
                        try {
                            reference = (PhantomReference<Object>) phantomQueue.remove();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }


                        if (reference != null) {
                            System.out.println("追踪GC,HandGC对象被回收了");
                        }
                    }
                }
            }
        });
        checkThread.setDaemon(true);
        checkThread.start();


        try {
            System.out.println(phantomReference.get());


            obj = null;
            System.gc();


            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj is null");
            } else {
                System.out.println("obj可用");
            }


            System.out.println("第二次gc");


            obj = null;
            System.gc();


            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj is null");
            } else {
                System.out.println("obj可用");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize方法被调用");
        obj = this;
    }
}

运行后,日志输出如下

null
finalize方法被调用
obj可用
第二次gc
追踪GC,HandGC对象被回收了
obj is null


Process finished with exit code 0

第一个"null"表示虚引用的get()一直为空

第二个"finalize方法被调用"表示第一次gc后,由于强引用断开,堆中对象被回收finalize方法被调用,对象被重新强引用,所以复活

第三个"obj可用"是自然的,因为引用了堆中对象

再次把obj置为null,并执行System.gc()后,堆中对象没有强引用,彻底被回收。此时检测线程终于从引用队列phantomQueue.remove()方法的阻塞中出来,获取虚引用对象,所以输出了对应的日志"追踪GC,HandGC对象被回收了"

由于finalize()方法只能被调用一次,所以obj此刻为空,输出"obj is null"

由于引用检测线程为守护线程,当用户线程执行完后,它也结束了。

终结器引用

终结器引用(FinalReference类),主要用来实现对象的finalize()方法。

终结器引用无需手动实现,其内部和虚引用一样,配合引用队列使用

在GC时,终结器引用入队,由Finalizer线程通过终结器引用找到被引用的对象并调用它的finalize()方法,第二次GC时才对其回收

垃圾回收器

GC分类与性能指标

分类

1)、按回收线程数来分,可以分为串行回收器(仅限于client模式)和并行回收器

2)、按工作模式来分:可以分为并发式回收器(用户线程和GC线程并发,STW短)和独占式回收器(STW长)

3)、按碎片处理方式来分:可分为压缩式(标记-压缩算法、复制算法、指针碰撞再分配)和非压缩式(标记-清除算法、空闲列表再分配)

4)、按工作的内存区间来分:年轻代回收器和老年代回收器

性能指标

1)、吞吐量:运行用户代码的时间占总运行时间的比例

2)、暂停时间STW:GC时,程序的工作线程被暂停的时间

3)、内存占用:java堆区所占内存大小

4)、GC开销:吞吐量的补数,垃圾回收所用时间占总运行时间的比例

5)、收集频率:相对于程序执行,收集操作发生的频率

6)、快速:一个对象从诞生到被回收所经历的时间

1)2)3)共同构成一个不可能三角,三者总体的表现会随着技术进步而越来越好,一款优秀的收集器最多同时满足其中两项

主要抓住两项:吞吐量和暂停时间

吞吐量

运行用户代码时间 / (运行用户代码时间 + GC时间)

如果吞吐量优先,意味着STW时间缩短,而且可以容忍更长的暂停时间

暂停时间

一个时间段内,应用程序暂停让GC执行的时间。如果注重暂停时间(低延迟),那么GC频率就高,吞吐量可能会低

注重吞吐量和注重低延迟的GC策略示意图如下所示

高吞吐量和低延迟是相互矛盾的,现在采用的标准是:在最大吞吐量优先的情况下,降低停顿时间

不同的垃圾回收器概述

串行回收器:Serial、Serial Old

并行回收器:ParNew、Parallel Scavenge、Parallel Old

并发回收器:CMS、G1

几种类型的典型回收器如下图所示,其中Serial为串行回收器,Parallel为并行回收器,CMS为并发回收器,G1为分区回收器

几种经典收集器和垃圾分代之间的关系如下图所示

新生代收集器:Serial、ParNew、Parallel Scanvenge

老年代收集器:Serial Old、Parallel Old、CMS

G1既能收集新生代,也能收集老年代

垃圾收集器的组合关系如下图所示,红线表示jdk8废弃的组合,绿线表示jdk14废弃的组合,青色边框的CMS在jdk14里直接删除了

jdk8以前,CMS和Serial Old的组合为后备组合,因为CMS并发回收器不能在老年代满的时候回收,所以老年代满时需要Serial Old代替它

现在(jdk14)只剩下Serial GC-Serial Old GC、Parallel Scavenge-Parallel Old和G1三种组合了,由于64位PC只能是server模式,所以就只剩下G1和Parallel Scavenge-Parallel Old两组搭档

为何要有这么多回收器呢?因为java使用场景很多,需要针对不同的场景,提供不同的收集器,提高收集的性能

可以使用-XX:+PrintCommandLineFlags查看命令行相关参数,里面包含使用的回收器

-XX:InitialHeapSize=265990592 -XX:MaxHeapSize=4255849472 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC

可见使用的是ParallelGC回收器,也就是Parallel Scavange-Parallel Old组合

也可以使用jinfo -flag 参数查看使用的回收器

C:\Users\songzeceng>jps
17568 RemoteMavenServer36
960 KotlinCompileDaemon
10852 Launcher
18772 RebornObj
13368
12460 Jps

C:\Users\songzeceng>jinfo -flag UseParallelGC 9756
-XX:+UseParallelGC

C:\Users\songzeceng>jinfo -flag UseParallelOldGC 9756
-XX:+UseParallelOldGC

C:\Users\songzeceng>jinfo -flag UseG1GC 18772
-XX:-UseG1GC

可见没有使用G1回收器

Serial回收器:串行回收

Serial收集器是HotSpot中Client模式下的默认新生代收集器,采用复制算法、串行回收和STW机制进行GC

Serial Old专门回收老年代,使用标记-压缩算法、串行、STW机制进行GC,是Client模式下默认的老年代回收器

Serial Old在Server模式下主要有两个问题:与新生代的Parallel Scavenge配合使用;作为老年代CMS收集器的后备方案

Serial-Serial Old组合工作模式如下图所示

此收集器是一个单线程收集器,只用一个GC线程去回收垃圾,并在回收时进行STW

优势:简单高效,适用于Client模式下的JVM(可用内存不大、GC回收时间短、频率低)

可以使用-XX:+UseSerialGC来使用Serial-Serial Old来使用串行收集器收集新生代和老年代

ParNew回收器:并行回收

采用并行回收、复制算法、STW机制回收新生代内存

ParNew是很多JVM在Server模式下新生代的默认垃圾回收器

ParNew-Serial Old搭档收集器示意图如下

对于新生代,回收次数频繁,使用并行方式高效;对于老年代,回收次数少,使用串行方式节省资源

在单个CPU环境下,ParNew收集器不比Serial收集器更高效

现在除Serial外,只有ParNew能与CMS配合工作

可以使用-XX:+UseParNewGC使能ParNew对年轻代进行GC,使用-XX:ParallelGCThreads限制并行线程数量,默认和CPU核数一样

Parallel回收器:吞吐量优先

Parallel Scavenge(简称Parallel)也采用了复制算法、并行方式和STW进行GC。

Parallel和ParNew的不同是,Parallel目的是达到一个可控的吞吐量,自适应调节策略也是Parallel的一个改进点

高吞吐量可以高效利用CPU时间,适合后台运行的任务。

对于老年代,Parallel提供了Parallel Old收集器,使用标记-压缩算法、并行回收、STW机制进行GC,用以替代Serial Old收集器。

Parallel-Parallel Old组合工作示意图如下

jdk8中,Parallel-Parallel Old是默认的回收器

可以使用-XX:+UseParallelGC和-XX:+UseParallelOldGC使能Parallel新生代回收器和Parallel Old老年代回收器

使用-XX:ParallelGCThreads设置年轻代并行收集器线程数量,最好和CPU数相等。默认时,如果CPU数≤8,那么此值=CPU个数;如果CPU数>8,此值为3 + 5 * CPU_NUM / 8

另外,-XX:MaxGCPauseMillis用来设置STW最大时间,单位毫秒,-XX:GCTimeRatio用来设置垃圾收集时间占总时间的比例,默认99,也就是GC时间占比不超过百分之一。这两个参数是此消彼长的关系

C:\Users\songzeceng>jinfo -flag MaxGCPauseMillis 16480
-XX:MaxGCPauseMillis=18446744073709551615


C:\Users\songzeceng>jinfo -flag GCTimeRatio 16480
-XX:GCTimeRatio=99

最后,-XX:+UseAdaptiveSizePolicy使能Parallel的自适应调节策略,自适应调节策略也用在了年轻代里伊甸园区和幸存区的空间占比设置上,也是这个参数

CMS回收器:低延迟

CMS(Concurrent Mask Sweep)收集器是HotSpot中第一款真正意义上的并发收集器,它首次实现了让GC线程和用户线程的同时工作

它采用标记-清除算法,自然也会STW

CMS不能和Parallel配合,只能和ParNew或Serial合作

其工作原理如下图所示

在初始标记和重新标记环节,都出现了STW

初始标记阶段:只标记出根结点能直接关联的对象,此阶段会进行STW。由于直接关联的对象比较少,所以这一步速度非常快

并发标记阶段:从根结点的直接关联对象开始遍历整个对象图,此过程耗时较长,但不需要暂停用户线程

重新标记阶段:修正并发标记阶段,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,此过程比初始阶段耗时长,但比并发标记时间短

并发清除阶段:清理删除掉标记阶段被判断已经死亡的对象,释放内存空间。此过程不需要移动活对象,所以可以和用户线程并发执行

由于最耗费时间的并发标记和并发清除阶段都不用STW,所以整体回收是低停顿的。在CMS回收过程中,还要确保用户线程有足够的内存可用,所以当堆内存使用率达到某一阈值时,便开始进行回收。如果CMS运行期间内存无法满足用户需要,就会出现一个Concurrent Mode Failure,此时,JVM将临时起用Serial Old来重新进行老年代收集,这样停顿时间就长了。

由于CMS采用的是标记-清除算法,所以会产生内存碎片,为新对象分配内存时只能使用空闲列表法来进行分配。下图带阴影的就是产生的不连续的内存碎片

为何CMS不能使用标记-压缩算法呢?因为CMS清理垃圾线程和用户线程是并行的,用户线程不能停,那么使用的对象地址就不能变,自然就不能为了整理内存空间而移动对象地址,所以只能使用标记-清除算法

CMS优点:并发收集、低延迟

CMS缺点:会产生内存碎片;对CPU资源非常敏感(占用了一部分线程,所以导致吞吐量降低);无法处理浮动垃圾(如果并发标记阶段产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,从而不能将其回收)

可以使用-XX:+UseConcMarkSweepGC使能CMS,使能后,将自行打开-XX:+UseParNewGC。也就是ParNew+CMS+Serial Old的组合

使用-XX:CMSInitiatingOccupancyFraction设置堆内存使用阈值,达到此阈值,就开始回收。jdk6及以上版本默认值为92%

可以使用-XX:ParallelCMSThreads(jdk8及以后叫做-XX:ConcGCThreads)设置CMS线程数量,CMS默认启动的线程数是(ParallelGCThreads + 3)  / 4

-XX:+UseCMSCompactAtFullCollection用于指定在CMS的Full GC后进行内存整理,注意整理过程不能和用户线程并行执行,所以带来了比较长的停顿时间

-XX:CMSFullGCsBeforeCompaction用来设置在多少次执行Full GC后进行内存整理

在此,对Serial GC、Parallel GC、 CMS进行总结:

如果要最小化使用内存和并行开销,使用Serial GC

如果要最大化吞吐量,使用Parallel GC

如果要最小化停顿时间,使用CMS

jdk9开始,废弃了CMS,但还能用;jdk14直接删除了CMS

G1回收器:区域化分代式

G1的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起全功能收集器的重任

G1把堆内存分割为很多不相关的区域(Region)(物理上是不连续的),用这些区域来表示伊甸园区、幸存者区、老年代等。它有计划地避免在整个堆中进行全区域的垃圾收集,跟踪各个区域里垃圾堆积的价值大小(回收所得空间大小,以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。由于这种方式的侧重点在于回收垃圾最大量的区间,所以G1的名字就是Garbage First

G1主要针对多核CPU以及大容量内存的机器,是jdk9以后的默认回收器,取代了CMS、Parallel + Parallel Old组合,被称为全功能的垃圾收集器

jdk8中还不是默认的,需要使用-XX:+UseG1GC来使能

分区算法的特点

G1的分区算法有以下几个特点:

1)、并行与并发

并行性:G1回收期间,可以用多个GC线程同时工作,有效利用多核计算能力,此时用户线程被暂停

并发性:G1有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此在GC时不会完全阻塞应用程序

G1回收器可以采用应用线程承担后台运行的GC工作,也就是当GC线程处理速度慢时,系统会调用应用程序线程帮助加速GC过程

2)、分代收集

G1依旧会区分年轻代和老年代,年轻代依旧分为伊甸园区和幸存者区。但从堆结构上看,它不要求整个伊甸园区、整个年轻代或老年代都是连续的,也不坚持固定大小和固定数量

它将堆空间分为若干区域,这些区域中包含了逻辑上的老年代和年轻代,如下图所示

所以,G1可以兼顾老年代和年轻代,而不像其他回收器那样,要么工作在年轻代,要么工作在老年代

3)、空间整合

G1将内存划分成一个个区域,回收以区域为单位。区域之间采用复制算法,整体上可看成标记-压缩算法。

4)、可预测的事件停顿模型(软实时)

由于分区的原因,G1只选取部分区域进行回收,这样就缩小的回收的范围,因此对于全局停顿的情况也会得到较好的控制

G1跟踪各个区域里的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。这样保证了G1在有限的时间内可以获取尽可能高的收集效率

G1未必能做到最好的情况下CMS的延时停顿,但在最差的情况下要好很多

G1回收器也有缺点,相比于CMS,它为了GC产生的内存占用还是程序运行时带来的额外负载都比CMS要高。因此,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则会发挥其优势。平衡点在6-8GB之间

在下面的情况下,G1可能比CMS表现好:

1)、超过50%的堆内存被活动对象占用

2)、对象分配频率或年代晋升频率变化很大

3)、GC停顿时间过长(0.5s~1s)

参数设置

G1回收器的参数设置:

1)、-XX:+UseG1GC 使能G1。使能后,可以使用以下参数进行进一步设置

2)、-XX:G1HeapRegionSize 设置每个区域大小,值应该是2的整数幂,范围是1MB~32MB,默认值为堆内存的1/2000

3)、-XX:MaxGCPauseMillis 设置最大GC停顿时间,默认200ms(JVM会尽力实现,但不保证达到)

4)、-XX:ParallelGCThreads 设置STW工作线程数值

5)、-XX:ConcGCThreads 设置并发标记的线程数,将值设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右

6)、-XX:InitiatingHeapOccupancyPercent 设置触发并发GC的java堆占用率阈值,超过此值,就出发GC,默认45

C:\Users\songzeceng>jinfo -flag UseG1GC 16112
-XX:+UseG1GC


C:\Users\songzeceng>jinfo -flag ParallelGCThreads 16112
-XX:ParallelGCThreads=10


C:\Users\songzeceng>jinfo -flag MaxGCPauseMillis 16112
-XX:MaxGCPauseMillis=200


C:\Users\songzeceng>jinfo -flag G1HeapRegionSize 16112
-XX:G1HeapRegionSize=1048576

C:\Users\songzeceng>jinfo -flag ConcGCThreads 16112
-XX:ConcGCThreads=3


C:\Users\songzeceng>jinfo -flag InitiatingHeapOccupancyPercent 16112
-XX:InitiatingHeapOccupancyPercent=45

使用步骤:开启G1回收器、设置堆的最大内存(-Xms和-Xmx)、设置最大停顿时间

G1有三种回收模式:Young GC、Mixed GC和FullGC,在不同的条件下被触发

区域Region:化整为零

将堆划分成月2048个大小相同的独立分区块,每个区域大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的所有的整次幂,可用-XX:G1HeapRegionSize设定。区域大小相同,且在JVM生命周期内不会被改变。

此时,新生代和老年代不再是物理隔离的了,他们都是一部分不一定连续的区域的集合,通过区域的动态分配实现逻辑上的连续,如下图所示

同一个区域在一个时刻只能承担一个角色,但角色可以变换。当对象比较大(大于1.5个区域)时,就放到大对象区(Humongous,简称H),如果一个H区都放不下,就使用多个连续的H区进行存储,如果还找不到,就进行Full GC。大对象区是专门为短期存在的对象设置的,它在GC时被看作老年区的一部分

对于一个区域,使用指针碰撞来为对象分配内存,也可以在头部设置TLAB来实现对象的线程安全。关于TLAB,请参见文章JVM学习笔记之堆的TLAB部分

简要回收过程

G1回收过程如下图所示,分为三部分:年轻代GC、老年代并发标记回收(也会出现年轻代GC)、混合回收(也会出现年轻代GC),如果必要,单线程、独占式、高强度的Full GC还是存在,这是针对GC的评估失败提供的失败保护机制,即强力回收

当年轻代的伊甸园区要用完时,就会触发年轻代GC。年轻代回收是一个并行独占收集器,暂停所有应用线程,启动多线程对年轻代进行GC。然后从年轻代区间移动存活对象到幸存者区或老年代,也可能是两个区域都有涉及

当堆内存使用率达到阈值时,开始老年代并发标记过程

标记完成立刻开始混合回收过程。对于一个混合回收期,G1回收器从老年区移动存活对象到空闲区间,这些空闲区间也就成了老年区的一部分。G1的老年代回收器不需要回收整个老年代,一次只需要扫描/回收一小部分老年代的区域就行了。同时,老年代区域和年轻代是一起被回收的。

记忆集与写屏障

每个区域都有一个对应的记忆集,作用是避免全局扫描。每次引用类型数据进行写操作时,都会产生一个叫做写屏障的暂时中断,然后检查将要写入的引用指向的对象是否和该引用类型数据在不同的区域(其他收集器则是检查老年代对象是否引用了新生代对象)。如果在不同的区域,就通过CardTable把相关的引用信息记录到指向对象的所在区域对应的记忆集中,如下图所示。在进行GC时,对每个根结点所在的区域,进行记忆集遍历,就可以既不全局扫描,又不会有遗漏

图中,区域2分别被区域1和3引用,那么它的记忆集里就会记录下区域1和区域3中的引用位置,也就是哪个位置的引用指向了自己哪个位置的对象

详细回收过程

1)、年轻代GC:当伊甸园空间耗尽时,G1会启动一次年轻代GC。年轻代GC只会回收伊甸园区和幸存者区

YGC时,首先G1停止应用程序的执行,创建回收集,这是一个需要执行被回收的内存分代集合,包含需要回收的伊甸园区和幸存者区的内存分段。如下图所示

如图所示,先对伊甸园区和一个幸存者区使用复制算法,把活对象复制到空闲区域,此区域作为第二个幸存者区,然后清空伊甸园区和原幸存者区

YGC具体过程如下:

1)、扫描根结点

2)、更新记忆集:处理dirty card queue中的card,更新记忆集。此阶段更新后,记忆集可以准确反应老年代对新生代中对象的引用

dirty card queue(脏卡队列)中保存的是对象引用信息,比如对于代码object.field = obj,JVM就会把object对象和field对象的引用关系保存到脏卡队列中。不在赋值时更新记忆集,是为了保证性能。记忆集的处理是同步过程,使用队列缓存可以提高性能

3)、处理记忆集:识别被老年代对象指向的伊甸园中的对象,这些被指向的伊甸园对象就是存活的对象

4)、复制对象:此阶段中,对象树被遍历,伊甸园区中的存活对象会被复制到空闲的幸存者区内存分段中。如果幸存者区中存活对象的年龄没达到阈值,年龄就+1;达到阈值就会被复制到老年代中空闲的内存分段。如果幸存者区空间不够,伊甸园区的部分数据会直接晋升到老年代

5)、处理引用:对于软引用、弱引用、虚引用、Final、JNI Weak等引用。最终伊甸园数据为空,GC停止工作,而且目标内存中对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

并发标记过程如下:

1)、初始标记阶段:标记从根结点直接可达的对象,STW

2)、根区域扫描:G1扫描幸存者区直接可达的老年区对象,并对之标记。此过程必须在YGC之前完成

3)、并发标记:在整个堆中进行并发标记,此过程可能被YGC中断。并发标记时,若发现某个区域中所有对象都是垃圾,那这个区域将被立刻回收。同时,并发标记过程中,会计算每个区域的对象活性(存活对象的比例)

4)、再次标记:由于应用程序没有在并发标记阶段中停止,所以需要修正上一次标记的结果,并且是STW的。而且G1采用了比CMS中更快算法——初始快照算法

5)、独占清理:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域,STW。这个阶段并不会实际去做垃圾收集

6)、并发清理:识别并清理完全空闲的区域

混合回收:当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,也就是Mixed GC,也就是除了回收整个新生代,也会收集一部分老年代,注意这不是Full GC,如下图所示。

具体过程如下:

并发标记结束后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代内存段会分8词回收(数量可以通过-XX:G1MixedGCCountTarget设置)

混合回收的回收集包括八分之一的老年代内存分段、伊甸园区内存分段和幸存者区内存分段,混合回收算法处理年轻代内存的方法和年轻代回收算法一模一样

由于老年代中内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的越先被回收。并且有一个阈值(-XX:G1MixedGCLiveThresholdPercent,默认65%)会决定内存分段是否参与回收,垃圾占内存分段比例要达到此阈值才会被回收。

混合回收不一定要进行8次,有一个阈值-XX:G1HeapWastePercent(默认值10%),意思是允许整个堆内存中有10%的空间被浪费,如果可回收垃圾占内存的比例不足这个阈值,就不会进行混合回收,因为不值当

可选过程Full GC:当回收时没有足够的目的空间来存放晋升的对象或并发处理过程完成之前空间耗尽时,会发生串行独占的Full GC

补充:回收阶段其实也想过设计成和用户线程并发执行,但此需求优先级比较低,这个特性被放到后来的低延迟垃圾收集器ZGC中来实现

优化建议

1)、年轻代大小:避免使用-Xmn或-XX:NewRatio来显示设置年轻代大小,因为这样会覆盖暂停时间的目标

2)、暂停时间目标不能太苛刻:G1的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间。暂停时间太短,表示要承受更多的垃圾回收开销,从而直接影响吞吐量

垃圾回收器总结

截至jdk8,七款经典回收器的对比总结如下表所示

现在互联网项目,基本都用G1

没有最好的收集器,更没有万能的收集;

调用永远是具体问题具体分析,不存在一劳永逸的收集器

GC日志分析

打印GC详细日志使能:-XX:+PrintGCDetails

打印GC日志使能:-XXPrintGC

打印GC时间戳(以基准时间的形式):-XX:+PrintGCTimeStamps

打印GC日期:-XX:+PrintGCDateStamps

打印GC前后的堆信息:-XX:+PrintHeapAtGC

日志文件的输出:-Xloggc:../logs/gc.log

某次年轻代GC日志如下

[GC (Allocation Failure) [PSYoungGen: 65019K->10732K(75776K)] 65019K->61402K(249344K), 0.0077635 secs] [Times: user=0.08 sys=0.05, real=0.01 secs]

Allocation Failure表示GC发生原因是年轻代内存空间不够存储新数据了

PSYoungGen表示回收器,这是Parallel的年轻代回收器

65019K->10732K(75776K)表示新生代占用内存从65019K减少到了10732K,目前总共有75776K,里面的10732K想必就是幸存者区占用的空间了

后面的65019K->61402K(249344K)表示原来的堆空间占用了65019K,GC后变成了61402K,目前堆空间总共249344K。GC后堆占用空间(61402K)比幸存者区(10732K)大,说明有些数据直接进入了老年代

[Times: user=0.08 sys=0.05, real=0.01 secs]中的user表示用户态回收耗时,sys表示内核态回收耗时,real表示实际耗时。由于多核原因,用户态+内核态回收总耗时可能大于实际耗时,因为涉及状态的切换

某次Full GC日志如下

[Full GC (Ergonomics) [PSYoungGen: 860971K->461160K(915968K)] [ParOldGen: 2771781K->2771697K(2771968K)] 3632753K->3232858K(3687936K), [Metaspace: 3767K->3767K(1056768K)], 0.1855559 secs] [Times: user=1.56 sys=0.00, real=0.19 secs]

ParOldGen表示老年代的回收器是Parallel Old,2771781K->2771697K(2771968K)表示GC前后老年区占用内存大小和老年区总大小

[Metaspace: 3767K->3767K(1056768K)表示元空间占用大小在GC前后的值,以及元空间总大小

加入-XX:+PrintGCDateStamps后的输出如下

2020-05-20T21:15:38.873+0800: [GC (Allocation Failure) [PSYoungGen: 2046K->512K(2560K)] 2046K->886K(9728K), 0.0010088 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-05-20T21:15:38.883+0800: [GC (Allocation Failure) [PSYoungGen: 2560K->496K(2560K)] 2934K->974K(9728K), 0.0013327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-05-20T21:15:38.889+0800: [GC (Allocation Failure) [PSYoungGen: 2544K->496K(2560K)] 3022K->1030K(9728K), 0.0014997 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

可见输出了日期2020-05-20T21:15:38.873+0800

再加入-XX:+PrintGCTimeStamps后的输出如下

2020-05-20T21:17:11.029+0800: 0.203: [GC (Allocation Failure) [PSYoungGen: 2046K->504K(2560K)] 2046K->893K(9728K), 0.0011316 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-05-20T21:17:11.041+0800: 0.214: [GC (Allocation Failure) [PSYoungGen: 2552K->488K(2560K)] 2941K->965K(9728K), 0.0015585 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-05-20T21:17:11.054+0800: 0.227: [GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 3013K->1021K(9728K), 0.0012158 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

可见又续上了GC时间戳0.203,表示JVM启动之后0.203s发生了GC

再加入-XX:+PrintHeapAtGC后的输出更加复杂,因为这会输出GC前后的堆信息

{Heap before GC invocations=1 (full 0):
PSYoungGen      total 2560K, used 2046K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 99% used [0x00000000ffd00000,0x00000000ffeffac8,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
Metaspace       used 2978K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 321K, capacity 386K, committed 512K, reserved 1048576K
2020-05-20T21:20:13.237+0800: 0.202: [GC (Allocation Failure) [PSYoungGen: 2046K->504K(2560K)] 2046K->863K(9728K), 0.0009002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap after GC invocations=1 (full 0):
PSYoungGen      total 2560K, used 504K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 0% used [0x00000000ffd00000,0x00000000ffd00000,0x00000000fff00000)
  from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e030,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 359K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 5% used [0x00000000ff600000,0x00000000ff659ed8,0x00000000ffd00000)
Metaspace       used 2978K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 321K, capacity 386K, committed 512K, reserved 1048576K
}

加入输出路径-Xloggc:C:/Users/songzeceng/Desktop/gc.log后,可以在桌面生成gc.log文件,内容如下

Java HotSpot(TM) 64-Bit Server VM (25.231-b11) for windows-amd64 JRE (1.8.0_231-b11), built on Oct  5 2019 03:11:30 by "java_re" with MS VC++ 10.0 (VS2010)
Memory: 4k page, physical 16624412k(8666512k free), swap 19770140k(7853452k free)
CommandLine flags: -XX:InitialHeapSize=10485760 -XX:MaxHeapSize=10485760 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -XX:+PrintStringTableStatistics -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
{Heap before GC invocations=1 (full 0):
PSYoungGen      total 2560K, used 2046K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 99% used [0x00000000ffd00000,0x00000000ffeffac8,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
Metaspace       used 2957K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 320K, capacity 386K, committed 512K, reserved 1048576K
2020-05-20T21:46:52.197+0800: 0.217: [GC (Allocation Failure) [PSYoungGen: 2046K->512K(2560K)] 2046K->881K(9728K), 0.0009109 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap after GC invocations=1 (full 0):
PSYoungGen      total 2560K, used 512K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 0% used [0x00000000ffd00000,0x00000000ffd00000,0x00000000fff00000)
  from space 512K, 100% used [0x00000000fff00000,0x00000000fff80000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 369K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 5% used [0x00000000ff600000,0x00000000ff65c448,0x00000000ffd00000)
Metaspace       used 2957K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 320K, capacity 386K, committed 512K, reserved 1048576K
}
{Heap before GC invocations=2 (full 0):
PSYoungGen      total 2560K, used 2560K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 100% used [0x00000000ffd00000,0x00000000fff00000,0x00000000fff00000)
  from space 512K, 100% used [0x00000000fff00000,0x00000000fff80000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 369K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 5% used [0x00000000ff600000,0x00000000ff65c448,0x00000000ffd00000)
Metaspace       used 3235K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
2020-05-20T21:46:52.206+0800: 0.226: [GC (Allocation Failure) [PSYoungGen: 2560K->496K(2560K)] 2929K->977K(9728K), 0.0013469 secs] [Times: user=0.05 sys=0.00, real=0.00 secs]
Heap after GC invocations=2 (full 0):
PSYoungGen      total 2560K, used 496K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 0% used [0x00000000ffd00000,0x00000000ffd00000,0x00000000fff00000)
  from space 512K, 96% used [0x00000000fff80000,0x00000000ffffc010,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen       total 7168K, used 481K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 6% used [0x00000000ff600000,0x00000000ff678448,0x00000000ffd00000)
Metaspace       used 3235K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
}
{Heap before GC invocations=3 (full 0):
PSYoungGen      total 2560K, used 2544K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 100% used [0x00000000ffd00000,0x00000000fff00000,0x00000000fff00000)
  from space 512K, 96% used [0x00000000fff80000,0x00000000ffffc010,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen       total 7168K, used 481K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 6% used [0x00000000ff600000,0x00000000ff678448,0x00000000ffd00000)
Metaspace       used 3235K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
2020-05-20T21:46:52.213+0800: 0.233: [GC (Allocation Failure) [PSYoungGen: 2544K->512K(2560K)] 3025K->1041K(9728K), 0.0015696 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap after GC invocations=3 (full 0):
PSYoungGen      total 2560K, used 512K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 0% used [0x00000000ffd00000,0x00000000ffd00000,0x00000000fff00000)
  from space 512K, 100% used [0x00000000fff00000,0x00000000fff80000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 529K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 7% used [0x00000000ff600000,0x00000000ff6847e0,0x00000000ffd00000)
Metaspace       used 3235K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
}
Heap
PSYoungGen      total 2560K, used 2156K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 80% used [0x00000000ffd00000,0x00000000ffe9b0e0,0x00000000fff00000)
  from space 512K, 100% used [0x00000000fff00000,0x00000000fff80000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen       total 7168K, used 529K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 7% used [0x00000000ff600000,0x00000000ff6847e0,0x00000000ffd00000)
Metaspace       used 3242K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 350K, capacity 388K, committed 512K, reserved 1048576K

垃圾回收器的新发展

目前G1也在不断地改进;而随着Serverless等云计算新的应用场景下,Serial GC找到了新的舞台;而CMS在jdk9中被标记为废弃,在jdk14中被彻底移除

EpsilonGC

jdk11中,出现了Epsilon收集器和ZGC收集器

Epsilon收集器是一个No-op收集器,只负责内存的分配,不负责内存的回收,适合内存分配完直接程序结束的场景

Shenadoah GC

jdk12出现了Shenadoah GC(约翰丹佛的《Country Roads take me home》一歌中有提及这条Shenandoah河——雪兰多河),这个GC是红帽公司提出来的,旨在针对JVM上的GC实现低停顿的需求(但系统吞吐量会下降),但被甲骨文公司排除在OpenJdk12之外

ZGC

ZGC算是一个革命性的新回收器,旨在尽可能对吞吐量影响不大的前提下,实现任意堆大小下都能把GC的停顿时间限制在10毫秒以内的低延迟。它使用了基于Region区域内存布局、暂时不设分代的,使用了染色指针、读屏障和内存多重映射等技术来实现可并发的标记-压缩算法的,工作过程可分为四个阶段:并发标记——并发预备重分配——并发重分配——并发重映射

ZGC除了在初始标记阶段是STW的,其余所有地方都是并发执行,所以耗时非常短

jdk14开始,mac和windows上都能用ZGC了,添加参数:-XX:+UnlockExperimentalVMOpions -XX:+UseZGC

关于这些新gc在jdk发展中的引入过程,可以参见文章新版本jdk(9、11、12、13、14)特性中的相关章节

其他GC

阿里的JVM团队基于G1算法,面向大堆应用场景,研发了AliGC

结语

JVM学习的笔记到这儿就整理完了,测试代码都是在jdk8的环境下运行的

猜你喜欢

转载自blog.csdn.net/qq_37475168/article/details/106741652