JVM(2):垃圾收集器

        相比于程序计数器、虚拟机栈和本地方法栈这种应分配的内存大小在类确定时就已固定下来的内存区域,Java堆和方法区这两个区域有着不确定性:一个接口可能有多个实现类,一个方法可能有多个分支。垃圾收集器所关注的也就是这部分不确定的内存。

1. 对象状态

        在进行垃圾收集之前,垃圾收集器需要对对象的状态进行判断,即判断其是否仍在被使用。

1.1 引用计数算法

        引用计数 ( $Reference\ \ Counting$ ) 算法,可以简单的解释为在对象中添加一个引用计数器,当对象被引用时计数器便加一,反之减一。如此往复,当对象的引用计数器为零时,可以认为其不再被使用。虽然这种算法简单而且效率很高,但是因为存在着很多例外情况,因此不被主流的JVM所采纳。比较简单的例子就比如存在对象 $A$ 和 $B$ ,它们都存在着一个字段互相引用对方,这样它们的引用计数器就都为 $1$ 。但实际上,除了互相引用之外,它们没有被其他对象引用,可以认为是无用的对象,应该被垃圾收集。另一方面,它们的引用计数又不为 $0$ ,无法触发垃圾收集。因此,引用计数算法需要配合大量额外处理才能保证正常工作。

1.2 可达性分析算法

        可达性分析 ( $Reachability\ \ Analysis$ ) 算法,基本思路就是通过一系列的称为GC Roots的根对象出发,不断搜索引用关系,搜索路径称为引用链 ( $Reference\ \ Chain$ )。当一个对象没有被搜索到,或者称不可达时,就认为这个对象不再被使用。在Java技术体系里固定可作为GC Roots的对象包括:

        除此之外,根据用户选择的垃圾收集器以及当前收集的内存区域,还会有临时对象的加入。譬如要针对某个特定区域进行垃圾收集时,不仅要加入该区域的,还要加入关联区域的其他对象。虽然现在查找引用链的过程已经可以做到并发,但迄今为止,所有收集器在根节点枚举这一步骤都要暂停用户线程。

1.3 对象引用

        在JDK 1.2之前,Java对应用的定义为:如果 $reference$ 类型的数据中存储的数值代表的是另一块内存的起始地址,就称其代表某块内存或某块对象的引用。而在JDK 1.2之后,Java扩充了引用的概念,分为 $4$ 种引用强度:

1.4 对象死亡

        一个对象的死亡至少需要经历两次标记过程:第一次为可达性分析,第二次为筛选,筛选覆盖了 $finalize(\ )$ 方法或者执行过 $finalize(\ )$ 方法的对象。如果 $finalize(\ )$ 方法未被覆盖或者未被执行,那么对象会进入 $F-Queue$ 队列,并在之后由一条自动建立的低优先级的 $Finalizer$ 线程执行其 $finalize(\ )$ 方法。稍后收集器会检查队列中的对象,如果对象在 $finalize(\ )$ 方法中建立了与其他对象的关联,那么它就会被移出队列。需要注意的是,为了避免长时间等待或者进入死循环,收集器并不会等待对象的 $finalize(\ )$ 方法执行完毕,而且一个对象的 $finalize(\ )$ 方法只能调用一次,因此有可能在下次对象回收时该对象不能再次自救。

1.5 方法区回收

        正如之前所说的,方法区可以不实现垃圾收集,而且方法区进行垃圾收集的性价比也不高。方法区的垃圾收集包括废弃常量和不再使用的类型。相比于前者,后者的判定难度就大得多了。判断一个类型不再使用需要满足以下条件:

        是否对类型进行回收,HotSpot虚拟机中可以通过 $+Xnoclassgc$ 参数控制,还可以使用 $-verbose:class$、$-XX:+TraceClassLoading$、$-XX:+TraceClassUnLoading$ 查看类加载和卸载信息,前两者可以在Product版中使用,后者需要在FastDebug版中使用。

2. 垃圾收集算法

        垃圾收集算法可以分为引用计数式垃圾收集和追踪式垃圾收集两大类,它们也被称作直接垃圾收集和间接垃圾收集。

