JVM自动内存管理(二):垃圾收集器与内存分配策略

JVM自动内存管理(二):垃圾收集器与内存分配策略

(一)判断对象是否死亡

引用计数法

  1. 基本思路:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
  2. 主流的JVM没有选用此法来管理内存:意外情况很多(如:对象间的循环引用)、需要大量的额外资源来保证正确。

可达性算法(主流JVM使用)

  1. 思路:通过一系列的“GC Roots”根对象作为起始节点开始,利用引用关系向下搜索,搜索过程中走过的路径称为“引用链”,如果某个对象到GC Roots没有引用链相连,那么这个对象不可达(从图的观点来看),即不可再被使用。

    image-20200216155759094.png

  2. GC Roots对象分类

    1. 虚拟机栈(栈帧中的局部变量表)中引用的对象:各线程中被调用的方法堆栈中使用到的参数局部变量临时变量

    2. 方法区中的类静态属性引用的对象:类的静态属性引用到的对象。

    3. 方法区中常量引用的对象:字符串常量池(堆区)里的引用

    4. 本地方法栈中的JNI引用的对象:Native方法引用的对象

    5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器

    6. 所有被同步锁(synchronized关键字)持有的对象

    7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

    8. 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合:比如分代收集、局部回收。

    上述1-7是一定存在GC Roots集合中的

  3. 引用分类

    在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

    1. 强引用:指在程序代码之中普遍存在的引用赋值,即类似Object obj=new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
    2. 软引用SoftReference类关联的对象。只被软引用关联着的对象,**在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。**在JDK 1.2版之后提供了SoftReference类来实现软引用。
    3. 弱引用WeakReference类关联的对象。弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,**被弱引用关联的对象只能生存到下一次垃圾收集发生为止。**当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
    4. 虚引用PhantomReference类关联的对象。也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。**为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。**在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

对象是否死亡

  1. 可达性分析算法的两次标记

    1. 第一次标记

      进行可达性分析后发现没有与GC Roots相连的引用链:做第一次标记;

      判断该对象是否有必要执行finalize()方法(筛选):如果没有重写finalize()方法或者其finalize()方法已经被JVM调用过,则都会被视为没必要执行;

      有必要执行finalize()方法:放入F-Queue队列,之后由JVM自动建立的低调度优先级Finalizer线程去执行(触发此方法,但不一定等待这个方法执行完毕)他们的finalize()方法;

      不保证finalize()执行结束:如果finalize()方法执行太慢或者发生死循环,很可能导致F-Queue队列中其他对象永久处于等待状态,甚至导致整个内存回收子系统的崩溃。

    2. 第二次标记

      finalize()是对象逃离死亡的最后机会:在上述过程JVM执行finalize()过程中,对象只需要与引用链上的任何一个对象建立关联即可,这样,在执行finalize()后,收集器会对F-Queue中的对象进行第二次小规模标记(执行过、正在执行finalize()方法的对象),如果对象“求生”了,那么在第二次标记时会被移除即将回收的集合中。

  2. 任何一个对象的finalize()方法都只会被系统自动调用一次:如果对象面临下一次回收,它的finalize()方法不会被再次执行。

    public class FinalizeEscapeGC {
        public static FinalizeEscapeGC SAVE_HOOK = null;
        public void isAlive() {
        	System.out.println("yes, i am still alive :)");
        }
        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            System.out.println("finalize method executed!");
            FinalizeEscapeGC.SAVE_HOOK = this;
        }
        public static void main(String[] args) throws Throwable {
            SAVE_HOOK = new FinalizeEscapeGC();
            //对象第一次成功拯救自己
            SAVE_HOOK = null;
            System.gc();
            // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
            Thread.sleep(500);
            if (SAVE_HOOK != null) {
            	SAVE_HOOK.isAlive();
            } else {
            	System.out.println("no, i am dead :(");
            }
            // 下面这段代码与上面的完全相同,但是这次自救却失败了
            SAVE_HOOK = null;
            System.gc();
            // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
            Thread.sleep(500);
            if (SAVE_HOOK != null) {
            	SAVE_HOOK.isAlive();
            } else {
            	System.out.println("no, i am dead :(");
            }
        }
    }
    /****************************************************
    finalize method executed!
    yes, i am still alive :)
    no, i am dead :(
    ******************************************************/
    

    因此第二段代码的自救行动失败了。

  3. 忘掉finalize()方法finalize()方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时。

