专注于 JetBrains IDEA 全家桶,永久激活,教程
持续更新 PyCharm,IDEA,WebStorm,PhpStorm,DataGrip,RubyMine,CLion,AppCode 永久激活教程

JVM 详解系列垃圾收集

前言

文中大量参考了《深入理解Java虚拟机 第三版》,结合自己的理解进行了总结概述。但是并发的可达性分析章节是对书中的扩展,可以仔细看,其他章节如果已经了解可直接忽略。

垃圾收集器和内存分配策略

概述

在 Java 运行时区域中,程序计数器,虚拟机栈,本地方法栈这三个区域随线程而生,随线程而亡,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配的内存在类结构确定下来的时候就已知了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了

而 Java 堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能是不一样的,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收时动态的。垃圾收集器所关注的正是这部分内存该如何管理。

对象是否可回收

引用计数算法

引用计数是如何判断对象的存活:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一。当引用失效时,计数器值机减一。任何时刻计数器值为零的对象就是不可能再被引用。

引用计数算法虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判断效率也很高,在大多数情况下都是一个不错的算法。但是在 Java 领域,至少主流的 Java 虚拟机里面都是没有选用引用计数算法来管理内存,主要原因是:这个看似简单的算法有很多例外情况要考虑,必须配合大量额外处理才能保证正确的工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

可达性分析算法

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

61_1.png

  • 红色:仍然存活的对象
  • 白色:判定可回收的对象

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

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如:局部变量,参数,临时变量等
  • 在方法区中类静态属性引用的对象。譬如:引用类型静态变量
  • 在方法区中常量引用的对象。譬如:字符串常量池中的引用
  • 在本地方法栈中JNI引用的对象。
  • 所有被同步锁(synchronized 关键字)持有的对象
  • Java 虚拟机内部的引用,譬如:基本数据类型对应的 Class 对象,一些常驻的异常对象,还有系统类加载器。
  • 反映 Java 虚拟机内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同组成完成的 GC Roots 集合。

譬如:在分代收集和局部回收下,如果只针对 Java 堆中某一块区域发起垃圾收集时,必须考虑到该区域里的对象完全有可能被位于其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。

引用类别

强引用

强引用是最传统的“引用”定义,是指在程序代码中普遍存在的引用赋值。即类似 Object obj = new Object() 这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象。

软引用

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。通过 SoftReference 类来实现软引用。

弱引用

弱引用也是用来描述那些非必须的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。通过 WeakReference 类来实现弱引用。

虚引用

虚引用是最弱的一种引用关系,一个对象是否存在虚引用,完全不会对其生存时间构成影响,也无法通过一个虚引用来获得一个对象的实例。对一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。通过 PhantomReference 类实现虚引用。

死亡还是生存

即使在可达性分析算法中判断为不可达的对象,也不是一定会被垃圾收集器回收。这时候它还处于一个缓刑的阶段,就是还有一次机会可以让该对象设置上引用关系。要真正宣告一个对象死亡,至少要经历两次标记过程:第一次标记是在可达性分析算法中发现它没有与GC Roots相链接的引用链。随后会进行一次筛选,筛选的条件是对象是否有必要执行 finalize 方法,如果没有必要执行,那么将会直接被判定为死亡,将会等待垃圾收集器的回收。

如何判断对象的 finalize 方法是否需要执行:

  • 对象没有覆盖finalize 方法,则没有必要执行。
  • 虚拟机已经调用过一次该对象的 finalize 方法,那么也没有必须执行。

如果这个对象被判定为确有必要执行 finalize 方法,那么该对象将会被放置在一个为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的,低调的优先级的 Finalizer 线程去执行它们的 finalize 方法。

这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

finalize 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模标记,如果对象要在finalize() 中成功拯救自己——只要重新有引用链上的任何一个对象建立关联即可,如果建立了关联,那在第二次标记的时候将会被移出即将回收的集合。

如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

