现在我们就深入了解一下当前Java GC收集器中的最新流行的代表作:G1收集器(Garbage-First Garbage Collector
前言
G1收集器是一款在server端运行的垃圾收集器,专门针对于拥有多核处理器和大内存的机器,在JDK 7u4版本发行时被正式推出,在JDK9中更被指定为官方GC收集器。它满足高吞吐量的同时满足GC停顿的时间尽可能短。G1收集器专门针对以下应用场景设计
- 可以像CMS收集器一样可以和应用并发运行
- 压缩空闲的内存碎片,却不需要冗长的GC停顿
- 对GC停顿可以做更好的预测
- 不想牺牲大量的吞吐量性能
- 不需要更大的Java Heap
G1从长期计划来看是以取代CMS为目标。与CMS相比有几个不同点使得G1成为GC的更好解决方案。
- 第一点:G1会压缩空闲内存使之足够紧凑,做法是用regions代替细粒度的空闲列表进行分配,减少内存碎片的产生。
- 第二点:G1的STW更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
1. G1 收集器
在传统的GC收集器(serial,parallel,CMS)无一不例外都把heap分成固定大小连续的三个空间:young generation, old generation, and permanent generation
但G1却独辟蹊径,采用了一种全新的内存布局
在G1中堆被分成一块块大小相等的heap region,一般有2000多块,这些region在逻辑上是连续的。每块region都会被打唯一的分代标志(eden,survivor,old)。在逻辑上,eden regions构成Eden空间,survivor regions构成Survivor空间,old regions构成了old 空间。
通过命令行参数-XX:NewRatio=n来配置新生代与老年代的比例,默认为2,即比例为2:1;-XX:SurvivorRatio=n则可以配置Eden与Survivor的比例,默认为8。
GC时G1的运行方式与CMS方式类似,会有一个全局并发标记(concurrent global marking phase)的过程,去确定堆里对象的的存活情况。并发标记完成之后,G1知道哪些regions空闲空间多(可回收对象多),优先回收这些空的regions,释放出大量的空闲空间。这是为什么这种垃圾回收方式叫G1的原因(Garbage-First)。
G1将其收集和压缩活动集中在堆中可能充满可回收对象(即垃圾)的区域,使用暂停预测模型来满足用户定义的暂停时间目标,并根据指定的暂停时间目标选择要收集的区域数量。
需要注意的是,G1不是实时收集器。它能够以较高的概率满足设定的暂停时间目标,但不是绝对确定的。根据以前收集的数据,G1估算出在用户指定的目标时间内可以收集多少个区域。因此,收集器对于收集区域的成本有一个相当准确的模型,它使用这个模型来确定在暂停时间目标内收集哪些区域和收集多少区域。
1.1 G1 region
G1中每个Region大小是固定相等的,Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
决定逻辑:
size =(堆最小值+堆最大值)/ TARGET_REGION_NUMBER(2048)
然后size取最靠近2的幂次数值, 并将size控制在[1M,32M]之间。
具体代码如下
// share/vm/gc_implementation/g1/heapRegion.cpp
// Minimum region size; we won't go lower than that.
// We might want to decrease this in the future, to deal with small
// heaps a bit more efficiently.
#define MIN_REGION_SIZE ( 1024 * 1024 )
// Maximum region size; we don't go higher than that. There's a good
// reason for having an upper bound. We don't want regions to get too
// large, otherwise cleanup's effectiveness would decrease as there
// will be fewer opportunities to find totally empty regions after
// marking.
#define MAX_REGION_SIZE ( 32 * 1024 * 1024 )
// The automatic region size calculation will try to have around this
// many regions in the heap (based on the min heap size).
#define TARGET_REGION_NUMBER 2048
void HeapRegion::setup_heap_region_size(size_t initial_heap_size, size_t max_heap_size) {
uintx region_size = G1HeapRegionSize;
if (FLAG_IS_DEFAULT(G1HeapRegionSize)) {
size_t average_heap_size = (initial_heap_size + max_heap_size) / 2;
region_size = MAX2(average_heap_size / TARGET_REGION_NUMBER,
(uintx) MIN_REGION_SIZE);
}
int region_size_log = log2_long((jlong) region_size);
// Recalculate the region size to make sure it's a power of
// 2. This means that region_size is the largest power of 2 that's
// <= what we've calculated so far.
region_size = ((uintx)1 << region_size_log);
// Now make sure that we don't go over or under our limits.
if (region_size < MIN_REGION_SIZE) {
region_size = MIN_REGION_SIZE;
} else if (region_size > MAX_REGION_SIZE) {
region_size = MAX_REGION_SIZE;
}
}
1.2 跨代引用
在进行Young GC的时候,Young区的对象可能还存在Old区的引用, 这就是跨代引用的问题。为了解决Young GC的时候,扫描整个老年代,G1引入了Card Table 和Remember Set的概念,基本思想就是用空间换时间。这两个数据结构是专门用来处理Old区到Young区的引用。Young区到Old区的引用则不需要单独处理,因为Young区中的对象本身变化比较大,没必要浪费空间去记录下来。
- RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
- Card: JVM将内存划分成了固定大小的Card。这里可以类比物理内存上page的概念。
RS(Remember Set)是一种抽象概念,用于记录从非收集部分指向收集部分的指针的集合。 在传统的分代垃圾回收算法里面,RS(Remember Set)被用来记录分代之间的指针。在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。这种记录可以带来一个极大的好处:在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。
那么,如果一个线程修改了Region内部的引用,就必须要去通知RS,更改其中的记录。为了达到这种目的,G1回收器引入了一种新的结构,CT(Card Table)——卡表。每一个Region,又被分成了固定大小的若干张卡(Card)。每一张卡,都用一个Byte来记录是否修改过。卡表即这些byte的集合。实际上,如果把RS理解成一个概念模型,那么CT就可以说是RS的一种实现方式。
在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。
图中RS的虚线表名的是,RS并不是一个和Card Table独立的,不同的数据结构,而是指RS是一个概念模型。实际上,Card Table是RS的一种实现方式。关于RSet结构的维护,可以参考这篇文章,这里不做过多的深入。
1.3 STAB
上面global concurrent marking提到了STAB算法,那这个STAB到底为何物?STAB全称为snapshot-at-the-beginning,其目的是了维持并发GC的正确性
SATB(snapshot-at-the-beginning),是最开始用于实时垃圾回收器的一种技术。G1垃圾回收器使用该技术在标记阶段记录一个存活对象的快照("logically takes a snapshot of the set of live objects in the heap at the start of marking cycle")。然而在并发标记阶段,应用可能修改了原本的引用,比如删除了一个原本的引用。这就会导致并发标记结束之后的存活对象的快照和SATB不一致。G1是通过在并发标记阶段引入一个写屏障来解决这个问题的:每当存在引用更新的情况,G1会将修改之前的值写入一个log buffer(这个记录会过滤掉原本是空引用的情况),在最终标记(final marking phase)阶段扫描SATB,修正SATB的误差。
首先要介绍三色标记算法。
- 黑色:根对象,或者该对象与它的子对象都被扫描
- 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
- 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象。
三色标记法 GC Root 扫描过程如下:
- 在GC扫描C之前的颜色如下:
- 在并发标记阶段,应用线程改变了这种引用关系
A.c=C
B.c=null
得到如下结果。
- 在重新标记阶段扫描结果如下
这种情况下C会被当做垃圾进行回收。Snapshot的存活对象原来是A、B、C,现在变成A、B了,Snapshot的完整性遭到破坏了,显然这个做法是不合理。
G1采用的是pre-write barrier(写屏障)解决这个问题。
简单说就是在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并保存在一个队列里,在JVM源码中这个队列叫satb_mark_queue。在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象,snapshot的完整性也就得到了保证。
2. G1 GC收集
G1保留了YGC并加上了一种全新的MIXGC用于收集老年代。G1中没有Full GC,G1中的Full GC是采用serial old Full GC。
2.1 YGC 收集
当Eden空间被占满之后,就会触发YGC。在G1中YGC依然采用复制存活对象到survivor空间的方式,当对象的存活年龄满足晋升条件时,把对象提升到old generation regions(老年代)。
G1控制YGC开销的手段是动态改变young region的个数,YGC的过程中依然会STW(stop the world 应用停顿),并采用多线程并发复制对象,减少GC停顿时间。
-
YGC开始
-
YGC结束
我们从图中看到Eden区中存活对象被复制到新的Survior区。
YGC是否需要扫描整个老年代?
我们知道判断对象是否存活需要从GC ROOTS结点出发,从GC ROOTS结点可达的对象就是存活的。在YGC时,老年代中的对象是不回收的,也就意味着GC ROOTS里面应包含了老年代中的对象。但扫描整个老年代会很耗费时间,势必影响整个GC的性能! 。所以在CMS中使用了Card Table的结构,里面记录了老年代对象到新生代引用。Card Table的结构是一个连续的byte[]数组,扫描Card Table的时间比扫描整个老年代的代价要小很多!G1也参照了这个思路,不过采用了一种新的数据结构 Remembered Set 简称Rset。 RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。每个Region都有一个对应的Rset。
RSet究竟是怎么辅助GC的呢?
在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。
所以G1中YGC不需要扫描整个老年代,只需要扫描Rset就可以知道老年代引用了哪些新生代中的对象。
2.2 Mixed GC
G1中的MIXGC选定所有新生代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region,在用户指定的开销目标范围内尽可能选择收益高的老年代Region进行回收。所以MIXGC回收的内存区域是新生代+老年代。
在介绍MIXGC之前我们需要先了解global concurrent marking,全局并发标记。因为老年代回收要依赖该过程。
全局并发标记
全局并发标记过程分为五个阶段
- Initial Mark初始标记 STW
Initial Mark初始标记是一个STW事件,其完成工作是标记GC ROOTS 直接可达的对象。并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,而不使用对象头的mark word里的mark bit。因为 STW,所以通常YGC的时候借用YGC的STW顺便启动Initial Mark,也就是启动全局并发标记,全局并发标记与YGC在逻辑上独立。
2. Root Region Scanning 根区域扫描
根区域扫描是从Survior区的对象出发,标记被引用到老年代中的对象,并把它们的字段在压入扫描栈(marking stack)中等到后续扫描。与Initial Mark不一样的是,Root Region Scanning不需要STW与应用程序是并发运行。Root Region Scanning必须在YGC开始前完成。
3. Concurrent Marking 并发标记
不需要STW。不断从扫描栈取出引用递归扫描整个堆里的对象。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描SATB write barrier所记录下的引用。Concurrent Marking 可以被YGC中断
4. Remark 最终标记 STW
STW操作。在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理。这个阶段就负责把剩下的引用处理完。同时这个阶段也进行弱引用处理(reference processing)。注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。
5. Cleanup 清除 STW AND Concurrent
STW操作,清点出有存活对象的Region和没有存活对象的Region(Empty Region)
- STW操作,更新Rset
- Concurrent操作,把Empty Region收集起来到可分配Region队列。
经过global concurrent marking,collector就知道哪些Region有存活的对象。并将那些完全可回收的Region(没有存活对象)收集起来加入到可分配Region队列,实现对该部分内存的回收。对于有存活对象的Region,G1会根据统计模型找处收益最高、开销不超过用户指定的上限的若干Region进行对象回收。这些选中被回收的Region组成的集合就叫做collection set 简称Cset!**
在MIXGC中的Cset是选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。
在YGC中的Cset是选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。
YGC与MIXGC都是采用多线程复制清除,整个过程会STW。 G1的低延迟原理在于其回收的区域变得精确并且范围变小了。
最佳实践
1. G1 收集相关参数
命令使用:
java XX:+UseG1GC ‐XX:MaxGCPauseMillis=100 ‐XX:+PrintGCDetails ‐Xmx256m TestGC \n
-jar c:\javademos\demo\jfc\Java2D\Java2demo.jar
## 使用 G1 垃圾收集器
-XX:+UseG1GC
## 将日志的详细级别设置为详细。
-verbosegc(相当于-XX:+PrintGC)
## 将细节级别设置为更精细,显示每个阶段的平均时间、最小时间和最大时间。
-XX:+PrintGCDetails
## 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是 200 毫秒。
-XX:MaxGCPauseMillis
## 设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。
## 目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的1/2000。
-XX:G1HeapRegionSize=n
## 设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。
## n 的值与逻辑处理器的数量相同,最多为 8。
-XX:ParallelGCThreads=n
## 设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads)的 1/4 左右。
-XX:ConcGCThreads=n
## 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%
-XX:InitiatingHeapOccupancyPercent=n
2. G1 收集器的建议
- 年轻代大小:避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。
- 暂停时间目标不要太过严苛。G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意,承受更多的垃圾回收开销,而这会直接影响到吞吐量
总结
在文章介绍了很多关于G1的一些原理和概念
最后简单归纳一下:
- G1把内存分成一块块的Region,每块的Region的大小都是一样的。
- G1保留了YGC并加上了一种全新的MIXGC用于收集老年代。G1中没有Full GC,G1中的Full GC是采用serial old Full GC。在MIXGC中的Cset是选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。在YGC中的Cset是选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。YGC与MIXGC都是采用多线程复制清除,整个过程会STW。
- G1的低延迟原理在于其回收的区域变得精确并且范围变小了。
- 全局并发标记分的五个阶段。
- 用STAB来维持并发GC的准确性。
- 使用G1的最佳实践
- G1 GC日志打印
相关文章