回收方法区

《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在,而且判定条件十分苛刻,性价比很低。

主要回收两部分内容:废弃常量和不再使用的类型

  • 废弃常量:如字符串常量池中不再使用的字符串对象;

  • 不再使用的类型:判断该类所有的实例都已回收(包含派生子类)+加载该类的类加载器已经被回收(十分困难,除非设计好了可以替换的类加载器)+没有任何地方引用了该类对应的java.lang.Class对象(无法在任何地方通过反射访问该类)。

    JVM只是被允许回收满足这三个条件的无用类,但不是必然会收(可以用参数设置),这个特性被广泛用于大量使用反射、动态代理、CGLib等字节码框架。

(二)垃圾收集算法

分类

  1. 引用计数式垃圾收集:很少使用
  2. 追踪式垃圾收集

分代收集

  1. 两个分代假说

    • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。(绝大多数用完就扔)
    • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。(老油条)

    这两个假说支持垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

  2. Java堆划分

    1. 新生代(Young Generation):每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

    2. 老年代(Old Generation)

    3. 几种收集方式

      • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
      • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。(除了CMS收集器,其他都不允许只收集老年代)
      • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
      • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
    4. 问题对象不是孤立的,对象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。

      解决思路

      • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
      • 在新生代上建立一个全局的数据结构(“记忆集”,Remembered Set):这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
  3. 标记-清除算法

    1. 最古の、最基础の垃圾收集算法(思路):首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。

    2. 缺点

      • 执行效率不稳定(主):堆中如果有大量对象且大部分需要回收,那么会进行大量的标记和清除操作。
      • 内存空间碎片化(主):标记、清除后,会有大量不连续的内存,可能导致之后大对象分配内存却得到内存不足的反馈,导致进行第二次GC。
      • 停顿用户线程(次):标记和清理时需要停顿用户线程,但是时间很短。

      image-20200218181713735.png

    3. 应用关注延迟(特指GC过程的耗时)的CMS收集器,当碎片过多时,CMS采用标记-整理算法。

  4. 标记-复制算法

    解决标记-清除算法面对大量可回收对象时执行效率低的问题

    1. 思路:半区复制,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

      如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销(复制内存后需要重新指定引用地址?),但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可

    2. 缺点:可用内存缩小为了原来的一半,空间浪费未免太多了一点;标记和复制时需要停顿用户线程,但是时间很短。

      image-20200218183635457.png

    3. 应用:大多数垃圾收集器用这个算法进行Minor GC

    4. Appel式回收:IBM研究过,98%的新生代对象熬不过第一轮GC。现在新生代收集器(HotSpot默认采用的方式)均采用Appel式回收:

      • 把新生代分为一块较大的Eden空间两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生GC时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

        新生代.jpg

      • HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,只有一个Survivor空间(10%的新生代空间)会被浪费。

      • 当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保,实际上是这片内存中的对象直接进入老年代

  5. 标记-整理算法

    基于最基础的标记-清除算法,针对老年代对象的存亡特性设计(本质区别是是否是移动式回收)。

    1. 思路:标记过程和标记-清除算法一样,让所有存活的对象都向内存空间的一端移动,然后清理掉边界以外的内存。

      image-20200218185648440.png

    2. 缺点:老年代有大量的存活对象,移动起来负担很大,且必须全程暂停用户程序。

      从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量(赋值器(用户程序、用户线程)和收集器的效率总和)来看,移动对象会更划算。

      即使不移动对象会使得GC的效率提升一些,但因内存分配和访问相比GC频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。

    3. 应用:HotSpot的关注吞吐量的Parallel Scavenge收集器。