每个对象的 finalize() 只会被执行一次。如果一个对象在第一次 finalize() 中自救成功,逃脱了垃圾回收,但是等到下一次垃圾回收的时候,它如果没有与引用链上的任何一个对象建立关联,那么就会直接等待垃圾收集器的回收,因为该对象的 finalize 方法已经执行过一次了,在整个虚拟机生命周期中不会执行第二次同一个对象的 finalize 方法。

不鼓励使用该方法。

回收方法区

方法区中的垃圾收集主要是回收两部分内容:

  • 废弃的常量
  • 不再使用的类型

废弃的常量

回收废弃的常量与回收 Java 堆中的对象非常类似。

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

废弃的类型

判定一个类型是否属于不再被使用的类的条件比较苛刻,需要同时满足下面三个条件:

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

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

Hotspot虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class-XX:+TraceClassLoader 查看类加载的信息,-XX:TraceClassUnLoading 查看类卸载的信息,该参数需要 FastDebug 版的虚拟机支持。

垃圾收集算法

从如果判定对象消亡的角度出发,垃圾收集算法可以划分为==引用计数式垃圾收集==和==追踪式垃圾收集==,而这两类也常被称作==直接垃圾收集==和==间接垃圾收集==,在主流的 Java 虚拟机中,都是使用==追踪式垃圾收集==

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论进行设计,分代收集是建立在两个分代假说上:

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

这两个假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将对象依据其年龄分配到不同的区域之中存储。

如果一个区域中大都数的对象都是朝生夕灭的,难以熬过垃圾收集过程时,那么把他们集中放在一起,每次回收时只关注少量的存活对象而不是去标记那些大量将要被回收的对象,就能以较低的代价回收到大量的空间。

如果剩下的对象都是难以消亡的对象,那把他们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域。

分代收集至少存在一个明显的困难:对象都不是孤立的,对象之间会存在跨代引用

假如要现在进行一次只局限于新生代区域的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要堆分代收集理论添加第三条假说:

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

比如:如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代,这是跨代引用也跟着消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,该结构被称为记忆集,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。

垃圾收集名称解释

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

标记 – 清除算法

该算法分为标记清除两个阶段:

1、 首先标记出所有需要回收的对象(也可以标记所有不需要回收的对象)
2、 在标记完成后,统一回收掉所有需要回收的对象。

该算法存在两个缺点:

1、 第一个是执行效率不太稳定,如果 Java 堆中包含大量的对象,而且其中大部分是需要被回收的,这是就必须进行大量的标记和清除的动作,导致标记和清除这两个过程的执行效率都随着对象数量增长而降低。
2、 第二个是内存空间的碎皮化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

61_2.png

标记 – 复制算法

基础算法

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

它可将内存按容量划分为大小相等的两份,每次都只使用其中的一份。当一块内存用完的时候,就会还存活的对象复制到另外一块内存上面,然后将之前的那块内存直接清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存空间复制的开销。

但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复制情况,只要移动堆顶指针,按顺序分配即可。

这样实现简单,运行高效,不过缺陷也很明显,这种复制回收算法的代价是将可用内存缩小为原来的一半,空间浪费未免太多了点。

61_3.png

Apple 式算法

主流 Java 虚拟机大多数都优先采用这种收集算法去回收新生代。

但是根据新生代朝生夕灭的特性(新生代中有 98% 的堆都熬不过第一轮),该复制算法并不需要按照 1 : 1 的比例去划分新生代的内存空间。

一种更加优化的半区复制分代策略称为 ==Apple 式回收==,具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 空间和其中一块 Survivor 空间。发生垃圾收集时,将 Eden 和 Survivor 中任然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已经使用过的那块 Survivor 空间。

Hotspot 虚拟机中的 Serial,ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。默认的 Eden 和 Survivor 空间的比较是 8 :1,也即每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间。即 10% 的新生代会被浪费掉。

该算法还有一个弊端:98% 的对象可被回收只是仅仅针对一般的情况下测得的数据,没有人可以保证每次堆新生代进行垃圾收集都只有不多于 10% 的对象存活,因此该算法还有一个充电罕见情况的逃生门的安全设计,当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行内存分配担保。

内存的分配担保就是如果另外一块 Survivor 空间没有足够空间存放上一次新生代存活下来的对象,这些对象便将通过分配担保机制直接进入老年代,这对于虚拟机来说是安全的。

