JVM笔记(三):垃圾回收篇

  • Post author:
  • Post category:其他




垃圾回收篇



一、垃圾回收算法与引用



1.判断对象已死(标记垃圾算法)




1

引用计数算法

引用计数算法判断对象已死


  • 在对象添加一个引用计数器,有地方引用此对象,该引用计数器+1,引用失效时,该引用计数器-1,当引用计数器为0时,说明没有任何地方引用对象,对象已死

image-20201119213937671

  • 但是该方法无法解决

    循环引用

    (比如对象A的字段引用了对象B,对象B的字段引用了字段A,此时都将null赋值给对象A,B它们的引用计数器上都不为0,也就是表示对象未死,但实际上为null已经死了)。

    • 优点 :

      标记垃圾对象简单,高效

    • 缺点:

      无法解决循环引用,存储引用计数器的空间开销,更新引用记数的时间开销

  • 因为

    无法解决循环引用所以JVM不使用引用计数法

证明Java未采用引用计数算法

public class ReferenceCountTest {
    //占用内存
    private static final byte[] MEMORY = new byte[1024 * 1024 * 2];

    private ReferenceCountTest reference;

    public static void main(String[] args) {
        ReferenceCountTest a = new ReferenceCountTest();
        ReferenceCountTest b = new ReferenceCountTest();
        //循环引用
        a.reference = b;
        b.reference = a;

        a = null;
        b = null;
//        System.gc();
    }
}

image-20211008192548380




2

可达性分析算法


  • Java使用可达性分析算法,可以解决循环引用

可达性分析算法判断对象已死



  • GC Roots

    对象开始,根据引用关系向下搜索,搜索的过程叫做

    引用链

    • 如果通过

      GC Roots

      可以通过引用链达到某个对象则该对象称为

      引用可达对象

    • 如果通过

      GC Roots

      到某个对象没有任何引用链可以达到,就把此对象称为

      引用不可达对象

      ,将它放入

      引用不可达对象集合

      中(如果它是首个引用不可达对象节点,那它就是引用不可达对象根节点)。

image-20201119214426164

可以作为GC Roots对象的对象

  1. 在栈帧中局部变量表中引用的对象==

    参数,临时变量,局部变量

    ==。

  2. 本地方法引用的对象。

  3. 方法区的类变量引用的对象。

  4. 方法区的常量引用的对象(字符串常量池中的引用)。



  5. sychronized

    同步锁持有的对象。

  6. JVM内部引用(基础数据类型对应的Class对象,系统类加载器,常驻异常对象等)。

  7. 跨代引用。

  • 缺点:


    • 使用可达性分析算法必须在保持一致性的快照中进行(某时刻静止状态)


    • 这样会导致STW(Stop the Word)从而让用户线程短暂停顿




3

真正的死亡

  • 真正的死亡最少要经过2次标记:

    • 通过GC Roots经过可达性分析算法,得到某对象不可达时,进行第一次标记该对象。

    • 接着进行一次筛选(筛选条件: 此对象是否有必要执行

      finalize()

      ):

      • 如果此对象没有重写

        finalize()

        或JVM已经执行过此对象的

        finalize()

        都将被认为此对象没有必要执行

        finalize()

        ,这个对象真正的死亡了

      • 如果认为此对象有必要执行

        finalize()

        则会把该对象放入

        F-Queue

        队列中,JVM自动生成一条低优先级的Finalizer线程:


        1. Finalizer线程是守护线程,不需要等到该线程执行完才结束程序,也就是说不一定会执行该对象的finalize()方法


        2. 设计成守护线程也是为了防止执行finalize()时会发生阻塞,导致程序时间很长,等待很久

        3. Finalize线程会扫描

          F-Queue

          队列,如果此对象的

          finalize()

          方法中让此对象重新与引用链上任一对象搭上关系,那该对象就完成自救==

          finalize()方法是对象自救的最后机会

          ==。

测试不重写finalize()方法,对象是否会自救

/**
 * @author Tc.l
 * @Date 2020/11/20
 * @Description:
 * 测试不重写finalize方法是否会自救
 */
public class DeadTest01 {
    public  static DeadTest01 VALUE = null;
    public static void isAlive(){
        if(VALUE!=null){
            System.out.println("Alive in now!");
        }else{
            System.out.println("Dead in now!");
        }
    }
    public static void main(String[] args) {
        VALUE = new DeadTest01();

        VALUE=null;
        System.gc();
        try {
            //等Finalizer线程执行
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isAlive();
    }
}
/*
Dead in now!
*/