(三)HotSpot算法具体实现

枚举根节点

  • 使用可达性分析算法,需要从GC Roots开始搜索,也即遍历“根节点”,固定的GC Roots主要在全局引用(例如常量或类的静态属性)与执行上下文中(例如栈帧中的本地变量表)。

  • 枚举根节点必须暂停用户线程:一致性原则:在枚举过程中,根节点集合的引用对象关系不能够发生更改。否则无法保证分析的准确性。

  • 并不需要检查完所有执行上下文和全局引用

    HotSpot虚拟机使用一组称为OopMap的数据结构(专门存储对象引用信息,Ordinary Object Pointer,OOP),一旦类加载完成,HotSpot会计算对象内的什么偏移量是什么类型的数据,在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。OopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。在即时编译过程中,也会在特定位置记录下里的和寄存器里哪些位置是引用。

    OopMap

    OopMap 记录了栈上本地变量到堆上对象的引用关系。其作用是:垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。垃圾回收时,如果扫描整个栈,开销很大,因此出现了OopMap,以空间换时间的举措。

    Class Person{
    	String name;/*类加载后在栈中name类型可以由OopMap确定,整个Person的引用也全部存储到OopMap中*/
        int age;
    }/*比如在这里如果方法体内只有Person数组,那么Person数组就将作为GC Roots,之后向下查找引用链*/
    

    一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。GC 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。

安全点(在何时何处停下用户程序进行GC)

  • 用户程序执行时并非在代码指令流的任意位置都能停顿下来收集垃圾:为每一条指令都生成OopMap代价很大,因此只会在特定位置——安全点记录这些信息,只要到达安全点后才能够暂停用户程序。

  • 安全点位置选取:以“是否具有让程序长时间执行的特征”为标准进行选定的,特征主要是指令序列的复用(例如方法调用、循环跳转、异常跳转等都属于指令序列复用),只有在这些地方才会产生安全点。

  • GC时让所有线程都跑到最近的安全点

    1. 抢占式中断(Preemptive Suspension)

      不需要线程执行代码主动配合,GC时系统首先中断全部线程,如果发现有线程不在安全点上,则让其继续执行到跑到安全点上。(几乎没有虚拟机这么做)

    2. 主动式中断(Voluntary Suspension)

      GC需要中断线程的时候,不直接操作线程,而是设置一个标志位,各个线程执行时不断去轮询这个标志(轮询的地方与安全点重合),一旦标志位为true时,就在最近的安全点主动中断挂起。

      为了保证轮询的高效,HotSpot使用内存保护陷阱的方式(自陷异常),把轮询精简为一条汇编指令

  • 线程除了轮询外,还需要检查所有创建对象等需要在Java堆上分配内存的地方所需要的内存:这是为了确定是否即将发生GC,以免没有足够内存进行对象分配。

  • 弊端:程序“不执行”(比如没有分配到时间片、用户线程处于Sleep或Blocked状态)

安全区域

  • 解决安全点程序不执行的弊端
    1. 概念:安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的,可以看作是安全点扩展为一个区间。
    2. 过程:用户线程执行到安全区域内的代码时,首先标识自己进入了安全区,如果此时需要GC,则不用管这些线程;线程即将离开安全区时,检查JVM是否完成了根节点的枚举(或者GC过程中其他需要暂停用户线程的阶段),如果完成了,则线程继续执行,否则在安全区中一直等待,直到JVM发出可以离开安全区的信号为止。

