JVM(九) - 垃圾回收机制

各语言内存操作对比:

 

语言

申请内存

释放内存

C

malloc

free

C++

new

delete

Java

new

自动释放

Java语言的自动内存管理设计最终可以归结为自动化地解决了两个问题:

  1. 给对象分配内存,可查看JVM内存模型、Java对象创建等文章;
  2. 回收分配给对象的内存。

即对象内存的分配和回收。

了解JVM是垃圾回收机制,如何有效防止内存泄露、保证内存的有效使用,需要思考三个方向的问题:

  • 什么对象的内存需要回收?(什么是垃圾?怎么判断对象是垃圾?)
  • 用什么方式回收?(垃圾回收的算法?)
  • 有什么垃圾收集器?

一、对象回收判断

进行垃圾回收的第一步:什么是垃圾? 没有引用指向的一个对象或多个对象(循环引用)。

定位垃圾的方法有两种:引用计数法可达性分析

1、对像的引用

Java中将数据类型分为两大类:基本类型和引用类型。如果reference类型数据中存储的数值为另一个块内存的起始地址,就称这块内存代表一个引用。在JDK1.2开始对引用的概念进行了扩充,分为强、软、弱、虚四种引用,且强度依次逐渐降低。详细见Java-四种引用类型。

Person p = new Person();

// 等号后面的 new Person(); 是真正的实例对象,其内容存储在Java堆内存中
// 等号前面的 p 只是一个引用标识符,保存在虚拟机栈内存中,它存储的只是一个地址,是 new Person() 在堆内存中的起始位置,因此 p 就是一个引用。
// 等号是一种强引用方式

2、引用计数法(Reference Count已淘汰 )

引用计数法是垃圾收集器中的早期策略,通过判断对象的引用数量来决定对象是否可以被回收。该方法为堆中每个对象添加一个计数器,有地方引用了此对象则该对象的计数器加1,如果引用失效了则计数器减1,引用计数为0的对象实例则可以被当作垃圾收集

优点:实现简单,判定效率也很高;

缺点:很难解决对象之间循环引用的问题;互相引用导致他们的引用计数都不为0,最终不能回收他们。

JVM(九) - 垃圾回收机制_第1张图片

3、根可达性分析算法(Root Searching)

通过判断对象的引用链是否可达GCRoot来决定对象是否可以被回收。从离散数学中的图论引入,把所有的引用关系当作一张关系图谱,从被称为"GCRoot"的对象作为起始点,沿着引用链(Reference Chain,即引用路径)向下搜索,如果一个对象没有任何引用链连接到GCRoot节点,则证明此对象是不可用的/不可达,则此对象可被回收。

JAVA中可以作为GCRoot对象/根对象包括以下几种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象;
  • 方法区中类静态变量引用的对象;
  • 方法区中运行常量池引用的对象;
  • 本地方法栈中Native方法引用的对象;

如图,引用链连接上GCroot的Object1、2、3、4都是被使用的对象,但是Object5、6、7却不能通过任何方式连接上根节点,因此判定Object5、6、7为可回收的节点。

JVM(九) - 垃圾回收机制_第2张图片

4、对象死亡和自我拯救

可达性分析之后,不可达的对象一定会被垃圾收集器回收吗?不一定。

在可达性分析后发现不可达的对象会被进行一次标记,然后进行筛选,筛选的条件是判断该对象有没有必要执行finalize()方法;

  • 回收情况:如果对象没有重写finalize()方法或者对象的finalize方法已经被虚拟机调用过一次了,则都将视为"没有必要执行",垃圾回收器直接回收对象。
  • 自救情况:如果对有重写finalize()方法且对象的finalize方法没有被虚拟机调用过,则将该对象判定"有必要执行",那么虚拟机会把这个对象放置在一个F-Queue的队列中,然后由一个专门的Finalizer线程去执行这个对象的finalize()方法;在这个方法中可以进行对象的"自我拯救",即重新与引用链上的任何一个对象建立关联就可以了,如把this赋值给某个类的变量、或者对象的成员变量;但在又被第二次GC标记时它将被移除"即将回收"的集合

1、对象无finalize方法:直接去除引用链,一次GC时便直接回收实例对象。

// JVM执行参数 -Xmx20m -XX:+PrintGCDetails
public class FinalizeTest {
    static class M {
        // 10M大小
        private byte[] bytes = new byte[10 * 1024 * 1024];
    }

