0%

深入理解JVM&G1GC 笔记

本文整理自:《深入理解JVM&G1GC》 作者:周明耀
本书很一般,建议粗略的看看就行
出版时间:2017-06-01

推荐阅读官方文档:
https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-g1-garbage-collector1.html#GUID-3A99AE6C-F80A-4565-A27C-B4AEDF5CDF71

调优:
https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-garbage-collector-tuning.html#GUID-4914A8D4-DE41-4250-B68E-816B58D4E278

PDF版文档:
Hotspot Virtual Machine Garbage Collection Tuning Guide

JVM GC基本知识

引言

G1内部主要有四个操作阶段:

  • 年轻代回收(A Young Collection)
  • 运行在后台的并行循环(A Background,Concurrent Cycle)
  • 混合回收(A Mixed Collection)
  • 全量回收(A Full GC)

基本术语

Java相关术语

Interned Strings

在Java语言中有8种基本类型和一种比较特殊的类型 String这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。常量池就类似一个Java系统级别提供的缓存。8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的String对象会直接存储在常量池中
  • 如果不是用双引号声明的String对象,可以使用String提供的Intern方法。 intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。

Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串池的位置调整到Java堆内,这个改动意味着你再也不会被固定的内存空间限制了。所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。字符串池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用 String intern()。

Java 对象头

在HotSpot虚拟机中,对象在内存中的布局可以分成对象头、实例数据、对齐填充三部分。

  • 对象头:它主要包括对象自身的运行行元数据,比如哈希码、GC分代年龄、锁状态标志等,同时还包含一个类型指针,指向类元数据,表明该对象所属的类型。
  • 实例数据:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)。
  • 对齐填充:它不是必要存在的,仅仅起着占位符的作用

对象头大小在32位HotSpot VM和64位 HotSpot VM之间是不一样的,对象头在32位系统上占用8yte,在64位系统上占用16yte。我们可以通过Java对象布局工具获取头大小,这个工具简称为JOL。

G1 涉及术语

Metaspace

JDK8 HotSpot JVM使用本地内存来存储类元数据信息并称为元空间(Metaspace)。

默认情况下,大部分类元数据都在本地内存中分配,类元数据只受可用的本地内存限制(容量取决于是32位或是64位操作系统的可用虚拟内存大小)。新参数(MaxMetaspace Size)用于限制本地内存分配给类元数据的大小。如果没有指定这个参数,元空间会在运行时根据需要动态调整。

一般情况下,适时地监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。持续的元空间垃圾回收情况如果频繁发生,说明可能存在类、类加载器导致的内存泄漏或是大小设置不合适。

G1 GC与Metaspace相关的选项如下:

  • -XX:MetaspaceSize:初始化元空间的大小(默认12 Mbytes在32bit client VM and 16 Mbytes在32bit server VM,在64 bit VM上会更大些)。
  • -XX:MaxMetaspaceSize:最大元空间的大小(默认本地内存)。
  • -XX:MinMetaspaceFreeRatio:扩大空间的最小比率,当GC后,内存占用超过这一比率,就会扩大空间。
  • -XX:MaxMetaspaceFreeRatio:缩小空间的最小比率,当GC后,内存占用低于这一比率,就会缩小空间。

Mixed GC Event

即混合GC事件,在这个事件内部,所有的年轻代Region和一部分老年代Region一起被回收。混合GC事件一定是跟在Minor GC之后的,并且混合GC只有在存活对象元数据存在的情况下才会触发。

Reclaimable

Gl GC为了能够回收,创建了一系列专门用于存放可回收对象的Region。这些Region都在个链表队列里面,这个队列只包含存活率小于-XX: G1MixedGCLiveThresholdPercent(默认85%)的Region。Region的值除以整个Java堆区,如果大于-XX:G1HeapWastePercen(默认5%),则启动回收机制。

Rset

全称Remembered Set,简称Rset,即跟踪指向某个堆区(Region)内的对象引用。

在标记存活对象时,G1使用RememberSet的概念,将每个分区外指向分区内的引用记录在该分区的RememberSet中,避免了对整个Heap的扫描,使得各个分区的GC更加独立。堆内存中的每个区都有一个RSet,Rset的作用是让堆区能并行独立地进行垃圾集合。RSet所占用的JVM内存小于总大小的5%。在这样的背景下,可以看出G1GC大大提高了触发 Full GC时的Heap占用率,同时也使得 Minor GC的暂停时间更加可控,对于内存较大的环境非常友好。

G1 GC引入了一些新的选项。G1RSetUpdatingPauseTimePercent设置STW阶段(独占阶段)为G1收集器指定更新RememberSet的时间占总STW时间的期望比例,默认为10。而G1ConcRefinementThreads则是在程序运行时维护RememberSet的线程数目。通过对这两个值的对应调整,我们可以把STW阶段的RememberSet更新工作压力更多地移到并行阶段。

CSet

全称Collection Set,简称CSet,即收集集合,保存一次GC中将执行垃圾回收的区间(Region)。GC时在CSet中的所有存活数据(Live Data)都会被转移(复制/移动)。集合中的堆区可以是Eden, Survivor和/或Old Generation。CSets所占用的JVM内存小于总大小的1%。

从这里可以知道,实际上CSet相当于一个大圈,里面包含了很多的小圈(Rset),这些圈圈都是需要被回收的信息。这样可以把CSet比作垃圾场,RSet是垃圾场里面一个个绿色的可回收垃圾桶。

PLAB

全称为Promotion Local Allocation Buffers,它被用于年轻代回收。PLAB的作用是避免多线程竞争相同的数据,处理方式是每个线程拥有独立的PLAB,用于针对幸存者和老年空间。当应用开启的线程较多时,最好使用-XX:-ResizePlaB来关闭PLAB()的大小调整,以避免大量的线程通信所导致的性能下降。

TLAB

全称为Thread Local Allocation Buffers,即线程本地分配缓存,是一个线程专用的内存分配区域。

总的来说,TLAB是为了加速对象分配而生的。由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每一次对象分配都必须要进行同步,而在竞争激烈的场合分配的效率又会进一步下降。考虑到对象分配几乎是Java最最常用的操作,所以JVM就使用了TLAB这种线程专属的区间来避免多线程冲突,提高对象分配的效率。TLAB本身占用了Eden区的空间,即JVM会为每一个Java线程分配一块TLAB空间。

对于G1 GC来说,TLAB是Eden的一个Region,被一个单一线程用于分配资源。主要用途是让一个线程通过栈操作方式独享内存空间,用于对象分配,这样比多个线程之间共享资源要快很多。如果每个线程的分配内存不够,那么它会去全局内存池申请新的内存。这样也就是说,如果TLAB值设置过小,容易造成频繁申请,也就会造成GC性能下降。反之,如果设置过大,会造成TLAB使用不完,也就是说内存浪费。

Region

从字面上来说, Region表示一个区域,每个区域里面的字母代表不同的分代内存空间类型(如[E]Eden,[O]Old,[S]Survivor),空白的区块不属于任何一个分区。G1可以在需要的时候任意指定这个区域属于Eden或是O区之类的。

Ergonomics Heuristic Decision

在很多英文书里都能看到这串单词,特别是Ergonomics Heuristi,它们的字面意思是人体工程学,可以理解为适合人类理解的行为、习惯。GC日志里面看到Ergonomics这个单词,它后面一般跟着的是G1 GC相关的详细描述,比如堆内存日志、CSet划分等,通常采用选项-XX:+PrintAdaptiveSizePolicy时会看到这个单词。