标记 – 整理算法

标记 – 复制算法在对象存活率较高是就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

而标记 – 整理算法,其中的标记过程仍然跟标记 – 清除一样,但后续步骤不是直接堆可回收的对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

61_4.png

标记 – 清除和标记 – 整理的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 如果移动对象,尤其是在老年代这种每次回收都有大量对象存活的区域。移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且移动对象必须暂停用户应用程序才能进行。像这样的停顿被称为 “Stop The World”
  • 如果跟标记 – 清除一样完全不考虑移动和整理存活对象,那么空间碎片问题就只能依赖于更为复复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动对象则内存回收会更复制,不移动对象则内存分配会更加复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间和更短,甚至可以不需要停顿,但从整个程序的吞吐量来看,移动对象会更加划算。吞吐量的实质是用户程序与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但是内存分配和访问相比垃圾收集频率要多得多,这部分的耗时增加,总吞吐量仍然是下降的。

Hotspot 虚拟机中关注吞吐量的 Parallel Scavenge 收集器是基于标记 – 整理的,而关注延迟的 CMS 收集器则死基于标记 – 清除的

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

算法实现细节

根节点的枚举

固定可作为 GC Roots 的节点主要在全局性的引用(常量,类静态属性)和执行上下文(栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。

迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,因为如果在根节点枚举的过程中,根节点集合的对象引用关系还在不断的变化,分析结果准确性将会无法得到保障。所以根节点枚举始终都是必须在一个能保障一致性的快照中才得以进行。

由于目前主流的 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

在 Hotspot 中,是使用一组称为 OopMap 的数据结构来达到这个目的的。一旦类加载完成,Hotspot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息了,并不需要一个不漏地从方法区等 GC Roots 开始查找。

安全点

在 OopMap 的协助下,Hotspot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上 Hotspot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在特定的位置记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

安全点是根据以下几点进行选定的:

  • 方法调用
  • 循环跳转
  • 异常跳转

对于安全点,另外一个需要考虑的问题是:如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来:

  • 抢先式中断:该方式不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
  • 而主动式中断的思想是当垃圾收集器需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

安全区域

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

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

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

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

被暂停的线程如果处于安全区域内,那么他就是可以进行垃圾回收的,因为安全区域内的对象状态是稳定的。

如果被暂停的线程不在安全区域内呢???

记忆集和卡表

在分代收集理论的中,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。

事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集行为的垃圾收集器都会面临相同的问题,如:G1、ZGC、Shenandoah。

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

在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了(如果,收集新生代的内存,那么非收集区域就是老年代,收集区域就是新生代,老年代中含有新生代对象的引用。),并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记忆粒度来节省记忆集的存储和维护成本,下面是一些可供选择的记录精度:

  • 字长精度:每个记录精确到一个机器字长,如 32 位和 64 位。该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种卡精度所指的是用一种称为卡表的方式去实现记忆集,这也是目前子常用的一种记忆集实现形式。

卡表是记忆集的一种具体实现,卡表和记忆集的关系类似于 Java 中的 HashMap 和 Map 的关系。

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

CARD_TABLE[this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页。卡页大小都是以 2 的N 次幂的字节数,通过上面代码可以看出 Hotspot 中使用的卡页是 2 的 9 次幂,即 512 字节。

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

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

写屏障

卡表可以解决缩减 GC Roots 扫描范围的问题,但是没有解决卡表元素如何维护的问题,例如它们何时变脏,谁来把他们变脏等

卡表元素何时变脏的答案是很明确的——有其他区域的对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏的时间点原则上是应该发生在引用类型赋值的那一刻。

但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节指令的执行,有充分的介入空间。

但在编译执行的场景中,即经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在 Hotspot 虚拟机里是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层面对”引用类型字段赋值“这个动作的 AOP 切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说在赋值前后都在写屏障的覆盖范围内。在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障。直至 G1 收集器出现之前,其他收集器都只用到了写后屏障。

并发的可达性分析

当前的主流虚拟机的垃圾收集器都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是少极少数,且在各种优化技巧的加持下,它带来的停顿已经是非常短暂且相对固定的了。

可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系:堆越大,存储的对象越多,对象图结构越复杂,要标记更多的对象而产生的停顿时间自然就更长。

要想解决这个问题,可以使垃圾回收线程和用户线程并发的运行,但是这样就会产生一个问题,在垃圾回收器标记阶段,堆中的对象图状态是在不断的改变的,那么要如何保障在一致性的快照中分析呢?

三色标记

我们先来了解一下三色标记,这有助于我们理解并发的可达性分析。

把遍历对象图过程中遇到的对象,按照是否访问过这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表以及扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

如果在垃圾回收的过程中,用户线程是冻结的,那么垃圾回收不会出现任何问题。但是如果用户线程跟垃圾收集器线程是并发工作的,也就是垃圾收集器一遍在进行标记,但是用户线程同时又在修改着对象图的结构,这样就会导致两个问题:

  • 把原本消亡的对象标记为存活。原本对象已经被标记为黑色了,此时用户线程将该对象的引用给删除了,即该对象变得没有引用了,也就是这个原本应该被回收的对象,却存活了下来。这种原本应该被回收却存活了下来的对象被称为浮动垃圾。但是这种情况并不是影响程序的运行,下次将它清理即可,但是会占用一些内存开销。
  • 把原本存活的对象标记为消亡。这个是非常致命的后果,通常是将被灰色对象引用的白色对象,在垃圾收集器扫描到该白色对象之前,用户将该白色对象的引用到已经扫描完成的黑色对象上,然后将该白色对象与灰色对象的引用关系解除。此时白色对象就因为不可达而被回收,但是这个白色对象却是一个不能被回收的对象。从而导致程序发生错误。

实例1

61_5.png

状态 1:在该初始状态中,存在一个已经被标记为可达的黑色对象 X 和一个正在扫描的灰色对象 Y,灰色对象 Y 存在一个指针 a 指向白色对象 Z,但垃圾收集器此时还没扫描到该白色对象 Z。

61_6.png

状态 2:用户线程在状态 1 的基础上,通过灰色对象 Y 上的指针 a 可以获取到白色对象 Z,将获取到的白色对象 Z 插入到黑色对象 X 中,从而形成了指针 b,此时黑色对象 X 持有一个白色对象 Z 的引用。

61_7.png

状态 3:在状态 2 的基础上,用户线程将灰色对象 Y 对白色对象 Z 的引用进行了删除。即将灰色对象 Y 的指针 a 删除,而指针 a 是唯一一个可以指向白色对象 Z 的指针。

61_8.png

状态 4:最终,垃圾收集器发现对象 Y 已经没有了可扫描的对象,完成了对象 Y 的扫描,并将其标记为黑色,表示这是已经标记完成的对象。

结果:黑色对象 X 持有了一个对白色对象 Z 的引用,但是因为已经被标记为黑色的对象是不会进行二次扫描的,所以该白色对象 Z 将会被垃圾收集器当作垃圾进行回收,所以黑色对象 X 的引用是一个 NULL。从而导致了程序运行的错误。

这种情况属于直接删除从灰色对象指向白色对象的指针而导致对象丢失

实例2

61_9.png

状态 1:在该初始状态中,存在一个黑色对象 P,灰色对象 Q,白色对象 R,白色对象 S,并且灰色对象 Q 存在一个指针 c 指向白色对象R,白色对象 R 存在一个指针 d 指向白色对象 S。

61_10.png

状态 2:因为白色对象 S 可以通过白色对象 R 获得,用户线程将白色对象S插入到黑色对象 P 中,从而得到黑色对象 P 指向白色对象 S 的指针 e。

61_11.png

状态 3:用户线程将指针 c 删除,即灰色对象 Q 指向白色对象 R 的引用被删除。

61_12.png

状态 4:垃圾线程发现对象 Q 已经没有需要扫描的对象,所以扫描结束,将其标记为黑色。

结果:黑色对象 P 持有了白色对象 S 的引用,但是因为白色对象 S 和白色对象 R 都是没有被标记为黑色的,所以标记阶段结束后,都会被回收。所以会导致黑色对象P的指针 e 指向的是一个 NULL,从而导致程序运行错误。

这种情况属于破坏从灰色对象到白色对象的间接指针链而导致对象丢失

Wilson 于 1994 年在理论证明了,当且仅当以下两个条件同时满足时,会产生对象消失的问题

  • 赋值器插入了一条或多条黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

注意该理论的两个条件是由顺序的,必须要先插入黑色对象到白色对象的新引用,再删除全部灰色对象到该白色地下的直接或间接引用才会导致对象消失。

假如我们先删除全部从灰色对象到白色对象的直接或间接引用,那么在程序中,我们就已经无法获取得到这些白色对象,因为已经没有路径可以获取到这些白色对象,既然都获取不到这些白色对象,那么自然也就无法为黑色对象插入这些白色对象的引用。

一个更加实际的例子是:

// 为变量var设置一个对象
Integer var = new Integer(1);
// 删除变量var对该对象的引用
var = null;

我们首先为变量 var 设置一个 new Integer(1) 的对象 A 的引用,此时 A 是可以通过 var 这个变量进行访问,接着我们将 var 设置为 null,也就是我们切断了 var 于对象 A 之间的引用。此时我们就已经没有任何途径可以获取得到这个对象 A,也就无法将这个对象 A 赋值给其他的变量了。

解决方案

增量更新

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

CMS 就是基于增量更新来做并发标记的。

原始快照

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

G1、Shennandoah 就是通过原始快照来做并发标记的。

经典垃圾收集器

61_13.png

图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。

图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

Serial 收集器

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

61_14.png

该收集器是 Hotspot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。

对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

61_15.png

除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。是不少运行在服务端模式下的 Hotspot 虚拟机,尤其是 JDK7 以前的遗留系统中首选的新生代收集器。

ParNew 收集器是激活 CMS 后的默认的新生代收集器,也可以使用 -XX:+/-UsePaeNewGC 选项来强制指定或者禁用它。

ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果。但是,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数和处理器核心数量相同,在处理器核心非常多的环境中,可以使用 -XX:ParallelGCThreads 参数限制垃圾收集的线程数。

Parallel Scavenge 收集器

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

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

61_16.png

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验。

高吞吐量则可以做最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

该收集器提供了两个参数用于精确控制吞吐量:

  • 控制最大垃圾收集停顿时间的 -XX:maxGCPauseMillis
  • 直接设置吞吐量大小的 -XX:GCTimeRatio

-XX:maxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。但不是这个参数设置得越小越好,因为垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一点,收集 300M 新生代肯定比收集 500M 新生代要快,但这也导致新生代会较早被对象填满,从而更加频繁得触发垃圾回收。假如 500M 的情况下,垃圾收集 10 秒触发一次,一次收集 100 毫秒。那么 300M 的情况也许就会变为,垃圾收集5秒触发一次,一次收集 70 毫秒。每次收集的时间的确在下降,但是总体的收集时间却在上升,导致吞吐量下降。

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

该收集器还有一个参数 -XX:UseAdaptiveSizePolicy,这是一个开关参数。

当该参数被激活后,就不需要人工指定新生代的大小、Eden和Survivor区的比例、晋升老年代对象大小等细节参数,虚拟机会根据当前系统的允许情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式被称为垃圾收集的自适应的调节策略。

Serial Old收集器

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

在服务端模式下,它主要有两种用途:

  • 在 JDK5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用
  • 作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

61_17.png

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。

这个收集器是 JDK 6 才开始提供的,在此之前,新生代垃圾收集器 Parallel Scavenge 只能与老年代垃圾收集器 Serial Old 搭配使用,其他良好的老年代垃圾收集器。,如 CMS 无法与它配合使用。

直到 Parallel Old 收集器出现,吞吐量优先收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,均可优先考虑 Parallel Scavenge + Parallel Old 组和。

61_18.png

CMS 收集器

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。

该收集器是基于标记 – 清除算法的。整个回收过程分为四个步骤:

1、 初始标记 (CMS initial mark), Stop The World
2、 并发标记 (CMS concurrent mark)
3、 重新标记 (CMS remark), Stop The World
4、 并发清除 (CMS concurrent sweep)

初始标记仅仅只是标记一下 GC Roots 能够关联到的对象,速度很快。

并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发执行。

重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一点,但是也远比并发标记阶段的时间短。

并发清除阶段,清理删除掉标记阶段判断为死亡的对象,由于不需要移动存活对象,所以这个也是可以与用户线程同时并发的。

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

61_19.png

CMS 存在以下三个明显的缺点:

1、 CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。CMS 默认启动的回收线程数是(处理器核心数量 + 3)/ 4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
2、 CMS 收集器无法处理浮动垃圾,有可能出现”Concurrent Mode Failure“失败进而导致另一次完全”Stop The World“的 Full GC 的产生。在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为浮动垃圾。同样由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满再收集,必须预留一部分空间供并发收集时的程序运作使用。JDK 6 之后,CMS 收集器的启动阈值默认为 92%。但这会面临另一种风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败(Concurrent mode failure),这时候虚拟机将不得不启动后背预案:冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长。可以通过参数 -XX:CMSInitiatingOccupancyFraction 进行设置 CMS 收集器的启动阈值。
3、 CMS 是一款基于标记-清除算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间老分配当前对象,而不得补提前触发一次 Full GC 的情况。为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction (此参数在 JDK 9 开始废弃),这个参数的作用要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认为 0,表示每次进入 Full GC 时都进行碎片整理)。

Garbage First 收集器

G1 是一款主要面向服务端应用的垃圾收集器。作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起”停顿时间模型“的收集器,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是这个新生代(Minor GC),要么是整个老年代(major GC),再要么是整个 Java 堆(Full GC)。而 G1 跳出了整个牢笼,它可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式

G1 开创的基于 Region 的堆内存布局时它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间,Survivor 空间,老年代空间。收集器能够扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是以及存活一段时间、熬过多次收集的旧对象都能够取得很好的收集效果。

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

61_20.png

虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域的动态集合。

G1 收集器之所以可以能建立预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。即让 G1 垃圾收集器去跟踪各个 Region 里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定运行的收集停顿时间,优先处理回收价值收益最大的那些 Region。这种使用 Region 划分内存空间,以及既有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。

参数 -XX:MaxGCPauseMillis 可以设置允许的收集停顿时间,默认为 200 毫秒

跨代引用

Region 里面存在跨 Region 引用如何解决?使用记忆集避免全堆扫描作为 GC Roots 扫描。

61_21.png

与 CMS 不同的是,在 G1 中每一个 Region 都会有一个自己的 Rset (记忆集),这些记忆集会记录下别的 Region 指自己的指针,并标记这些指针分别在哪些卡页范围之内。

G1 的记忆集在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,value 是一个集合,里面存在的元素是卡表的索引号,也就是 value 就是一个卡表。

61_22.png

而卡表在之前的章节已经讲到,是用于记录一块区域,该区域内有对象含有跨代指针。

卡表的每一个元素,即索引号对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页。在 Hotspot 中,卡页的大小是 2 的 9 次幂,即 512 个字节。

假设 RegionA 的起始地址是 0x0000,则索引 0 代表的地址范围就是 0x0000 – 0x01FF,索引 1 代表的地址范围就是 0x0200 – 0x03FF,以此类推。如果索引对应的值为 1,则标识该索引对应的地址范围内存在跨代指针,则在进行 GC 的时候,就要将该区域也一起包含进来做 GC Roots 的遍历。

卡表与卡页的映射结构:

61_23.png

由于 Region 数量比传统收集器的分代数量明显多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。G1 至少要消耗大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。

对象消失问题

关于对象消失问题,在 CMS 中是采用增量更新算法实现,在要改变对象的引用关系时,通过一个写后屏障将改变关系的对象记录下来,等到并发标记结束之后,再重新遍历这些记录下来的对象。

而在 G1 收集器则是通过原始快照算法实现的。在删除一个对象的引用时,通过一个写前屏障将被删除的对象记录下来,然后等并发标记结束后,在以记录起来的对象为根进行扫描标记。也就是==只要在扫描开始之前就可达的对象,那么就一定不会被回收,垃圾收集器会保守得将这种对象当作存活对象,但是也会因此产生浮动垃圾。==

此外,垃圾收集对用户线程得影响还体现在回收过程中,新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个 Region 设计了两个名为 TAMS 的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时的新对象地址都必须在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

与 CMS 中的”Concurrent mode failure“失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1 收集器也会被迫冻结用户线程执行,导致 Full GC 而产生长时间 Stop The World。

停顿预测模型

用户通过 -XX:MaxGCPauseMillis 参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但 G1 收集器要怎么做才能满足用户的期望值呢?

G1 收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值,标准偏差,置信度等统计信息。这里强调的衰减平均值是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。也就是,Region 的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的利益。

运行过程

1、 初始标记(Initial Marking):仅仅只是标记以下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
2、 并发标记(Concurrent Marking):从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时由引用变动的对象
3、 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
4、 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个 Region 的全部空间。这个的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成的。

61_24.png

可以由用户指定期望的停顿时间时 G1 收集器很强大的一个功能,设置不同的期望停顿时间,可使得 G1 在不同应用场景中取得关注吞吐量和关注延迟之间的最近平衡。

默认的停顿时间为 200ms,如果我们把停顿时间调得非常低,譬如设置 20ms,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空虚的堆内存中获得一些喘息的时间,但应用时间一长就不行了,最终占满堆引发 Full GC 反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

G1 和 CMS 的比较

G1 和 CMS 都是基于分代理论进行内存划分的:

  • CMS 将整个 Java 堆划分为 Eden 区,Survivor 区,Old 区
  • G1 将整个 Java 堆划分为若干个 Region,每个 Region 都可以扮演 Eden 区,Survivor 区,Old 区,Humongous 区

实现算法不同:

  • G1 是整体上通过标记-整理算法实现,而局部上(两个 Region 之间)又是通过标记 – 复制算法实现的。
  • CMS 是通过标记-清除算法实现。

两个收集器都是通过卡表来处理跨代指针,实现方式不同:

  • G1 是每个 Region 都存在一个卡表,这导致 G1 的记忆集对比 CMS 来说要占用较多的内存空间
  • CMS 在全局只有一份卡表,而且只需要处理老年代到新生代的引用,反过来则不需要。由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,所以不需要处理新生代到老年代的引用,但是作为代价就是当 CMS 发生 Old GC 时,要把整个新生代作为 GC Roots 来扫描。

处理对象消失的方式不同:

  • G1 是通过原始快照的方式,通过一个写前屏障,记录被删除的从灰色对象到白色对象的引用,在并发标记结束后,重新扫描记录下来的对象。
  • CMS 是通过增量更新的方式,通过一个写后屏障,记录插入了引用的黑色对象,在并发标记结束后,重新以记录的对象为原点,重新扫描。

内存分配与回收策略实战

  • 对象优先在 Eden 分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
  • 动态对象年龄判定,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无需等待 -XX:MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次的 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotonFailure 参数的设置是否允许担保失败,如果允许,则会继续检查老年代最大可用的连续空间是否大于历次晋升老年代对象的平均大小,如果大于 ,将尝试进行一次 Minor GC,尽管这次 Minor GC 是由风险的。如果小于,或者 -XX:HandlePromotonFailure 设置为不允许冒险,那这时就要改为进行一次 Full GC。

文章永久链接:https://tech.souyunku.com/37988

未经允许不得转载:搜云库技术团队 » JVM 详解系列垃圾收集

JetBrains 全家桶,激活、破解、教程

提供 JetBrains 全家桶激活码、注册码、破解补丁下载及详细激活教程,支持 IntelliJ IDEA、PyCharm、WebStorm 等工具的永久激活。无论是破解教程,还是最新激活码,均可免费获得,帮助开发者解决常见激活问题,确保轻松破解并快速使用 JetBrains 软件。获取免费的破解补丁和激活码,快速解决激活难题,全面覆盖 2024/2025 版本!

联系我们联系我们