2.1 分代收集理论

        分代收集理论建立在两个假说上:

        根据这两个假说,多款垃圾收集器中都给Java堆中划分出不同的区域,每次回收根据对象的年龄,将对象分配到不同区域中。在划分出不同的区域后,垃圾收集器可以每次只回收某个或某些特定区域。在Hotspot虚拟机中,Java堆被分为新生代 ( $Young\ \ Generation$ ) 和老年代 ( $Old\ \ Generation$ ) 两个区域。但是分代收集并没有想象的那么简单,因为对象不是孤立的,跨代引用是存在的。在此基础上有了第三条经验法则:

        隐含推论是:互相引用的两个对象,倾向于同时生存或者同时消亡。因此没必要再为跨代引用去扫描老年代,只需要在新生代上建立一个全局数据结构,称为记忆集 ( $Remembered\ \ Set$ ),将老年代划分为若干小块,标识其中存在跨代引用的小块。在之后进行局限于新生代的垃圾收集 ( $Minor\ \ GC$ ) 时,只需要将这些小块中的对象加入GC Roots即可。

2.2 标记-清除算法

        标记-清除 ( $Mark-Sweep$ ) 算法,首先标记处需要回收的对象,在标记完成后统一收集标记的对象,也可以反过来标记不应清除的对象。标记-清除算法的缺点主要有两个:效率不稳定以及会导致内存碎片化。

2.3 标记-复制算法

        标记-复制算法是为了解决标记-清除算法在面对大量可回收对象时的效率低下问题而提出的。首先是半区复制 ( $Semispace\ \ Copying$ ) 算法,它将可用内存分为大小相等的两块,每次只使用一块。当其中一块使用完之后,将其中存活的对象移到另一块,再将这块内存清理掉。虽然实现简单,运行高效,但是可用内存缩小为一半,浪费了大量空间。之后又提出了 $Appel$ 式回收,它将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和一块Survivor。在垃圾收集时将它们中的存活对象复制到另一块Survivor中,然后清理剩下的EdenSurvivorHotSpot虚拟机中EdenSurvivor的比例为 $8:1$ 。如果Survivor空间无法容纳存活对象,那么就需要其他内存区域进行分配担保 ( $Handle\ \ Promotion$ ),也就是把新生代的对象转移到老年代。

2.4 标记-整理算法

        标记-复制算法在面对大量存活对象时效率较低,所以不适用于老年代。标记-整理 ( $Mark-Compact$ ) 算法,其标记过程和标记-复制算法一样,但不进行复制,而是将存活对象向内存一端移动,再清理掉边界外内存。但是对于老年代这种存在大量存活对象的内存区,移动需要耗费大量时间,而且必须暂停用户程序。