Top-at-mark-start

每个区间记录着两个TAMS指针(Top-at-mark-start),分别为prevTAMS和nextTAMS在TAMS以上的对象是新分配的,因而被视为隐式标记。

JVM&GC 深入知识

Java虚拟机内存模型

程序计数器

程序计数器,英文全称Program Counter Register,它是一块很小的内存空间,它是运行速度最快的存储区域,这是因为它位于不同于其他存储区的地方—处理器内部。寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配。实际上在Java应用程序内部不能直接控制寄存器,也不能在程序中感觉到寄存器存在的任何迹象。可以把程序计数器看作当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器的工作就是通过改变程序计数器的值来选择下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。

简单概括上面的描述,即在多线程环境下,为了让线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间互不影响、独立存储,因此这块内存是线程私有的。JVM中的寄存器类似于物理寄存器的一种抽象模拟,正如前面说的,它是线程私有的,所以生命周期与线程的生命周期保持一致。

根据Java虚拟机定义来看,程序寄存器区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemory Error情况的区域。

虚拟机栈

JVM的架构是基于栈的,即程序指令的每一个操作都要经过入栈和出栈这样的组合型操作才能完成。

总的来说,栈的优势是访问速度比堆要快,它仅次于寄存器,并且栈数据是可以被共享的。栈的缺点是存储在栈里面的数据大小与生存期必须是确定的,从这一点来看,栈明显缺乏灵活性。虚拟机栈内主要被用来存放一些基本类型的变量,例如int、 short、long、byte、foat、 double、boolea、char,以及对象引用。

前面说过,虚拟机栈有一个很重要的特殊性,就是存放在栈内的数据可以共享。假设同时定义:

1
2
int a=1;
int b=1;

对于上面的代码,虚拟机处理第一条语句,首先它会在栈内创建一个变量为a的引用,然后查找栈内是否有1这个值,如果没找到,就将1存放进来,然后将a指向1。接下来处理第二条语句,在创建完b的引用变量后,因为在栈内已经有1这个值,便将b直接指向1。这样,就出现了a与b同时均指向1的情况。这时,如果存在第三条语句,它针对a再次定义为a=4,那么编译器会重新搜索栈内是否有4值,如果没有,则将4存放进来,并令a指向4,如果已经有了,则直接将a指向这个地址,因此a值的改变不会影响到b的值。要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享的方式存在明显的不同,因为这种情况a的修改并不会影响到b,它是由虚拟机完成的,这样的做法有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。

与程序计数器一样,Java虚拟机栈也是线程私有的内存空间,它和Java线程在同一时间创建,它保存方法的局部变量、部分结果,并参与方法的调用和返回

虚拟机栈在运行时使用一种叫作栈帧的数据结构保存上下文数据,栈帧里面存放了方法的局部变量表、操作数栈、动态连接方法和返回地址等信息。每一个方法的调用都伴随着栈帧的入栈操作,相应地,方法的返回则表示栈帧的出栈操作。

使用JClassLib工具可以查看Class文件中每个方法所分配的最大局部变量区的容量。JClassLib工具是开源软件,它可以用于查看 Class文件的结构,包括常量池、接口、属性、方法,还可以用于查看文件的字节码。

Java堆

Java堆区在JVM启动的时候即被创建,它只要求逻辑上是连续的,在物理空间上可以是不连续。所有的线程共享Java堆,在这里可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

正是因为Java堆区是GC的重点回收区域,所以GC极有可能会在大内存的使用和频繁进行垃圾回收过程上成为系统性能瓶颈。为了解决这个问题,JVM的设计者们开始考虑是否一定需要将对象实例存储到Java堆区内。基于OpenJDK深度定制的TaobaoJVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

方法区

方法区主要保存的信息是类的元数据。方法区与堆空间类似,它也是被JVM中所有的线程共享的区域。如下图所示,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息。类型信息包括类的完整名称、父类的完整名称、类型修饰符(public/protected/private)和类型的直接接口类表。

常量池包括类方法、域等信息所引用的常量信息。域信息包括域名称、域类型和域修饰符。方法信息包括方法名称、返回类型、方法参数、方法修饰符、方法字节码、操作数栈和方法栈帧的局部变量区大小以及异常表。方法区是线程间共享的,当两个线程同时需要加载一个类型时,只有一个类会请求ClassLoader加载,另一个线程则会等待。总而言之,方法区内保存的信息大部分来自于Class件,是Java应用程序运行必不可少的重要数据。

在Hotspot虚拟机中,方法区也被称为永久区,是一块独立于Java堆的内存空间。虽然被叫作永久区,但是在永久区中的对象同样也是可以被GC回收的,只是对于GC的对应策略与Java堆空间略有不同。

GC针对永久区的回收,通常主要从两个方面分析:一是GC对永久区常量池的回收,二是永久区对类元数据的回收。HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

垃圾收集算法

根搜索算法

在HotSpot中,根对象集合中包含了5个元素,Java栈内的对象引用、本地方法栈内的对象引用、运行时常量池中的对象引用、方法区中类静态属性的对象引用以及与一个类对应的唯一数据类型的Class对象。

这部分了解一下就好
注意,在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或者finalized方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行finalize方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由条由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize方法中执行缓慢,或者发生了死循环(更极端的情况),很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalized中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合。如果对象这时候还没有逃脱,那它就真的离死不远了。

标记清除算法(Mark-Sweep)

算法涉及几个概念,先来了解一下mutator和collector,这两个名词经常在垃圾收集算法中出现,collector指的就是垃圾收集器,而 mutator是指除了垃圾收集器之外的部分,比如说我们的应用程序本身。mutator的职责一般是NEW(分配内存)、READ(从内存中读取内容)、WRITE(将内容写入内存),而collector则就是回收不再使用的内存来供mutator进行NEW操作的使用。mutator根对象一般指的是分配在堆内存之外,可以直接被mutator直接访问到的对象,一般是指静态/全局变量以及ThreadLocal变量。

复制算法(Copying)

基于分代的概念,Java堆区如果还要更进一步细分的话,还可以划分为年轻代(YoungGen)和老年代(OldGen),其中年轻代又可以被划分为Eden空间、From Survivor空间和To Survivor空间。在HotSpot中,Eden空间和另外两个Survivor空间默认所占的比例是8:1,当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。当执行一次Minor GC(年轻代的垃圾回收),Eden空间中的存活对象会被复制到To空间内,并且之前已经经历过一次 Minor GC并在From空间中存活下来的对象如果还年轻的话同样也会被复制到To空间内。需要注意的是,在满足两种特殊情况下,Eden和From空间中的存活对象将不会被复制到To空间内。首先是如果存活对象的分代年龄超过选项“-XX:MaxTenuringThreshold”所指定的阈值时,将会直接晋升到老年代中。其次当To空间的容量达到阈值时,存活对象同样也是直接晋升到老年代中。当所有的存活对象都被复制到To空间或者晋升到老年代后,剩下的均为垃圾对象,这就意味着GC可以对这些已经死亡了的对象执行一次Minor GC,释放掉其所占用的内存空间。

