《深入理解Java虚拟机》读书笔记----垃圾收集器与内存分配策略

前言:本篇文章是《深入理解Java虚拟机》读书笔记第二篇章


垃圾收集就是Garbage Collection,也就是GC。

判断对象是否存活

堆里面存放几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。

判断对象是否存活有以下几种算法。

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;引用失效就减一;任何时刻计数器为零,那么就代表对象不能再被使用。

这个算法存在的缺点是:这个算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,比如单纯的引用计数法无法解决对象之间循环引用的问题(对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们)

可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到到GC Roots间没有任何引用链相连, 或者从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

如下图所示,Object5、6、7虽然互有关联,但是他们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

image-20210727003521639

固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等。

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

如果只针对Java堆中某一块区域发起垃圾收集时,必须考虑到内存区域是虚拟机自己的实现细节,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

问:判定为不可达的对象,就是直接死亡,被回收吗?

答:不是的。被判定为不可达的对象,还有“缓刑“阶段,要宣告一个对象死亡,至少要经历两次标记过程。如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

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

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

什么是引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可 达,判定对象是否存活都和“引用”离不开关系。

JDK 1.2 之前,Java里面的引用是很传统的定义: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

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

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

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java堆中的对象非常类似。

举个例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“ java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且 垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就 比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样,没有引用了就必然会回收。

是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压 力。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接 垃圾收集”。

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计。分代收集虽然被叫做理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

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

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,所以有了Minor GC、Major GC、Full GC这样的回收类型的划分。在划分出不同的区域之后发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为: 1、新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。 2、老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。 3、合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代和老年代两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

分代收集并非只是简单划分一下内存区域那么容易,它存在一些需要解决的重要问题,比如对象不是孤立的,对象之间会存在跨代引用。举个例子,假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,需要对分代收集理论添加第三条经验法则:跨代引用假说(Intergenerational Reference Hypothesis)→跨代引用相对于同代引用来说仅占极少数。这其实是可根据前两条假说逻辑推理得出的隐含推论,存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,不清楚怎么判定就可以看看引用计数法和可达性分析算法了。

之所以说它是最基础的收集算法,是因为后续的垃圾收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如下图所示。

image-20210727003537129

标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,标记-复制算法应运而生。

标记-复制算法常被简称为复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。标记-复制算法的执行过程如下图所示。

image-20210727003545149

IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。

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

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是 银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款 时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块 Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果 不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。于是标记-整理算法被有针对性的研究出来了,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如下图所示。

image-20210727003553806

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

算法细节实现

根节点枚举

目前为止,所有的垃圾收集器(包括CMS,G1,ZGC)在初始标记这一阶段都是需要STW的,而如果这一步耗时过长,就无法满足所谓低延迟垃圾收集器的需求。 在HotSpot的解决方案里,使用一个叫做OopMap的数据结构解决上述问题。在类加载动作完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在初始标记阶段就不用遍历GC Root了,而是可以直接获得这些信息。

image-20210727003605322

安全点

使用OopMap导致的问题:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。 ① 实际上HotSpot也没有每条指令都生成OopMap,而是强制要求字节码执行到某一个安全点才能暂停生成OopMap。因此,安全点不能太少以至于让收集器等待时间过长,也不能太频繁以至于过分增大运行时内存负荷。 ② 如何在垃圾收集发生时让所有线程,都跑到最近的安全点,然后停顿下来?

答:

  • 主动式中断

当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

  • 抢先式中断

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

安全区域

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但没有保证程序不执行时。

的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。

记忆集与卡表

分代收集理论中有提到,为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了叫做记忆集的数据结构,用来避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题。所有涉及部分区域收集行为的垃圾收集器,比如G1、ZGC和Shenandoah收集器都会有这样的问题。所以我们需要理清记忆集的原理和实现方式。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

不考虑效率和成本,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结 构

//以对象指针来实现记忆集的伪代码 Class RememberedSet { Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE]; }

这个实现的缺点就是,空间占用和维护成本都很高。其实收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。所以可以选择别的没有那么细致的记忆精度,比如说:

  • 字长精度

每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

  • 对象精度

每个记录精确到一个对象,该对象里有字段含有跨代指针。

  • 卡精度

每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,是目前最常用的一种记忆集实现形式,卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式可以只是一个字节数组,HotSpot虚拟机确实也是这样做的。

//HotSpot默认的卡表标记逻辑 CARD_TABLE [this address >> 9] = 0;

