jvm-gc

本文基于JDK8分析HotSpot Jvm的GC,并且重点介绍G1 GC

前言

GC是jvm中内存管理核心部分,所有的对象回收,内存释放都依赖于此,那么gc的工作原理是怎样的呢,用到了哪些算法,各自的优缺点是什么?带着这些问题,开始阅读下文。
前排术语提示:
CMS全程是concurrent mark sweep,应该是一种用的最多的gc算法,JDK8之前不二之选。
G1全程是garbage first,是JDK8主推的gc算法,核心卖点是延迟低,速度快。
STW全称是stop the world,是gc的一个特点,暂停所有用户工作线程,只处理垃圾回收。

GC算法

evacuation & compaction

evacuation和compaction可以说是GC两大派系,以不同的思路来实现gc算法,贯穿着GC的演变。其中evacuation采用copy的思路,每次清理只注意把活着的内存块a拷贝到内存块b,最典型的gc代表就是G1,另外一般的young gc也会采用这个思路。而compaction采用清理合并的思路,把内存块a的死对象清理干净,然后再对内存块a压缩,防止内存碎片化,使其在内存地址上连续,比如CMS的老年代回收采用的就是compaction思路。
两种思路各有利弊,evacuation性能好,不会产生内存碎片,但是浪费内存,每清理一块内存就需要同时存在另一块空闲的内存,这使得不能完全利用100%的内存。而compaction就没有内存浪费的问题,每次清理都是关起门清理自己的内存块,但是碎片就出现了,因此性能上相比前者差了点,而且做内存合并也是一件很耗时的STW操作。
那怎么选择呢?我们大致可以从垃圾的产生速度作为选择依据,如果垃圾产生的很快,尤其是占用内存小的垃圾,那么优先选择evacuation类的gc,很好的例子就是young gc的策略,几乎所有的young gc都采用了copy思路,特点就是小垃圾多,处理快。那么什么时候用compaction算法的gc呢?内存大,垃圾产生频率低的场景比较合适。内存大意味着冗余内存的成本高,需要更加注重内存的利用率,垃圾产生频率低意味着清理频率低,内存碎片少。

延迟 & 吞吐量

在算法的优化方向上,也有着两个大方向,延迟和吞吐量。吞吐量意味着尽可能的让垃圾回收占用程序总运行时间的比例小,不关心一时间的STW等问题,考虑的是总体的gc时间占比,常见的思路就是采用多线程并发gc。而延迟方向则是要求每次gc的时间尽可能短,但是允许让gc的总时间拉长,这就要求gc线程与用户工作线程法尽可能的并发执行,尽可能避免STW,降低对用户工作线程的影响,常见的思路有增量式gc,分步骤gc,将整个gc流程拆开来,并且每次gc只处理一部分内存空间。
当然这两个方向在极端情况下是相对的,但是从优化的角度来说可以同时进行。比如G1,虽然以低延迟为卖点,但是只要合理设置单次gc的时间,吞吐量也是相当不错的。而CMS更偏重吞吐量,毕竟合并内存碎片的延迟没办法控制。

分步骤gc

如果把gc整个流程看做是一个整体,那么工作量是很多的,STW的时间也很长,因此需要化整为零,通常分为4个大步骤,其中需要STW的1,2,4执行速度是很快的,正真耗时的操作2独立成不影响用户工作线程的操作。

  1. 找到存活着的根对象(STW)
  2. 并发标记根对象引用的对象
  3. 重新标记在步骤2期间产生的新对象(STW)
  4. 对标记的死亡对象做清理(STW)
    当前CMS的gc流程基本上是这个思路,g1稍有区别,第四个步骤替换为evacuation。所以有些书上会总结为,CMS是一种标记清理的gc算法,G1是一种标记整理的gc算法,虽然只差一个字,但却相去甚远。

G1

G1是什么

G1官方介绍,G1是一款通用的,分步骤的,增量的,并发的,有着STW操作的垃圾回收器。和其他gc不同的地方在于,G1可以控制单次gc的时间,对延迟要求大的服务很友好。

G1如何进行垃圾回收