标记压缩算法(Mark-Compact)

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。简单来说,就是针对不同的代空间,从而结合使用不同的垃圾收集算法。为年轻代选择的垃圾收集算法通常是以速度优先,因为年轻代中所存储的瞬时对象生命周期非常短暂,可以有针对性地使用复制算法,因此执行Minor GC时,一定要保持高效和快速。而年轻代中的生存空间通常都比较小,所以回收年轻代时一定会非常频繁。但老年代通常使用更节省内存的回收算法,因为老年代中所存储的对象生命周期都非常长,并且老年代占据了大部分的堆空间,所以老年代的Full GC并不会跟年轻代的Minor GC一样频繁,不过一旦程序中发生一次Full GC,将会耗费更长的时间来完成,那么在老年代中使用标记-清除算法或者标记-压缩算法执行垃圾回收将会是不错的选择。

Garbage Collection

GC 概念

在许多情况下,GC不应该成为影响系统性能的瓶颈,可以根据以下六点来评估一款GC的性能。

  • 吞吐量:程序的运行时间(程序的运行时间+内存回收的时间)。
  • 垃圾收集开销:吞吐量的补数,垃圾收集器所占时间与总时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 堆空间:Java堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

Parallel收集器

需要注意的是,垃圾收集器中吞吐量和低延迟这两个目标本身是相互矛盾的,因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

举个例子,在60s的JVM总运行时间里,GC的执行频率是20秒/次,那么60s内一共会执行3次内存回收,按照每次GC耗时100ms来计算,最终一共会有300ms(3×100)被用于执行垃圾回收。但是如果我们将选项“-XX:MaxGCPauseMills”的值调小后,年轻代的内存空间也会自动调整,内存空间越小就越容易被耗尽,也就越容易造成GC的执行频繁发生。之前在60s的JVM总运行时间里,最终会有300ms被用于执行内存回收,而如今GC的执行频率却是10s/次,60s内将会执行6次内存回收,按照每次GC耗时60ms来计算,虽然看上去暂停时间更短了,但最终会耗时360ms(6×60)用于执行内存回收,很明显程序的吞吐量下降了。所以大家在设置这两个选项时,一定需要注意控制在一个折中的范围之内。Parallel收集器还提供个“-XX:UseAdaptiveSizePolicy”选项用于设置GC的自动分代大小调节策略,一旦设置这个选项后,就意味着开发人员将不再需要显式地设置年轻代中的一些细节参数,JVM会根据自身当前的运行情况动态调整这些相关参数。

Garbage First (G1) GC

G1很重视老年代的垃圾回收,一旦整个堆空间占有率达到指定的阈值(启动时可配置),G1会立即启动一个独占的并行初始标记阶段(initial-mark phase)进行垃圾回收。在G1 GC,判断的是整个Java堆内部老年代的占有率,足以见G1对老年代的重视。

初始标记阶段一般和年轻代GC一起运行,一旦初始标记阶段结束,并行多线程的标记阶段就开始启动去标记所有老年代还存活的对象,注意这个标记阶段不是独占式的,它允许应用程序线程和它并行执行。当这个标记阶段运行完毕之后,为了再次确认是否有逃过扫描的对象,“启动一个独占式的再次标记阶段(remark phase),尝试标记所有遗漏的对象。在这个再次标记阶段结束之后,G1就掌握了所有的老年代 Region的标记信息,这和国家的户口统计方式差不多。一旦老年代的某些Region内部不存在任何的存活对象,它就可以在下一个阶段,即清除阶段(cleanup phase)被清除了,就是可以销户了,又被放回了可用Region队列。同样地,再次标记阶段结束后就可以对一些老年代执行收集动作。

前面提到了CSet概念,一个CSet里面可以包含多少Region取决于多少空间可以被释放、G1停顿目标时间这两个因素。前面说起过混合GC(Mixed GC),这里就要具体说明一下了。当CSet被确定之后,会在接下来的一个年轻代回收过程当中对CSet进行回收,通过年轻代GC的几个阶段,一部分的老年代Region会被回收并放入年轻代使用。这个概念很灵活,即G1只关注你有没有存活对象了,如果没有,无论你属于老年代,还是属于年轻代,你都会被回收并放入可用Region队列,下一次你被分配到哪里就不确定了。也正是因为Region、混合收集这些特性,让G1对老年代的垃圾收集方式有别于Serial GC、Parallel GC和CMS GC,G1采用Region方式让对象之间的联系存在于虚拟地址之上,这样就不需要针对老年代的压缩和回收动作对整个Java堆执行扫描,为老年代回收节约了时间。

G1 设计思路

Gl把整个Java堆划分为若干个区间(Region)。每个Region大小为2的倍数,范围在1MB~32MB之间,可能为1MB、2MB、4MB、8MB、16MB、32MB。所有的Region有一样的大小,在JVM生命周期内不会被改变。

注意,在年轻代、混合代、Full GC这三个阶段,年轻代的Eden Region和Survivor Region的数量会随时变化。Humongous Region(大对象 Region)是老年代Region的一部分,里面的对象超过每个Region的50%空间,这一点有别于一般对象Region。

从之前的介绍我们知道没有必要去刻意区分Region的用途,因为G1设计Region的分配原则是很灵活的。一开始G1会从可用 Region队列里面挑选出Region并设置为Eden Region,一个Eden Region里面填满对象以后,又会从可用Region队列里再挑出一个。当所有的Eden Region都被填满时,一个年轻代GC收集就会开始执行了,在这个收集阶段,我们会收集Eden和Survivor Region,所有的存活对象要么进入到下一个Survivor region,要么进入老年代Region。

G1提供了一个选项-XX:InitiatingHeapOccupancyPercent,默认值是Java堆空间的45%,这个选项决定了是否开始一次老年代回收动作,即年轻代GC结束之后,G1会评估剩余的对象是否达到了45%这个阈值。

如果标记阶段(Marking Phase)结束后一个老年代的Region已经不存在对象,那么它会被放回可用Region队列,反之,它会被放入混合收集器。

由于标记阶段不是一个独占式的多线程并行程序,这样应用程序线程就会和它一起并行执行。为了避免标记阶段占用过多的CPU资源,G1采用时间片方式分段执行操作,即在时间片内全力运行,然后休息一段时间,这个休息时间就是让应用程序尽可能多地使用CPU资源运行。

大对象(Humongous Object)

大对象Region属于老年代的一部分,它只包含一个对象。当并行标记阶段发现没有存活对象时,G1会回收这个大对象 Region,注意这个动作可以是一个批量回收。

全垃圾收集(Full Garbage Collection)

G1的Full GC和Serial GC的Full GC采用的是同一种算法。Full GC会对整个Java堆进行压缩。G1的Full GC是单线程的,会引起较长的停顿时间,因此G1的设计目标是减少Full GC的发生次数。

并行循环(Concurrent Cycle)

一个G1并行循环包括几个阶段的活动:初始标记(Initial Marking)、并行Root区间扫描(Concurrent Root Region Scanning)、并行标记(Concurrent Marking)、重标记(Remarking)和清除(Cleanup)。除了最后的Cleanup阶段以外,其余阶段都属于标记存活对象阶段。

初始标记阶段的目的是收集所有GC根(Roots)。 Roots是一个对象的起源指针。为了收集根引用,从应用线程开始,应用线程必须停下来,所以初始标记阶段是一个独占式的。由于个年轻代GC必须收集所有的Roots,所以G1的初始标记在一个年轻代GC里完成。