字节数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,就是512字节(地址右移9位,相当于用地址除以512)。

那如果卡表标识内存区域的起始地址是0x0000的话,数组的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。

image-20210727003616105

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

写屏障

卡表来解决缩减GC Roots扫描范围的问题。但卡表元素何时变脏,谁来把它们变脏还未解决。

在其他分代区域中对象引用了本区域对象的时候对应的卡表元素应该变脏,我们要解决的其实是如何在对象赋值的那一刻去更新维护卡表。在HotSpot中通过写屏障(Write Barrier)来维护卡表状态。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知供程序执行额外的动作,赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直 至G1收集器出现之前,其他收集器都只用到了写后屏障。

并发的可达性分析

垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析要求全过程都基于一个能保障一致性的快照中才能够进行分析, 这意味着必须全程冻结用户线程的运行。

在根节点枚举这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,而且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定的了。但是从GC Roots再继续往下遍历对象图,这个步骤的停顿时间就会和堆容量直接成正比关系,堆越大对象图结构越复杂,要标记更多对象产生的停顿时间就更长。所以我们需要降低这个停顿。想降低这个停顿就必须搞清楚为什么我们需要在一个保障一致性的快照上才能进行对象图的遍历。

按照是否访问过这个条件标记对象为白、黑、灰三种颜色

  • 白色

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

  • 黑色

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

  • 灰色

表示对象已经被垃圾回收期访问过,但这个对象至少存在一个引用没被扫描过。

可达性分析的扫描过程可以把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。如果用户线程和收集器是并发工作的,收集器在对象图上标记颜色,同时用户线程在修改引用关系,这样可能出现把原本消亡的对象错误标记为存活,或者把原本存活的对象错误标记为已消亡。前者会产生浮动垃圾,后者则是致命的结果,程序会因此发生错误。

image-20210727003628567

当且仅当以下两个条件同时满足时,会产生“对象消失”的问 题,即原本应该是黑色的对象被误标为白色:

1、赋值器插入了一条或多条从黑色对象到白色对象的新引用;

2、赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案

1、增量更新(Incremental Update)

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。

2、原始快照(Snapshot At The Beginning, SATB ) 。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来 进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,比如CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

经典垃圾回收器

image-20210727003639733

上图展示了其中作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器或是老年代收集器。

Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经是HotSpot新生代收集器唯一的选择。

它是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

“Stop The World”这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况 下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。以下是Serial Old收集器的运行图

image-20210727003651345

对于“Stop The World”带给用户的恶劣体验,早期HotSpot的设计者们虽然表示完全理解但同时也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”

这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个工种,但实际上肯定还要比打扫房间复杂得多!

虽然依照上述描述,Serial收集器像是一个最早出现,但目前已经老而无用,食之无味, 弃之可惜的“鸡肋”,但事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的,对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规 则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。ParNew收集器的工作过程如下图所示。

image-20210727003659607

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它 却是不少运行在服务端模式下的HotSpot虚拟机中(尤其是JDK 7之前的遗留系统)首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS 收集器配合工作。

ParNew收集器在单核心处理器的环境没有比Serial收集器更好的效果,由于存在线程交互的开销,它在通过超线程技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。不过,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用-XX:ParallelGCT hreads参数来限制垃圾收集的线程数。

虽说当时CMS的出现巩固了ParNew的地位,但自JDK 9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了,因为更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是 能够并行收集的多线程收集器。

它的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。

image-20210727003706243

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算 任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是:

1、控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数

该参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过不是这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

2、直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

该参数的值是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比例,相当于吞吐量的倒数。比如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个 参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。

这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:

1、在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用

2、作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

下图是它的工作过程

image-20210727003715095

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实 现。直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,因为如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器(比如CMS)无法与它配合工作。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。

直到Parallel Old收集器出现后,Parallel Scavenge这样的“吞吐量优先”收集器有了比较好的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如下图所示。

image-20210727003721423

CMS收集器

CMS全称是Concurrent Mark Sweep。是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会更关注服务的响应速度,希望系统停顿时间尽可能短,给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字“Mark Sweep”上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

1、初始标记

2、并发标记

3、重新标记

4、并发清除

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

image-20210727003730444

CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。

CMS收集器是 HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,有以下三个明显的缺点:

1、CMS收集器非常依赖处理器资源。

面向并发设计的程序都对处理器资源比较敏感。在并发阶段它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种, 在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从 JDK 7开始不再提倡用户使用,到JDK 9发布后被完全废弃。