    public static void main(String[] args) throws Exception {
        M m = new M();
        m = null;
        // new M()被去掉引用,且M中没有自救方法finalize,所以在GC时会直接回收
        System.gc();
        // GC相关线程优先级低,主线程等待一下
        Thread.sleep(1000);
    }
}
// 日志
/* 年轻代PSYoungGen的总内存大小为6144k=6M,所以10M的new M()对象会直接被分配在老年代ParOldGen*/
[GC (System.gc()) [PSYoungGen: 1749K->480K(6144K)] 11989K->10765K(19968K), 0.0018203 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
// FGC情况下,ParOldGen老年代从10285K->471K
[Full GC (System.gc()) [PSYoungGen: 480K->0K(6144K)] [ParOldGen: 10285K->471K(13824K)] 10765K->471K(19968K), [Metaspace: 3087K->3087K(1056768K)], 0.0046541 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
/* 可知new M()有10M大小,从整个堆上各代的使用内存情况,可知new M()已经不在堆上面,也可以通过jmap来查看实例对象存活*/
/* 后面的三个内存地址值指的是:起始地址,已使用空间的结束地址,对应区整个内存空间的结束地址 */
Heap
 PSYoungGen      total 6144K, used 1255K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 5632K, 22% used [0x00000007bf980000,0x00000007bfab9f10,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
  to   space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
 ParOldGen       total 13824K, used 471K [0x00000007bec00000, 0x00000007bf980000, 0x00000007bf980000)
  object space 13824K, 3% used [0x00000007bec00000,0x00000007bec75e70,0x00000007bf980000)
 Metaspace       used 3613K, capacity 4536K, committed 4864K, reserved 1056768K
  class space    used 399K, capacity 428K, committed 512K, reserved 1048576K

Process finished with exit code 0

2、对象有finalize方法,但不进行自我拯救:直接去除引用链,第一次GC时对象放入F-Queue的队列且进行finalize方法的拯救,不会被回收,但finalize中并未拯救,所以第二次GC时对象会被回收

public class FinalizeTest {
    static class M {
        // 10M大小
        private byte[] bytes = new byte[10 * 1024 * 1024];

        @Override
        protected void finalize() {
            System.out.println("m的finalize执行了,但并不拯救");
        }
    }

    public static void main(String[] args) throws Exception {
        M m = new M();
        m = null;
        System.out.println("第一次GC-----------");
        // 因为M的存在finalize,所以被放入F-Queue的队列中,拯救线程执行finalize方法,所以10M的new M()对象本次不被回收
        System.gc();
        // GC相关线程优先级低,主线程等待一下
        Thread.sleep(1000);

        // 第二次GC,
        System.out.println("第二次GC-----------");
        // 因为M的finalize已经执行了,但并未拯救,所以10M的new M()对象被回收了
        System.gc();
        Thread.sleep(1000);
    }
}
// 日志
第一次GC-----------
[GC (System.gc()) [PSYoungGen: 1862K->512K(6144K)] 12102K->10797K(19968K), 0.0022455 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
/* 第一次FGC情况下,ParOldGen老年代从10285K->10712K,说明并未回收10M的new M()对象*/
[Full GC (System.gc()) [PSYoungGen: 512K->0K(6144K)] [ParOldGen: 10285K->10712K(13824K)] 10797K->10712K(19968K), [Metaspace: 3101K->3101K(1056768K)], 0.0040101 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
m的finalize执行了,但并不拯救
第二次GC-----------
[GC (System.gc()) [PSYoungGen: 1422K->448K(6144K)] 12134K->11168K(19968K), 0.0008161 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
/* 第二次FGC情况下,ParOldGen老年代从10720K->773K,因为M的finalize已经执行了,但并未拯救,所以10M的new M()对象被回收了*/
[Full GC (System.gc()) [PSYoungGen: 448K->0K(6144K)] [ParOldGen: 10720K->773K(13824K)] 11168K->773K(19968K), [Metaspace: 3614K->3614K(1056768K)], 0.0054856 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 6144K, used 150K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 5632K, 2% used [0x00000007bf980000,0x00000007bf9a5a40,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
  to   space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
 ParOldGen       total 13824K, used 773K [0x00000007bec00000, 0x00000007bf980000, 0x00000007bf980000)
  object space 13824K, 5% used [0x00000007bec00000,0x00000007becc1520,0x00000007bf980000)
 Metaspace       used 3620K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 399K, capacity 428K, committed 512K, reserved 1048576K

Process finished with exit code 0

3、对象有finalize,且会自我拯救:直接去除引用链,第一次GC时对象放入F-Queue的队列且进行finalize方法的拯救,不会被回收;第二次GC时,被拯救的对象保持着新的引用链,所以不会被回收

 

public class FinalizeTest {
    public static M saveMen = null;

    static class M {
        // 10M大小
        private byte[] bytes = new byte[10 * 1024 * 1024];

        @Override
        protected void finalize() {
            FinalizeTest.saveMen = this;
            System.out.println("m的finalize执行了,且自我拯救");
        }
    }

    public static void main(String[] args) throws Exception {
        M m = new M();
        m = null;
        System.out.println("第一次GC-----------");
        System.gc();
        // GC相关线程优先级低,主线程等待一下
        Thread.sleep(1000);

        System.out.print("saveMen: " + saveMen);

        // 第二次GC
        System.out.println("第二次GC-----------");
        System.gc();
        Thread.sleep(1000);
        System.out.print("saveMen: " + saveMen);
    }
}
// 日志
第一次GC-----------
[GC (System.gc()) [PSYoungGen: 1862K->496K(6144K)] 12102K->10781K(19968K), 0.0019234 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
/* 第一次FGC情况下,ParOldGen老年代从10285K->10712K,说明并未回收10M的new M()对象*/
[Full GC (System.gc()) [PSYoungGen: 496K->0K(6144K)] [ParOldGen: 10285K->10712K(13824K)] 10781K->10712K(19968K), [Metaspace: 3103K->3103K(1056768K)], 0.0043965 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
m的finalize执行了,且自我拯救
/* 打印拯救的变量saveMen的引用,说明拯救成功 */
saveMen: edward.com.FinalizeTest$M@3fee733d第二次GC-----------
[GC (System.gc()) [PSYoungGen: 1423K->384K(6144K)] 12136K->11104K(19968K), 0.0018592 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
/* 第二次FGC情况下,ParOldGen老年代从10720K->11013K,因为M的finalize已经执行且已拯救,所以10M的new M()对象被saveMen强引用着*/
[Full GC (System.gc()) [PSYoungGen: 384K->0K(6144K)] [ParOldGen: 10720K->11013K(13824K)] 11104K->11013K(19968K), [Metaspace: 3618K->3618K(1056768K)], 0.0074772 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
/* 第二次GC后打印拯救的变量saveMen的引用,说明对象还在 */
saveMen: edward.com.FinalizeTest$M@3fee733dHeap
 PSYoungGen      total 6144K, used 263K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 5632K, 4% used [0x00000007bf980000,0x00000007bf9c1ce0,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
  to   space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
 ParOldGen       total 13824K, used 11013K [0x00000007bec00000, 0x00000007bf980000, 0x00000007bf980000)
  object space 13824K, 79% used [0x00000007bec00000,0x00000007bf6c16f0,0x00000007bf980000)
 Metaspace       used 3625K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 399K, capacity 428K, committed 512K, reserved 1048576K

Process finished with exit code 0

4、对象有finalize,且会自我拯救,但两次去除引用链:直接去除引用链,第一次GC时对象放入F-Queue的队列且进行finalize方法的拯救,不会被回收;但第二次GC时,被拯救的对象的新引用链又去除,这次对象直接被回收

public class FinalizeTest {
    public static M saveMen = null;

    static class M {
        // 10M大小
        private byte[] bytes = new byte[10 * 1024 * 1024];

        @Override
        protected void finalize() {
            FinalizeTest.saveMen = this;
            System.out.println("m的finalize执行了,且自我拯救");
        }
    }

    public static void main(String[] args) throws Exception {
        M m = new M();
        m = null;
        System.out.println("第一次GC-----------");
        System.gc();
        // GC相关线程优先级低,主线程等待一下
        Thread.sleep(1000);

        System.out.print("saveMen: " + saveMen);

        // 第二次GC,且再次去除次拯救new M()对象的引用链
        saveMen = null;
        System.out.println("第二次GC-----------");
        System.gc();
        Thread.sleep(1000);
        System.out.print("saveMen: " + saveMen);
    }
}
// 日志
第一次GC-----------
[GC (System.gc()) [PSYoungGen: 1862K->496K(6144K)] 12102K->10781K(19968K), 0.0008208 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] 
/* 第一次FGC情况下,ParOldGen老年代从10285K->10712K,说明并未回收10M的new M()对象*/
[Full GC (System.gc()) [PSYoungGen: 496K->0K(6144K)] [ParOldGen: 10285K->10712K(13824K)] 10781K->10712K(19968K), [Metaspace: 3111K->3111K(1056768K)], 0.0044683 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
m的finalize执行了,且自我拯救
/* 打印拯救的变量saveMen的引用,说明拯救成功 */
saveMen: edward.com.FinalizeTest$M@3fee733d第二次GC-----------
[GC (System.gc()) [PSYoungGen: 1423K->416K(6144K)] 12136K->11136K(19968K), 0.0018508 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
/* 第二次FGC情况下,ParOldGen老年代从10720K->774K,因为M的finalize已经执行且已拯救,所以再次去掉引用后GC,10M的new M()对象直接被回收*/
[Full GC (System.gc()) [PSYoungGen: 416K->0K(6144K)] [ParOldGen: 10720K->774K(13824K)] 11136K->774K(19968K), [Metaspace: 3624K->3624K(1056768K)], 0.0078671 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
/* 第二次GC后打印拯救的变量saveMen的引用,对象不在 */
saveMen: nullHeap
 PSYoungGen      total 6144K, used 263K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 5632K, 4% used [0x00000007bf980000,0x00000007bf9c1ce0,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
  to   space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
 ParOldGen       total 13824K, used 774K [0x00000007bec00000, 0x00000007bf980000, 0x00000007bf980000)
  object space 13824K, 5% used [0x00000007bec00000,0x00000007becc1938,0x00000007bf980000)
 Metaspace       used 3631K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 399K, capacity 428K, committed 512K, reserved 1048576K

Process finished with exit code 0

二、垃圾收集算法

1、标记清除算法(Mark-Sweep)

算法分为"标记"和"清除"两个阶段。

  • 标记:从根集合进行扫描,对需要回收的对象进行标记。(也可以标记存活的对象)
  • 清除:标记完毕后,再扫描整个空间中被标记的对象并进行回收。

JVM(九) - 垃圾回收机制_第3张图片

优点:逻辑清晰,易于操作;

缺点:

  1. 效率不稳定:标记和清除两个过程需要大量标记和清除的动作;
  2. 内存空间碎片化:标记清除后会产生内存空间碎片,会导致需要分配大对象时空间不足再次触发回收。

2、标记复制算法(Coping)

将可用内存划分为大小相等的两块,每次只使用其中的一块,当一块的内存用完,将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

适用于对象存活率低的场景,研究发现新生代中的对象每次回收都基本上只有10%左右的对象存活,所以需要复制的对象很少,效率不错。

JVM(九) - 垃圾回收机制_第4张图片

优点:内存区域连续性,不会产生内存碎片,实现简单,运行高效。

缺点:

  1. 空间浪费:可用内存缩小为了原来的一半;
  2. 内存的复制开销:存在大量存活对象时复制开销越大;

3、标记压缩算法(Mark-Compact) 

和标记清除算法类似,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程

适用于对象存活率高的场景(老年代)。

JVM(九) - 垃圾回收机制_第5张图片

优点:

  1. 解决了标记清除算法产生内存碎片的问题
  2. 解决了复制算法只能利用一半内存的问题

 

三、分代收集算法(Generational Collecting)

HotSpot虚拟机的堆内存分代设计:

JVM(九) - 垃圾回收机制_第6张图片

1、新生代(Young Generation):

一般情况下,所有新生成的对象首先都是放在新生代的,目标就是尽可能快的收集掉那些生命周期短的对象。

  • 新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区,大部分对象在Eden区中生成;
  • 新生代发生的GC也叫做MinorGC,MinorGC发生频率比较高,不一定等 Eden区满了才触发
  • Minor GC垃圾回收时,先将eden区存活对象复制到survivor0区,然后清空eden区;
  • survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区;
  • 交换survivor0区和survivor1区的角色,保持 一个survivor为空;
  • 某个survivor区也不足以存放eden区和另一个survivor区的存活对象时,就将存活对象直接存放到老年代;
  • 老年代也满了,就会触发一次FullGC,也就是新生代、老年代都进行回收;

YGC条件:Eden区满

2、老年代(Old Generation):

一般情况下,存放的都是一些生命周期较长的对象,在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。

  • 老年代的内存也比新生代大很多(大概比例是2:1);
  • 老年代满时会触发Major GC(Full GC),老年代对象存活时间比较长,因此FullGC发生的频率比较低。

Full GC的原因:

  • 老年代被写满、永久代(JDK1.7 Perm)被写满
  • 空间担保原则生败
  • System.gc()被显式调用,执行jmap -dump时。

对象进入老年代的情况:

  1. 大对象:指需要大量连续内存空间的对象,如很长的字符串和数组。因为复制成本较高,所以 直接进入到老年代,避免新生代发生大量的内存复制。(由JVM参数-XX:+PretenureSizeThreshold=100000设置,单位为字节)
  2. 长期存活的对象:每个对象都有一个年龄计数器。每进行一次Minor GC,年龄就加1,当年龄超过一定值时(默认是15,由JVM参数-XX:MaxTenuringThreshold=15配置;CMS是6岁,不同收集器略微不同),就进入到老年代。
  3. 对象动态年龄判断:
    1. 什么时候动态判断:mimor GC后,为解决Survivor存活区对象放不下。
    2. 动态判断规则定义:Survivor空间中同一年龄的所有对象大小总和 > 一半Survivor区空间,则将 >= 该年龄的对象放入老年代,不一定要求对象年龄到达15。
    3. 内存判断的标准:一半Survivor空间大小,即默认50%,可通过-XX:TargetSurvivorRatio来配置
    4. 计算规则:从小到大算,先从age1算,不满足再从age2计算。

在JVM运行稳定后,以FGC后老年代存活对象空间大小为参考:

  • Java堆,参数-Xms和-Xmx,建议扩大至3-4倍FGC后的老年代空间占用;
  • 年轻代,参数-Xmn,建议扩大至1-1.5倍FGC之后的老年代空间占用;
  • 老年代,2-3倍FGC后的老年代空间占用;

老年代的空间分配担保原则:

空间担保原则触发条件:Minor GC前

在发生Minor GC之前,虚拟机会计算老年代剩余可用空间 > 年轻代所有对象总空间(包括垃圾对象)?

  • 如果大于,那么Minor GC可以确保是安全的(因为极端情况下,就算新生代所有对象都存活,也可以保证安全晋升到老年代)。
  • 如果小于,虚拟机会查看-XX:+HandlePromotionFailure配置的值是否允许担保失败。允许担保失败,那么会继续检查老年代可用空间是否 > 历次晋升到老年代对象的平均大小。如果大于,将尝试进行一次Minor GC(尽管有风险);如果小于或者HandlePromotionFailure设置不允许冒险,那么就先进行一次Full GC。

以上说的有风险,是因为取历次晋升到老年代对象的平均值,并不能保证每次都能担保成功;如果担保失败的话,依然需要进行Full GC。所以我们最好还是打开HandlePromotionFailure开关,避免过多频繁的Full GC(因为Full GC的执行速度比Minor GC慢的多)。

3、永久代(Permanent Generation)/方法区/元空间

  • 永久代主要存放的是类信息静态变量编译代码运行时常量池
  • 在JDK1.8之前永久代是有固定默认内存大小,也可通过JVM参数配置,所以需要进行垃圾回收;
  • 永久代的内存回收目标主要是针对 常量池的回收 和 对类型的卸载,同堆的回收一样;
  • 在JDK1.8开始把永久代移除了,而用元空间代替。字符串常量和类引用被移动到 Java Heap中,元空间直接使用本机物理内存,不受JVM内存限制;

GC情况:

  • 元空间默认就是21M,满了之后就会FGC,收集器并增大元空间内存。又满后FGC,再增大元空间内存,直到扩大的元空间大小合适不再发生FGC,或到MaxMetaspaceSize设置的最大值。
  • 一般建议JVM参数将-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置成一样的值,8G内存的机器,一般会将这两个值设置为256M或者512M都可以

类对象的回收条件:

  • 该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

 

JVM堆内存为什么分代 ?

不同对象的生命周期(存活情况)是不一样的,分代可以根据不同对象的情况进行不同的管理和回收。

新生代:存活率低,每次垃圾收集都有大批对象死去,选择复制算法快速且只需要付出少量存活对象的复制成本即可;

老年代:存活率高,没有额外空间对它进行分配担保,因此使用标记清除或者标记整理算法;

gc频率和耗时也不一样,使性能最高效。

新生代各区内存比例为什么是8:1:1?

研究发现新生代中的对象每次回收都基本上只有10%左右的对象存活。这样划分内存的空间利用率达到了90%,只有10%的空间是浪费掉;

四、垃圾收集器

JVM的出现过的十种垃圾收集器,及组合方式:

JVM(九) - 垃圾回收机制_第7张图片

Serial(年轻代)/Serial Old(老年代)适用于单CPU的环境,单线程、简单高效、没有线程切换的开销,垃圾收集时会暂停其他用户进程。

Serial Old在JDK1.5中配合PS使用;也作为CMS的后备选择,在concurrent mode failure时使用。

ParNew其实就是 Serial 收集器的多线程并行版本(参数控制、收集算法、STW、对象分配规则、回收策略等),在多核CPU环境下有着比Serial更好的表现。可以使用-XX:ParallelGCThreads参数来控制垃圾收集线程数,默认和CPU数量一样。也是为了和CMS组合的。

PS是吞吐优先的回收器,GC自适应调节策略。

https://hllvm-group.iteye.com/group/topic/37095#post-242695

ParNew为什么不能和Parallel Old组合?

PNew和PS是不同人在同一时期开发的,PS的开发者更注重高吞吐(自适应策略),和PNew低停顿是对立的,PS开发之后效果不错就合上去了,正好同一期PO的开发(也有PS的开发)就是为了兼容PS,所以就组合一起了,且PO只能和PS组合。

查看JVM默认使用的垃圾回收器命令:

// PrintCommandLineFlags打印JVM启动的命令行参数
java -XX:+PrintCommandLineFlags -version

 JVM垃圾回收器使用配置:

  • -XX:+UseSerialGC:新生代使用Serial,老年代使用Serial Old;
  • -XX:+UseParallelGC/-XX:UseParallelOldGC:新生代使用Parallel Scavenge,老年代使用Parallel Old
  • -XX:+UseParNewGC:新生代使用ParNew,老年代自动使用Serial Old;
  • -XX:+UseConMarkSweepGC:新生代使用ParNew,老年代使用CMS + Serial Old;
  • -XX:+UseG1GC:使用G1;

JDK1.8server模式下,默认使用PS+PO收集器。

JVM(九) - 垃圾回收机制_第8张图片

1、Serial(串行)收集器

年轻代的单线程收集器,采用复制算法,GC时会暂停所有用户进程(Stop The Wold);

JVM(九) - 垃圾回收机制_第9张图片

2、Serial Old收集器

老年代的单线程收集器,采用标记-整理算法,Serial收集器的老年代版本,也会STW。

  • Serial Old是Serial的老年代版本;
  • Serial Old在JDK1.5中配合PS使用;
  • 也作为CMS的后备选择,在concurrent mode failure时使用。

3、ParNew收集器

年轻代的并行收集器,采用复制算法,ParNew收集器实质上是 Serial 收集器的多线程并行版本在多核CPU环境下有着比Serial更好的表现,和CMS收集器配合使用

  • 并行(Parallel)收集器:指多条垃圾收集线程并行工作,但此时用户线程仍然STW。有PN、PS、PO收集器。
  • 并发(Concurrent)收集器:指用户线程与垃圾收集线程同时执行(交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

JVM(九) - 垃圾回收机制_第10张图片

 

4、Parallel Scavenge收集器

也是年轻代收集器,采用复制算法的多线程并行收集器 ,但Parall Scavenger 收集追求高吞吐量,高效利用CPU;所以也叫吞吐量优先收集器

与Parallel New的区别:

  • 关注高吞吐量:吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间;
  • GC自适应调节策略:虚拟机会根据系统运行状况收集监控信息,动态设置相关内存参数以提供最优的停顿时间和最高的吞吐量。
    • 开关参数设置:-XX:+UseAdptiveSizePolicy,打开开关后不需要手动指定新生代大小(-Xmn)、Eden区和Survivor区比例(-XX:SurvivorRation)、晋升老年代年龄(-XX:PretenureSizeThreshold)等;
    • 吞吐参数设置:
      • -XX:MaxGCPauseMillis:控制最大的GC停顿时间
      • -XX:GCRation:直接设置吞吐量的大小

5、Parallel Old收集器

老年代的多线程并行收集器,采用标记-整理算法,吞吐量优先,为配合PS收集器的老年代版本。

6、CMS(Concurrent Mark Sweep)收集器

老年代并发收集器,采用标记 - 清除算法,以最短回收低停顿时间为目标的收集器,HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。STW时间能降低到200ms内。

CMS收集器使用的是标记 - 清除算法,所以会产生内存碎片,对于内存碎片的处理需要通过 标记-整理算法来进行压缩整理,所以CMS收集器的老年代也会搭配Serial Old收集器使用!!!

  • -XX:CMSInitiatingOccupancyFraction:老年代使用内存回收阈值,超过该阀值后开始CMS收集,默认是68%。
  • -XX:+UseCMSCompactAtFullCollection:在FGC时压缩整理内存,通过SerialOld收集器
  • -XX:CMSFullGCsBeforeCompaction:多少次FGC之后进行压缩整理

基于标记 - 清除算法的实现四个步骤 :

  1. 初始标记(Initial Mark):找到GC Roots 对象能关联的对象(GCroot第一层子节点),速度很快,需要STW;
  2. 并发标记(Concurrent Mark):从GC Roots 关联对象开始遍历整个对象图的过程,与用户线程并发运行,会耗时较长但是不需要停顿用户线程;
  3. 重新标记(remark):修正在并发标记期间,因用户程序运行而导致标记产生变动的那一部分对象的标记记录。这个阶段会STW,重新从头扫描有用的树(所以不存在漏标),停顿时间会比初始标记长些,但也远比并发标记阶段时间短。
  4. 并发清除(Concurrent Sweep):清除掉标记阶段判断已死亡的对象,由于不需要移动存活对象,这个阶段也是并发执行的。

JVM(九) - 垃圾回收机制_第11张图片

缺点:

  • 浮动垃圾(Floating Garbage):在并发清除阶段,用户线程将已标记不用回收的对象引用去掉,而GC并不会回收该阶段产生的垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会concurrent mode failure再次触发垃圾回收,导致性能降低;
  • 因会产生内存碎片,导致大对象无法分配,所以也会提前触发FGC,也可通过参数控制-XX:CMSInitiatingOccupancyFraction内存回收阈值,默认是68%;
  • 虽然重新标记阶段会解决部分漏标场景,但是三色标记算法的还会存在不可以解决的漏标问题。

CMS的并发预处理和并发可中断预处理?

1、首先,CMS是一个关注停顿时间,以回收停顿时间最短为目标的垃圾回收器。并发预处理阶段做的工作是标记,重标记需要STW(Stop The World),因此重标记的工作尽可能多的在并发阶段完成来减少STW的时间。此阶段标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。

2、并发可中断预清理(Concurrent precleaning)是标记在并发标记阶段引用发生变化的对象,如果发现对象的引用发生变化,则JVM会标记堆的这个区域为Dirty Card。那些能够从Dirty Card到达的对象也被标记(标记为存活),当标记做完后,这个Dirty Card区域就会消失。CMS有两个参数:CMSScheduleRemarkEdenSizeThreshold、CMSScheduleRemarkEdenPenetration,默认值分别是2M、50%。两个参数组合起来的意思是预清理后,eden空间使用超过2M时启动可中断的并发预清理(CMS-concurrent-abortable-preclean),直到eden空间使用率达到50%时中断,进入重新标记阶段。

为什么CMS后有G1?

因为现代服务器可用内存发展越来越大,CMS清理之后会老年代会产生内存碎片,内存不够时还是会FGC,可能老年代的内存碎片空间并不大,但是内存压缩整理时需要移动很多对象的内存,导致FGC停顿时间很长(如128G的堆,停顿10~20分钟)。所以CMS不适用于大内存的JVM虚拟机。CMS起着承上启下的作用。

7、G1(Garbage First)收集器

G1 跳出固定大小以及固定分代区域划分的限制,而是把连续的 Java 堆分成了多个大小相等的不需要连续的独立区域 ( region ),每一个 region 都可以根据需要扮演新生代的 Eden Survivor空间 或者 老年代空间。分而治之的思想,STW时间能降低到10ms内,主要适用于多处理器、大容量内存的、追求低停顿同时具备高吞吐的垃圾回收器

内存结构:

G1特点:

  • 用在heap memory很大的情况下,把heap划分为很多很多的region块,然后并行进行垃圾回收;
  • JVM规定不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过但是不推荐;
  • Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M;也可以用JVM参数 -XX:G1HeapRegionSize指定Region大小。
  • Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象,G1 认为大对象 -即超过 region内存的一半的对象;
  • Humongous区专门存放短期大型对象,不用直接进老年代,节约老年代的空间,避免因为老年代空间不够的GC开销。
  • Region保留了年轻代和老年代的概念,它们不再是物理隔离,它们都是(可以不连续)Region的集合;
  • 收集器根据对Region的不同角色采用不同的策略去回收
  • 年轻代的垃圾回收使用复制算法,把Eden区和Survivor区的对象复制到新的Survivor区域;
  • 老年代的垃圾收集,G1也分为四个阶段如下文;
  • 回收Region的时候基本不会STW,从整体来看是基于标记-整理算法,从局部(两个region之间)来看基于复制算法;
  • G1会维护一个优先级列表,优先处理回收价值收益最大的那些Region,价值即回收所获得的空间大小、回收所需时间,就如其名。
  • 根据该回收的Region中的Rset(Remember Set为每个Region中记录其内的对象被引用的对象所在区)找到其他分区。
  • Full GC除了收集年轻代和老年代之外,也会将Humongous区一并回收。
  • 一个Region可能之前是年轻代,该Region进行垃圾回收后可能又会变成老年代;Region的区域功能可能会动态变化。

G1老年代收集器的运作可分四个步骤:

  1. 初始标记:同CMS一样,标记 GC Roots 直接关联的对象,速度很快,也会STW。但是G1的初始标记结点是跟Minor gc一起发生的,把老年代上的初始标记给做了;不用像CMS那样,FGC时的执行。
  2. 并发标记:同CMS一样,扫描整个堆的对象图找到要回收的对象,会耗时较长但可与用户线程并发执行。但G1会多做一件事件,计算每个Region的存活率,方便后面的清除阶段使用,以及在该阶段发现哪些老年代Region中对象的存活率很小或者基本没有对象存活,则G1就会在这个阶段将其回收掉,不用等到后面的清除阶段,这也是Garbage First名字的由来。
  3. 最终标记:同CMS一样,修正在并发标记期间,因用户程序运行而导致标记产生变动的那一部分对象的标记记录,用户线程会STW。但是GC采用的算法不一样,G1采用了一种叫做STAB(snapshot-at-the-begining)的算法能够在Remark阶段更快的标记可达对象。
  4. 筛选回收(clean up/Copy):从Region的统计数据中对各区的回收价值和成本进行排序,必须暂停用户线程。根据用户期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。G1会挑选出那么对象存活率低的region进行回收,这个阶段也是和minor gc一同完成的

JVM(九) - 垃圾回收机制_第12张图片

G1垃圾收集的种类:

  1. YoungGC:YGC并不是现有Eden区满了就马上触发 ,G1会计算Eden区回收大概需要的时间,如果回收时间远小于-XX:MaxGCPauseMills配置的指,则增加年轻代Eden区的Region,继续给新对象存放 ;直到Eden区再次放满,计算回收时间接近参数设定的指,才会触发YoungGC;
  2. MixedGC:不是FullGC,老年代的堆占有率达到-XX:InitiatingHeapOccupancyPercent设定的比例则触发MixedGC,回收所有年轻代和部分老年代(根据期望GC停顿时间确定Old区的垃圾收集先后顺序),以及大对象区,正常情况G1是先进行MixedGC,使用复制算法,把各个Region中存活的对象拷贝到别的Region里去,拷贝过程如果发现没有足够的空Region能放拷贝的对象就会触发一次FullGC;
  3. FullGC:STW,然后采用线程进行标记、清理、压缩整理,空出Region来供下一次MixedGC使用,该过程非常耗时。(Shenandoah优化成多线程收集了)。

为什么G1能保证-XX:MaxGCPauseMills配置的最大停顿时间?

G1垃圾回收器在垃圾遍历的时候就会计算对应回收需要的时间,当接近所配置的时间时就去进行部分收集,远小于则会继续遍历更多的垃圾来回收。

G1从整体来看是基于标记整理算法实现的收集器,但从局部上看又是基于标记复制算法实现。最终这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。

 

8、ZGC(Zero paused GC)

STW时间能降低到10ms内(比拟C++回收效率),JDK11可以使用。ZGC

  • ZGC利用对象指针处理,和以往堆内存空间的处理思想不一样;
  • ZGC不支持32位的指针,不支持指针压缩;
  • 64位的地址指针,ZGC使用44位来描述地址,可达到16T的内存空间地址寻找;
  • 指针其余位用于Colored Pointer + Load Barrier,Load Barrier根据指针颜色来做不同的事情。

什么是颜色指针?什么是mmap?不同指针间是怎么映射到同一个位置上的?什么是重定位?什么是转发表?什么是线程自愈?

9、shenandoan

实验状态。

10、Eplison

实验状态。

五、三色标记算法

对象标记使用的是三色标记算法,将对象标记状态分为三种颜色,每次寻找都是基于上一次标记状态的结果来进行下一步遍历。

三种状态:

  1. 白色:对象还未被标记的节点,需要完全遍历寻找;可达性分析开始阶段所有对象都是白色,在可达性分析结束后,仍是白色对象的节点代表不可达。
  2. 灰色:对象已经标记的节点,但至少存在一个以上fields节点没有标记,会遍历其fields寻找;
  3. 黑色:对象已经标记的节点,且其fields节点(所有引用)都标记完成。代表已经扫描,安全存活状态,如有其他对象引用黑色状态对象,则无需再遍历一遍;黑色对象不可能不经过灰色对象直接指向白色对象;

颜色状态的存储:CMS垃圾回收器是在对象头部的Mark Word拿出两个二进制位来存储对象的标记颜色

1、CMS三色标记的问题:

问题1:多标 - 浮动垃圾Floating Garbage

并发标记阶段,用户线程和垃圾线程并行运行。

1、当前垃圾线程标记及引用链的状态:GCRoot -> A(黑) -> B(黑)

2、线程切换导致垃圾线程停止,用户线程运行,将A(黑) -> B(黑)引用去掉。则会导致B成为浮动垃圾。

  • 另外并发标记/并发清除阶段,开始后产生的新对象,通常的做法是直接当为黑色对象,本轮也不会当为垃圾,这段时间就算成为了垃圾,也只能等下一轮 清理。

JVM(九) - 垃圾回收机制_第13张图片

问题2:漏标 - 误删(已解决)

并发标记阶段,用户线程和垃圾线程并行运行。

1、当前垃圾线程标记及引用链的状态:A(黑) -> B(灰) -> D(白)

2、线程切换导致垃圾线程停止,用户线程运行,正好将B(灰) -> D(白)引用去掉,增加A(黑) ->D(白)引用。

3、垃圾线程再次运行时,A(黑)不会再标记其fields节点,而遍历B(灰)时,D(白)已不是B的fields节点,所以则会导致D(白)的漏标。可能会被误删。

CMS解决方案:Incremental Update

增量更新指当黑色对象新增引用为白色的对象时,会将这个引用记录下来,等并发标记结束后,再将这些引用关系的黑色对象作为根重新扫描一次。也可简单理解为JVM通过写屏障,在A.xField = D的时候,如果A是黑色,D是白色,则将A置为灰色,这样垃圾线程再次遍历会重写标记A的fields节点

JVM(九) - 垃圾回收机制_第14张图片

问题3:但是CMS的Incremental Update解决方案依然存在漏标。

1、当前垃圾线程标记及引用链的状态:A(灰) -> B(白) -> D(白),且垃圾线程1已扫描完A对象的field1属性。

JVM(九) - 垃圾回收机制_第15张图片

2、线程切换导致垃圾线程停止,用户线程运行,正好将B(白) -> D(白)引用去掉,增加A.field1 ->D(白)引用。

JVM(九) - 垃圾回收机制_第16张图片 3、垃圾线程1再次运行时,A.field1不会再标记其fields节点的D,当扫描完A.field2和B时会变为黑,已完成标记,则会导致D的漏标。

JVM(九) - 垃圾回收机制_第17张图片

 所以CMS在remark阶段,必须从GCRoot对已标记树再扫描一遍。

2、G1的三色标记:

G1实现三色标记算法的方案是SATB(Snapshot At The Begining,原始快照)

如有引用A(黑) -> B(灰) -> D(白)引用链,在B -> D引用消失的时候,将B->D的引用推到堆栈中记录,并发标记结束后,然后从堆栈中取出该引用(即指向的D对象)来扫描,来看D对象是否还被引用。避免本次误删。再加上G1的分区的Remember Set设计,可以找到当前分区对象被其他哪些分区所引用。

3、写屏障

JVM(九) - 垃圾回收机制_第18张图片

JVM(九) - 垃圾回收机制_第19张图片 

 

你可能感兴趣的:(JVM系列,jvm,java,算法)