JVM-垃圾回收理论、分代算法、垃圾收集器

  垃圾收集器顾名思义是负责回收不再使用的对象。
  回收关键步骤:
  1.找到无用对象;
  2.回收无用对象,释放内存。

如何判断对象需要回收?

  我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收,那么 JVM 是如何判断哪些对象已经是“无用对象”的呢?

  这里有两种判断方式,引用计数法可达性分析算法

引用计数法

  引用计数法的判断标准是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,但在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题

  循环引用问题是两个对象之间互相依赖着对方,除此之外,再无其他引用,虚拟机无法判断对象引用是否为零从而进行垃圾回收操作。

可达性分析算法

  当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。这种引用方式如下:在这里插入图片描述  如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可达的,所以被认为是可以回收的对象。

  在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有:

  1. 方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量;
  2. 方法区中常量引用的对象,比如字符串常量池中的引用;
  3. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  4. 本地方法栈中 JNI 引用的对象
  5. JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些异常对象比如 NullPointerException、OutOfMemoryError 等,还有系统类加载器;
  6. 所有被 synchronized 持有的对象
  7. 还有一些 JVM 内部的比如 JMXBean、JVMTI 中注册的回调,本地代码缓存等;
  8. 根据用户所选的垃圾收集器以及当前回收的内存区域的不同,还可能会有一些对象临时加入,共同构成 GC Roots 集合。

  基于可达性分析法的 GC 垃圾回收的效率较高,实现起来比较简单(引用计算法是算法简单,实现较难),但是其缺点在于 GC 期间,整个应用需要被挂起(STW,Stop-the-world,下同),后面很多此类算法的提出,都是在解决这个问题(缩小 STW 时间)。

  虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用这一层关系。

  这里涉及到到强引用、软引用、弱引用、虚引用的引用关系。

  • 强引用:强引用就是指在程序代码之中普遍存在的,类似“Object obj-new Object( )”这类的引用只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用:软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SofReference类来实现软引用。
  • 弱引用:弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
    例如:java.lang.ThreadLocal.ThreadLocalMap.Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
	/** The value associated with this ThreadLocal. */
	Object value;

	Entry(ThreadLocal<?> k, Object v) {
		super(k);
		value = v;
}
  • 虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

可达性分析算法中不可达的对象,一定会被回收吗?

如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,对象一定会被回收吗?

答案:不一定。

要真正宣告一个对象死亡,至少要经历两次标记过程:

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选筛选的条件是此对象是否有必要执行finalize ( ) 方法。当对象没有覆盖finalize ( ) 方法,或者finalize ( ) 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize ( )方法,那么这个对象将会放置在一个叫做F-Oueue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize ( ) 方法中执行缓慢,或者发生了死循环( 更极端的情况 ),将很可能会导致F-Oueue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

fnalize()方法是对象逃脱死亡命运的最后一次机会,后GC将对F-Oueue中的对象进行第二次小规模的标记,如果对象要在finalize ( ) 中成功拯救自己只要重新与引用链上的任何一个对象建立关联即可,譬如把自己( this关键字 )赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合:如果对象这时候还没有逃脱,那基本上它就真的被回收了。

看看finalize方法实战:

public class FinalizeEscapeGCTest {

    public static void main(String[] args) throws InterruptedException {
        FinalizeObject.SAVE_HOOK = new FinalizeObject();

        //触发gc,对象第一次拯救自己
        FinalizeObject.SAVE_HOOK=null;
        System.gc();
        //finalize方法优先级很低,暂停等待一会
        Thread.sleep(500);
        if (Objects.nonNull(FinalizeObject.SAVE_HOOK)){
            System.out.println("object is alive!");
        }else {
            System.out.println("object is dead!");
        }

        //第二次gc
        //finalize方法只能执行一次,第二次gc时不再执行finalize方法,对象会被回收
        FinalizeObject.SAVE_HOOK=null;
        System.gc();
        //finalize方法优先级很低,暂停等待一会
        Thread.sleep(500);
        if (Objects.nonNull(FinalizeObject.SAVE_HOOK)){
            System.out.println("object is alive!");
        }else {
            System.out.println("object is dead!");
        }
    }

    public static class FinalizeObject {
        public static FinalizeObject SAVE_HOOK = null;

        @Override
        protected void finalize() throws Throwable {
            super.finalize();

            SAVE_HOOK = this;
            System.out.println("execute finalize()");
        }
    }
}

结果输出:

execute finalize()
object is alive!
object is dead!

第一次执行GC时,finalize方法确实执行了,对象也成功逃脱了GC回收。第二次执行GC时,finalize方法没有再执行,对象被回收了。

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

JVM 分代收集理论

  一般商业的虚拟机,大多数都遵循了分代收集的设计思想,分代收集理论主要有两条假说。

  • 第一个是强分代假说,强分代假说指的是 JVM 认为绝大多数对象的生存周期都是朝生夕灭的;
  • 第二个是弱分代假说,弱分代假说指的是只要熬过越多次垃圾收集过程的对象就越难以回收。

  就是基于这两个假说理论,JVM 将堆区划分为不同的区域,再将需要回收的对象根据其熬过垃圾回收的次数分配到不同的区域中存储。

  JVM 根据这两条分代收集理论,把堆区划分为新生代(Young Generation)和老年代(Old Generation)这两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,剩下没有死去的对象会直接晋升到老年代中。

  上面这两个假说没有考虑对象的引用关系,而事实情况是,对象之间会存在引用关系,基于此又诞生了第三个假说,即跨代引用假说(Intergeneration Reference Hypothesis),跨代引用相比较同代引用来说仅占少数。

  正常来说存在相互引用的两个对象应该是同生共死的,不过也会存在特例,如果一个新生代对象跨代引用了一个老年代的对象,那么垃圾回收的时候就不会回收这个新生代对象,更不会回收老年代对象,然后这个新生代对象熬过一次垃圾回收进入到老年代中,这时候跨代引用才会消除。

  根据跨代引用假说,我们不需要因为老年代中存在少量跨代引用就去直接扫描整个老年代,也不用在老年代中维护一个列表记录有哪些跨代引用,实际上,可以直接在新生代中维护一个记忆集(Remembered Set),由这个记忆集把老年代划分称为若干小块,标识出老年代的哪一块会存在跨代引用。记忆集的图示如下:在这里插入图片描述  从图中我们可以看到,记忆集中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在垃圾回收时,只需要扫描记忆集就可以简单地确定跨代引用的位置,是个典型的空间换时间的思路。