  • 对象并没有发生自救,对象以及死了

测试重写finalize()方法,对象是否会自救

/**
 * @author Tc.l
 * @Date 2020/11/20
 * @Description:
 * 测试重写finalize方法是否会自救
 */
public class DeadTest02 {
    public  static DeadTest02 VALUE = null;
    public static void isAlive(){
        if(VALUE!=null){
            System.out.println("Alive in now!");
        }else{
            System.out.println("Dead in now!");
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("搭上引用链的任一对象进行自救");
        VALUE=this;
    }

    public static void main(String[] args) {
        VALUE = new DeadTest02();
        System.out.println("开始第一次自救");
        VALUE=null;
        System.gc();
        try {
            //等Finalizer线程执行
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isAlive();

        System.out.println("开始第二次自救");
        VALUE=null;
        System.gc();
        try {
            //等Finalizer线程执行
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isAlive();
    }
}
/*
开始第一次自救
搭上引用链的任一对象进行自救
Alive in now!
开始第二次自救
Dead in now!
*/

  • 第一次自救成功,第二次自救失败,说明了finalize()执行过,JVM会认为它是没必要执行的了


  • 重写finalize()代价高,不能确定各个对象执行顺序,不推荐使用



2.垃圾回收算法




1

垃圾回收分类

  • 部分收集(Partial GC): 收集目标不是整个堆:


    • 新生代收集(Minor GC/ Young GC):收集新生代

      • Eden
      • Survive to
      • Survive from

    • 老年代收集(MajorGC/Old GC):收集老年代

    • 混合收集(Mixed GC):收集整个新生代和部分老年代

  • 整堆收集(Full GC): 整个堆 + 元空间




2

标记-清除算法


  • Mark-Sweep


    • 标记:从GCRoots开始遍历引用链,标记所有可达对象 (在对象头中标记)


    • 清除:从堆内存开始线性遍历,发现某个对象没被标记时对它进行回收

大致流程

image-20210502214946152

总结

  • 优点:

    • 简单。
    • 不会改变引用的地址。
  • 缺点:

    • 2次遍历扫描,

      效率不高


    • 会出现内存碎片,需要空闲列表记录


    • 移动对象时必须全程暂停用户应用才可以进行(Stop The World)


  • 注意: 这里的清除,并不是真正意义上的回收内存,只是更新空闲列表


    • 把要回收对象所在这块内存地址标记为空闲的

    • 后续如果新对象需要就来覆盖。
    • 类似删除文件后如果没有其他对象覆盖此空间时还可以恢复文件。




3

复制算法


  • Copying

    • Survive区分为两块容量一样的Survive to区和Survive from区。
    • 每次GC将Eden区和Survive from区存活的对象放入Survive to区,此时Survive to区改名为Survive from区,原来的Survive from区改名为Survive to区


      保证Survive to区总是空闲的


  • 如果Survive from区的对象经过一定次数的GC后(默认15次),把它放入老年代。

大致流程(图中的dean为Eden区写错了)

image-20201120164106097

image-20201120164200591

细节流程

image-20210502220340767

总结

  • 优点:


    • 高效


    • 无内存碎片

  • 缺点:


    • 浪费半块Survive区的内存


    • 移动对象时必须全程暂停用户应用才可以进行(Stop The World)


    • 复制后会改变引用地址

      • 因为hotspot中使用直接指针访问,还需要改变栈中

        reference

        指向改引用地址。

  • 如果对象存活率高就会非常浪费资源,所以适用于新生代




4

标记-整理算法


  • Mark-Compact


    • 标记


      • 从GCRoots开始遍历引用链,标记所有可达对象 (在对象头中标记)

      • 与标记-清除算法一致。

    • 整理


      • 让所有存活对象往内存空间一端移动


      • 然后直接清理掉边界以外的所有内存

大致流程

image-20210502221256677

  • 优点:


    • 没有内存碎片,不需要空闲列表 (解决标记-清除算法的问题)


    • 没有浪费空间 (解决复制算法的问题)

  • 缺点:


    • 移动对象时必须全程暂停用户应用才可以进行(Stop The World)


    • 效率低

      (比标记清除还低,多了整理步骤)。

    • 需要移动对象

      (需要改变栈中

      reference

      指向改引用地址)。
  • 如果不移动对象会产生内存碎片,内存碎片过多,将无法为大对象分配内存,移动对象则会Stop The World。

  • 还有种方法:多次标记-清除,等内存碎片多了再进行标记-整理。




5

分代收集算法

image-20210502222504113


  • 需要移动对象 意味着 要改变改对象引用地址 也就是说要改变栈中

    reference

    指向改对象的引用地址


  • 分代收集算法 : 对待不同生命周期的对象可以采用不同的回收算法

    (没有最好的算法,只有最合适的算法)。


  • 年轻代: 对象生命周期短,存活率低,回收频繁


    • 采用复制算法,高效


  • 老年代: 对象生命周期长,存活率高,回收没年轻代频繁


    • 采用标记-清除 或混用 标记-整理

      • mark开销 和 compact 开销 与 存活对象数量 成正比。
      • sweep 开销 与 堆空间大小 成正比 (遍历)。




6

增量收集算法

  • mark-sweep,copying,mark-compact算法都存在STW,如果垃圾回收时间很长,会严重影响用户线程的响应。


  • 增量收集算法: 采用用户线程与垃圾收集线程交替执行


    • 优点


      • 提高用户线程的响应时间


    • 缺点


      • 存在线程上下文切换的开销,降低吞吐量,垃圾回收成本变大




7

分区算法

  • 堆空间越大,GC时间就会越长,用户线程响应就越慢。


  • 分区算法: 将堆空间划分为连续不同的区,根据要求的停顿时间合理回收n个区,而不是一下回收整个堆

  • 每个区独立使用,独立回收。

    • 优点:

      • 根据能承受的停顿时间控制一次回收多少个区。



3.HotSpot垃圾回收算法细节




1

根节点枚举

  • 遍历GC Roots以及引用链的过程。


  • 根节点枚举必须暂停用户线程,因为要保证一致性的快照(根节点枚举是用户线程停顿的重要原因)

  • 如果单纯遍历GC Roots和引用链过程会非常的耗时,

    使用OopMap记录引用所在位置,扫描时不用去方法区找直接可以知道


  • 使用OopMap快速,精准的让HotSpot完成根节点枚举




2

安全点与安全区域

safe point

  • 如果为每条指令都生成OopMap内存开销是很大的,所以只有特定位置才生成OopMap。

  • 这个特定位置就是安全点。


  • 用户程序不是停在任意指令就可以开始垃圾回收的,必须停在安全点


  • 安全点的特定位置:

    是否具有让程序长时间执行的特征(指令序列的复用: 循环跳转,异常跳转,方法调用等)




  • 抢先式中断和主动式中断

    让用户线程到最近的安全点停下来:

    • 抢先式中断: 垃圾收集发生时,中断所有用户线程,如果有用户线程没在安全点上就恢复它再让它执行会到安全点上(不采用这种方式)。

    • 主动式中断: 设置一个标志位,当要发生垃圾回收时,就把这个标记位设置为真,用户线程执行时会主动轮询查看这个标志位,一旦发现它为真就去最近的安全点中断挂起


  • 安全点设立太多会影响性能,设立太少可能会导致GC等待时间太长


  • 安全点保证程序线程执行时,在不长时间内就能够进入垃圾收集过程的安全点

safe region


  • 安全点只能保证程序线程执行时,在不长时间内进入安全点,如果是Sleep或者Blocking的线程呢?


    1. 安全区域: 确保某一段代码中,引用关系不发生变化,这段区域中任意地方开始垃圾收集都是安全的

  • 实际执行:

    • 用户线程执行到安全区,会标识自己进入安全区,垃圾回收时就不会去管这些标识进入安全区的线程。
    • 用户线程要离开安全区时,会去检查是否执行完根节点枚举,执行完了就可以离开,没执行完就等待,直到收到可以离开的信号。




3

记忆集与卡表


  • 解决跨代引用问题,避免把老年代也加入GC Roots扫描范围(避免全局扫描)

  • 记忆集:

    记录从非收集区指向收集区的指针集合

  • 卡表: 实现记忆集的卡精度(每个记录精确到内存区,该区域有对象有跨代指针)。

  • 卡表简单形式是一个字节数组,数组中每个元素对应着其标识内存区域中一块特定大小的内存区(这块内存区叫:卡页)。

image-20210506203528466

  • 如果卡页上有对象含有跨代指针,就把对应卡表数组值改为1(卡表变脏),1说明卡表对应的内存块有跨代指针,把卡表数组上元素为1的内存块加入GC Roots中一起扫描。


  • 记忆集缩减了GC Roots扫描范围




4

写屏障


  • 卡表变脏是在引用类型赋值时发生的,利用写屏障把维护卡表的动作放在每一个赋值操作中


  • 写屏障是引用类型赋值那一刻的AOP切面,赋值前为写前屏障,赋值后为写后屏障

  • 更新卡表操作产生额外的开销,在高并发情况下还可能发生伪共享问题,降低性能。

  • 避免伪共享问题: 不采用无条件的写屏障,先检查卡表标记,只有未被标记过时才将其标记为变脏。


  • -XX:+UseCondCardMark

    是否开启卡表更新条件判断,开启增加额外判断的开销,可以避免伪共享问题。




5

并发可达性分析

如果GC线程和用户线程不同时执行

  • 初始状态

image-20201120181155661

  • 扫描过程中

image-20201120181229978

  • 结束状态

image-20201120181406802


  • 黑色: 当前对象已经被扫描过,并且它的所有引用也被扫描了


  • 白色: 当前对象未被扫描过


  • 灰色: 当前对象被扫描过,至少有一个引用未被扫描


  • 如果扫描结束还是白色说明不可达

如果GC线程和用户线程同时执行,用户线程标记时,并发修改引用

  • 一种情况会把原本死亡的对象改成活的对象,成为浮动垃圾,下次再回收掉。

  • 另一种情况会把原本存活的对象改成死亡的对象,非常危险(如下图)。

image-20201120182009236

  • 这个现象又叫:对象消失问题。

  • 对象消失需要满足的条件:

    1. 赋值器插入一条或多条从黑色对象到白色对象的新引用。
    2. 赋值器删除全部从灰色对象到该白色对象的直接或间接引用。
  • 只需要破坏其中一条就可以避免对象消失。


  • 增量更新和原始快照分别破坏1,2条件

  • 增量更新: 记录新增加的引用,并发扫描结束后,把记录的引用中以黑色对象为根节点再扫描一遍(

    理解:黑色对象一旦添加新白色对象的引用,就变回灰色对象需要重新扫描

    )。

    增量更新+写后屏障 (需要记录新的引用对象)

  • 原始快照: 记录删除的引用,并发扫描结束后,把记录的引用中以灰色对象为根节点再扫描一遍(

    理解:无论删除与否,都从开始扫描那一刻的对象图快照来进行搜索

    )。

    原始快照+写前屏障(不需要记录新的引用对象,需要记录旧引用对象)



4.相关概念




1

System.gc()


  • 提醒垃圾收集器进行Full GC,不一定会执行



  • Runtime.getRuntime().gc()

    作用相同。
  • 可以使用

    System.gc()

    +

    System.runFinalization()

    强制执行finalize方法。

代码测试GC不可达对象

image-20210504131459139

public class SystemGCTest {
    public static void main(String[] args) {
       test5();
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("执行了finalize");
    }

    public static void test1() {
        byte[] bytes = new byte[5 * 1024 * 1024];
        System.gc();
    }

    public static void test2() {
        test1();
        System.gc();
    }

    public static void test3() {
        byte[] bytes = new byte[5 * 1024 * 1024];
        bytes = null;
        System.gc();
    }

    public static void test4() {
        {
            byte[] bytes = new byte[5 * 1024 * 1024];
        }
        System.gc();
    }

    public static void test5() {
        {
            byte[] bytes = new byte[5 * 1024 * 1024];
        }
        int i = 0;
        System.gc();
    }
}




2

内存溢出



  • OutOfMemoryError(OOM)

    :没有空闲内存,并且垃圾收集器无法提供更多内存

  • 当应用程序占用内存速度大于垃圾回收内存速度时可能发生OOM。

  • 内存溢出的情况:

    • 堆内存设置太小 (通过

      -Xmx

      来调整)。
    • 创建大量大对象,且生命周期长,不能被回收。
  • 抛出OOM之前通常会进行Full GC,如果进行Full GC后依旧内存不足才抛出OOM。

  • JVM参数

    -Xms10m -Xmx10m -XX:+PrintGCDetails

image-20210504133922780




3

内存泄漏


  • 内存泄漏Memory Leak: 对象不会被程序用到了,但是不能回收它们

image-20210504134520596

  • 内存泄漏可能导致最终出现内存溢出OOM(因为不用这些对象了,但也不能回收,一直占用空间)。

    • 广义内存泄漏: 不正确的操作导致对象生命周期变长也可能OOM:

      • 单例中引用外部对象,当这个外部对象不用了,但是因为单例还引用着它导致内存泄漏。
      • 一些需要close的资源未关闭导致内存泄漏。




4

STW


  • STW: GC中为了分析垃圾过程确保一致性,会导致所有Java执行线程停顿


  • 可达性分析算法枚举根节点导致STW

    (因为不停顿线程的话,在分析垃圾的过程中,引用会变化,这样分析的结果会不准确)




5

并行与并发


  • 并发: 某时间段上有很多任务执行,但某时刻上只有一个任务在执行,这个时间段上任务是交替执行的


  • 并行: 某时刻有多个任务在执行(前提条件: 1核以上的处理器)

垃圾回收的串行,并行与并发

image-20210504161519334


  • 串行: 需要GC时,暂停用户线程,单条GC线程串行运行


  • 并行: 需要GC时,暂停用户线程,多条GC线程并行运行


  • 并发: 需要GC时,用户线程与GC线程并发执行(交替执行)

image-20210504161805977



5.引用

  • 传统引用: 如果

    reference

    类型存储的数据代表某块内存地址,那就称

    reference

    为某内存,某对象的引用。

  • 只有被引用或没被引用。

  • 根据引用的强弱可分为: 强引用>弱引用>软引用>虚引用。

image-20201119220035297




1

强引用

  • 程序代码中普遍存在的引用赋值。
List list = new ArrayList();

  • 只要强引用的引用关系存在,垃圾收集器就不会回收该对象,因此强引用是造成Java内存泄漏的主要原因




2

弱引用


  • 描述有用,非必须的对象


  • 内存充足: 不会回收


  • 内存不充足: 在内存溢出异常前将弱引用放入收集范围中,继续第二次回收,如果回收后还没充足内存则抛出异常


  • 使用

    SoftReference

    实现弱引用

内存充足情况下的弱引用

public static void main(String[] args) {
    int[] list = new int[10];
    SoftReference listSoftReference = new SoftReference(list);
    //      以上三行代码等价下面这行使用匿名对象的代码
    //      SoftReference listSoftReference = new SoftReference(new int[10]);
    //        [I@61bbe9ba
    System.out.println(listSoftReference.get());

}

内存不充足情况下的弱引用(JVM参数:-Xms5m -Xmx5m -XX:+PrintGCDetails)

//-Xms5m -Xmx5m -XX:+PrintGCDetails
public class SoftReferenceTest {
    public static void main(String[] args) {
        int[] list = new int[10];
        SoftReference listSoftReference = new SoftReference(list);
        list = null;
//      以上三行代码等价下面这行使用匿名对象的代码
//      SoftReference listSoftReference = new SoftReference(new int[10]);

        //[I@61bbe9ba
        System.out.println(listSoftReference.get());

        //模拟空间资源不足
        try{
            byte[] bytes = new byte[1024 * 1024 * 4];
            System.gc();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            //null
            System.out.println(listSoftReference.get());
        }
        
    }
}

  • 弱引用可以用来作高速缓存




3

软引用


  • 描述非必须的对象,强度比弱引用还弱


  • 使用

    WeakReference

    ,无论内存是否足够,都会对软引用进行回收

内存充足情况下的软引用

public static void test1() {
    WeakReference<int[]> weakReference = new WeakReference<>(new int[1]);
    //[I@511d50c0
    System.out.println(weakReference.get());

    System.gc();
    //null
    System.out.println(weakReference.get());
}

wekaHashMap

image-20210504212605513

  • 当key被终结时,weakHashMap中该键值对就会被删除。
	public static void test2() {
        WeakHashMap<String, String> weakHashMap = new WeakHashMap<>();
        HashMap<String, String> hashMap = new HashMap<>();

        String s1 = new String("3.jpg");
        String s2 = new String("4.jpg");

        hashMap.put(s1, "图片1");
        hashMap.put(s2, "图片2");
        weakHashMap.put(s1, "图片1");
        weakHashMap.put(s2, "图片2");

        //只将s1赋值为空时,那个堆中的3.jpg字符串还会存在强引用,所以要remove
        hashMap.remove(s1);
        s1=null;
        s2=null;

        System.gc();

        System.out.println("hashMap:");
        test2Iteration(hashMap);

        System.out.println("weakHashMap:");
        test2Iteration(weakHashMap);
    }

    private static void test2Iteration(Map<String, String>  map){
        Iterator iterator = map.entrySet().iterator();
        while (iterator.hasNext()){
           Map.Entry entry = (Map.Entry) iterator.next();
            System.out.println(entry);
        }
    }
  • 执行结果。
hashMap:
4.jpg=图片2
weakHashMap:
4.jpg=图片2
  • 并没有显示删除weakHashMap中的该key,当这个key没有其他地方引用时就删除该键值对。

软引用,弱引用适用的场景

  • 假如有一个应用需要读取大量本地图片:

    1. 如果每次读取图片都从硬盘读取会影响性能。
    2. 一次性全部加载到内存中又可能造成内存溢出。
  • 设计思路:

    • 用HashMap保存图片路径与相应图片对象关联软引用之间的映射,内存不足时,JVM会自动回收这些缓存图片对象所占内存,从而避免OOM。

      Map<String,SoftReference<Bitmap>> imageCache = new HashMap<String,SoftReference<Bitmap>>();
      




4

虚引用


  • 虚引用只是为了能在这个对象被收集器回收时收到一个通知


  • 无法通过虚引用得到该对象实例

    (其他引用都可以得到实例)。


  • 使用

    PhantomReference

    创建虚引用,需要搭配引用队列

    ReferenceQueue

    使用

引用队列搭配虚引用使用

public class PhantomReferenceTest {
    private static PhantomReferenceTest reference;
    private static ReferenceQueue queue;

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


    }

    public static void main(String[] args) {
        reference = new PhantomReferenceTest();
        queue = new ReferenceQueue<>();
        PhantomReference<PhantomReferenceTest> phantomReference = new PhantomReference<>(reference, queue);

        Thread thread = new Thread(() -> {
            PhantomReference<PhantomReferenceTest> r = null;
            while (true) {
                if (queue != null) {
                    r = (PhantomReference<PhantomReferenceTest>) queue.poll();
                    //说明被回收了,得到通知
                    if (r != null) {
                        System.out.println("实例被回收");
                    }
                }
            }
        });
        thread.setDaemon(true);
        thread.start();


        //null
        System.out.println(phantomReference.get());

        try {
            System.out.println("第一次gc 对象可以复活");
            reference = null;
            System.gc();
            TimeUnit.SECONDS.sleep(1);
            if (reference == null) {
                System.out.println("object is dead");
            } else {
                System.out.println("object is alive");
            }
            reference = null;
            System.out.println("第二次gc 对象死了");
            System.gc();
            TimeUnit.SECONDS.sleep(1);
            if (reference == null) {
                System.out.println("object is dead");
            } else {
                System.out.println("object is alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}
  • 结果:
null
第一次gc 对象可以复活
调用finalize方法
object is alive
第二次gc 对象死了
实例被回收
object is dead
  • 虚引用回收后,引用队列有数据,来通知告诉我们reference这个对象被回收了。

使用场景

  • GC只能回收堆内内存,而直接内存GC是无法回收的,直接内存代表的对象创建一个虚引用,加入引用队列,当这个直接内存不使用,这个代表直接内存的对象为空时,这个虚内存就死了,然后引用队列会产生通知,就可以通知JVM去回收堆外内存(直接内存) 。



二、垃圾收集器与内存分配策略

  • 垃圾收集(Garbage Collection):

    • 哪些内存需要收集?

    • 什么时候收集?

    • 怎么收集?



1.分析GC日志


  • -XX:+PrintGCDetails

    输出GC详细日志。

image-20211008200057515

使用GC日志分析工具GCeasy


  • -Xloggc:./logs/gc.log

    把日志文件输出到当前项目下logs包中的gc.log。

  • 生成日志文件后,放到GCeasy官网解析 https://gceasy.io/。

image-20210507000534990



2.GC分类与性能指标




1

GC分类

  • 同时执行的GC线程数分类:

    • 串行垃圾回收器。

    • 并行垃圾回收器。

      image-20210505094305911

  • 工作模式分类:

    • 独占式垃圾回收器。

    • 并发式垃圾回收器。

      image-20210505094351819

  • 内存碎片处理分类:

    • 压缩式垃圾回收器 (对内存进行压缩整理,无碎片内存)。
    • 非压缩式垃圾回收器。
  • 工作内存区间分类:

    • 新生代垃圾回收器。
    • 老年代垃圾回收器。




2

性能指标

  • 主要有吞吐量和延迟。

  • **吞吐量: CPU处理用户代码时间/总时间(垃圾收集时间+处理用户代码时间) **。


  • 延迟: STW暂停用户线程的时间

  • 尽量追求

    高吞吐量和低延迟

  • 高吞吐量表示程序运行快,低延迟在交互程序中给用户带来好的响应。

吞吐量和延迟冲突

  • 以高吞吐量为优先,就要减少GC频率,这样会导致GC需要更长的时间,从而导致延迟升高。

  • 以低延迟为优先,为了降低每次GC暂停更短的时间,只能增大GC频率,这样导致吞吐量降低。

image-20210505095218917

  • 现在的目标:

    • 在最大吞吐量优先情况下,尽量降低停顿时间。
    • 在可以接收的停顿时间中,尽量增大吞吐量。



3.经典垃圾收集器

image-20210505103225068

  • 串行垃圾收集器:

    Serial

    ,

    Serial Old

  • 并行垃圾收集器:

    ParNew

    ,

    Parallel Scavenge

    ,

    Parallel Old

  • 并发垃圾收集器:

    G1

    ,

    CMS

  • 低延迟垃圾收集器:

    ZGC

    ,

    Shenandoah

用户线程停顿与吞吐量


  • 用户线程停顿时间越短就说明响应给用户会快,提升用户体验


  • 吞吐量越高说明最高效率利用处理器资源完成程序

查看JDK8使用的垃圾收集器

image-20210505104901171




1

Serial收集器和Serial Old收集器

  • 新生代: Serial收集器

    复制算法

  • 老年代: Serial Old收集器

    标记-整理算法

(新生代)Serial收集器 + (老年代)Serial Old 收集器 运行图

image-20210505105401634

  • 若不是频繁回收,停顿时间客户端模式下的用户完全可以接受。


  • 默认Client模式的收集器,适合单核CPU

  • 适合桌面应用场景(嵌入式),不适合交互的应用(Web)。


  • 串行收集器(单线程) 简单高效(在单线程中) 内存开销最小


  • 收集过程中,必须暂停其他所有工作线程,直到它收集结束

参数设置



  • -XX:+UseSerialGC

    :新生代使用Serial GC 老年代使用 Serial Old GC

image-20210505110114601




2

ParNew收集器

  • 并行收集器,新生代收集器。

  • ParNew是Serial新生代的并行版本

    新生代: 复制算法

  • 第一款并发收集器: 首次实现了让用户线程和GC线程基本上同时工作。

(新生代)ParNew收集器 + (老年代)Serial Old收集器 运行图

image-20201120184236244

  • 单核时Serial比ParNew高效。

参数设置


  • -XX:+UseParNewGC

    新生代使用ParNew。


  • -XX:ParallelGCThreads=线程数

image-20210505115325540




3

Parallel Scavenge收集器 和 Parallel Old收集器

  • Parallel Scavenge俗称==

    吞吐量优先收集器

    ==并行收集器。


  • 新生代收集器 复制算法

  • Parallel Old 是Parallel Scavenge 的

    老年代版本 标记-整理算法

Parallel Scavenge收集器 + Parallel Old收集器运行图

image-20201120185757606

  • 与ParNew的不同:

    精确控制吞吐量,自适应调节策略


  • 吞吐量越高说明最高效率利用处理器资源完成程序

参数设置


  • -XX:UseParallelGC



    -XX:UseParallelOldGC

    互相激活

    • 新生代使用ParallelGC 老年代使用Parallel Old GC。

  • -XX:MaxGCPauseMillis

    • 最大垃圾收集停顿时间。

  • -XX:GCTimeRatio

    • 直接设置吞吐量大小来精确控制。

  • -XX:+UseAdaptiveSizePolicy


    • 自适应调节策略

      默认开启。
    • 开启JVM会根据系统运行情况动态的调整以上2个参数来提高最合适的停顿时间或最大的吞吐量。




4

CMS收集器

  • 全称: Concurrent Mark Sweep 并发标记清除收集器。


  • 老年代收集器,采用标记-清除算法



  • 最短停顿时间(低延迟)为目标

    的收集器。


  • 多数应用于互联网网站或浏览器的B/S系统,极短的停顿时间可以给用户非常好的体验

执行步骤

  1. 初始标记:

    标记GC Roots直接关联的对象(STW时间极短)

  2. 并发标记:

    从GC Roots直接关联对象开始遍历整个引用链的过程(耗时长,不需要停顿用户线程,用户线程与GC线程并发执行)

  3. 重新标记:

    使用增量更新避免对象消失问题,修正并发标记期间改动的对象(需要STW,耗时比步骤1长,比步骤2短)

  4. 并发清除:

    清理标记阶段判断已死亡的对象,该阶段也是并发执行

    (不懂增量更新,可以去垃圾回收算法这篇笔记中查找垃圾回收算法的细节)

CMS执行图

image-20201120190819689

  • 优点:

    1. 停顿时间短(初始标记,重新标记)。
    2. 时间长的并发标记和并发清理与用户线程并发执行,加快响应速度,提升用户体验。
  • 缺点:

    1. 吞吐量降低。

      在处理器核数少时,GC线程与用户线程并发执行(使用i-CMS解决:减少GC线程独占时间,垃圾回收时间变长,对用户线程执行影响变小)

    2. 无法处理浮动垃圾:

      • 浮动垃圾: 在并发标记阶段产生的新垃圾,不能在这一次的GC中被回收,只能下一次GC时被回收。

      • CMS不能等老年代满了再垃圾回收,因为与用户线程并发执行,所以需要留一部分内存。

    3. 内存碎片多。

      (解决: 多次垃圾回收后进行一次标记-整理算法,采用替补方案Serial Old)

CMS会产生碎片,那为什么不直接采用标记整理算法(而是使用标记清除)

  • CMS中GC清理垃圾的线程与用户线程会并发执行,如果采用标记整理会改变对象引用地址,此时用户线程也在执行,可能发生严重的错误

参数设置


  • -XX:UseConcMarkSweepGC

    • 老年代使用CMS垃圾收集器,新生代使用ParNew收集器。

  • -XX:CMSInitiatingOccupancyFraction

    • 设置老年代使用多少空间时开始垃圾回收:

      • 如果设置的太高,不够内存分配,不能满足并发执行,就会冻结用户线程启动Serial Old收集器,停顿时间就会变长。
      • 如果内存增长缓慢可以设置高一些,如果内存增长很快就要设置低一些 默认92%。

  • -XX:+UseCMSCompactAtFullCollection

    • 指定在FULL GC后是否对内存进行压缩整理。
    • 开启后,通过

      -XX:CMSFullGCsBeforeCompaction

      设置执行多少次FULL GC后进行内存压缩整理。

  • -XX:ParallelCMSThreads

    • 设置CMS线程数量。




5

G1收集器

  • 全称 Garbage First 面向服务端的垃圾收集器。


  • 目的: 在延迟可控的情况下尽可能高的吞吐量


  • 面向堆内存任何部分来组成区(Region)进行收集,不再分代,哪块内存垃圾最多,收益最大,就回收哪块


  • 整体上: 标记-整理算法


  • 局部上(区域间): 复制算法

  • 把Java堆内存分为多个大小相等的区域(Region)。

  • 区域的3种情况。

image-20201120200851268

  1. 新生代Eden。
  2. 新生代Survive。
  3. Humongous区

    用于存放大对象

    (如果一个Humongous不够存放大对象,就把这个大对象存放在连续的多个Humongous上)

    大多数情况下把Humongous当作老年代来看


  • G1收集器在后台维护一个优先级队列,跟踪各个区域中的垃圾回收价值(回收垃圾的大小和回收时间的情况),每次通过(

    -XX:MaxGCPauseMillis

    )用户规定的收集停顿时间来优先回收垃圾回收价值最大的区域

  • 每个区域设计2个

    TAMS(Top at Mark Start)指针,把区域中一部分空间划分出来用来为新对象分配内存(指针碰撞,因为内存规整)

    (不懂原始快照,可以去垃圾回收算法这篇笔记中查找垃圾回收算法的细节)


  • 采用原始快照来解决GC线程与用户线程并发时的对象消失问题

执行过程

  1. 初始标记 :

    标记GC Roots能直接关联的对象,修改TAMS指针(停顿用户线程,耗时短)

  2. 并发标记 :

    从GC Roots直接关联对象开始遍历整个引用链的过程(GC线程与用户线程并发指向,耗时长),扫描完还要处理原始快照记录在并发时有引用变动的对象

  3. 最终标记 :

    处理并发阶段遗留下来的少量原始快照记录(停顿用户线程,耗时短)

  4. 筛选回收 :

    更新区域的垃圾回收价值,把各个区域的垃圾回收价值(要算成本:回收时间)在优先级队列中排序,根据用户所期望的停顿时间制定回收计划,把要回收的那片区域存活的对象复制到空区域中(复制对象要暂停用户线程,GC线程并发执行)

  • 如果期望停顿时间设置太短(不符合实际),由于停顿时间短,回收垃圾速度<为新对象分配内存速度,会导致堆满Full GC反而会降低性能。

G1执行图 (图中最终标记是GC并行)

image-20201120195247651

参数 (前三个常用)


  • -XX:+UseG1GC

    使用G1收集器。


  • -XX:G1HeapRegionSize

    设置每个region大小。


  • -XX:MaxGCPauseMillis

    设置预期停顿时间 (默认200ms,最好不要太小)。


  • -XX:ParallelGCThread

    设置STW时GC线程数。


  • -XX:ConcGCThreads

    设置并发标记线程数。


  • -XX:InitiatingHeapOccupancyPercent

    设置触发老年代GC的堆占用率阈值。

总结

  • 优点:


    1. 整体:标记-整理,局部:复制,不会产生内存碎片


    2. 从用户期望时间来回收垃圾价值最高的垃圾


    3. 原始快照的好处(不需要添加新引用记录): 减少并发标记,重新标记阶段的消耗

  • 缺点:

    1. 因为有很多区域,跨区域引用对象问题频繁出现,每个区域要维护记忆集(Key:别的区域地址 Value:集合存储卡表的索引号),卡表实现复杂(其他卡表:我指向谁,这里的卡表:我指向谁+谁指向我),所以

      占用内存更大


    2. 执行负载大(写屏障操作复杂)

      • 写后屏障维护复杂的卡表。

      • 原始快照的坏处(需要记录旧引用):写前屏障跟踪并发指针变化,在用户程序运行中产生有跟踪引用变化带来的额外负担。

      • CMS写屏障可以直接同步,而G1写屏障太复杂要把写前屏障和写后屏障中做的事放到队列中,异步处理。

  • 适用场景:

    • 大内存,多处理器的机器,面向服务端。




6

总结

image-20210506204421377



4.低延迟垃圾收集器

  • 衡量垃圾收集器的三个标准:

    内存占用,吞吐量,延迟

  • 随着硬件的提高,吞吐量会增大,堆内存也会增大,堆内存增大代表着收集器收集时间变长,延迟也会变长。




1

Shenandoah收集器



(一)执行过程
  • Shenandoah也是一个基于Region区域的堆内存布局,默认也是回收价值最大的区域。

Shenandoah与G1的区别

  1. G1回收阶段,GC线程与用户线程不能并发执行; 而Shenandoah在回收阶段可以。

  2. G1采用分代收集 ; Shenandoah不采用分代收集。

  3. Shenandoah没采用G1中解决跨代指针问题的复杂的记忆集,而是采用

    连接矩阵

    来记录跨区域指针问题,即降低了维护记忆集的消耗也降低了伪共享问题发生的概率。

    • 连接矩阵示意图:

image-20201121191412812

Shenandoah收集器执行详细步骤

  1. 初始标记 :

    标记与GC Roots对象关联的直接对象(短暂停顿)

  2. 并发标记 :

    从GC Roots直接关联对象开始遍历整个引用链的过程(GC线程与用户线程并发执行,耗时长)

  3. 最终标记 :

    处理原始快照剩余的记录,统计出回收价值最高的区域,构成回收集(短暂停顿)

  4. 并发清理 :

    清理直接垃圾区域(也就是一个存活对象都没有的区域)

  5. 并发回收 :

    把回收集里的存活对象移动到未被使用的区域中(GC线程与用户线程并发执行)

    • 对象移动后,整个内存中指向对象的引用还是旧对象的地址,很难一瞬间改变,以往的垃圾收集器就是这一步骤不能并发执行的,但是Shenandoah采用

      读屏障,转发指针

      来解决这个问题。
  6. 初始引用更新:

    确保所有的GC线程完成分配给它们的移动任务,可以开始引用更新(短暂停顿)

    • 引用更新: 把内存中所有指向这些存活旧对象的指针修正为新地址。
  7. 并发引用更新:

    按照物理地址顺序,线性搜索出引用类型,把旧地址改为新地址(时间长短与要修改的引用有关)

  8. 最终引用更新:

    修正存在于GC Roots的引用(短暂停顿)

  9. 并发清理 :

    经过并发标记,并发引用更新后,整个回收集没有存活对象,并发清理回收集的区域

Shenandoah 收集器执行图

  • Shenandoah收集器执行步骤大致分为:并发标记->并发引用更新->并发清理。

image-20201121190512185



(二)转发指针与读屏障
  • 对象移动后,整个内存中指向对象的引用还是对象的旧地址,很难一瞬间改变。

  • 如果GC线程一边移动对象,用户线程一边访问,可能会导致用户线程访问到一个旧地址。

  • 解决对象移动与用户程序并发执行的2中方案: 内存保护陷阱,转发指针。

内存保护陷阱

  • 在被移动对象原有内存上设置保护陷阱,一旦用户线程访问到旧对象的内存空间就会自陷中毁.进入预设好的异常处理器,再有代码逻辑把访问转发到移动后的新对象上

    没有操作系统的支持,会频繁的用户态切换到核心态,开销非常大

转发指针


  • 在原有对象头结构上增加一个新引用字段,在正常情况下指向自己,在并发移动情况下指向新对象

  • 类似句柄,只不过句柄统一存在句柄池,而转发指针是在对象头上。

  • 正常情况下:

image-20201121193043606

  • 并发移动情况下:

image-20201121193201071

  • 事件1: GC线程复制新对象的副本(准备把这个副本赋值给旧对象的转发指针)。

  • 事件2:用户线程对对象进行写操作(更新对象的某个字段)。

  • 事件3:GC线程更新转发指针的引用值为新副本地址。

  • 如果事件2在事件1,3之间发生,那么就对旧对象进行写操作了,十分危险。

  • 所以要对旧对象的转发指针访问进行同步操作,Shenandoah收集器使用CAS操作来保证这里的原子性。


  • 转发指针保证了并发时对对象的正确访问性

  • 要覆盖全部对象访问操作,Shenandoah需要设置读屏障去拦截。

  • 读屏障的设置带来很大的性能开销。



(三)总结
  • Shenandoah是基于Region区域的堆内存布局,默认回收价值最大的区域,无分代,使用转发指针,读写屏障等技术并发执行标记-整理算法的低延迟垃圾收集器:

    • 优点: 低延迟。
    • 缺点: 高运行负负担使得吞吐量下降,运行时间变长。




2

ZGC收集器

  • ZGC收集器是一款基于Region内存布局的,不设分代的,使用读屏障,染色指针,内存多重映射等技术来实现并发执行标记-整理算法的低延迟垃圾收集器。


(一)内存布局
  • ZGC也是基于Region(Page,ZPage)区域的堆内存布局,无分代


  • ZGC的区域具有动态性,动态创建,销毁,以及动态的区域容量大小

    • 小型Region 容量固定2MB,存放的 Object<258KB。

    • 中型Region 容量固定32MB,存放的 258KB<=Object<4MB。

    • 大型Region 容量不固定,可以动态变化,为2MB的整倍 存放的 Object>4MB。


    • 大型Region只能分配一个大对象(所以大型Region可能比中型Region小),大型Region不会被重分配(执行过程的动作,后面介绍)

image-20201121201038547



(二)染色指针
  • 某个对象只有它的引用关系能决定它的存活,它的属性都不能影响它的存活判定。

  • 标记的实现方案:

    1. 标记在对象头(Serial收集器)。
    2. 标记在独立的数据结构上(G1,Shenandoah收集器)。
    3. ZGC采用染色指针,直接把标记信息记录在引用对象的指针上。

  • 染色指针是一种直接将少量额外信息存储在指针上的技术

  • Linux下64位指针高18位不能寻址,染色指针把低46位中的高4位提取出来,存储标志信息。

  • 通过染色指针上的标志,虚拟机就可以直接从指针中看到

    引用对象的三色标记状态

    ,

    是否进入了重分配集

    ,

    是否只能通过finalize()方法才能被访问到

image-20201121201912632

  • 因为染色指针,所以ZGC收集器只存在于Linux下。

染色指针的优点

  1. 一旦某个区域的存活对象移动走后,这个区域就可以被释放和重用 (不用等待更新引用再清理)。
  2. 大幅减少垃圾收集过程中内存屏障的使用数量(无分代,不用解决跨区域的指针问题,省去一部分内存屏障,没有了这部分的开销,所以ZGC的吞吐量也不低)。
  3. 染色指针作为可扩展的存储结构,用来记录更多对象标记,重定位过程相关数据。


(三)多重映射
  • 因为JVM被当作一个进程.处理器只会把整个指针当作一个内存地址来对待。

  • 要解决这个问题需要虚拟映射技术。

  • Linux上的

    ZGC采用多重映射将多个不同的虚拟内存地址同时映射到同一个物理地址, 多对一的映射关系


  • 把染色指针上的标志位看成地址分段符,将这些不同的地址段都映射到同一个物理内存,经过多重映射后,染色指针就可以正常寻址了

image-20201121202253198



(四)执行过程

ZGC大致的执行过程

  1. 并发标记 : (在该阶段前后还有初始标记,终止标记,这里省略)

    从GC Roots直接关联对象开始遍历整个引用链的过程,标记阶段更新染色指针上的Marked0,Marked1标记位

  2. 并发预备重分配 :

    根据特定的查询条件统计出来本次收集过程中需要清理的区域,将这些区域组成重分配集

    • 重分配集不是回收集,ZGC每次回收会扫描所有的区域,使用范围更大的扫描成本换取G1中维护记忆集的成本。


    • 重分配集:只决定了哪些区域的存活对象会被重新复制到其他区域中,重分配集中的区域会被释放

  3. 并发重分配 :

    把重分配集中存活的对象复制到新的区域上,并为每个重分配集的区域维护一个转发表:记录旧对象到新对象的转向关系


    • 指针的自愈: ZGC收集器只从引用上就可以直到这个对象是否处于重分配集中,如果用户线程并发访问重分配集中的对象,此次访问会被内存屏障拦截,然后立即根据转发表记录,将此次访问改为新对象的地址,并同时修改该引用的值,使其直接指向新对象

    • ZGC只访问1次旧对象就能修正,而Shenandoah转发指针只有等并发更新引用完成才修正(这期间可能进行多次访问,开销比ZGC大)。

  4. 并发重映射:

    修正整个重分配集中旧对象的引用,因为有转发表所以不用迫切进行,可以合并到下次垃圾回收的并发标记阶段顺便做了,所有指针被修正后,就可以释放转发表

image-20201121205227138



(五)总结
  • 优点:

    1. 低延迟。
    2. 吞吐量高。
  • 缺点:

    1. 分配对象速率慢,如果ZGC应对分配对象速率高,将创建大量对象,新对象很难进入收集标准范围,所以会产生浮动垃圾,如果高速分配对象一直维持的话,剩余空间就越来越小了。



5.内存分配与回收策略

img

  1. 对象优先在Eden区分配。

  2. 大对象可以直接分配到老年代:

    2.1 新生代不够内存存放大对象。

    2.2 Serial,ParNew收集器中使用

    -XX:PretenureSizeThreshold=?k

    可直接让大于这个数的对象进入老年代。

  3. 长期存活对象进入老年代:

    3.1 对象通常在Eden诞生,经过第一次Minor GC后仍然存活,且能被放入Survive区,则标记它的年龄为1,之后每经历一次Minor GC且能存活下来,年龄+1,满足

    -XX:MaxTenuringThreshold=?

    (默认15)后,将会进入老年代。

    3.2 如果在Survive区,年龄小于或等于A年龄的对象占Survive区的一半,那年龄大于或等于A年龄的对象就可以直接进入老年代。

  4. 空间分配担保:

    • 发生Minor GC前,JVM会检查老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,那这次Minor GC就是安全的,如果不大于就会先看看是否允许担保失败。


    • -XX:HandlePromotionFailure

      是否允许担保失败

    ​ 如果允许,会检查老年代最大连续可用空间是否大于历届晋升到老年代对象的平均大小

    ​ 如果大于则进行有危险的Minor GC(有可能这次晋升到老年代的对象比以往多得多,以至于老年代最大连续可用空间不够,这样就担保失败了,还是会发 生Full GC)。

    ​ 如果小于或

    -XX:HandlePromotionFailure

    不允许担保失败,则直接进行Full GC。

  • 在JDK6 update24后,

    -XX:HandlePromotionFailure

    没用了,

    默认: 只要老年代最大连续可用空间大于新生代所有对象或历届升到老年代的平均大小就进行Minor GC,否则进行 Full GC



6.回收方法区

  • 方法区的垃圾回收主要有两部分:

    不使用的常量和类

  • 回收方法区性价比比较低,因为不使用的常量和类比较少。

不使用的常量


  • 没有任何地方引用常量池中的某常量

    ,则该常量会在垃圾回收时,被收集器回收。

不使用的类

  • 成为不使用的类需要满足以下要求:


    1. 没有该类的任何实例对象


    2. 加载该类的类加载器被回收


    3. 该类对应的Class对象没在任何地方被引用



版权声明:本文为m0_46078443原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。