并行根区间扫描阶段必须扫描和标记所有幸存者区间的对象引用,这一阶段所有的应用程序线程都可以并行执行,唯一的约束是扫描必须在下一个GC开始前完成。这一约束的原因是个新的GC事件会产生一堆新的幸存者对象集合,这些对象和初始化标记阶段的幸存者对象不一样,容易发生混淆。

并行标记阶段完成了几乎所有的标记工作。在这一阶段,利用多线程并行标记存活对象及对应的逻辑地图。这一阶段允许所有的Java线程并行执行,但是对应用程序来说总体的吞吐量可能会下降。其实任何一个系统都和人体循环一样,当没有外部干扰时,系统可以正常运行,如果受到外部干扰,人体系统也会出现混乱,甚至出现短时间的休克。

重标记阶段是一个独占式阶段,通常是一个很短的停顿,这个阶段会完成所有的标记工作。

最后一个并行标记步骤是清除阶段。在这个阶段,没有包含存活对象的Region会被回收,并随即被加入可用Region队列。这个阶段的重要意义是最终决定了哪些 Region可以进入混合GC。在G1内部,混合GC是非常重要的释放内存机制,避免了G1出现没有可用Region的情况发生,否则就会触发Full GC事件。

堆大小(Heap Sizing)

G1在以下几种情况下可能会增大堆内存大小:

  • Full GC阶段。
  • Young或Mixed GC发生时,G1计算GC花费的时间与Java线程的花费时间比例,如果-XX:GCTimeRatio设置GC花费时间很长,则堆大小会增大,这样的设计思路是希望G1发生GC的频率降低,这样GC花费时间和Java线程花费时间比例也会相应下降。
    -XX:GCTimeRatio选项的默认值是9,所有其他HotSpot GC的默认值是99。这个值越大,代表Java堆空间大小增长越偏激,即越容易扩大堆空间大小,这样也是为了达到降低GC花费时间的设计目标。
  • 如果一个对象分配失败,即便一个GC刚刚结束,G1采用的策略不是立即重复Full GC,而是通过增大堆内存大小,确保对象分配成功。这样的设计理念符合G1的避免Full GC发生的最初思想。
  • 和第3条一样,如果出现一个大对象分配失败,前面说过,大对象需要几个连续的Region区间才能确保对象分配成功。如果发生这种分配失败的情况,采用的设计理念也不是调用Full GC,而是扩大堆内存。
  • 当GC申请加入一个新的Region时。

引用一段在StackOverfall.com上看到的经验分享,”我在一个真实的、较大规模的应用程序中使用过G1:大约分配有60GB-70GB内存,存活对象大约在20GB~50GB之间。服务器运行Linux操作系统,JDK版本为6u22。G1与PS/PS Old相比,最大的好处是停顿时间更加可控可预测。如果我在PS中设置一个很低的最大允许GC时间,譬如期望50ms内完成GC(-XX:MaxGCPauseMillis=50),但在65GB的Java堆下有可能得到的直接结果是一次长达30s至2min的漫长的Stop-the-World过程。而Gl与CMS相比,它们都立足于低停顿时间,CMS仍然是我现在的选择,但是随着Oracle对G1的持续改进,我相信Gl会是最终的胜利者。如果你现在采用的收集器没有出现问题,那么就没有任何理由现在去选择G1;如果你的应用追求低停顿,那么G1现在己经可以作为一个可尝试的选择:如果你的应用追求吞吐量,那么G1并不会为你带来什么特别的好处。”

G1 GC应用示例

G1 GC给我们提供了很多的命令行选项,也就是参数,这些参数一类以布尔类型打头,“+”表示启用该选项,“-”表示关闭该选项。另一类采用数字赋值,不需要布尔类型打头。

选项解释及应用

首先在cmd命令行模式下输入java -X,,如C:Users\Administrator> java -X,输出如代码如下:

-XX:+PrintGCDetails

该选项用于记录GC运行时的详细数据信息并输出,是最基本、使用最普遍的一个选项这个选项适用于所有GC,输出内容主要包括新生成对象占用内存大小以及耗费时间、各个年龄代的情况、每次回收的对应数据等。

-Xloggc

如果想要以文件形式保存这些GC日志,可以在启动参数中输入-XX:+PrintGCDetails -verbose:gc -XLoggc:gc.log,运行后我们会发现生成了一个 gc.log文件。

-Xloggc:example_gc.log (设置垃圾回收日志打印的文件,文件名称可以自定义)

-XX:initialHeapSize和-XX:MaxHeapSize就是我们比较熟悉的-Xms和-Xmx,它们允许我们指定JVM的初始和最大堆内存大小-XX:+UseCompressedClassPointers、XX:+UseCompressedOops
以及-XX:-UseLargePagesIndividualAllocation这三个选项和OOP有关。OOP的全称是Ordinary Object Pointer,即普通对象指针。通常64位JVM消耗的内存会比32位的大1.5倍,这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址)。对于那些将要从32位平台移植到64位的应用来说,平白无故多了1/2的内存占用,作为开发者一定不愿意看到这种场景。所以,从JDK1.6 update4开始,64 bit JVM正式支持了-XX:+UseCompressedOops这个可以压缩指针,起到节约内存占用的选项。CompressedOops的实现方式是在机器码中植入压缩与解压指令,可能会给JVM增加额外的开销。-XX:+UseCompressedClassPointers选项是在JDK8出现的,也是在永久区消失之后出现的新的选项,主要用于对类的元数据进行压缩。-XX:UseLargePagesIndividualAllocation和oops是一起使用的,在大页内存使用发生时这个选项也会自动启用。

-XX:+PrintGCApplicationStoppedTime

打印垃圾回收期间程序暂停的时间,如果使用该选项,会输出GC造成应用程序暂停的时间。一般和-XX:+PrintGCApplicationConcurrentTime组合起来一起使用,这样比较有利于查看输出。

-XX:ConcGCThreads

这个选项用来设置与Java应用程序线程并行执行的GC线程数量,默认为GC独占时运行线程的1/4。这个选项设置过大会导致Java应用程序可以使用的CPU资源减少,如果小一点则会对应用程序有利,但是过小就会增加GC并行循环的执行时间,反过来减少Java应用程序的运行时间(因为独占期时间拉长)。

-XX:G1HeapRegionSize

这是G1GC独有的选项,它是专门针对Region这个概念的对应设置选项,后续GC应该会继续采用 Region这个概念。 Region的大小默认为堆大小的1/200,.也可以设置为1MB、2MB、4MB、8MB、16MB,以及32MB,这六个划分档次。

增大Region块的大小有利于处理大对象。前面介绍过,大对象没有按照普通对象方式进行管理和分配空间,如果增大Region块的大小,则一些原本走特殊处理通道的大对象就可以被纳入普通处理通道了。这就好比我们在机场安检,飞行员、空姐可以走特殊通道,乘客如果也搞特殊化,一部分人去特殊通道处理,那么特殊通道就得増加几个,相应的普通通道就得减少了,对效率就起了降低作用。反之,如果Region大小设置过小,则会降低G1的灵活性,对于各个年龄代的大小都会造成分配问题。

-XX:G1HeapWastePercent