JVM 中的垃圾回收算法

  在聊具体的垃圾回收算法之前,需要明确一点,哪些对象需要被垃圾收集器进行回收?也就是说需要先判断哪些对象是“垃圾”?

  判断的标准在上面如何判断对象已经死亡的问题中描述了,有两种方式,一种是引用计数法,这种判断标准就是给对象添加一个引用计数器,引用这个对象会使计数器的值 + 1,引用失效后,计数器的值就会 -1。但是这种技术无法解决对象之间的循环引用问题。

  还有一种方式是 GC Roots,GC Roots 这种方式是以 Root 根节点为核心,逐步向下搜索每个对象的引用,搜索走过的路径被称为引用链,如果搜索过后这个对象不存在引用链,那么这个对象就是无用对象,可以被回收。GC Roots 可以解决循环引用问题,所以一般 JVM 都采用的是这种方式。

  基于 GC Roots 的这种思想,发展出了很多垃圾回收算法,下面我们就来聊一聊这些算法。

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

  标记-清除(Mark-Sweep)算法可以说是最早最基础的算法了,标记-清除顾名思义分为两个阶段,即标记和清除阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记存活的对象,回收未被标记的对象。这个标记的过程就是垃圾判定的过程。

  标记阶段是把所有活动对象都做上标记的阶段,有对象头标记位图标记(bitmap marking) 这两种方式,后者可以与写时复制技术(copy-on-write)相兼容。清除阶段是把那些没有标记的对象,也就是非活动对象回收的阶段,回收时会把对象作为分块,连接到被称为空闲链表(free-lis)的链表中去。

  清除操作并不总是在标记阶段结束后就全部完成的,一种延迟清除(Lazy Sweep)的算法可以缩减因清除操作导致的应用 STW 时间。延迟清除算法不是一下遍历整个堆(清除所花费的时间与堆大小成正比),它只在分配对象时执行必要的堆遍历,同时其算法复杂度只与活动对象集的大小成正比。

  后续大部分垃圾回收算法都是基于标记-清除算法思想衍生的,只不过后续的算法弥补了标记-清除算法的缺点,那么它有什么缺点呢?

  主要有两个缺点:

  • 执行效率不稳定,因为假如说堆中存在大量无用对象,而且大部分需要回收的情况下,这时必须进行大量的标记和清除,导致标记和清除这两个过程的执行效率随对象的数量增长而降低;
  • 内存碎片化,标记-清除算法会在堆区产生大量不连续的内存碎片。碎片太多会导致在分配大对象时没有足够的空间,不得不进行一次垃圾回收操作;具有引用关系的对象可能会被分配在堆中较远的位置,这会增加程序访问所需的时间,即访问的局部性(Locality)较差。

  标记算法的示意图如下:在这里插入图片描述

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

  标记-压缩算法是在标记-清除算法的基础上,用「压缩」取代了「清除」这个回收过程,如下图所示,GC 将已标记并处于活动状态的对象移动到了内存区域的起始端,然后清理掉了端边界之外的内存空间在这里插入图片描述  压缩阶段需要重新安排可达对象的空间位置(reloacate)以及对移动后的对象引用重定向(remap),这两个过程都需要搜索数次堆来实现,因此会增加 GC 暂停的时间。标记-压缩算法的好处是显而易见的:在进行这种压缩操作之后,新对象的分配会变得非常方便——通过指针碰撞即可实现。与此同时,因为 GC 总是知道可用空间的位置,因此也不会带来碎片的问题

标记-复制算法(Mark-Copy)

  标记-复制算法与标记-压缩算法非常相似,因为它们会对活动对象重新分配(reloacate)空间位置。两个算法区别是:在标记-复制算法中,reloacate 目标是一个不同的内存区域。

  由于标记-清除算法极易产生内存碎片,研究人员提出了标记-复制算法,标记-复制算法也可以简称为复制算法,复制算法是一种半区复制,它会将内存大小划分为相等的两块,每次只使用其中的一块,用完一块再用另外一块,然后再把用过的一块进行清除。虽然解决了部分内存碎片的问题,但是复制算法也带来了新的问题,即复制开销,不过这种开销是可以降低的,如果内存中大多数对象是无用对象,那么就可以把少数的存活对象进行复制,再回收无用的对象。

  不过复制算法的缺陷也是显而易见的,那就是内存空间缩小为原来的一半,空间浪费太明显。

  标记-复制算法示意图如下:在这里插入图片描述  现在 Java 虚拟机大多数都是用了这种算法来回收新生代,因为经过研究表明,新生代对象 98% 都熬不过第一轮收集,因此不需要按照 1 :1 的比例来划分新生代的内存空间。

  基于此,研究人员提出了一种 Appel 式回收,Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块 Survivor 空间,每次分配内存都只使用 Eden 和其中的一块 Survivor 空间,发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已使用过的 Survivor 空间。

  在主流的 HotSpot 虚拟机中,默认的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,所以会浪费掉 10% 的空间。这个 8:1 只是一个理论值,也就是说,不能保证每次都有不超过 10% 的对象存活,所以,当进行垃圾回收后如果 Survivor 容纳不了可存活的对象后,就需要其他内存空间来进行帮助,这种方式就叫做内存担保(Handle Promotion) ,通常情况下,作为担保的是老年代。

分代算法(Generational GC)

  分代算法算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

  而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记一清理”或者“标记 整理”算法来进行回收。

  分代算法对基础算法的改进主要体现在该算法减小了 GC 的作用范围。如前所述,标记过程和对象的 reloacate 过程都需要完全停止应用程序进行堆搜索,堆空间越大,进行垃圾回收所需的时间就越长,如果 GC 的堆空间变小,应用暂停时间也会相应地降低。