3. HotSpot虚拟机算法

        在可达性分析算法的根节点收集这一步中,用户线程必须暂停。为了减小这段暂停的时间,HotSpot虚拟机中有一组称为 $OopMap$ 的数据结构。当类加载完成时,HotSpot会把对象内偏移量对应的数据类型计算出来,并在即时编译过程中记录栈和寄存器中的引用位置。这样扫描器在扫描时就不需要慢慢查找了,可以直接获取这些信息。但是如果给每一条指令都建立一个 $OopMap$ ,那么会占用大量存储空间。因此引入了安全点 ( $Safepoint$ ) 的概念。只有程序执行到安全点后,虚拟机才会生成 $OopMap$ 。同样,也只有执行到安全点后,程序才能开始垃圾收集。安全点的选取要以能否让程序长时间执行为标准,譬如方法调用、循环跳转、异常跳转这些需要指令序列的复用的地方就可以作为安全点。HotSpot虚拟机为了避免安全点过多带来的负担,会对循环次数较少的循环不设安全点,而判断一个循环的循环次数的依据是其使用的数据类型:对于 $int$ 及更小的数据类型作为索引值的循环默认不会放置安全点,这种循环称为可数循环 ( $Counted\ \ Loop$ );对应的,使用 $long$ 或更大的数据类型作为索引值的循环就称为不可数循环 ( $Uncounted\ \ loop$ ) 。如果需要在可数循环中也放置安全点,那么需要使用 $-XX:+UseCountedLoopSafepoints$ 参数,但是在JDK 8下这个参数可能会导致虚拟机崩溃,因此最好将原来的数据类型替换为更大的数据类型如 $long$ 等。
        在多线程环境下,每个线程的执行速度不同,可能会出现当一个线程到达安全点之后其他线程还未到达的情况,因此需要采取不同策略。这里有两种方案可以选择:抢占式中断 ( $Preemptive\ \ Suspension$ ) 和主动式中断 ( $Voluntary\ \ Suspension$ ),几乎所有虚拟机都采用了主动式中断的方案。抢占式中断会在垃圾收集发生时直接中断所有线程,如果这时某条线程不在安全点上,就恢复其执行,直到其在安全点上。主动式中断设置了一个标志位,线程们会不断轮询这个标志位,当其为真时,线程会执行到最近的一个安全点上然后中断。轮询点包括安全点、创建对象的地方和其他需要在Java堆上分配内存的地方,以避免因为内存不够而导致的无法分配问题。在HotSpot虚拟机中采用了内存保护陷阱的方式,使得轮询操作只需要一条汇编指令即可执行,从而保证了高效性。
        虽然安全点的引入解决了停顿线程的问题,但是无法在程序不执行的状态下运行。这时候就引入了安全区域 ( $Safe\ \ Region$ ) 的概念。安全区域指代一块代码段,在这段代码中,引用关系不会发生改变,因此可以在这段代码的任意地点开始垃圾收集。当线程进入安全区域,会标识其已进入安全区域,这样在垃圾收集时虚拟机就不会管这些线程。当线程离开时,如果仍在进行根节点枚举这种需要暂停用户线程的步骤,那么就要等待直到完成。
        在进行部分区域收集 ( $Partial\ \ GC$ ) 时,可能会存在跨代引用问题,因此引入了记忆集的概念。记忆集专门用于记录非收集区域指向收集区域的指针集合。设计者在设计记忆集时,可以选择不同的记忆精度,比较常见的有:

        卡精度通过一种称为卡表 ( $Card\ \ Table$ ) 的方式实现,也是目前最常用的实现方式。最简单的卡表形式可以是一个字节数组,这也是HotSpot虚拟机中的实现方式。字节数组中的每一个元素都标识一块内存区域,称为卡页 ( $Card\ \ Page$ )。当卡页中存在跨代指针时,就要将卡页标识为 $1$ ,称为元素变 ( $Dirty$ )。在垃圾收集时,只需要筛选变脏的卡页,然后加入GC Roots即可。卡表变脏显然是需要虚拟机负责维护的,但是经过即时编译后的代码已经是一条条机器指令了,这时就需要一种手段可以将维护卡表的操作放到引用赋值之中。HotSpot虚拟机通过写屏障 ( $Write\ \ Barrier$ ) 维护卡表状态。写屏障可以看作在虚拟机层面堆“引用类型字段赋值"这个动作的AOP切面,在引用赋值时产生一个环形 ( $Around$ ) 通知,供程序执行额外动作。赋值前的部分叫写前屏障 ( $Pre-Write\ \ Barrier$ ),赋值后的部分叫做写后屏障 ( $Post-Write\ \ Barrier$ )。写前屏障可以应用于SATB算法中,而写后屏障可以用于更新卡表中。
        在对象标记的过程中,我们引入三色标记 ( $Tri-color\ \ Marking$ ) 来辅导。白色指未被访问的对象,黑色指所有引用都被扫描过的对象,灰色指部分引用还未被扫描的对象。而在对象标记的过程中,如果用户线程仍在执行,那么可能会触发对象消失问题,即原本存活的对象被标记为死亡。当且仅当以下条件同时满足时,就会产生对象消失问题:

        而要避免这种情况,实现并发标记,那么就要破坏这两个条件的任意一个。对此有两种方案:增量更新 ( $Increment\ \ Update$ ) 和原始快照 ( $Snapshot\ \ At\ \ The\ \ Beginning$, $SATB$ )。增量更新破坏第一个条件,即记录黑色到白色的新引用,在扫描结束后再以它们为根重新进行一次扫描。原始快照破坏第二个条件,即记录灰色对象删除指向白色对象的引用,在扫描结束后以其中灰色对象为根再进行一次扫描。在HotSpot虚拟机中,这两种方案都有应用。