2、CMS收集器无法处理浮动垃圾,有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。

在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。

3、基于“标记-清除”算法实现导致收集结束时会有大量空间碎片产生,会给大对象分配带来很大麻烦

碎片过多时,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从 JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此提供了另外一个参数-XX:CM SFullGCsBefore- Compaction(此参数从JDK 9开始废弃),作用是要求CMS收集器在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

Garbage First 收集器

Garbage First 收集器又叫G1收集器,是垃圾收集器技术发展历史上的里程碑式的成果,是一款主要面向服务端应用的垃圾收集器。

1、G1收集器和其他收集器的区别就是 衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。它可以面向堆内存任何部分来组成回收集进行回收。这就是G1收集器的Mixed GC模式。

2、G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

3、Region中有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1Heap RegionSize设定,取值范围为1M B~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。如下图所示

image-20210727003744364

4、虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域的动态集合。它将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。 这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

5、G1收集器现存的几个问题有:

  • 将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决

解决的思路是使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一 种哈希表,Key 是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。

  • 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行
  • 怎样建立起可靠的停顿预测模型

6、G1收集器的运作过程分为以下四个步骤

  • 初始标记

仅仅只是标记一下GC Roots能直接关联到的对象让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短

  • 并发标记

从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。

  • 最终标记

用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  • 筛选回收

负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,它并非纯粹地追求低延迟,它的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”。

image-20210727003753644

7、对比G1和CMS

1、G1可以指定最大停顿时间

2、G1从整体来看是基于“标记-整理”算法实现,但从局部上看又是基于“标记-复制”算法实现,所以G1运作期间不会产生内存空间碎片

3、在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载 都要比CMS要高。

4、内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间

5、执行负载的角度上,它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化情况。这相比于增量更新算法,能够减少并发标记和重新标记阶段的消耗避免CMS那样在最终标记阶段停顿时间过长的缺点但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

低延迟垃圾收集器

HotSpot的垃圾收集器从Serial发展到CMS再到G1,距离“完美”还是很遥远。

衡量“完美的垃圾收集器”的三项重要指标是:

  • 内存占用(Footprint)
  • 吞吐量(Throughput)
  • 延迟(Latency)

这三者最多只能同时达到其中两项。这三项指标中,延迟的重要性现在日益凸显。因为随着硬件发展性能提升,我们已经可以容忍收集器占用多一点的内存,而且硬件越好,吞吐量也能越高,而硬件升级内存增大对延迟反而会带来负面效果,因为虚拟机要回收更多的堆内存了,那么结论就是,延迟是垃圾回收最重要的指标。

下图中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。在CMS和G1之前的全部收集器,工作的所有步骤都会产生Stop The World式的停顿。CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进都避免不了空间碎片的产生,空间碎片的堆积最终依然逃不过Stop The World的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

image-20210728234624173

Shenandoah和ZGC这两款收集器几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上它们都可以在任意可管理的堆容量下,实现垃圾收集的停顿都不超过十毫秒。

停顿不超过十毫秒这在以前听起来就是不可能的目标,因此,这两款目前仍处于实验状态的收集器被官方命名为“低延迟垃圾收集器” Low-Latency Garbage Collector

Shenandoah收集器

1、Shenandoah和G1一样也是使用基于Region的堆内存布局,有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region

2、Shenandoah和G1不同在于

  • Shenandoah支持并发的整理算法,G1的回收阶段是可以多线程并行的但不能与用户线程并发。
  • Shenandoah默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在,没有实现分代更多是出于性价比的权衡,基于工作量上的考虑将其放到优先级较低的位置上。
  • Shenandoah摒弃了记忆集,G1中耗费大量内存和计算资源去维护记忆集。Shenandoah改用叫做连接矩阵的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题发生的概率。(连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。如下图所示)

image-20210731133551372

3、Shenandoah收集器的工作过程大致可以划分为九个阶段

  • 初始标记

与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是Stop The World的,但停顿时间与堆大小无关,只与GC Roots的数量相关。

  • 并发标记

与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度

  • 最终标记

与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。

  • 并发清理

这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。

  • 并发回收

并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah会通 过读屏障和被称为Brooks Pointers的转发指针来解决,并发回收阶段运行的时间长短取决于回收集的大小。

  • 初始引用更新

并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。

  • 并发引用更新

真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  • 最终引用更新

解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。

  • 并发清理

经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

以上这九个阶段的工作过程其中三个最重要的并发阶段是并发标记、并发回收、并发引用更新