这个选项控制G1 GC不会回收的空闲内存比例,默认是堆内存的5%。G1 GC在回收过程中会回收所有Region的内存,并持续地做这个工作直到空闲内存比例达到设置的这个值为止,所以对于设置了较大值的堆内存来说,需要采用比较低的比例,这样可以确保较小部分的内存不被回收。这个很容易理解,城市越大就越容易出现一些死角,出于性能的原因可以不去关注那里,但是这个比例不能大。

-XX:G1MixedGCCountTarget

老年代Region的回收时间通常来说比年轻代Region稍长一些,这个选项可以设置一个并行循环之后启动多少个混合GC,默认值是8个。设置一个比较大的值可以让G1 GC在老年代Region回收时多花一些时间,如果一个混合GC的停顿时间很长,说明它要做的事情很多,所以可以增大这个值的设置,但是如果这个值过大,也会造成并行循环等待混合GC完成的时间相应的增加。

当占用内存超过InitiatingHeapOccupancyPercent阀值时, 最多通过多少次Mixed GC来将内存控制在阀值之下。

-XX:+G1PrintRegionLivenessInfo

由于开启这个选项会在标记循环阶段完成之后输出详细信息,专业一点的叫法是诊断选项,所以在使用前需要开启选项UnlockDiagnosticVMOptions。这个选项启用后会打印堆内存内部每个Region里面的存活对象信息,这些信息包括使用率、RSet大小、回收一个Region的价值(Region内部回收价值评估,即性价比)。

这个选项输出的信息对于调试堆内Region是很有效的,不过对于一个很大的堆内存来说,由于每个 Region信息都输出了,所以信息量也是挺大的。

-XX:G1ReservePercent

每个年龄代都会有一些对象可以进入下一个阶段,为了确保这个提升过程正常完成,我们允许G1GC保留一些内存,这样就可以避免出现“ to space exhausted”错误,这个选项就是为了这个用途。

这个选项默认保留堆内存的10%。注意,这个预留内存空间不能用于年轻代。

对于一个拥有大内存的堆内存来说,这个值不能过大,因为它不能用于年轻代,这就意味着年轻代可用内存降低了。减小这个值有助于给年轻代留出更大的内存空间、更长的GC时间,这对提升性能吞吐量有好处。

-XX:+G1SummarizeRSetStats

和GIPrintRegionLivenessInfo选项一样,这个选项也是一个诊断选项,所以也需要开启UnlockDiagnosticVMOptions选项后才能使用,这也就意味着-XX:+UnlockDiagnosticVMOptions选项需要放在-XX:+G1SummarizeRSetStats选项的前面。

这个选项和-XX:G1SummarizePeriod一起使用的时候会阶段性地打印RSets的详细信息,这有助于找到RSet里面存在的问题。

-XX:+G1TraceConcRefinement

这是一个诊断选项。如果启动这个诊断选项,那么并行Refinement线程相关的信息会被打印。注意,线程启动和结束时的信息都会被打印。

这里提到了Refinement线程,我们来提前梳理这个概念。请看每一代GC对应的GC线程:

Garbage Collector Worker Threads Used
Parallel GC ParallelGCThreads
CMS GC ParallelGCThreads
ConcGCThreads
G1 GC ParallelGCThreads
ConcGCThreads
G1ConcRefinementThreads

上面列出了三类GC线程,分别是ParallelGCThreads、ConcGCThreads和G1ConcRefinementThreads。关于这三个线程的区别:

名称 选项控制 作用
ParallelGC Thread -XX:ParallelGCThreads GC的并行工作线程,专门用于独占阶段的工作,比如拷贝存活对象
ParallelMarkingThreads -XX:ConcGCThreads 并行标记阶段的并行线程,它由一个主控(Master)线程和一些工作(Worker)线程组成,可以和应用程序并行执行
G1ConcurrentRefinementThreads -XX:G1ConcRefinementThreads 和应用程序一起运行,用于更新RSet,如果ConcurrentRefinementThreads没有设置,那么默认为ParallelGCThreads+1

-XX:+G1UseAdaptiveConcRefinement

这个选项默认是开启的。它会动态地对每一次GC中XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone、-XX:G1ConcRefinementRedZone的值进行重新计算。

并行Refinement线程是持续运行的,并且会随着update log buffer积累的数量而动态调节。前面说到的三个配置选项-XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone、-XX:G1ConcRefinementRedZone,是被用来根据不同的 buffer使用不同的Refinement线程,目的就是为了保证 Refinement线程一定要尽可能地跟上update log buffer产生的步伐。但是这个Refinement线程不是无限增加的,一旦出现 Refinement线程跟不上update log buffer产生的速度、update log buffer开始出现积压的情况,Mutator线程(即应用业务线程)就会协助Refinement线程执行RSet的更新工作。这个 Mutator线程实际上就是应用业务线程,当业务线程去参与Rset修改时,系统性能一定会受到影响,所以需要尽力去避免这种状况。

-XX:GCTimeRatio

这个选项代表Java应用线程花费的时间与GC线程花费时间的比率。通过这个比率值可以调节Java应用线程或者GC线程的工作时间,保障两者的执行时间.

HotSpot VM转换这个值为一个百分比,公式是100/(1+GCTimeRatio),默认值是9,表示花费在GC工作量上的时间占总时间的10%。

-XX:+HeapDumpBeforeFullGC/-XX:+HeapDumpAfterFullGC

这个选项启用之后,在Full GC开始之前有一个hprof文件会被创建。建议这个选项和-XX:+HeapDumpAfterFullGC一起使用,可以通过对Full GC发生前后的Java堆内存进行对比,找出内存泄漏和其他问题。

获取full GC前后的heap dump

-XX:InitiatingHeapOccupancyPercent

该选项的默认值是45,表示G1 GC并行循环初始设置的堆大小值,这个值决定了一个并行循环是不是要开始执行。它的逻辑是在一次GC完成后,比较老年代占用的空间和整个Java堆之间的比例。如果大于这个值,则预约下一次GC开始一个并行循环回收垃圾,从初始标记阶段开始。这个值越小,GC越频繁,反之,值越大,可以让应用程序执行时间更长。不过在内存消耗很快的情况下,我认为早运行并行循环比晚运行要好,看病要趁早。

-XX:+UseStringDeduplication

该选项启动Java String对象的去重工作。JDK8u20开始引入该选项,默认为不启用。我们知道一个判断Java String对象值是否一样的语句“Stringl equals(String2)tue”,如果开启了该选项,并且如果两个对象包含相同的内容,即返回“tue”,则两个String对象只会共享一个字符数组。这个选项是G1GC独有的,也可以和其他GC一起使用。

延伸一点我们的知识面,一个去重对象的必备条件有如下三点:

  • Java.lang String对象的一个实例。
  • 这个对象在年轻代堆区间。
  • 这个对象的年龄达到去重年龄代,或者这个对象已经在老年代堆区间并且对象年龄比去重年龄小。选项-XX:StringDeduplicationAgeThreshold设置了这个年龄界限。

前面介绍过的可修改和不可修改字符串的处理方式有所不同,不可修改字符串默认就是去重的,在插入到HotSpot VM的String Table时已经注明了是去重的,这样就避免了HotSpot服务器JIT编译优化措施。

-XX:StringDeduplicationAgeThreshold

这个选项是针对-XX:+UseStringDeduplication选项的,默认值是3。它的意思是一个字符串对象的年龄超过设定的阈值,或者提升到G1 GC老年代Region之后,就会成为字符串去重的候选对象,去重操作只会有一次。