4. 经典垃圾收集器

4.1 Serial收集器

        Serial是最基础、历史最悠久的收集器。它是一个单线程收集器,也就是说在其进行垃圾收集时,其他所有工作都要暂停。对新生代的收集会采用复制算法,对老年代的收集会采用标记-整理算法。Serial收集器虽然是单线程,但是它相比于其他收集器有着简单高效的特点。对于内存资源受限的环境,它的内存消耗是最小的,而且内存资源小往往也意味着需要收集的对象少,因此停顿时间也不会很多。

4.2 ParNew收集器

        ParNew收集器本质上是Serial收集器的多线程版本,也就是除了使用多条线程并行扫描新生代外,其余的包括算法、控制参数和回收策略等都和Serial收集器一致。ParNew收集器和Serial收集器是仅有的能够与CMS收集器一起配合工作的收集器,其中ParNew是激活CMS后的默认收集器。

4.3 Parallel Scavenge收集器

        Parallel Scavenge收集器是一款新生代收集器,同样基于标记-复制算法实现,支持并行收集。它的目标是使处理器达到一个可控的吞吐量 ( $Throughput$ ),即用户代码运行时间与处理器运行时间的比值。由于与吞吐量密切相关,因此也被称作吞吐量优先收集器。Parallel Scavenge收集器提供了一种自适应的调节策略,允许虚拟机根据当前系统的运行情况实现动态调整Java堆中各个区域的大小。

4.4 Serial Old收集器

        Serial OldSerial收集器的老年代版本,同样也是一个单线程收集器,使用标记-整理算法。在服务器模式下可能有两种用途:在JDK 5之前版本中与Parallel Scavenge收集器搭配使用,或者作为CMS收集器发生失败时后备预案。

4.5 Parallel Old收集器

        Parallel OldParallel Scavenge的老年代版本,同样支持多线程,基于标记-整理算法。在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel ScavengeParallel Old这个组合。

4.6 CMS收集器

        CMS ( $Concurrent\ \ Mark\ \ Sweep$ ) 收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法。在JDK 8之后,CMS收集器逐渐被G1收集器所取代。它的运行过程分为四步:

        CMS具有并发收集、低停顿的优点。但是具有三个明显的缺点:

4.7 Garbage First收集器

        Garbage First,简称G1收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾收集器。在G1之前,收集器范围要么是新生代 ( Minor GC ),要么是老年代 ( Major GC ),要么是整个Java堆 ( Full GC )。而G1可以面向堆内存的任何部分组成回收集 ( $Collection\ \ Set$, $CSet$ ) 进行回收,称为Mixed GC。实现这个的关键是基于Region的堆内存布局。G1不是按照固定大小和固定数量的分代区域划分,而是将Java堆分为多个等大连续的区域,可以通过参数 $-XX:G1HeapRegionSize$ 设置,大小应为 $2$ 的 $N$ 次幂,每一个区域都可以作为EdenSurvivor或者老年代。Region中有一特殊的Humongous区域,专门用于存储大对象,只要对象大小超过了Region的一半就被认为是大对象。而对于超过了Region的大对象,会用几个连续的Humongous存放。G1的大多数行为都会把Humongous作为老年代看待。Region是每次收集的最小单元,这样可以避免Full GC。收集器会跟踪每个Region里的垃圾价值,价值即回收所获得的空间大小以及回收所需时间的经验值,并在后台维护一个优先级列表,每次根据用户设定收集允许停顿时间,通过 $-XX:MaxGCPauseMillis$ 设置,优先处理价值大的Region
        G1收集器的每个Region都会维护一个记忆集,以哈希表的形式,$Key$ 为其他指向自己的Region的起始地址,$Value$ 为一个存储着卡表索引号的集合,代表着 $Key$ 指向Region的范围。也因为这种复杂的卡表,G1比其他垃圾收集器有着更高的内存占用。
        为了保证标记的正确进行,CMS收集器使用了增量更新算法,而G1使用了原始快照 ( SATB ) 算法。此外,G1收集器为每个Region设计了两个TAMS ( $Top\ \ at\ \ Mark\ \ Start$ ) 指针,并发回收时的新分配对象必须要在这两个指针的位置上,G1会默认它们是存活的。G1收集器仍然存在与CMS相同的并发失败问题,一旦发生并发失败,那么同样也需要停顿用户线程。
        G1提供的 $-XX:MaxGCPauseMillis$ 参数可以设定允许停顿的时间,为此需要可靠的停顿预测模型,即预测停顿时间。G1通过衰减均值 ( $Decaying\ \ Average$ ) 理论,记录每个Region的回收耗时、脏卡数量等,由此得出统计信息,从而得到平均状态。
        可以将G1收集器的运作过程分为四步:

        从整体上看,G1基于标记-整理算法,但从局部上看,又是基于标记-复制算法,但无论如何,都意味着不会产生内存碎片。再者,由于G1的卡表复杂,因此不能像CMS那样通过写后屏障更新卡表,而是使用类似于消息队列的方式,再在之后异步处理。自G1开始,垃圾收集器的设计导向开始变为追求内存分配速率 ( $Allocation\ \ Rate$ ),而非清理干净,所以可以说G1是收集器技术发展的一个里程碑。

