JVM G1GC Q&A

前面的文章中,我们援引了美团技术博客的文章,简单介绍和了解了G1GC。但是心中还是有很多疑问,所以这篇文章,我就将这些疑惑一一列举出来,并一一解答。争取做到对G1GC有一个深入的详细的了解。

G1 指的是 Garbage First,GC 在JVM 中有两个含义,内存的分配和针对已分配内存的回收。

参考

G1 的基本概念

G1GC的优势

并行与并发

  • 并行性:G1在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。

  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。

  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理

  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

可预测的停顿时间模型

可预测的停顿时间模型(即:软实时soft real-time

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。

  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

  • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

如何设置Heap Region的大小

G1 中每个Region的的大小都是相同的,如何设置Heap Region的大小?设置HR的时候有哪些考虑?

HR 的大小直接影响分配和回收的效率。HR过大,可以存放多个对象,分配效率高,但是回收效率低。过小,则分配效率低下。为了均衡二者,所以HR设置了一个上下限,分别是1MB,2MB,4MB,...,32MB,也就是2的指数次幂。默认情况下,整个堆空间有2048个HR(该值可以通过最小的堆分区大小计算出来)。

  • G1HeapRegionSize 可以用来指定HR的大小,一般默认为0.

  • 不指定的时候,由G1自行推断。

按照默认值来计算的话,G1可以管理的最大内存为 2048×32MB = 64G。假设 xms=32G,xmx=128G,那么计算过程如下:

  • average_heap_size=(32GB+128GB)/2= 80GB 判断是否设置过分区大小,如果有就使用,没有则根据初始内存和最大分配内存,获得平均值

  • region_size=max(80GB/2048,1m)= 40M 并根据HR的个数得到Region的大小,和Region的下限进行比较,取两者的最大值。

  • region_size= 32M 对region_size 按2的幂次进行对齐,保证其落在上下限范围内

那这样计算出来的每个 HR 的大小就是32MB,则根据最小内存和最大内存的计算,HR个数的变化范围就是从1024 到4096个。

G1 大对象不使用 新生代,直接进入老年代,那什么是大对象?

简单来说,就是region_size 的一半。

新生代大小如何设置?

新生代大小指的是新生代内存空间的大小。G1中还新增了两个参数G1MaxNewSizePercent 和G1NewSizePercent用于控制新生代的大小。 整体逻辑如下:

  • 如果设置新生代最大值(MaxNewSize)和最小值(NewSize),可以根据这些计算出最大的分区和最小的分区,注意设置了Xmn等价与设置了MaxNewSize和NewSize,且 NewSize=MaxNewSize。

  • 如果设置 最大值(MaxNewSize)和最小值(NewSize) ,又设置了NewRatio,则忽略NewRatio。

  • 如果没有设置最大值(MaxNewSize)和最小值(NewSize),但是设置了NewRatio,则最大值和最小值相同 (= 整个heap/(NewRatio+1))

  • 如果没有设置最大值(MaxNewSize)和最小值(NewSize),或者只设置了其中一个,那么G1将根据G1MaxNewSizePercent (默认60)和G1NewSizePercent(默认5)占整个堆空间的比例来计算最大最小值。

分配新的分区时,如何扩展,一次扩展多少?

G1 是自适应扩展内存的, 参数 -XX:GCTimeRatio 表示GC与应用的耗时时间比,默认为9.即G1 GC时间与总时间占比不超过10%时,不需要动态扩展,当GC超过这个值时,可以动态扩展。计算方式是 100 × (1.0 /(1.0 + GCTimeRatio)。扩展时有一个参数,G1ExpandByPercentOfAvailable(默认20),即每次都从未提交的内存中申请20%。

CardTable And RSet(Remember Set)

CardTable 在之前的GC中就已经存在了,它存在的目的是为了对内存的引用关系做标记。从而根据引用关系遍历活跃对象。 关于两者的介绍可以参看前面的文章。

参数介绍和调优

  • G1HeapRegionSize 指定堆分区的大小,分区大小可以指定,也可以不指定,不指定时由内存管理器自动推断堆分区大小。

  • xms/xmx 指定堆空间最小/最大值

  • GCTimeRatio 指的是GC时间与整体时间的占比。前面我们已经介绍过计算,增大该值,能够减少GC占用,但是后果就是动态扩展内存更容易发生。

  • G1NewSizePercent是一个实验参数。需要使用-XX:+UnlockExperimentalVMOptions 配合才能改变选项。有实验表明G1在回收Eden分区的时候,大概每GB需要100ms,所以可以根据停顿时间进行相应的调整。这个值在内存比较多大的时候,可以相应的减少。

注意: G1 中不需要设置MaxNewSize、NewSize、Xmn和NewRatio,原因是,一,G1对内存的管理是不连续的,即使重新分配一个堆分区代价也不高,G1的目标满足 垃圾收集停顿,如果设置了固定分区,则G1不能调整新生代的大小,可能就满足不了垃圾收集停顿了。

G1 的对象分配

G1 提供了两种对象分配策略: 基于线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB)的快速分配和慢速分配。 无论快速分配还是慢速分配,都应该在STW(Stop the World)之外进行调用。

在分配线程对象时,从JVM 堆中分配一个固定大小的内存区域将于作为线程的私有缓冲区,这个缓冲区就是TLAB。只有为线程分配TLAB时候才需要锁定JVM。也就是加锁。其实这个比较好理解,在Java中线程是资源分配的最小单位。不同线程不共享TLAB.

当我们需要去分配一个对象时,优先从当前线程的TLAB去分配,不需要全局锁,因此就达到了快速分配的目的。

当不能进行快速分配时,就会进入慢速分配,而且慢的过程中会有一些 诸如大对象直接分配到老年代,分配不会收需要先进行GC等,失败一定次数之后,则分配失败。

G1垃圾回收的时机

  • 分配时发生回收,快速分配和慢速分配时都有可能存在内存不足,都有可能发生回收,回收之后再继续分配。

  • 外部调用的回收,例如显式地调用了system.gc 。 JNI(Java Native Interface) 代码进入了临界区(synchronized),为了保证安全需要加锁,加锁又发出了GC请求,导致GC等。

参数介绍和调优

  • 在优化调试TLAB的时候,在调试环境可以打开PrintTLAB来观察TLAB的分配和使用情况

  • UseTLAB,指是否打开TLAB,大量的实验证明使用TLAB能够加速TLAB的分配和使用的情况

  • ResizeTLAB,是否允许动态调整。基准测试表明,使用动态调整TLAB大小,效率更高

  • TLABSize,指设置TLAB的大小,实际使用中不要设置,设置之后就不能动态调整了。

新生代回收

内存分配时,剩余空间不能满足分配要求就会优先触发新生代回收(Young GC ,YGC)。

为了便于理解,下面用自己的话进行整理描述:

  • 收集之前先STW.

  • 选择要收集的区域,对于YGC来说,要收集的就是整个新生代。

  • 进行并行任务处理。

  • 将符合条件的对象复制到新的Survivor区,对象的field入栈等待复制处理.

  • 处理老年代到新生代的代际引用,即更新RSet(前面的文章中有介绍)

  • 对栈中的对象进行深度递归遍历复制对象。

参数介绍和调优

  • ParallelGCThreads ,默认值为0,表示并行执行GC的线程个数。G1可以根据CPU的核数自动推断线程数。GC是CPU密集型,通常来说,线程个数不应该超过CPU核数,一般不用设置该值。

  • ResizePLAB,默认为true。表示在垃圾回收之后会根据内存的使用情况来调整PLAB(promotion local allocation buffer)的大小,在一些测试中发现关闭这个参数可能有更好的效果。

  • SurvivorRatio,默认值为8,指Eden和一个Survivor分区之间的比例。默认8:1:1。G1 中并会因为增大这个值,就导致Eden变小,因为Eden时根据GC的时间来预测的。

混合回收

混合回收可以总结为两个阶段:

  • 第一阶段: 并发标记,目的就是识别老年代分区中的活跃对象,并计算分区中垃圾对象所占空间的多少,用于垃圾回收过程中判断是否回收分区

    • 初始标记子阶段

    • 并发标记子阶段

    • 再标记子阶段

    • 清理阶段

  • 第二阶段: 垃圾回收。这个过程和新生代回收的步骤完全一致,重用了新生代的逻辑。最大的区别就是,不仅要回收新生代,还要回收并发标记中识别到的垃圾多的老年代分区。

下面就根据混合回收发生的逻辑顺序依次介绍一下这些阶段:

初始标记子阶段

标记所有的根对象(根对象,全局对象,JNI对象),根是对象图的起点,需要STW.混合回收的初始标记与YGC的初始标记几乎一样。实际上就是借用了YGC之后的结果,即Survivor分区作为根,所以混合回收一定发生再YGC之后,且不需要再一次进行初始标记。

并发标记子阶段

当YGC执行结束之后,如果发现满足并发标记的条件,并发线程就开始并发标记。根据新生代的Survivor分区以及老年代的Rset开始并发标记。并发标记会对所有的分区进行标记,这个阶段并不需要STW,这时标记线程和应用程序线程同时运行。

再标记子阶段

这是最后一个标记阶段。这时,G1需要暂停一下,找出所有未被访问到的对象,同时完成存活内存数据计算。

这个阶段也是并行执行的,通过参数 -XX:ParallelGCThreads 可以设置GC 暂停时可用端的GC 线程数。

清理子阶段

再标记之后进入清理子阶段,也是需要STW的。清理子阶段需要完成虾米那的操作。

  • 统计存活对象,主要是利用RSet和位图来实现。

  • 交换标记位图,并为下次并发标记做准备

  • 重置RSet,此时老年代已经标记完,如果标记后的分区没有引用对象,这说明引用已经发生改变,可以删除原来RSet里面的引用关系。

  • 把空闲分区放到空闲分区列表中,这里的空闲指的是全部是垃圾对象的分区,如果分区还有任何分区活跃对象都不会被释放,真正释放是在混合GC中。

这个阶段容易让人误解,清理阶段并没有真正的GC,也不会执行存活对象的拷贝,极端情况下,该阶段结束之后,空闲分区列表和JVM内存的使用情况可能毫无变化。

混合回收阶段

这个阶段是辉进行真正的GC的。 与YGC 一样,第一个步骤是从分区中选出若干个进行回收,这些被选中的分区称为Collect Set(简称CSet);第二个步骤是把存活的对象复制到空闲的分区中区,同时把这些已经被回收的分区放到空闲分区列表中。垃圾回收总是要在一次新的YGC开始才会发生。

并发标记法---三色标记法

三色标记法是一个逻辑上的抽象

  • 白色,没有被收集器表标记的对象

  • 灰色,自身被标记到,但是其拥有的field字段引用到其他对象还没有被处理。

  • 黑色, 自己本身被标记,同时field引用的对象也被标记。

这里虽然讲的很简单,但是实际实现过程中却非常复杂,这里先略过了。

参数介绍和调优

  • InitiationHeapOccupancyPercent(IHOP),默认值是45,这个值是启用并发标记的先决条件。只有当老年代内存占总空间45%之后才会启动并发标记任务。增加该值,可能导致 并发标记花费更多时间。也会导致YGC或者MixedGC收集时的分区变少,还有可能导致FullGC。根据经验,这个值通常根据整体应用占用的平均值来设置,比平均内存稍微高一点此时性能最好(即YGC 和 MixedGC 比较快,FGC比较少),IHOP的设置非常有用,但是设置IHOP并不容易,需要不断尝试。

  • G1ReservePercent,默认是10,当发现GC晋升失败导致FGC,可以增大这个值。

  • HeapSizePerGCThread,默认为64M,可以简单地理解为每64M分配一个线程。

FULL GC

DK 10 之前的FGC都是串行GC,但是不管是串行还是并行,过程和步骤都是一样的。而且采用的标记清除算法。

  • 标记活跃对象

  • 计算新对象的地址

  • 把所有的引用都更新到新的地址上面

  • 移动对象完成压缩

参数介绍和调优

  • MinHeapFreeRatio,用于判断是否可以扩展堆空间。增大该值扩展概率变小,减少该值扩展几率变大

  • MaxHeapFreeRatio,用于判断是否可以收缩堆空间。增大该值收缩概率变小,减少该值收缩几率变大

实践

找到一台运行Java程序的机器,运行jps命令,找到相应的进程ID,然后运行 jmap -heap pid就可以查看到该进程的堆栈以及GC信息。

jmap -heap 15784
Picked up JAVA_TOOL_OPTIONS: -Duser.timezone=UTC -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
Attaching to process ID 15784, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.121-b13

using thread-local object allocation.
Garbage-First (G1) GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 40
   MaxHeapFreeRatio         = 70
   MaxHeapSize              = 5368709120 (5120.0MB)
   NewSize                  = 1363144 (1.2999954223632812MB)
   MaxNewSize               = 3221225472 (3072.0MB)
   OldSize                  = 5452592 (5.1999969482421875MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 2097152 (2.0MB)

Heap Usage:
G1 Heap:
   regions  = 2560
   capacity = 5368709120 (5120.0MB)
   used     = 1002454008 (956.0146408081055MB)
   free     = 4366255112 (4163.9853591918945MB)
   18.67216095328331% used
G1 Young Generation:
Eden Space:
   regions  = 436
   capacity = 3353346048 (3198.0MB)
   used     = 914358272 (872.0MB)
   free     = 2438987776 (2326.0MB)
   27.267041901188243% used
Survivor Space:
   regions  = 14
   capacity = 29360128 (28.0MB)
   used     = 29360128 (28.0MB)
   free     = 0 (0.0MB)
   100.0% used
G1 Old Generation:
   regions  = 29
   capacity = 1986002944 (1894.0MB)
   used     = 58735608 (56.01464080810547MB)
   free     = 1927267336 (1837.9853591918945MB)
   2.9574783953593173% used

Last updated