-XX:+PrintStringDeduplicationStatistics

这个选项挺有用的,能够帮助我们通过读取输出的统计资料来了解是否字符串去重后节约了大量的堆内存空间,默认是关闭的,就是说不会输出字符串去重的统计资料。

-XX:+G1UseAdaptiveIHOP

JDK9提供的新的选项。这个选项的作用是通过动态调节标记阶段开始的时间,以达到提升应用程序吞吐量的目标,主要通过尽可能迟地触发标记循环方式来避免消耗老年代空间。

这个选项的值在VM刚开始启动时和-XX:InitiatingHeapOccupancyPercent的值一样,如果出现标记循环阶段内存不够用,则它会自动调节大小,确保标记循环启用更多的堆内存。

注意,-XX:+G1UseAdaptiveIHOP这个选项会在JDK9里默认启用,即-XX:InitiatingHeapOccupancyPercent和XX:+GIUseAdaptivelHOP在JDK9之后只需要启用一个就可以了。

JDK8环境下运行该选项会输出:“Unrecognized VM option ‘G1UseAdaptivelHOP’”

-XX:+MaxGCPauseMills

这个选项比较重要。它设置了G1的目标停顿时间,单位是ms,默认值为200ms。这个值是一个目标时间,而不是最大停顿时间。G1 GC尽最大努力确保年轻代的回收时间可以控制在这个目标停顿时间范围里面,在G1GC使用过程中,这个选项和-Xms、Xmx两个选项一起使用,它们三个也最好在JVM启动时就一起配置好。

-XX:+MinHeapFreeRatio

这个选项设置堆内存里可以空闲的最小的内存空间大小,默认值为堆内存的40%。当空闲堆内存大小小于这个设置的值时,我们需要判断-Xms和-Xmx这两个值的初始化设置值,如果-Xms和-Xmx不一样,那么我们就有机会扩展堆内存,否则就无法扩展。

-XX:+MaxHeapFreeRatio

这个选项设置最大空闲空间大小,默认值为堆内存的70%。这个选项和上面那个最小堆内存空闲大小刚好相反,当大于这个空闲比率时,G1 GC会自动减少堆内存大小。需要判断-Xms和-Xmx这两个值的初始化设置值,如果-Xms和-Xmx不一样,那么就有机会减小堆内存,否则就无法减小。

-XX:+PrintAdaptiveSizePolicy

这个选项决定是否开启堆内存大小变化的相应记录信息打印,即是否打印这些信息到GC日志里面。这个信息对于Parallel GC和G1 GC都很有用。

-XX:+ResizePLAB

GC使用的本地线程分配缓存块采用动态值还是静态值进行设置是由这个选项决定的,它默认是开启的,这个设置对应的是GC在提升对象时是否会调整PLAB的大小。

这个选项大家还是慎用,据说会出现性能问题,启用后可能会增加GC的停顿时间。当应用开启的线程较多时,最好使用-XX:ResizePlaB来关闭PLAB()的大小调整,以避免大量的线程通信所导致的性能下降。

-XX:+ResizeTLAB

Java应用线程使用的本地线程分配缓存块采用动态值还是静态值进行设置是由这个选项决定的,它默认是开启的,即TLAB值会被动态调整。

-XX:+ClassUnloadingWithConcurrentMark

这个选项开启在G1 GC并行循环阶段卸载类,尤其是在老年代的并行回收阶段,默认是开启的。这个选项开启后会在并行循环的重标记阶段卸载JVM没有用到的类,这些工作也可以放在Full GC里面去做,但是提前做了有很大的好处。但因为开启它意味着重标记阶段的GC停顿时间会拉长,这时候我们就要判断性价比了,如果GC停顿时间比我们设置的最大GC停顿目标时间还长,并且需要卸载的类也不多,那还是关闭这个选项吧。

-XX:+ClassUnloading

默认值是Ture,决定了JVM是否会卸载所有无用的类,如果关闭了这个选项,无论是并行回收循环,还是Full GC,都不会再卸载这些类了,所以需谨慎关闭。

-XX:+UnlockDiagnosticVMOptions

这个选项决定是否开启诊断选项,默认值是False,即不开启在GC里面有一些选项称之为诊断选项(Diagnostic Options),通过-XX:+PrintFlagsFinal 和XX:+Unlock。DiagnosticVMOptions这两个选项组合起来运行,就可以输出并查看这些选项。

-XX:+UnlockExperimentalVMOptions

除了之前说的诊断选项以外,JVM还有一些叫作试验选项(Experimental Options),这些选项也需要通过XX:+UnlockExperimentalVMOptions这个选项开启,默认是关闭的。

和诊断选项一样,也可以和-XX:+PrintFlagsFinal选项联合使用,即-XX:+PrintFlagsFinal和-XX:+UnlockExperimental VMOptions这两个选项联合使用时可以输出日志,输出的日志已经包含在了前一个选项-XX:+UnlockDiagnosticVMOptions的运行输出里,这里就不再重复。

总的来说,这些试验选项对整体应用性能可能会有些好处,但是它们并没有经历完整的测试环节,所以称为试验选项。

-XX:+UnlockCommercialFeatures

这个选项判断是否使用 Oracle特有的特性,默认是关闭的。

有一些属性是Oracle公司针对Oracle的Java运行时独有的,没有被包含在OpenJDK里面。举个例子,比如说 Oracle的监控和管理工具Java Mission Control,它有一个特性叫作Java Flight Recorder,这个特性作为Java Mission Control的一部分,属于事件回收框架,可以被用来显示应用程序和JVM的底层信息。

深入G1 GC

G1 GC概念简介

背景知识

G1使用了全新的分区算法,其特点如下所示:

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,可以有效利用多核的计算能力
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
  • 分代GC:G1依然是一个分代收集器,但是和之前的各类回收器不同,它同时兼顾了年轻代和老年代。对比其他回收器,它们或者工作在年轻代,或者工作在老年代。
  • 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS那样只是简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
  • 可预见性:由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。

随着G1 GC的出现,GC从传统的连续堆内存布局逐渐走向了不连续内存块布局,这是通过引入Region概念实现的,也就是说,由一堆不连续的Region组成了堆内存。其实也不能说是不连续的,只是它从传统的物理连续逐渐改变为逻辑上的连续,这是通过Region的动态分配方式实现的,可以把一个Region分配给Eden、Surviⅳvor、老年代、大对象区间、空闲区间等区间的任意一个,而不是固定它的作用,因为越是固定,越是呆板。

G1的区间设计灵感