下图黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。图中不仅展示了Shenandoah三个并发阶段的工作过程,还形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移 动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。

image-20210731141438185

4、Shenandoah支持并行整理的核心概念是Brooks Pointer。

之所以取名Brooks是因为他在论文中提出使用转发指针来实现对象移动与用户程序并发的一种解决方案。

在提出这种方案前要做类似的并发操作,需要在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到归属于旧对象对象的内存空间就会产生自陷中断,进入预设好的异 常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。这个方案会导致用户态频繁切换到核心态。

而这个转发指针的方案在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。当对象拥有了一份新的副本时,只需要修 改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转 发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址 访问的代码便仍然可用,都会被自动转发到新对象上继续工作,如下图所示。

image-20210815201145495

\

ZGC收集器

1、与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局。 不同的是,ZGC的Region 具有动态性,可以动态创建和动态销毁,并有动态的区域容量大小。在X64平台下,有大中小三类容量。

  • 小Region

容量固定为2MB,用于放置小于256KB的小对象。

  • 中Region

容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。

  • 大Region

容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。每个大型Region中只会存放一个大对象,虽然名字叫作大型 Region,但实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

image-20210731145642145

2、ZGC的并发整理算法的实现同样用到了读屏障,但是思路不同。

ZGC收集器有一个标志性的设计是它采用的染色指针技术,染色指针是一种直接将少量额外的信息存储在指针上的技术。ZGC的染色指针技术使用系统中剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如下图所示。

由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针但它带来的收益也是非常可观的,因为染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。理论上只要还有一个空闲Region,ZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region。

染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。

image-20210731150118246

3、ZGC的运作过程分为四个大的阶段。 全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段

  • 并发标记

与Shenandoah和G1不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0、Marked1标志位。

  • 并发预备重分配

这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。 ZGC的重分配集只是决定里面的存活对象会被重新复制到其他的Region中,里面的Region被释放。标记过程是针对全堆的。

  • 并发重分配

重分配是ZGC执行过程中的核心阶段,这个过程把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表记录从旧对象到新对象的转向关系。因为有染色指针,ZGC收集器能从引用上就明 确得知一个对象是否处于重分配集之中。如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”。

  • 并发重映射

重映射就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是非常迫切的,因为即使是就引用也是可以通过染色指针自愈的。重映射清理这些旧引用的主要目的是为了不变慢

4、ZGC在实现细节上做了一些不同的权衡选择,它完全没有使用记忆集,甚至连分代都没有,没有用到写屏障、记录新生代和老年代间引用的卡表。这有好处有坏处。好处就是给用户线程带来的运行负担也要小得多,而坏处就是限制了它能承受的对象分配速率不会太高

选择合适的垃圾收集器

Epsilon收集器

Epsilon垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦堆被耗尽就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。

收集器的权衡

如何选择一款适合自己应用的垃圾收集器,主要受三个因素影响

  • 应用程序的主要关注点

目标是能尽快算出结果, 那吞吐量就是主要关注点。服务应用那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点。如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

  • 运行应用的基础设施

也就是从硬件规格、处理器数量多少,内存大小多少,操作系统是什么来考虑

  • 使用JDK的发行商和版本号

虚拟机及垃圾收集器日志

JDK9之后,HotSpot所有功能的日志都收归到了-Xlog参数上,这个参数的能力也相应被极大拓展了

-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

命令行中最关键的参数是选择器Selector,它由标签Tag和日志级别Level共同组成。 标签可理解为虚拟机中某个功能模块的名字,它告诉日志框架用户希望得到虚拟机哪些功能的日志输出。垃圾收集器的标签名称为gc,由此可见,垃圾收集器日志只是HotSpot众多功能日志的其中一项,全部支持的功能模块标签名如下所示

add,age,alloc,annotation,aot,arguments,attach,barrier,biasedlocking,blocks,bot,breakpoint,bytecode,censu

日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度。另外,还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:

  • time 当前日期和时间
  • uptime 虚拟机启动到现在经过的时间,以秒为单位。
  • timemillis 当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
  • uptimemillis 虚拟机启动到现在经过的毫秒数。
  • timenanos 当前时间的纳秒数,相当于System.nanoTime()的输出。
  • pid 进程ID。
  • tid 线程ID。
  • level 日志级别。
  • tags 日志输出的标签集

如果不指定默认是uptime、level、tags,此时日志输出类似以下格式

[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值