分代算法把对象分类成几代,针对不同的代使用不同的 GC 算法:

  • 刚生成的对象称为新生代对象,对新对象执行的 GC 称为新生代 GC(minor GC);
  • 到达一定年龄的对象则称为老年代对象,面向老年代对象的 GC 称为老年代 GC(major GC

新生代对象转为为老年代对象的情况称为晋升(promotion)。

注:代数并不是划分的越多越好,虽然按照分代假说,如果分代数越多,最后抵达老年代的对象就越少,在老年代对象上消耗的垃圾回收的时间就越少,但分代数增多会带来其他的开销,综合来看,代数划分为2 代或者 3 代是最好的。

  在经过新生代 GC 而晋升的对象把老年代空间填满之前,老年代 GC 都不会被执行。因此,老年代 GC 的执行频率要比新生代 GC 低。通过使用分代垃圾回收,可以改善 GC 所花费的时间(吞吐量)。

如何标记代际之间的引用关系?

  分代算法引入,需要考虑跨代/区之间对象引用的变化情况。新生代对象不只会被根对象和新生代里的对象引用,也可能被老年代对象引用,GC 算法需要做到「在不回收老年代对象的同时,安全地回收新生代里面的对象」,新生代回收时,不适合也不可能去扫描整个老年代(变相搜索堆中的所有对象),否则就失去了对堆空间进行分代的意义了。在这里插入图片描述  解决上述引用问题的关键是引入写屏障:如果一个老年代的引用指向了一个新生代的对象,就会触发写屏障。写屏障执行过程的伪代码如下所示,其中参数 obj 的成员变量为 field,field变量将要被更新为 new_val 所指向的对象,记录集 remembered_sets 被用于记录从老年代对象到新生代对象的引用,新生代 GC 时将会把记录集视为 GC Roots 的一部分。

write_barrier(obj, field, new_obj){
    if(obj.old == TRUE && 
    	new_val.young == TRUE && 
    	obj.remembered == FALSE){
        remembered_sets[rs_index] = obj
        rs_index++
        obj.remembered = TRUE
    }
   *field = new_val
}

  在写入屏障里,首先会判断:

  • 发出引用的对象(obj)是不是老年代对象;
  • 目标引用标对象(new_val)是不是新生代对象;
  • 发出引用的对象是否还没有加入记录集。

  如果满足以上三点,则本次新建的引用关系中,老年代的对象会被加入到记录集。上述过程可能会带来「浮动垃圾」,原因是所有由老年代->新生代的引用都会被加入记录集,但老年代内对象的存活性,只有在下一次老年代GC 时才知道。

  分代算法的优点在于减小了 GC 的作用范围后带来的高吞吐,但与此同时我们需要注意的是,其假说「绝大多数对象都是朝生夕灭的」并不适用于所有程序,在某些应用中,对象会活得很久,如果在这样的场景下使用分代算法,老年代的 GC 就会很频繁,反而降低了 GC 的吞吐。此外,由于在记录代际引用关系时引入了写屏障,这也会带来一定的性能开销。

增量算法(Incremental GC)

  增量算法对基础算法的改进主要体现在该算法通过并发的方式,降低了 STW 的时间。下图是增量算法和基础的标记-清除算法在执行时间线上的对比,可以看到,增量算法的核心思想是:通过 GC 和应用程序交替执行的方式,来控制应用程序的最大暂停时间在这里插入图片描述  增量算法的「增量」部分,主要有「增量更新(Incremental Update)」和「增量拷贝(Incremental Copying)」两种,前者主要是做「标记」增量,后者是在做「复制」增量。

  增量更新(Incremental Update)我们已经比较熟悉了,在介绍读/写屏障的时候,我们提到过由于存在并发,会出现对象漏标的情况。同样的,在增量算法中,由于 GC 线程和应用线程是交替执行的,也会出现黑色节点指向白色节点的情况(黑色、白色节点,下面三色标记法介绍),增量算法中的漏标,同样是通过写屏障来解决的。

  增量拷贝(Incremental Copying)大部分逻辑与标记-复制算法相似,还是会通过遍历引用关系图,把所有引用的对象拷贝到另一半堆内存,不过这个过程是并发执行的。当应用程序访问到老的堆空间对象时,会触发读屏障,对象会从老的空间被拷贝至新的堆空间。

  增量算法中大量使用了读写屏障(主要是写屏障),给应用程序带来了负担,结果就是 GC 的吞吐相较于其他的算法来说不高。

并发算法(Concurrent GC)

  广义上的并发算法指的是在 GC 过程中存在并发阶段的算法,如 G1 中存在并发标记阶段,可将其整个算法视为并发算法。

  狭义上的并发垃圾回收算法是以基础的标记-复制算法为基础,在各个阶段增加了并发操作实现的。与复制算法的3个阶段相对应,分为并发标记(mark)并发转移(relocate)并发重定位(remap):

并发标记

  从 GC Roots 出发,使用遍历算法对对象的成员变量进行标记。同样的,并发标记也需要解决标记过程中引用关系变化导致的漏标记问题,这一点通过写屏障实现;

并发转移

  根据并发标记后的结果生成转移集合,把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收,转移过程中会涉及到应用线程访问待转移对象的情况,一般的解决思路是加上读屏障,在完成转移任务后,再访问对象;

并发重定位

  对象转移后其内存地址发生了变化,所有指向对象老地址的指针都要修正到新的地址上,这一步一般通过读屏障来实现。在这里插入图片描述  并发算法是 ZGC、Shenandoah、C4 等垃圾回收器的算法基础,在具体的实现中,不同的垃圾回收器又有自己的选择和取舍。

方法区垃圾回收

很多人认为方法区( 或者HotSpot虚拟机中的永久代 )是没有垃圾收集的,Java虚拟机范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低。

在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~95%的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容: 废弃常量无用的类

  • 废弃常量:没有任何对象引用常量池中的常量,也没有其他地方引用了这人字面量,如果这时发生内存回收,而且必要的话,这个常量就会被系统清理出常量池。
  • 无用的类:要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:
  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.langClass对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose: class-XX+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等BvteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

什么是记忆集和卡表?记忆集和卡表有什么关系?

  为了解决跨代引用问题,提出了记忆集这个概念,记忆集是一个在新生代中使用的数据结构,它相当于是记录了一些指针的集合,指向了老年代中哪些对象存在跨代引用。

  记忆集的实现有不同的粒度:

  • 字长精度:每个记录精确到一个字长,机器字长就是处理器的寻址位数,比如常见的 32 位或者 64 位处理器,这个精度决定了机器访问物理内存地址的指针长度,字中包含跨代指针;
  • 对象精度:每个记录精确到一个对象,该对象里含有跨代指针;
  • 卡精度:每个记录精确到一块内存区域,区域内含有跨代指针。

  其中,卡精度是使用了卡表作为记忆集的实现,关于记忆集和卡表的关系,大家可以想象成是 HashMap 和 Map 的关系。

什么是卡表(Card Table)?

  卡表(Card Table)就是 Remembered Set 卡精度的具体实现,是目前比较常用的 Remembered Set 实现方式。

  可以理解为 Remembered Set 就是抽象类,卡表 就是具体实现类。

  在 HotSpot 中,卡表是一个字节数组,数组的每一个元素对应着所表示的内存区域中一块特定大小的内存块,这个内存块称为 Card Page(卡页)。

  在 HotSpot 中,默认的卡表标记逻辑如下:

CARD_TABLE [this adress >> 9] = 0

  这意味着,卡页的大小是2的9次幂,512字节。

  以新生代的 Card Table 为例,Card Table 的每一个元素用来 标记 老年代的某一块内存区域(Card Page)的所有对象是否引用了新生代对象。

  只要存在一个对象引用了新生代对象,那么将对应 Card Table 的数组元素的值标记为 0,说明这个元素变脏(Dirty)。

  在 新生代GC 的时候,不需要全量扫描老年代的内存空间,只需要筛选出 Card Table 中标记为 0 的元素,扫描老年代指定范围的内存块。

  所以,卡页和卡表主要用来解决跨代引用问题的。

什么是三色标记法?三色标记法会造成哪些问题?

  根据可达性算法的分析可知,如果要找出存活对象,需要从 GC Roots 开始遍历,然后搜索每个对象是否可达。如果对象可达则为存活对象,在 GC Roots 的搜索过程中,按照对象和其引用是否被访问过这个条件会分成下面三种颜色:

  • 白色:白色表示 GC Roots 的遍历过程中没有被访问过的对象,出现白色显然在可达性分析刚刚开始的阶段,这个时候所有对象都是白色的,如果在分析结束的阶段,仍然是白色的对象,那么代表不可达,可以进行回收;
  • 灰色:灰色表示对象已经被访问过,但是这个对象的引用还没有访问完毕(自身已经被标记,但其拥有的成员变量还未被标记);
  • 黑色:黑色表示此对象已经被访问过了,而且这个对象的引用也已经被访问了(自身已经被标记,且对象本身所有的成员变量也已经被标记)。

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

  现代的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

  三色标记法会造成两种问题,这两种问题所出现的环境都是由于用户环境和收集器并行工作造成的 。当用户线程正在修改引用关系,此时收集器在回收引用关系,此时就会造成把原本已经消亡的对象标记为存活,如果出现这种状况的话,问题不大,下次再让收集器重新收集一波就完了,但是还有一种情况是把存活的对象标记为死亡,这种状况就会造成不可预知的后果。

  针对上面这两种对象消失问题,业界有两种处理方式,一种是增量更新(Incremental Update),一种是原始快照(Snapshot At The Beginning, SATB)。

写屏障&读屏障

  在标记对象是否存活的过程中,对象间的引用关系是不能改变的,这对于串行 GC 来说是可行的,因为此时应用程序处于 STW 状态。对于并发 GC 来说,在分析对象引用关系期间,对象间引用关系的建立和销毁是肯定存在的,如果没有其他补偿手段,并发标记期间就可能出现对象多标和漏标的情况。

  多标不会影响程序的正确性,只会推迟垃圾回收的时机,漏标会影响程序的正确性,需要引入读写屏障来解决漏标的问题。

  GC 里的读屏障(Read barrier)和写屏障(Write barrier)指的是程序在从堆中读取引用或更新堆中引用时,GC 需要执行一些额外操作,其本质是一些同步的指令操作,在进行读/写引用时,会额外执行这些指令。读/写屏障实现的是「对读/写引用这个操作的环切」,即该操作前后都在屏障的范畴内,可以将读/写屏障类比于 Spirng 框架里的拦截器。

读写屏障是如何解决并发标记时的漏标的?

  方法一:

  • 开启写屏障,当新增引用关系后,触发写屏障,发出引用的黑色或者白色对象会被标记成灰色,或者将被引用对象标记为灰色。
  • 开启读屏障,当检测到应用即将要访问白色对象时,触发读屏障,GC 会立刻访问该对象并将之标为灰色。这种方法被称为「增量更新(Increment Update)」。

  方法二:

  开启写屏障。当删除引用关系前,将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一遍,这种方法实际上是「SATB(Snapshot At The Begining) 算法」的一种具体实现。

  SATB 算法是由 Taiichi Yuasa 为增量式标记清除垃圾收集器开发的一个算法,其核心思想是:GC 开始之前,会复制一份引用关系快照,如果某个指针的地址被改变了,那么之前的地址会被加入待标记栈中,便于后面再次检查,这样就可以保证在 GC 时,所有的对象都会被遍历到,即使指向它们的指针发生了改变。鉴于篇幅原因,这里不再讲述,感兴趣的读者可自行查看 Yuasa 的论文(Real-time garbage collection on general-purpose machines[3])

  通过读写屏障可以解决并发标记时的漏标问题,具体在工程实践中,不同的垃圾回收器又有不同实现,譬如针对 HotSpot 虚拟机,CMS 使用了「写屏障 + 增量更新」的方法,G1 和 Shenandoah是通过「写屏障 + SATB」来完成的,而 ZGC 则采取了「读屏障」的方式。

  需要注意的是,读/写屏障只是一种理念,触发读写屏障后具体执行什么,取决于垃圾回收器的实现。由于从堆读取引用是非常频繁的操作,因此这两种屏障需要非常高效,在常见情况下就是一些汇编代码,读屏障的开销通常比写屏障大一个数量级(这也是为何大多数 GC 没有使用或者很少使用读屏障的原因,因为引用的读操作要远多于写操作),读屏障更多的时候是用在解决并发转移时的引用更新问题上。

  注意:JVM 里还有另外一组内存屏障的概念:读屏障(Load Barrier)写屏障(Store Barrier),这两组指令和上面我们谈及的屏障不同,Load Barrier 和 Store Barrier主要用来保证主缓存数据的一致性以及屏障两侧的指令不被重排序。

写屏障带来的问题?

  如果有其他分代区域中对象引用了本区域的对象,那么其对应的卡表元素就会变脏,这个引用说的就是对象赋值,也就是说卡表元素会变脏发生在对象赋值的时候,那么如何在对象赋值的时候更新维护卡表呢?

  在 HotSpot 虚拟机中使用的是写屏障(Write Barrier) 来维护卡表状态的,这个写屏障和我们内存屏障完全不同,希望读者不要搞混了。

  这个写屏障其实就是一个 AOP 切面,在引用对象进行赋值时会产生一个环形通知(Around),环形通知就是切面前后分别产生一个通知,因为这个又是写屏障,所以在赋值前的部分写屏障叫做写前屏障,在赋值后的则叫做写后屏障。

  写屏障会带来两个问题:

  • 无条件写屏障带来的性能开销
      每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,扫描整个老年代相比较,这个开销就低得多了。
      不过,在高并发环境下,写屏障又带来了伪共享(false sharing)问题。
  • 高并发下伪共享带来的性能开销
      在高并发情况下,频繁的写屏障很容易发生伪共享(false sharing),从而带来性能开销。

  假设 CPU 缓存行大小为 64 字节,由于一个卡表项占 1 个字节,这意味着,64 个卡表项将共享同一个缓存行。

  HotSpot 每个卡页为 512 字节,那么一个缓存行将对应 64 个卡页一共 64*512 = 32K B。

  如果不同线程对对象引用的更新操作,恰好位于同一个 32 KB 区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。

  一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为脏的。

  这就是 JDK 7 中引入的解决方法,引入了一个新的 JVM 参数 -XX:+UseCondCardMark,在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。

  简单理解如下:

if (CARD_TABLE [this address >> 9] != 0)

  与原来的实现相比,只是简单的增加了一个判断操作。

  虽然开启 -XX:+UseCondCardMark 之后多了一些判断开销,但是却可以避免在高并发情况下可能发生的并发写卡表问题。通过减少并发写操作,进而避免出现伪共享问题(false sharing)。

并行回收&串行回收

  根据垃圾回收的运行方式不同,GC 可以分为三类:串行执行、并行执行、并发执行。

串行执行

  垃圾回收器执行的时候应用程序挂起,串行执行指的是垃圾回收器有且仅有一个后台线程执行垃圾对象的识别和回收;

并行执行

  垃圾回收器执行的时候应用程序挂起,但是在暂停期间会有多个线程进行识别和回收,可以减少垃圾回收时间;

并发执行

  垃圾回收器和应用程序同时执行。
  垃圾回收器执行期间,应用程序不用挂起正常运行(当然在某些必要的情况下垃圾回收器还是需要挂起的)。

  上面并发和并行容易混淆,因为在 Java 中,我们提到的并发天然会联想到是「同一类多个线程」执行「同一类任务」,在 GC 中,并发描述的是「GC 线程」和「应用线程」一起工作。

  当我们说到某种垃圾回收器支持并发时,并不意味着在垃圾回收的过程中都是并发的,譬如,G1 和 CMS 垃圾回收器支持并发标记,但是在对象转移、引用处理、符号表和字符串表处理、类卸载时,是不支持并发的。总之,并发的表述具有「阶段性」。

安全点(Safepoint)和安全区域(SafeRegion)

安全点和安全区域的作用是为了让执行代码的线程停下,垃圾收集器开始执行垃圾收集。

安全点

为安全点( Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。

Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的–因为每条指分执行的时间都非常短暂,程序不太可能因为指合流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指分序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指合才会产生Safepoint。

另一个需要考虑的问题是如何在GC发生时让所有线程( 这里不包括执行JNI调用的线程 )都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择: 抢先式中断( Preemptive Suspension)和主动式中断( Vluntary Suspension ),

  • 抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
  • 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

安全区域

使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是程序“不执行”的时候呢 ?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求“走”到安全的地方去中断挂起JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域( Safe Region )来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举( 或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

  垃圾收集器有很多,不同商家、不同版本的 JVM 所提供的垃圾收集器可能会有很大差别,我们主要介绍 HotSpot 虚拟机中的垃圾收集器。

  垃圾收集器是垃圾回收算法的具体实现,我们上面提到过,垃圾回收算法有标记-清除算法、标记-压缩、标记-复制,所以对应的垃圾收集器也有不同的实现方式。

  我们知道,HotSpot 虚拟机中的垃圾收集都是分代回收的,所以根据不同的分代,可以把垃圾收集器分为:

  新生代收集器:Serial、ParNew、Parallel Scavenge;
  老年代收集器:Serial Old、Parallel Old、CMS;
  整堆收集器:G1;
在这里插入图片描述

各种垃圾回收器和垃圾回收算法间的关系

  • Serial:标记-复制

  • ParNew:标记-复制

  • Parallel Scavenge:标记-复制

  • Serial Old:标记-压缩

  • Parallel Old:标记-压缩

  • CMS(Concurrent-Mark-Sweep):(并发)标记-清除

  • G1(Garbage-First):并发标记 + 并行复制

  • Shenandoah GC:并发标记 + 并发复制

  • ZGC/C4(Java 13推出):并发标记 + 并发复制

  可以看到,如果堆空间进行了分代,那么新生代通常采用复制算法,老生代通常采用压缩-复制算法。G1、C4、ZGC、Shenandoah GC 是几种比较新的垃圾回收器。

Serial 收集器

  Serial 收集器是一种新生代的垃圾收集器。它是一个单线程工作的收集器,使用复制算法来进行回收。单线程工作不是说这个垃圾收集器只有一个,而是说这个收集器在工作时,必须暂停其他所有工作线程,这种暴力的暂停方式就是 Stop The World。Serial 就好像是寡头垄断一样,只要它一发话,其他所有的小弟(线程)都得给它让路。

  Serial 收集器的示意图如下:在这里插入图片描述  SefePoint 全局安全点:它就是代码中的一段特殊的位置,在所有用户线程到达 SafePoint 之后,用户线程挂起,GC 线程会进行清理工作。

  虽然 Serial 有 STW 这种显而易见的缺点。不过,从其他角度来看,Serial 还是很讨喜的,它还有着优于其他收集器的地方,那就是简单而高效,对于内存资源首先的环境,它是所有收集器中额外内存消耗最小的,对于单核处理器或者处理器核心较少的环境来说,Serial 收集器由于没有线程交互开销,所以 Serial 专心做垃圾回收效率比较高。

参数配置
#开启Serial 收集器
-XX:+UseSerialGC

#关闭Serial 收集器
-XX:-UseSerialGC

ParNew 收集器

  ParNew 是 Serial 的多线程版本,除了同时使用多条线程外,其他参数和机制(STW、回收策略、对象分配规则)都和 Serial 完全一致.

  ParNew 收集器的示意图如下:在这里插入图片描述  虽然 ParNew 使用了多条线程进行垃圾回收,但是在单线程环境下它绝对不会比 Serial 收集效率更高,因为多线程存在线程交互的开销,但是随着可用 CPU 核数的增加,ParNew 的处理效率会比 Serial 更高效。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。

参数配置
# 开启ParNew收集器
-XX:+UseParNewGC

#指定并行 GC 线程的数量,一般最好和 CPU 核心数量相当。
-XX:ParallelGCThreads=8

Parallel Scavenge 收集器

  Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的,而且它也能够并行收集,这么看来,表面上 Parallel Scavenge 与 ParNew 非常相似,那么它们之间有什么区别呢?

  Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到个可控制的吞吐量( Throughput)。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比。也就是:在这里插入图片描述  这里给大家举一个吞吐量的例子,如果执行用户代码的时间 + 运行垃圾收集的时间总共耗费了 100 分钟,其中垃圾收集耗费掉了 1 分钟,那么吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量,良好的响应速度可以提升用户体验,而高吞吐量可以最高效率利用处理器资源。

  Parallel Scavenge + Parallel Old 的组合有自适应调节策略,适用于对吞吐量敏感的场景;

参数配置
# 新生代使用Parallel收集器,老年代使用串行收集器(Serial Old)
-XX:+UseParallelGC
# 新生代和老年代都使用并行收集器
-XX:+UseParallelOldGC
#表示每次GC最大的停顿毫秒数
-XX:MaxGCPauseMillis=100
#表示希望在GC花费不超过应用程序执行时间的1/(1+nnn),nnn为大于0小于100的整数。
#参数设置为19,那么GC最大花费时间的比率=1/(1+19)=5%,程序每运行100分钟,允许GC停顿共5分钟,其吞吐量=1-GC最大花费时间比率=95%
-XX:GCTimeRatio=19

Serial Old 收集器

  前面介绍了一下 Serial,我们知道它是一个新生代的垃圾收集,使用了标记-复制算法。而这个 Serial Old 收集器却是 Serial 的老年代版本,它同样也是一个单线程收集器,使用的是标记-压缩算法(也叫整理算法)。

Serial Old 收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:

  • 一种用途是在JDK 1.5以及之前的版本中与Parallel Scaveng收集器搭配使用;
  • 一种用途就是作为CMS收集器的后备预案,在并发收集发生ConcurrentMode Failure时使用。

  Serial Old 的收集流程如下:在这里插入图片描述

参数配置
# 年轻代和老年代都使用串行收集器
# 等价于新生代使用Serial GC其老年代使用Serial Old GC
-XX:+UseSerialGC

Parallel Old 收集器

  前面我们介绍了 Parallel Scavenge 收集器,现在来介绍一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年代版本,支持多线程并发收集,基于标记 - 压缩算法实现,JDK 6 之后出现,吞吐量优先可以考虑 Parallel Scavenge + Parallel Old 的搭配:

在这里插入图片描述

参数配置
# 等价Parallel Scavenge+Parallel Old
-XX:+UseParallelGC
-XX:+UseParallelOldGC

CMS 收集器

  CMS 收集器的主要目标是获取最短的回收停顿时间,它的全称是 Concurrent Mark Sweep,从这个名字就可以知道,这个收集器是基于标记 - 清除算法实现的,而且支持并发收集。

  它的运行过程要比上面我们提到的收集器复杂一些,它的工作流程如下:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

  由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

  CMS 的收集过程如下:在这里插入图片描述  CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集低停顿,但是没有任何收集器能够做到完美的程度,CMS 也是一样,CMS 至少有三个缺点:

  1. CMS 对处理器资源非常敏感。在并发阶段,虽然不会造成用户线程停顿,但是却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量;CMS 无法处理浮动垃圾,有可能出现 Concurrent Mode Failure 失败进而导致另一次完全 Stop The World 的 Full GC 产生;
  2. 什么是浮动垃圾呢?由于并发标记和并发清理阶段,用户线程仍在继续运行,所以程序自然而然就会伴随着新的垃圾不断出现,而且这一部分垃圾出现在标记结束之后,CMS 无法处理这些垃圾,所以只能等到下一次垃圾回收时在进行清理。这一部分垃圾就被称为浮动垃圾。
  3. CMS 最后一个缺点是并发-清除的通病,也就是会有大量的空间碎片出现,这将会给分配大对象带来困难。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次FullGC。为了解决这个问题,CMS收集器提供了一个-XX: +UseCMSCompactAtFullCollection开关参数( 默认就是开启的 ),用于在CMS收集器顶不住要进行FulIGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的。,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX: CMSFuIGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的( 默认值为0,表示每次进入FuGC时都进行碎片整理 )。

  CMS 是适用于老年代的垃圾回收器,虽然在回收过程中可能也会触发新生代垃圾回收。CMS 在 JDK 9中被声明为废弃的,在JDK 14中将被移除;

参数配置
# 开启CMS收集器
-XX:+UseConcMarkSweepGC

# 4是指定并行 GC 线程的数量,一般最好和 CPU 核心数量相当。
#默认情况下,当 CPU 数量小于8, ParallelGCThreads 的值等于 CPU 数量,
#当 CPU 数量大于 8 时,则使用公式:ParallelGCThreads = 8 + ((N - 8) * 5/8) = 3 +((5*CPU)/ 8);
-XX:ParallelGCThreads=4

#是指设定CMS在对内存占用率达到70%的时候开始GC(因为CMS会有浮动垃圾,所以一般都较早启动GC);
-XX:CMSInitiatingOccupancyFraction=70 

#只是用设定的回收阈值(上面指定的70%),如果不指定,JVM仅在第一次使用设定值,后续则自动调整.
-XX:+UseCMSInitiatingOccupancyOnly 

#在CMS GC前启动一次年轻代gc,目的在于减少老年代对年轻代 gc gen的引用,降低remark时的开销-----一般CMS的GC耗时 80%都在remark阶段
-XX:+CMSScavengeBeforeRemark

#在进行Full GC之前进行一次内存整理
-XX:+UseCMSCompactAtFullCollection

G1(Garbage First) 收集器

  G1是一款面向服务端应用的垃圾收集器。JDK 7推出,JDK 9默认垃圾收集器。

  Garbage First 又被称为 G1 收集器,它的出现意味着垃圾收集器走过了一个里程碑,为什么说它是里程碑呢?因为 G1 这个收集器是一种面向局部的垃圾收集器,HotSpot 团队开发这个垃圾收集器为了让它替换掉 CMS 收集器,所以到后来,JDK 9 发布后,G1 取代了 Parallel Scavenge + Parallel Old 组合,成为服务端默认的垃圾收集器,而 CMS 则不再推荐使用。

  之前的垃圾收集器存在回收区域的局限性,因为之前这些垃圾收集器的目标范围要么是整个新生代、要么是整个老年代,要么是整个 Java 堆(Full GC),而 G1 跳出了这个框架,它可以面向堆内存的任何部分来组成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪个分代,这就是 G1 的 Mixed GC 模式。

  G1 是基于 Region 来进行回收的,Region 就是堆内存中任意的布局,每一块 Region 都可以根据需要扮演 Eden 空间、Survivor 空间或者老年代空间,收集器能够对不同的 Region 角色采用不同的策略来进行处理。Region 中还有一块特殊的区域,这块区域就是 Humongous 区域,它是专门用来存储大对象的,G1 认为只要大小超过了 Region 容量一半的对象即可判定为大对象。如果超过了 Region 容量的大对象,将会存储在连续的 Humongous Region 中,G1 大多数行为都会把 Humongous Region 作为老年代来看待。

  G1 的垃圾回收是分代的,整个堆分成一系列大小相等的分区(Region)。新生代的垃圾回收(Young GC)使用的是并行复制的方式,一旦发生一次新生代回收,整个新生代都会被回收(根据对暂停时间的预测值,新生代的大小可能会动态改变)。老年代回收不会回收全部老年代空间,只会选择一部分收益最高的 Region,回收时一般会搭便车——把待回收的老年代 Region 和所有的新生代 Region 放在一起进行回收,这个过程一般被称为 Mixed GC,Young GC 和 Mixed GC 最大的不同就在于是否回收了老年代的 Region。注意:Young GC 和 Mixed GC 都是在进行对象标记,具体的回收过程与这两个过程是独立的,回收时 GC 线程会根据标记的结果选择部分收益高的 Region 进行复制。从某种角度来说,G1 可视为是一种「标记-复制算法」的实现(注意这里不是压缩算法,因为 G1 的复制过程完全依赖于之前标记阶段对对象生死的判定,而不是自行从 GC Roots 出发遍历对象引用关系图)。

  在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域( Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region( 不需要连续 )的集合。

  G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小( 回收所获得的空间大小以及回收所需时间的经验值 ),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region( 这也就是Garbage-First名称的来由 )。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

  G1 收集器的运作过程可以分为以下四步:

  • 初始标记(STW):这个步骤也仅仅是标记一下 GC Roots 能够直接关联到的对象;并修改 TAMS 指针的值(每一个 Region 都有两个 RAMS 指针),使得下一阶段用户并发运行时,能够在可用的 Region 中分配对象,这个阶段需要暂停用户线程,但是时间很短。这个停顿是借用 Minor GC 的时候完成的,所以可以忽略不计。
  • 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆中的对象图,找出要回收的对象。当对象图扫描完成后,重新处理 SATB 记录下的在并发时有引用的对象;
  • 最终标记(STW):对用户线程做一个短暂的暂停,用于处理并发阶段结束后遗留下来的少量 SATB 记录(一种原始快照,用来记录并发标记中某些对象)
  • 筛选回收(STW):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择多个 Region 构成回收集,然后把决定要回收的那一部分 Region 存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作设计对象的移动,所以必须要暂停用户线程,由多条收集器线程并行收集。

  上面的四个阶段中,有三个阶段都是 STW 的,每个阶段的内容就不具体叙述了。为了降低标记阶段中 STW 的时间,G1 使用了记录集(Remembered Set, RSet)来记录不同代际之间的引用关系。在并发标记阶段,GC 线程和应用线程并发运行,在这个过程中涉及到引用关系的改变,G1 使用了 SATB(Snapshot-At-The-Beginning) 记录并发标记时引用关系的改变,保证并发结束后引用关系的正确性。实现 RSet 和 SATB 的关键就是之前提到的写屏障。

  下面是 G1 回收的示意图:在这里插入图片描述  G1 收集器同样也有缺点和问题:

  • 第一个问题就是 Region 中存在跨代引用的问题,我们之前知道可以用记忆集来解决跨代引用问题,不过 Region 中的跨代引用要复杂很多;
  • 第二个问题就是如何保证收集线程与用户线程互不干扰的运行?CMS 使用的是增量更新算法,G1 使用的是原始快照(SATB),G1 为 Region 分配了两块 TAMS 指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须在这两个指针位置以上。如果内存回收速度赶不上内存分配速度,G1 收集器也要冻结用户线程执行,导致 Full GC 而产生长时间的 STW;
  • 第三个问题是无法建立可预测的停顿模型。

  其他GC收集器相比,G1具备如下特点:

  1. 并行与并发: G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU( CPU或者CPU核心 )来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  2. 分代收集: 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  3. 空间整合:与CMS的“标记 清理”算法不同,G1从整体来看是基于“标记一整理”算法实现的收集器,从局部( 两个Region之间 ) 上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  4. 可预测的停顿:这是G1相对千CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在人长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几平已经是实时Java ( RTSJ)的垃圾收集器的特征了。
参数配置
# 开启G1收集器
-XX:+UseG1GC

Shenandoah 垃圾回收器

  Shenandoah 垃圾回收器是JDK 12推出。
  Shenandoah GC 最早是由 Red Hat 公司发起的,后来被贡献给了 OpenJDK,2014 年通过 JEP-189:A Low-Pause-Time Garbage Collector (Experimental)正式成为 OpenJDK 的开源项目,Shenandoah GC 出现的时间比 ZGC 要早很多,因此发展的成熟度和稳定性相较于 ZGC 来说更好一些,实现了包括括C1屏障、C2屏障、解释器、对 JNI 临界区域的支持等特性。

  和 ZGC 一样,Shenandoah GC 也聚焦在解决 G1 中产生最长暂停时间的「并行复制」问题,通过与 ZGC 不一样的方式,实现了「并发复制」,在 Shenandoah GC 中也未区别年轻代与老年代。ZGC实现并发复制的关键是:读屏障 + 基于着色指针(Color Pointers)的多视图映射,而 Shenandoah GC 实现并发复制的关键是:读写屏障 + 转发指针(Brook Pointers),转发指针(Brook Pointers)的原理将在下面详细介绍,其过程可以参考论文:Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware。

  Shenandoah GC 的 回收周期和 ZGC 非常类似,大致也可以分为并发标记和并发复制两个阶段,在并发标记阶段,也是通过 读屏障+ SATB 来实现的,并发复制阶段也分为并发转移和并发重定位两个子阶段。

Shenandoah GC 是如何实现并发复制?

  Shenandoah GC 将堆分成大量同样大小的分区(Region) ,分区大小从 256KB 到 32MB不等。在进行垃圾回收时,也只是会回收部分堆区域。上面提到,Shenandoah GC 实现高效读屏障的关键是增加了 转发指针(Brook Pointers)这个结构,这是对象头上增加的一个额外的数据,在读写屏障触发时时可以通过 Brook Pointer 直接访问对象。转发指针要么指向对象本身,要么指向对象副本所在的空间,如下图所示:在这里插入图片描述  Shenandoah GC 使用写屏障+转发指针完成了并发复制。

  上面并发转移的详细过程如下:首先判断待转移对象是否在待回收集合中(这个集合根据标记阶段的结果生成),同时转移指针是否指向了自己,如果没有在待收回集合,则不用转移,如果对象的转移指针已经指向了其他地址,说明已经转移过了,也不用转移;然后进行对象复制;对象复制结束后,会通过 CAS 的方式更新转移指针的值,使其指向新的复制对象所在的堆空间地址,如果 CAS 失败,会多次重试。

  Shenandoah GC 使用读屏障+转发指针保证转移过程中或转移结束后,应用线程可以读取到真实的引用地址,保证了数据的一致性,因为如果不这样做,可能会导致一些线程使用旧对象,而另一些线程使用新对象。

  需要注意的是,在 ZGC 中并发重定位和并发标记阶段是重合的,而在 Shenandoah GC 在某些情况下,可能会把并发标记、并发转移和并发重定位合并到同一个并发阶段内完成,这种回收方式在 Shenandoah GC 中被称为遍历回收,细节请参考相关资料。如下图所示,第1个回收周期会进行并发标记,第2回收周期会进行并发标记和并发转移,第3个以后的回收周期会同时执行并发标记、并发转移和并发重定位。在这里插入图片描述  看下并发复制的具体过程。
  步骤1:将对象从 From 复制到 to 空间,同时将新对象的转移指针指向新对象自己。在这里插入图片描述  步骤2:将旧对象的转移指针通过 CAS 的方式指向新对象。在这里插入图片描述  步骤3:将堆中其他指向旧对象的引用,更新为新对象的地址,如果在这个过程中有应用线程访问到了旧对象,则会通过读屏障的方式将新对象的地址返回给新的应用。在这里插入图片描述  步骤4:所有的引用被更新,旧对象所在的分区可以被回收。在这里插入图片描述  再次回顾一下 Shenandoah GC 里使用的各种屏障:读对象时,会首先通过读屏障来解析对象的真实地址,当需要更新对象(或对象的字段),则会触发写屏障,将对象从 From 空间复制到 to 空间。读写屏障在底层的应用,可以用下面的一个例子去理解。

  Shenandoah GC 的并发复制是基于读屏障+写屏障共同实现的( ZGC 只使用了读屏障)。Shenandoah GC 中所有的数据写操作均会触发写屏障,包括对象写、获取锁、hash code 的计算等,因此在具体实现时 Shenandoah GC 对写屏障也有若干的优化(譬如从循环逻辑中移除写屏障)。Shenandoah GC 还使用了一种称之为「比较屏障」的机制来解决对象引用间的比较操作,特别是同一个对象分别处于 From 和 to 空间时的比较。此外,Shenandoah GC 里屏障也不需要特殊的硬件支持和操作系统支持。

  Shenandoah GC 更适合用在大堆上,如果CPU资源有限,内存也不大,比如小于20GB,那么就没有必要使用Shenandoah GC。Shenandoah GC 在降低了暂停时间的同时,也牺牲了一部分的吞吐,如果对吞吐有较高的要求,则还是建议使用传统的基于 STW 的 GC 实现,譬如 Parallel 系列垃圾回收器。

Minor GC、Major GC、Full GC

Minor GC和Major GC是俗称,在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC中大致可以对应到某个Young GC和Old GC算法组合;

针对HotSpot VM的实现,Minor GC、Major GC、Full GC负责的内存区域:

  • Minor GC:收集Young Gen,当Young Gen内存空间被用完时,就会触发垃圾回收。
  • Major GC:收集Old Gen的垃圾收集叫做Major GC,Major GC通常是跟full GC是等价的,收集整个GC堆。
  • Full GC:收集整个堆,包括Young Gen、Old Gen、Perm Gen(如果存在的话)等所有部分的模式。

Hotspot JVM实现中几种GC算法组合:

  • Serial GC算法:Serial Young GC + Serial Old GC (实际上它是全局范围的Full GC);
  • Parallel GC算法:Parallel Young GC + 非并行的PS MarkSweep GC / 并行的Parallel Old GC(这俩实际上也是全局范围的Full GC),选PS MarkSweep GC 还是 Parallel Old GC 由参数UseParallelOldGC来控制;
  • CMS算法:ParNew(Young)GC + CMS(Old)GC + Full GC for CMS算法;
  • G1 GC:Young GC + mixed GC(新生代,再加上部分老生代)+ Full GC for G1 GC算法;

各类GC算法的触发条件:

  • 各种Young GC的触发原因都是eden区满了;
  • Serial Old GC/PS MarkSweep GC/Parallel Old GC的触发则是在要执行Young GC时候预测其需要进入Old Gen的对象总大小超过Old Gen剩余大小;
  • CMS GC的初始标记(Initial Marking)的触发条件是Old Gen使用比率超过某值;
  • G1 GC的初始标记(Initial Marking)的触发条件是Heap使用比率超过某值
  • 发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的:如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

参考&引用:
https://mp.weixin.qq.com/s/iklfWLmSD4XMAKmFcffp9g
https://mp.weixin.qq.com/s/sXv9XIIVDsflXr0FdrGM1A

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
以下是几个 JVM 垃圾回收器相关的面试题及其答案: 1. JVM 垃圾回收器都有哪些? JVM 垃圾回收器主要分为以下几种:串行垃圾回收器、并行垃圾回收器、CMS 垃圾回收器、G1 垃圾回收器等。 2. 串行垃圾回收器和并行垃圾回收器的区别是什么? 串行垃圾回收器和并行垃圾回收器的主要区别在于垃圾回收的方式。串行垃圾回收器是单线程执行的,即在垃圾回收过程中只有一个线程在执行,而并行垃圾回收器是多线程执行的,即在垃圾回收过程中可以有多个线程同时执行。 3. CMS 垃圾回收器的特点是什么? CMS 垃圾回收器是一种以最短回收停顿时间为目标的垃圾回收器。它采用分代收集算法,在回收老年代时,采用标记-清除算法,并发标记和并发清除,以减少垃圾回收的停顿时间,提高系统的响应速度。 4. G1 垃圾回收器的特点是什么? G1 垃圾回收器是一种面向服务端应用的垃圾回收器,它采用分代收集算法,在回收堆内存时,采用标记-整理算法。它具有以下特点:高效、可预测、可配置、可并发、可暂停等。 5. 垃圾回收器的主要算法有哪些? 垃圾回收器主要采用以下几种算法:标记-清除算法、复制算法、标记-整理算法分代算法等。 以上是一些常见的 JVM 垃圾回收器面试题及其答案,希望能对你有所帮助。在面试过程中,需要根据具体的问题进行回答,同时也需要对垃圾回收器的原理和实现有清晰的认识,才能更好地回答相关的问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冲上云霄的Jayden

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值