5. 低延迟垃圾收集器

        衡量垃圾收集器最重要的三个指标:内存占用 ( $Footprint$ )、吞吐量 ( $Throughput$ ) 和延迟 ( $Latency$ )。随着硬件的发展,延迟越来越重要。对于一些只进行短暂停顿的垃圾收集器,我们称为低延迟垃圾收集器 ( $Low-Latency\ \ Garbage\ \ Collector$ / $Low-Pause-Time\ \ Garbage\ \ Collector$ )。

5.1 Shenandoah收集器

        Shenandoah收集器是第一款不由Oracle公司的虚拟机团队领导开发的HotSpot垃圾收集器,被OracleJDK所排斥在外,只有OpenJDK才会包含,成为OpenJDK 12的正式特性之一。ShenandoahG1有着很多相似性,包括堆内存布局,初始标记、并发标记等阶段的处理思路等。但是它支持并发整理算法,即可以多线程回收;没有分代,即不存在新生代和老年代;摒弃了记忆集,改为连接矩阵 ( $Connection\ \ Matrix$ )。Shenandoah收集器工作过程可以划分为九个阶段:

        Shenandoah收集器做到短停顿的关键在于实现并发对象移动。在之前,要实现并发移动,通常是在被移动对象内存上设置保护陷阱 ( $Memory\ \ Protection\ \ Trap$ ),用户访问旧对象时会产生自陷中段,进入异常处理器中,再由异常处理器转发到新对象上。但是这种方案需要操作系统层面的支持,用户态要频繁地切换到核心态。Shenandoah使用了Brooks Pointer的概念,在对象结构前添加一个引用字段,在没有进行并发移动时指向自己,在并发移动时将其指向新对象。缺点也很明显,访问对象需要进行两次转发。此外,还要对转发指针进行同步,因为对旧对象进行写操作是无意义的,写操作只能对新对象进行,Shenandoah使用比较并交换 ( $Compare\ \ And\ \ Swap$, $CAS$ ) 操作来保证并发时对象的访问正确性。