记忆集与卡表(检查跨代引用)

  1. 记忆集

    • 目的:用于解决Minor GC时的对象跨代引用(不只是新生代、老年代才有跨代引用,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题)。

      记忆集用于处理这类问题:比如说,Minor GC (它发生得非常频繁)。一般来说, GC 过程是这样的:首先枚举根节点。**根节点有可能在新生代中,也有可能在老年代中。**GC在新生代中建立了记忆集,避免把整个老年代加入GC Roots的扫描范围。

      “老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是记忆集。所以“新生代的 GC Roots ” + “ 记忆集存储的内容”,才是新生代收集时真正的 GC Roots 。

    • 概念:一种用于记录从非收集区域(某个专属内存区域)指向收集区域的指针集合的抽象数据结构。

       Class RememberedSet {
      	Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
       }/*最简单的实现,直接记住所有的引用*/
      

      可选择的精度如下:

      • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数),该字包含跨代指针。
      • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
      • 卡精度(卡表):每个记录精确到一块内存区域,该区域内有对象含有跨代指针。(最粗粒度)
    • 卡表:记忆集的一种具体实现。卡表可以是一个简单的字节数组

       byte CARD_TABLE [this address >> 9] = 0;
      /*字节数组每个元素都对应其在内存中的一块特定大小的内存块(卡页,大小都是2^n),说白了就是bitmap的思想,在这里卡页大小是2^9=512 byte*/
       /*一个卡页中只要包含了至少一个存在着跨代引用指针的对象,对应的卡表元素值就为1。*/
      

      HotSpot的记忆集合的简要图示如下。

      记忆集合实际上就是内存空间的粗粒度的位图表示。它其中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在GC时,只需要扫描记忆集合就可以简单地确定跨代引用的位置,把他们加入GC Roots中一并扫描。是个典型的空间换时间的思路。

写屏障

  • 解决卡表元素的维护问题

    1. 卡表变脏时机:跨代引用(其他引用本代),本代对应区域的卡表变脏,时间点原则上是引用字段赋值的那一刻。

    2. 写屏障:可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面

      在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。(除了G1收集器以外,HotSpot虚拟机的其他收集器都只用到了写后屏障)

      void oop_field_store(oop* field, oop new_value) {
          // 引用字段赋值操作
          *field = new_value;
          // 写后屏障,在这里完成卡表状态更新
          post_write_barrier(field, new_value);
      }
      
    3. 伪共享问题:Cache以行进行存储,多线程之间修改互相独立的变量时,如果恰好共享同一个缓存行,会导致性能降低。避免伪共享的主要思路就是让不相干的变量不要出现在同一个缓存行中。

      性能降低

      1. 写回:优先写入Cache。如果没写过则直接写进Cache,并标此行为“脏”;如果写过,则先将Cache行写入内存,再写入Cache,最后标记为“脏”。

      2. 写失效(同步):要写的时候,cache映射的块不对应,需要将内存中的数据先调入cache再写(写分配),或者直接写内存不写cache(写不分配)。

      如果若干个卡表元素刚好共享一个Cache行,那么如果某个线程存在跨代引用的对象某个卡页,而且其他线程刚好也有存在跨代引用的对象这个卡页中,就会导致更新卡表的同一个位置(可能之前已经是“脏”状态),因此可以考虑更新卡表前加一个判断:是否标记过,只有未标记过才会使其变“脏”。(在这里似乎只会因为cache的“写回”策略导致性能降低

      HotSpot有-XX:+UseCondCardMark参数(JDK7之后),可以选择是否开启条件判断。

并发可达性分析(如果不并发,则堆越大,GC时间越长,并发就可使得GC的同时允许用户程序继续运行)

  1. GC扫描时的“对象消失”问题

    • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

    • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

    • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

      image-20200301193655544.png

      (可以看作是海浪)

    可以证明:对象消失(黑色误认为成白色)只有同时满足两个条件才可能发生

    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;(已经扫描完的对象又被加了新的引用)
    • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。(未扫描的对象不可达了,灰色不能变成黑色)

    两种解决方案(通过写屏障实现):

    1. 增量更新:破坏第一个条件,插入新的引用时,记录下新引用,在并发扫描结束后,再从黑色对象重新扫描。(插入新引用后,黑色对象变为灰色对象)
    2. 原始快照:破坏第二个条件,删除时,记录删除的引用,在并发扫描结束后,以灰色对象为根,重新扫描一次。(保证灰色对象能变成黑色对象)

(四)经典垃圾收集器

image-20200301195325570.png

上述为HotSpot采用的垃圾收集器,连线表示收集器可以搭配使用。

Serial收集器

  1. 特点

    • 新生代收集器
    • 标记-复制算法
  • 该收集器仅使用单线程,且进行垃圾收集时,必须暂停其他所有工作线程

    • 所有收集器中额外内存开销最小的,不需要多线程交互,适合虚拟机内存小的情况(客户端)

    image-20200301195822739.png

ParNew收集器

  1. 特点

    • Serial收集器的多线程并行(垃圾收集线程之间并行,用户线程处于等待状态)版本,新生代收集器
    • 标记-复制算法
  • 除了Serial收集器外,目前只有它能与CMS收集器(支持并发老年代垃圾收集器)配合工作。

    image-20200301200343549.png

Parallel Scavenge收集器

  1. 特点:

    • 新生代收集器

    • 多线程并行收集

    • 标记-复制算法

    • 与CMS专注于尽可能短的用户线程停顿时间不同,他专注于到达一个可控的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)),适用于不太需要交互的任务

      提供的几个参数可以设置:

      1. 最大垃圾收集停顿时间
      2. 吞吐量大小(1-99的整数)
      3. 自适应调节模式:这种设置下,不需要人工指定新生代大小、Eden和Survivor区的比例、晋升老年代对象大小等。