相比CMS算法,G1的更适合大堆场景,这也是G1和其他gc的不同之处。

  • 分区
    过去的GC算法都只是采用了分代gc,也就是年轻代和老年代,每次gc需要对某代操作。在申请内存动不动就32GB的现在,这无疑给gc带来了很大的压力。那么既然一次性回收大块内存效率低下,那么为什么不把内存分割开来呢?于是G1把整个堆(不管是你年轻代还是老年代)分割成了N个region,每个region拥有一部分内存空间,单个region的内存空间可以显式声明-XX:G1HeapRegionSize,但是有大小限制,必须在1MB到32MB之间,如果没有显示声明的话,jvm会根据配置的堆大小进行自适应,参考下面公式
    1
    2
    3
    4
    5
    TARGET_REGION_NUMBER=2048
    MIN_REGION_SIZE=1MB
    average_heap_size = (initial_heap_size + max_heap_size) / 2;
    region_size = MAX2(average_heap_size / TARGET_REGION_NUMBER,
    (uintx) MIN_REGION_SIZE);

举个例子,假设你配置了jvm的堆为-Xmx4G -Xms4G,那么自适应的region_size大小就是2MB。
分割后,就可以对某几个region做gc,类似化整为零的思路。

  • 超大对象
    将大内存分割成N个region后,会出现一个问题,如果一个对象申请内存大于单个region的大小,那么怎么办呢?G1给出了一个概念,叫做Humongous Object,其实就是大对象的意思,下面简称HO。每个HO都会被直接分配在老年代的region中,并且多个region是连续的。每次在为HO分配region之前,G1都会先检查当前的内存使用百分比,如果到达阈值,那么就会触发gc。这个阈值的配置是-XX:InitiatingHeapOccupancyPercent=45,默认是45%。这样做的目的是尽可能保持堆有足够的空闲内存,毕竟G1是基于evacuation的gc算法,因此如果出现OOM,那么可以适当降低此配置。

G1特性

  • 设置期望gc时间
    G1提供了一个很重要的配置-XX:MaxGCPauseMillis来约束每次gc的时间,默认是200,单位毫秒,这是一个软指标,毕竟不能中途停止正在执行的gc。只能采用估计,通过启发式算法,G1统计过去一段时间的gc时间,再辅以相关系数,然后估算出处理多少事情需要多少时间的这么一个公式,然后在接下来的gc过程中,只做满足时间的工作。比如1ms能处理1个region,那么200ms只能处理200个region,超过200剩下的只能留到下次gc循环处理。因此我们不能将这个约束时间设置的太小,产生的垃圾一直快于gc速度,那么就会出现OOM。

G1相关算法

  • SATB
    全程是snapshot-at-the-beginning,意味着每次在标记对象前,先做一份快照,做快照的目的是防止gc线程在进行对象的三色标记时候,用户线程用某个灰色对象引用了白色对象。没有SATB,整个gc就出问题了,有用的对象被无情回收。

  • 三色标记法
    在标记对象的时候,利用3种状态来判断对象是否可以被清理
    白色:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
    灰色:对象被标记了,但是它的field还没有被标记或标记完。
    黑色:对象被标记了,且它的所有field也被标记完了。

G1最佳实践

首先不能无脑用G1,G1的使用场景之前也提到过,最粗糙的估计就算8GB,超过8GB的堆可以考虑用G1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 开启G1
-XX:+UseG1GC
// 开启gc日志
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps
// 设置堆大小
-Xmx16g -Xms16g
// 默认是10,单位百分比,允许整个堆的垃圾占比,超过这个百分比就会启动mixed gc。
-XX:G1HeapWastePercent=10
// 在gc的STW阶段,gc线程的并发数,n默认是当前机器的核数,上限是8,如果机器的核数超过8,那么建议设置总核数的5/8
-XX:ParallelGCThreads=n
// 在gc的非STW阶段(并发标记阶段),gc线程的并发数,n默认是-XX:ParallelGCThreads值的1/4
-XX:ConcGCThreads=n
// 活着的对象内存占用大于65%的region,就直接跳过。这可以被看做是性价比,花大力气治理那些垃圾少的region不如优先
治理垃圾多的region
-XX:G1MixedGCLiveThresholdPercent

总结

gc算法自身在不断进化,单线程->并发->分步骤->增量式,带来的好处就是平均延迟降低,吞吐量提高。
但是除此之外,我们的程序也需要尽量避免对象频率创建,比如spark,自己维护一个memory pool,数据对象直接按需从memory pool里获取内存块,释放会归还,整个过程都不要gc参与,唯一的弊端就是需要常驻一块内存,可以理解成以空间换时间。

参考

redhat博客
oracle对G1的介绍
oracle对G1的优化思路

ulysses wechat
订阅+