5.2 ZGC收集器

        ZGC ( $Z\ \ Garbage\ \ Collector$ ) 是JDK 11中加入的低延迟垃圾收集器,但是与Shenandoah的实现思路具有显著差异。它基于Region内存布局,(暂时)不设分代,使用读屏障、染色指针 ( $Colored\ \ Pointer$ ) 和内存多重映射等技术实现并发的标记-整理算法。ZGC中的Region/Page可以动态创建和摧毁,具有动态大小,其中小型和中型Region容量固定,用于存放小对象,而大型Region容量不定,存放大对象。染色指针是一种直接将少量信息存放在指针的技术。$64$ 位Linux系统中高 $18$ 位不能寻址,染色指针技术从剩余的 $46$ 位中提取 $4$ 位作为标志位,也因此该技术限制了内存大小,也不支持 $32$ 位平台。四个标志从左到右分别是:是否只能通过 $finalize(\ )$ 方法访问、是否进入重分配集 ( 即被移动过 ) 以及引用对象的三色标记。染色指针很好的解决了写屏障的问题,因为不需要再去专门记录对象指针的变动。但是也带来了新问题:操作系统无法识别标志位。Linux/x86-64平台上的ZGC使用了多重映射 ( $Multi-Mapping$ ),将多个不同的虚拟内存地址映射到同一物理内存地址上,即多对一映射。以此,染色指针中的标志位可以视为分段符,只需要将这些地址段都映射到同一物理内存空间,就可以正常寻址了。
        ZGC的工作流程可以分为四个阶段,全部都可以并发执行:

        虽然ZGC给用户线程带来的负担很小,但它不能承受太高的对象分配速率,因为它会将垃圾收集期间的新对象都视为存活对象,从而在过高的分配速率下产生大量的浮动垃圾。

6. 垃圾收集器的选择

        衡量垃圾收集器的三个最重要的指标:内存占用、吞吐量和延迟。不同的垃圾收集器对这三个方面有不同的侧重点,同样的,不同的程序也对这三方面有着不同的要求。因此,可以根据这三方面进行选择。除此之外,收集器的选择还受到操作系统以及JDK发行商和版本的限制。

6.1 Epsilon收集器

        Epsilon是一款无操作的垃圾收集器 ( $No-Op\ \ Garbage\ \ Collector$ ),因为它并不能进行垃圾收集,更准确的工作应该是负责内存管理,即堆内存管理布局、对象分配、与解释器和编译器的协作等。Epsilon的主要应用在一些小型Java程序中,即只需要运行几分钟甚至数秒的程序,而Epsilon得工作就是保证虚拟机正确分配内存,使程序能在内存耗尽前退出。

6.2 虚拟机以及垃圾收集器日志

        每个收集器的日志格式可能都不一样,而且在JDK 9之前,HotSpot并没有提供统一日志框架,因此在其之前的日志系统很混乱。JDK 9之后,HotSpot将所有日志功能收归到了 $-Xlog$ 参数下。

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

        $Selector$ 用于选取日志输出,由标签和日志级别组成,标签可以视为虚拟机中某个功能模块的名字,垃圾收集器的标签名为 $gc$。日志级别分为六级:$Trace$, $Debug$, $Info$, $Warning$, $Error$, $Off$ 。$Decorator$ 用于附加额外内容,如时间、进程ID等。

7. 内存分配与回收策略

  1. 大多数情况下,对象在新生代Eden区中分配,当Eden中没有足够空间时,就会发起一次Minor GC
  2. 大对象容易导致垃圾收集提前触发,同时具有高昂的复制开销,通过 $-XX:PretenureSizeThreshold$ 参数 ( 只对SerialParNew有效 ),所有大于指定值的对象都会直接分配在老年代,从而减少复制操作。
  3. 虚拟机给每个对象定义了一个年龄计数器,存储在对象头中。通常对象在Eden区中诞生,每经历一次Minor GC并且进入Survivor区,年龄便加 $1$ 。到一定程度 ( 默认为 $15$ ),就会晋升至老年代,可以通过 $-XX:MaxTenuringThreshold$ 设置。不过为了更好的适应不同程序的内存状况,当Survivor中某个年龄的对象内存总和达到一半以上时,所有大于等于该年龄的对象都可以进入老年代。
  4. Minor GC之前,虚拟机要检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果为真,那么可以认为这次Minor GC是安全的;否则,查看 $-XX:HandlePromotionFailure$ 参数是否允许担保失败。如果允许,那么检查之前的最大可用连续空间是否大于历次晋升到老年对象的平均大小,如果小于将会尝试一次Minor GC。如果不允许,改为Full GC。但是在JDK 6之后,这个参数不再被使用,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC

JVM(2):垃圾收集器