Serial Old收集器

  1. 特点:
    • Serial收集器的老年代版本
    • 单线程收集器
    • 标记-整理算法
    • 客户端适用

Parallel Old收集器

  1. 特点:

    • Parallel Scavenge收集器的老年代版本
    • 多线程并行收集
    • 标记-整理算法

    8erhrD.png

CMS收集器

  1. 特点:

    • 标记-清除算法
    • 多线程并行收集
    • 以最短回收停顿时间为目标
    • 内存回收速度赶不上内存分配速度时,冻结用户线程,进行Full GC。
    • 使用写后屏障维护卡表
  2. 过程:

    • 初始标记:标记一下GCRoots能直接关联到的对象,速度很快,但是需要停顿用户线程
    • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
    • 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(CMS采用增量更新),这个阶段的停顿时间通常会比初始标记阶段稍长,但远比并发标记时间短;
    • 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

    image-20200303184925772.png

    图中的初始标记重新标记箭头的起始和结尾的safepoint是同一点

  3. 缺点:

    • CMS收集器对处理器资源非常敏感,并发阶段会降低总吞吐量
    • 无法处理“浮动垃圾”(并发标记、并发清除阶段用户程序产生的新垃圾)
    • 因为并发需求,不能等到老年代几乎填满再开始收集,需要设置一个老年代填充比例
    • 标记-清除算法会产生大量内存碎片