在G1中,堆被平均分成若干个大小相等的区域(Region)。每个Region都有个关联的Remembered Set(简称RS),RS的数据结构是Hash表,里面的数据是Card Table(堆中每512byte映射在card table 1byte)。简单地说,RS里面存在的是Region中存活对象的指针。当Region中数据发生变化时,首先反映到Card Table中的一个或多个Card上,RS通过扫描内部的Card Table得知Region中内存使用情况和存活对象。在使用Region过程中,如果Region 被填满了,分配内存的线程会重新选择一个新的Region,空闲Region被组织到一个基于链表的数据结构(LinkedList里面,这样可以快速找到新的Region。

G1 GC分代管理

年轻代

除非我们显示地通过命令行方式声明了年轻代的初始化值和最大值的大小,否则,一般来说,初始化值默认是整个Java堆大小的5%(通过选项-XX:G1NewSizePercent设置),最大值默认是整个Java堆大小的60%(通过选项-XX:G1MaxNewSizePercent设置)。

回收集合及其重要性

任何一次垃圾回收都会释放CSet里面的所有区间。一个CSet由一系列的等待回收的区间所组成。在一次垃圾回收过程中,这些回收候选区间的存活对象会被整体评估,并且在回收结束后这些区间会被加入到空闲区间队列(LinkedList队列)。在一次年轻代回收过程中,CSet只会包含年轻代区间,而在一个混合回收过程中,CSet会在年轻代区间基础上再包含一些老年代区间,这就是新增的混合回收概念,不再对年轻代和老年代完全切分。

G1 GC提供了两个选项用于帮助选择进入CSet的候选老年代区间:

  • -XX:G1MixedGCLiveThresholdPercent:JDK8u45默认值为一个G1 GC区间的85%。这个值是一个存活对象的阈值,并且起到了从混合回收的CSet里排除一些老年代区间的作用,即可以理解为G1 GC限制CSet仅包含低于这个阈值(默认85%)的老年代区间,这样可以减少垃圾回收过程中拷贝对象所消耗的时间。
  • -XX:G1OldCSetRegionThresholdPercent:JDK8u45默认值为整个Java堆区的10%。这个值设置了可以被用于一次混合回收暂停所回收的最大老年代区间数量。这个阈值取决于JVM进程所能使用的Java堆的空闲空间。

RSet及其重要性

一个RSet是一个数据结构,这个数据结构帮助维护和跟踪在它们单元内部的对象引用信息,在G1 GC里,这个单元就是区间(Region),也就是说,G1 GC里每一个RSet对应的是一个区间内部的对象引用情况。有了RSet,就不需要扫描整个堆内存了,当G1 GC执行STW独占回收(年轻代、混合代回收)时,只需要扫描每一个区间内部的RSet就可以了。因为所有RSet都保存在CSet里面,即Region-RSet-CSet这样的概念,所以一旦区间内部的存活对象被移除,RSet里面保存的引用信息也会立即被更新。这样我们就能够理解RSet就是一张虚拟的对象引用表了,每个区间内部都有这么一张表存在,帮助对区间内部的对象存活情况、基本信息做有序高效的管理。

G1 GC的年轻代回收或者混合回收阶段,由于年轻代被尽可能地设计为最大量的回收,这样的设计方式减少了对于RSet的依赖,即减弱了对于年轻代里面存储的跟踪引用信息的依赖程度,进而减弱了多余RSet的消耗。G1 GC只在以下两个场景依赖RSet。

  • 老年代到年轻代的引用:G1 GC维护了从老年代区间到年轻代区间的指针,这个指针保存在年轻代的RSet里面。
  • 老年代到老年代的引用:G1 GC维护了从老年代区间到老年代区间的指针,这个指针保存在老年代的RSet里面。

每一个区间只会有一个RSet由于对于对象的引用是基于Java应用程序的需求的,所以有可能会出现RSet内部的“热点”,即一个区间出现很多次的引用更新,都出现在同一个位置的情况。

对于一个访问很频繁的区间来说,这样的方式会影响RSet的扫描时间。

注意,区间(Region)并不是最小单元,每个区间会被进一步划分为若干个块(Chunks)。在G1 GC区间里,最小的单元是一个512个字节的堆内存块(Card)。G1 GC为每个区间设置了一个全局内存块表来帮助维护所有的堆内存块,如下图所示:

当一个指针引用到了RSet里面的一个区间时,包含该指针的堆内存块就会在PRT里面被标记。如果需要快速地扫描一张数据表,最好的方式是建立索引,一个粗粒度的PRT就是基于哈希表建立的。对于一个细粒度的PRT来说,哈希表内部的每一个入口对应一个区间,而区间内部的内存块索引也是存储在位图里面的。当细粒度PRT的最大值被突破的时候,我们就会开始采用粗粒度方式处理PRT。

在垃圾回收过程中,当扫描RSet并且内存块确实存在于PRT里时,G1 GC会在全局堆内存块数据表里标记对应的入口,这种做法避免了重新扫描这个内存块。G1 GC会在回收循环阶段默认清除内存堆表,在GC线程的并行工作(主要包括根外部扫描、更新和扫描RSet、对象拷贝、终止协议等)完成之后紧跟着的就是清除堆内存表标记(Clear CT)阶段。Update RS和Scan RS对应的是RSet的更新和扫描动作。

RSet的作用是很明显的,但是在使用过程中我们也遇到了写保护和并行更新线程的维护成本。

OpenJDK HotSpot的并行老年代和CMS GC都在执行JVM的一个对象引用写操作时使用了写保护机制,如代码object field = some_other_object。还记得我们对于每个区间是采用针对最小单元堆内存块进行管理的吗?这个写保护机制也会通过更新一个类似于堆内存块表的数据结构来跟踪跨年代引用。堆内存表在最小垃圾回收时会被扫描。写保护算法基于Urs Holzle的快速写保护算法,这个算法减少了编译代码时的外部指令消耗。

当跨越区间的更新发生的时候,G1 GC会将这些对应的堆内存块放入一个缓存,我们可以称这个缓存为“更新日志缓存”,写入该缓存的方式和写入队列的方式一样。G1 GC会使用一个专门的线程组去维持RSet信息,它们的职责是扫描“更新日志缓存”,然后更新RSet。JDK8u45采用选项-XX:G1ConcRefinementThreads设置这个线程组的数量,如果你没有设置,那么默认采用-XX:ParallelGCThreads选项。

一旦“更新日志缓存”达到了最大可用,它会被放入全局化的满载队列并启用一个新的缓存块。一旦更新线程在全局满载队列里面发现了入口,它们就开始并行处理整个满载缓存队列。

G1 GC针对并行更新线程采用的是分层方法,为了保证更新速度会加入更多的线程,如果实在跟不上速度,Java应用程序线程也会加入战斗,但尽量不要出现这样的情况,这种情况是发生了线程窃取,会造成应用程序花费了本可以用于自身程序算法运行的能力。

并行标记循环

并行标记循环的过程是初始标记阶段→根区间扫描阶段→并行标记阶段→重标记阶段→清除阶段,其中一部分是可以与应用程序并行执行的,一部分是独占式的。

1.初始标记阶段

这个阶段是独占式的,它会停止所有的Java线程,然后开始标记根节点可及的所有对象。这个阶段可以和年轻代回收同时执行,这样的设计方式主要是为了加快独占阶段的执行速度。

在这个阶段,每一个区间的NATMS值会被设置在区间的顶部。

2.根区间扫描阶段

设置了每个区间的TAMS值之后,Java应用程序线程重新开始执行,根区间扫描阶段也会和Java应用程序线程并行执行。基于标记算法原理,在年轻代回收的初始标记阶段拷贝到幸存者区间的对象需要被扫描并被当作标记根元素,相应地,G1 GC因此开始扫描幸存者区间。任何从幸存者区间过来的引用都会被标记,基于这个原理,幸存者区间也被称为根区间。

根区间扫描阶段必须在下一个垃圾回收暂停之前完成,这是因为所有从幸存者区间来的引用需要在整个堆区间扫描之前完成标记工作。

3.并行标记阶段

首先可以明确的是,并行标记阶段是一个并行的且多线程的阶段,可以通过选项-XX:ConcGCThreads来设置并行线程的数量。默认情况下,G1 GC设置并行标记阶段线程数量为选项-XX:ParallelGCThreads(并行GC线程)的1/4。并行标记线程一次只扫描一个区间,扫描完毕后会通过标记位方式标记该区间已经扫描完毕为了满足SATB并行标记算法的要求,G1 GC采用一个写前barrier执行相应的动作。

4.重标记阶段

重标记阶段是整个标记阶段的最后一环。这个阶段是一个独占式阶段,在整个独占式过程中,G1 GC完全处理了遗留的SATB日志缓存、更新。这个阶段主要的目标是统计存活对象的数量,同时也对引用对象进行处理。

G1 GC采用多线程方式加快并行处理日志缓存文件,这样可以节省下来很多时间,通过选项-XX:ParallelGCThreads可以设置GC数量。

注意,如果你的应用程序使用了大量的引用对象,例如弱引用、软引用、虚引用、强引用,那么这个重标记阶段的耗时会有所增加。

5.清除阶段

前面各个阶段在做的主要事情就是为了标记对象,那么为什么需要针对每一个区间进行标记呢?这是因为如果我们知道了每个区间的存活对象数量,如果这个区间没有一个存活对象,那么就可以很快地清除RSet,并且立即放入空闲区间队列,而不是将这个区间放入排队序列,等待一个混合垃圾回收暂停阶段的回收。RSet也可以被用来帮助检测过期引用,例如,如果标记阶段发现所有在特定堆块上的对象都已经死亡,那么RSet可以快速清除这块堆块。

一句话总结,清除阶段会识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。

评估失败和完全回收

如果在年轻代区间或者老年代区间执行拷贝存活对象操作的时候,找不到一个空闲的区间,那么这个时候就可以在GC日志里看到诸如“to-space exhausted”这样的错误日志打印。

发生这个错误的同时,G1 GC会尝试去扩展可用的Java堆内存大小。如果扩展失败,G1 GC会触发它的失败保护机制并且启动单线程的完全回收动作。

在这个完全回收阶段,单线程会针对整个堆内存里的所有区间进行标记、清除、压缩等动作。在完成回收后,堆内存就完全由存活对象填充,并且所有的年龄代对应的区间都已经完成了压缩任务。

也正是因为这个完全回收是单线程执行的,所以当堆内存很大时势必耗时很长,所以需要谨慎使用,最好不要让它经常发生,以避免不必要的长时间的应用程序暂停。

G1 GC使用场景

如果应用程序具有如下的一个或多个特征,那么将垃圾收集器从CMS或ParallelOldGC切换到G1将会大大提升性能:

  • Full GC次数太频繁或者消耗时间太长
  • 对象分配的频率或代数提升(promotion)显著变化。
  • 受够了太长的垃圾回收或内存整理时间(超过0.5~1s)

注意,如果正在使用CMS或ParallelOldGC,而应用程序的垃圾收集停顿时间并不长,那么继续使用现在的垃圾收集器是个好主意。

G1 GC性能优化方案

G1的年轻代回收

External Root Regions

外部根区间扫描指的是从根部开始扫描通过JNI中本地的类中调用Malloc函数分配出的内存。这个步骤是并行任务的第一个任务。这个阶段堆外(off-heap)根节点被开始扫描,这些扫描范围包括JVM系统字典、VM数据结构、JNI线程句柄、硬件注册器、全局变量,以及线程栈根部等,这个过程主要是为了找到并行暂停阶段是否存在指向当前收集集合(CSet)的指针。

这里还有一个情况需要引起大家的重视,就是查看工作线程是否在处理一个单一的根节点时耗时过长,导致感觉类似挂起的现象。这个现象可以通过查看工作线程对应的“termination”日志看出来。如果存在这个现象,你需要去查看是否存在比较大的系统字典(JVM System Dictionary),如果这个系统字典被当成了一个单一根节点进行处理,那么当存在大量的加载类时就会出现较长时间的耗时。

Rememebered Sets and Processed Buffers

Rset帮助维护和跟踪指向G1区间的引用,而这些区间本身拥有这些RSet。还记得我们在第4章介绍过的并行Refinement线程吗?这些线程的任务是扫描更新日志缓存,并且更新区间的RSet。为了更加有效地支援这些Refinement线程的工作,在并行回收阶段,所有未被处理的缓存(已经有日志写在里面了)都会被工作线程拿来处理,这些缓存也被称为日志里面的处理缓存。

为了限制花费在更新RSet上的时间,G1通过选项-XX:MaxGCPauseMills设置了目标暂停时间,采用相对于整个停顿目标时间百分比的方式,限制了更新RSet花费的总时长,让评估暂停阶段把最大量的时候花费在拷贝存活对象上。这个目标时间默认为整个停顿时间的10%,例如整个停顿时间是10s,那么花费在更新RSet上的时间最大为ls。G1 GC的设计目标是让更多的停顿时间花费在拷贝存活对象上面,因此暂停时间的10%被用于更新RSet也是比较合理的,百分比大了,花在干具体业务(各阶段拷贝存活对象)上的时间也就少了。

如果你发现这个值不太准确或者不符合你的实际需求,这里可以通过更新选项-XX:G1RSetUpdatingPauseTimePercent来改变这个更新RSet的目标时间值。切记,如果你改变了花费在更新RSet上的时间,那你必须有把握工作线程可以在回收暂停阶段完成它们的工作,如果不能,那这部分工作会被放到并行Refinement线程里面去执行,这会导致并行工作量增加、并行回收次数增多。最坏的情况是如果并行Refinement线程也不能完成任务,那么Java应用程序就会被暂停,原本负责执行Java应用程序的资源就会直接接手任务,这个画面“太美”不敢看!大家要尽量避免这种情况发生。

注意,-XX:G1ConcRefinementThreads选项的值默认和-XX:ParallelGCThreads的值一样,这意味着对于-XX:ParallelGCThreads选项的修改会同样改变-XX:G1ConcRefinementThreads选项的值。

在当前CSet里面回收之前,CSet内部的每个区间的Rset都需要被扫描,主要目的是找到CSet区间内部的引用关系。一个有较多存活对象的区间容易导致Rset的粒度变细,即每个区间对应的表格会从粗粒度变为细粒度,也可以理解为里面对象增多后扫描一个Rset需要更长的扫描时间,这样你就会看到更多的时间被花费在了扫描RSet上面。也可以理解为扫描时间取决于RSet数据结构的粗细粒度。

Summarizing Remembered Sets

XX:+G1SummarizeRSetStats选项用于统计RSet的密度数量(细粒度或者粗粒度),这个密度帮助决定是否并行Refinement线程有能力去应对更新缓存的工作,并且收集更多关于Nmethods的信息。这个选项每隔n次GC暂停收集一次RSet的统计信息,这个n次由选项-XX:G1SummarizeRSetStatsPeriod=n决定,也是需要通过选项进行设置的。

注意,-XX:+G1SummarizeRSetStats选项是一个诊断选项,因此必须启用-XX:+UnlockDiagnosticVMOptions选项才可以启用-XX:+G1SummarizeRSetStats选项。

PDF书籍下载地址:
https://github.com/jiankunking/books-recommendation/tree/master/Java

欢迎关注我的其它发布渠道