Garbage First收集器(G1)

  1. 特点:

    • 面向服务端应用

    • 多线程并行收集

    • 整体来看使用了标记-整理算法,局部来看使用了标记-复制算法(两个或多个Region构成回收集时)

    • 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

    • Mixed GC模式:可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。

      1. Region:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。Region大小可以通过参数设置(只允许设置为2的N次幂)
      2. Humongous RegionRegion中的一类专用于存储大对象的区域,只要超过Region容量的一半则判定为大对象,可以看作是老年代的一部分

      image-20200303195623170.png

      图中的E代表Eden,S代表Survivor,H代表超大对象区域

    • G1收集器会按照优先级回收Region,各个Region会按照回收所获得的空间大小以及回收所需时间的一个比例区分回收的“价值”。

    • 用户可以指定G1收集器的期望停顿时间

    • 使用写后屏障维护卡表、写前屏障跟踪并发时指针变化(所以需要额外的一次标记)

    • 内存回收速度赶不上内存分配速度时,冻结用户线程,进行Full GC。

  2. 具体设计实现

    • 跨Region间的引用:为每个Region维护他的记忆集,G1采用双向卡表,这会导致G1收集器占用内存较高,G1至少要耗费大约相当于Java堆容量10%至20%的额
      外内存来维持收集器工作。
    • 保证并发标记阶段下收集线程和用户线程互不干扰
      1. 用户线程改变引用关系时,G1采用原始快照算法实现。
      2. 用户线程创建新对象,G1为每个Region设计两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
    • 建立满足可靠的停顿预测模型:以衰减均值(Decaying Average)为理论基础来实现,垃圾收集过程中记录每个Region回收耗时、每个Region记忆集中的脏卡数量等各个可测量的步骤花费的成本,分析得到平均值、标准差、置信度等信息,得到的值比普通的全局平均值对于近期的状态更接近平均。
  3. 运行过程

    • 初始标记需要停顿用户线程,标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,保证用户线程下一阶段能正确地在可用的Region中分配新对象。
    • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。
    • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
    • 筛选回收需要暂停用户线程,多条收集器线程并行完成,更新Region的统计数据,对各个Region回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间(标记-复制算法)。

    image-20200303202609563.png

  4. 缺点

    • 内存占用比CMS还高:卡表更加复杂,而且所有的Region都有双向卡表
    • 写屏障操作太复杂:采用类似消息队列的结构,把写屏障的操作放到队列中,异步处理

(五)低延迟垃圾收集器

衡量垃圾收集器的三大最重要的指标:

  1. 内存占用(Footprint)
  2. 吞吐量(Throughput)
  3. 延迟(Latency)

一个收集器往往不能同时做好这三个方面的要求

image-20200303204008894.png

浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的

Shenandoah收集器

  1. 特点:

    • 只有免费开源版的openJDK才包含此收集器

    • 基于G1收集器的Region堆内存布局(RegionHumongous Region),默认回收性价比最高的Region

    • 支持并发(回收阶段和用户线程并发)的整理算法

    • 默认不使用分代收集

    • 摒弃G1中的记忆集结构,改用“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗和伪共享发生率。

      image-20200304125121102.png

  2. 过程

    • 初始标记需要停顿用户线程,与G1一样,首先标记与GC Roots直接关联的对象,时间只与GC Roots数量有关。
    • 并发标记与用户线程并发,与G1一样,遍历对象图,标记出全部可达的对象,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
    • 最终标记需要短暂停顿用户线程,与G1一样,处理剩余的SATB(原始快照)扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。
    • 并发清理:用于当前阶段下没有存活对象的Region
    • 并发回收:先把回收集里面的存活对象复制一份到其他未被使用的Region之中,通过读屏障和“Brooks Pointers”转发指针来解决和用户线程并发造成的对象操作问题,时间长短取决于回收集的大小。
    • 初始引用更新需要短暂停顿用户线程,初始引用更新阶段是为了建立一个线程集合点确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务(等待所有的收集器线程完成他们的回收任务)。
    • 并发引用更新:并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址与用户线程一起并发,时间长短取决于内存中涉及的引用数量的多少
    • 最终引用更新:修正存在于GC Roots中的引用。
    • 并发清理:经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

    其实主要就只有三个重要阶段:并发标记、并发回收、并发引用更新。

    image-20200304153123789.png

    黄色的横长条区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。

  3. 转发指针与读屏障

    • 在没有这项技术之前:通常是在被移动对象原有的内存上设置保护陷阱(MemoryProtection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到内核态,代价是非常大的,不能频繁使用。

    • Brooks Pointers

      在原有对象布局结构的最前面(对象头前)统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。

      image-20200304154957853.png

      存在并发写入的问题

      1. 收集器线程复制了新的对象副本;
      2. 用户线程更新对象的某个字段;
      3. 收集器线程更新转发指针的引用值为新副本地址。

      如果事件2发生在事件1、3之间的话,用户线程更新的将是旧对象的值。

      8er0rF.png

      Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性。这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。

    • 读屏障:由于读操作发生频率比写操作高得多,因此垃圾收集器的开销会很大,考虑采用引用访问屏障(内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写)来替代读屏障。

ZGC收集器

  1. 特点:

    • 由Oracle研发,也适用于OpenJDK(11之后)

    • 基于Region内存布局(PageZPage

      ZGC的Region具有动态性(动态创建、动态销毁、动态容量:

      小(2MB,存小于256KB对象)

      中(32MB,存大于等于256KB但小于4MB的对象)

      大(容量不固定,为2MB的整数倍,放置4MB及以上的大对象,但只放这一个对象,且不会重分配))

      image-20200305133659686.png

    • 暂不设置分代

    • 读屏障、染色质真和内存多重映射技术

    • 可并发、标记-整理算法

  2. 并发算法的实现——染色指针

    • 问题:如果对象可能会移动,如何标识这个对象是否移动过?或者在追踪式垃圾收集算法的对象标记阶段中如何标识这个对象是否存活?HotSpot虚拟机的几种收集器的实现方案如下:

      1. 把标记直接记录在对象头上(如Serial收集器)
      2. 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息)
      3. 直接把标记信息记在引用对象的指针上(ZGC染色指针)
    • 原理:实际上现在的64位系统,还用不完所有的内存地址(实际的寻址空间只到了46位即64TB),所以可以利用这个空缺,设置一些标志位。Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。image-20200305140519522.png

      可以通过这些标志位来标识三色标记状态、是否进入了重分配集(被移动过)、是否只能通过finalize()方法才能访问到,也因此,ZGC能够管理的内存不超过4TB(2^42),染色指针技术不支持32位平台,不支持指针压缩。

      染色指针在不同的平台上有不同的实现:

      1. 在Solaris/SPARC平台上,SPARC硬件层面本身就支持虚拟地址掩码,设置之后其机器指令直接就可以忽略掉染色指针中的标志位。

      2. x86/64平台上,可以通过多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了。

        image-20200305153251822.png

    • 优势

      1. 一旦某个Region的存活对象被移走后,可以立即释放、重用这个Region
      2. 没有用到写屏障,只使用了读屏障
  3. 过程

    image-20200305154655360.png

    • 并发标记:与G1相同,是遍历对象图做可达性分析的阶段,但ZGC的标记是在指针上,而不是在对象上进行,标记阶段会更新Mark0、Mark1标志位。

    • 并发预备重分配:根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set),重分配集决定了里面的存活对象会被重新复制到其他的Region中ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

    • 并发重分配:把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系

      ZGC仅从引用就能得知对象是否处于重分配集中,如果用户线程并发访问了该对象,就会被内存屏障捕获,根据Region上的转发表记录访问转到新复制的对象上,并更新该引用的值,使其直接指向新对象。(称为指针的自愈)比起Shenandoah收集器,这种方式只会在自愈的时候有额外开销,而不是每次都要多跳转一次。

    • 并发重映射是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,目的只是为了不变慢,因为清理结束后还要释放转发表,这个阶段被合并到了下一轮并发标记阶段(只要访问了便能自愈)。

  4. 缺点

    ZGC无法应对很高的对象分配速率,因为并发的时候,新对象大都被标记为存活对象,而大多数对象都是朝生夕灭的,就会有大量的浮动垃圾。(引入分代收集后可以解决这个问题)

(六)如何选择合适的垃圾收集器

Epsilon收集器(不收集垃圾的垃圾收集器)

  1. 自动内存管理子系统:一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。

收集器权衡因素

  1. 开发什么样的应用
  2. 运行环境(硬件)
  3. JDK版本
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值