写在文章开头
G1(Garbage First)垃圾回收器 作为JDK 9默认的垃圾回收器,采用了一种独特的内存管理策略,实现相对灵活可控的垃圾回收管理,而本文针对该垃圾回收器进行一个较为综合全面的分析并给出相应的实践建议,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
G1垃圾回收器基本概念
设计理念
相较于传统垃圾回收器而言,G1的堆内存并不是按照连续空间进行分代管理,取而代之的是以Region 作为基本管理单元。Region 的特别之处在于它可以是堆内存中的任何角色,它可以是Eden ,或者Survivor亦或者是老年代空间。
这里笔者特别说明一下,G1还有个特殊处理的区域,即Humongous(存放大对象,逻辑上属于老年代) ,一个专门用于处理大对象的Region。当对象超过 Region 50%的情况下,G1就会将其判定位大对象,并将其分配到一个连续的Humongous region内存区中:

G1的设计理念是随着场景变化动态调整随机应变,所以Region 的大小也是动态的,默认情况下,每个Region 的大小是基于给定堆内存大小按照进行2048 等分。假设我们分配堆内存为4G ,那么对应的堆内存的每个Region 大小就是4096/2048,也就是2M。
G1 也支持显示设定Region大小,例如我们需要显示设置Region大小为16M:
ini
# 设置每个 Region 大小为 16MB
-XX:G1HeapRegionSize=16m
相较于CMS 和传统的Parallel scavenge,它汲取了二者的优点,即在给定的停顿时间内清理出尽可能整齐的内存空间,以做到快速响应和高吞吐,这使得G1具备如下优势:
第一个优势是充分利用CPU资源,因为停顿时间可预测,这也就意味着在STW时间段G1会尽尽情释放并行线程利用CPU资源进行并行的垃圾回收,提升CPU资源利用率,也保证垃圾回收器良好的性能表现。
按照官方参数设定:
- 核心数 <= 8:ParallelGCThreads = CPU核心数
- 核心数 > 8:ParallelGCThreads = 8 + (CPU核心数 - 8) * 5/8
结合如下权威资料的说法和计算机设计的哲学,GC线程并行数并非越多越好,按照笔者的经验主义,GC本质属于计算密集型任务,要做到:
- 尽可能让线程和CPU保持亲和提升缓存
- 强行设置 ParallelGCThreads为CPU核心数,可能会收到超线程这种非物理核心数的欺骗,导致上下文竞争开销增大
- 过多GC线程运算,会增大内存带宽的负担,影响还行效率
For example, ParallelGCThreads will remain the same regardless of whether hyperthreading is turned on/off. Current hotspot code will have twice more GC threads if hyperthreading is on. Usually, GC causes huge number of cache misses, thus having two GC threads competing for the same physical core hurts the GC throughput. Current hotspot code doesn't consider CPU mask at all. For example, even though the machine has 64 cores, if CPU mask is set for 2 cores, current hotspot calculates the number of GC threads based on 64. Thus, this flag is actually evaluating the number of GC threads to the number of physical cores available for the JVM process.
第二大好处是,尽可能少的内存碎片,g1无论是新生代还是老年代,在微观上采用标记-复制,而宏观上采用标记-整理,即Region采用标记-复制的方式转移对象到新的空间,而转移操作会将Region整理清楚在内存中紧凑分配,由此很好的解决了CMS垃圾回收器的内存碎片问题。 
最后一点就是可预测的暂停时间,g1支持通过-XX:MaxGCPauseMillis 设置预期最大暂停时间,由此避免了长时间的STW,保证程序的高响应。
通过上述的这些设计理念,它在吞吐量、内存管理上都有比较明显的优势,这也是为什么JDK9将G1作为默认垃圾回收器的原因(JDK8最新版G1也已经比较稳定,同样建议在堆内存分配大于4G的情况下,采用G1垃圾回收器)。
常见参数
g1垃圾回收器有下面几个比较常用的参数,首先自然是配置垃圾回收器:
ruby
# 整堆启用G1的垃圾回收器
-XX:+UseG1GC
然后就是设置最大的STW时间,默认情况下是200ms:
ini
# 设置最大暂停时间(默认200ms)
-XX:MaxGCPauseMillis=n
还有就是设置Region的内存大小:
ini
# 指定Region的内存大小,n必须是2的指数幂,其取值范围是从1M到32M
-XX:G1HeapRegionSize=n
设置垃圾回收线程数:
ini
# 指定垃圾回收工作的线程数量
-XX:ParallelGCThreads=n
详解G1 GC的实现
分代收集理论
本文知识体系化原则,在正式介绍GC实现之前,笔者还是着重强调一下分代GC的历史进程。与传统的C、C++语言有所不同,Java内存管理策略是自动的,所以这就涉及到堆内存管理问题,按照业界经验法则的分代假说:
- 若分代假说:大部分对象都是**"朝生夕死"**
- 强分代假说:经过多次收集依然存活的对象,则不容易被回收
基于此假说,JVM垃圾回收器本着最大化内存原则的原则,提出分代的概念,即划分新生代、老年代进行内存管理,让不同生命周期的对象选用不同的回收算法进行分代处理。
Minor GC工作流程
和常规的新生代算法过程一样,G1垃圾回收器新生代回收也只是针对Eden 区和survivor 区,默认情况下,当Eden 区没有足够的空间进行对象分配时,虚拟机就会发起一次minor GC。
对应的回收算法依然是通过标记复制法,同时将存活的对象复制到另外的survivor区中,并增加其存活年龄。
后续这块Eden区就会被清空变为一块空闲region并维护到region空闲池中等待后续被分配使用:

为什么Minor GC不采用并发标记
大部分读者可能都会通过并发标记法用尽可能少的STW完成对象标记,进而提升GC效率,让响应速度还是吞吐量都会有所保证。实际上,设计者对此问题也有结合经验性数据的考量,从经验主义来看,新生代对象生命周期大多数都比较短,95%的对象都是可被回收的。
与此同时,且G1对应新生代空间是动态分配的(默认5%~60%) ,考虑到Region 体量,新生代每次也不会出现过多对象引用变更,采用简单的标记复制法,能保证短暂STW (合理的程序设计下控制在5ms以内),还能避免上下文切换的开销,就能很好的解决GC问题。
Mixed GC工作流程
mixed gc是g1垃圾回收器中的一个独有的概念,它是一种混合回收的垃圾回收模式,对应触发时机为:
- 老年代Region 占用堆内存比达到XX:InitiatingHeapOccupancyPercent(默认45%)
- Young GC后空间仍不足分配亦或者大对象分配失败
进行混合回收时,它会回收所有年轻代和部分老年代,本质是出于对象特性的考量,大部分情况下,晋升到老年代的对象可回收概率都不高,出于响应时间和吞吐量的保证,G1 提出可预测停顿模型的概念,针对老年代,它通过一套算法推算出在有限的停顿时间内可以得到尽可能多的回收效益的Region,并将其回收:

对Mixed GC 有了宏观的认识后,我们再来深入理解一下Mixed GC的实现细节,这其中设计并发标记和可达性两个概念。
我们先来说说并发标记的基本概念,G1本着可预测停顿的思想理念,在混合GC时采用一定的并行度进行垃圾回收,核心的四个步骤(这里涉及写屏障和记忆集操作流程后文会展开探讨):
- 初始标记**(STW)**:标记GC Roots直接关联的对象,该阶段需要停止线程,但是耗时较短
- 并发标记:和业务线程并行,对GC Root开始对对堆中对象进行可达分析,耗时较长
- 最终标记**(STW)**: 对用户线程做一个短暂暂停,同时对步骤2中标记信息进行矫正(涉及跨代引用问题,后续会展开说明)
- 筛选回收**(STW) :更新 Region数据,对Region**的回收成本和收益进行排序,并根据用户设定的停顿时间生成执行计划完成回收
在说明并发标记时,我们提到可达性分析的概念,G1回收器采用三色标记法进行可达性标记:
- 白色:初始状态,表示对象尚未被垃圾收集器访问,新创建的对象默认都是白色,若在分析结束后依然是白色,则说明对象不可达
- 灰色:中间状态,表示对象已被垃圾收集器访问过,但其引用对象还未完成可达性分析
- 黑色:终态,引用链完成可达性标记,直接标记为黑色,并发标记阶段无需重复处理

跨代引用问题
跨代引用假说
结合上述GC流程,我们来探讨一个更进阶的话题------跨代引用,即跨代引用对象后,GC如何感知到引用关系。假设新生代对象A被老年代对象B引用,进行Minor GC时,如何感知跨代引用关系?
结合上文提及的分代假说,我们可以得出第三条经验法则:
跨代引用假说:跨代引用相对于同代引用仅占极少数
实际上,这一假说并非独立的说法,而是基于上述假说的推理结果、我们假设我现在新生代和老年代分别存在两个对象A和B,假设老年代对象B指向新生代对象A,基于老年代对象的特性,对象A极大概率会随着数论GC收集后晋升至老年代。与之相反即新生代指向老年代的情况,结合新生代对象的生命周期,极大概率就是一个朝生夕死的对象,所以无需理会。
综合上述的说法,按照概率论的推测,针对跨代引用问题,在进行垃圾收集时,我们无需针对每个对象进行跨代引用扫描。取而代之而是在新生代建立一个全局的记忆集(Remember Set),这个结构把老年代分为多个内存小块,标识哪些老年代的哪一块内存存在跨代引用。
譬如:我们现在新生代有个对象A,在程序运行的生命周期内被老年代对象B引用,我们只需通过记忆集标记老年代对象B对其引用信息即可。如此一来,下次Minor GC时,我们只需通过记忆集即可定位到跨代引用,并将其加入GC roots进行扫描即可。

记忆集与卡表的实现
明确整体方案之后,我们再来聊聊记忆集(Remember Set以下简称Rset)的实现,垃圾收集器在新生代创建了名为记忆集的数据结构。考虑到庞大的堆内存,无论用记忆集记录引用当前对象的集合,无论是空间还是维护的代价都是非常高昂的:
vbnet
public class RememberedSet {
//对象数组维护跨代引用对象
Object[] set = new Object[Integer.MAX_VALUE];
}
结合上述的假说,按照概率学的角度进行统计,实际山记忆集也仅需维护跨代引用的区域信息即可,无需过分维护引用细节,所以设计者采用了一种更加粗矿的方案,即记录精确到每一块内存区域,该区域内有对象包含跨代指针。
所以G1垃圾回收器记忆集的实现是通过卡表(CARD_TABLE)实现,卡表是一段连续的字节数组空间,在卡表的基础之上,将堆内存划分为更细粒度的卡页(Card Page) ,用卡表的每一个字节去标识卡页的跨代引用情况:

结合如下Hotspot标记源码可知:
- 卡表通过CARD_TABLE [xxxxx] = 0计算对应卡页的索引地址,说明一个卡表项(1个字节)标识一个卡页。
- 进行索引定位时,通过this address >> 9 即addr/512 ,由此可知每个卡页为512byte,卡表通过当前算法定位该卡页在卡表哪个索引块。
- 基于上述算法定位到卡表项之后,将其设置为1,告知垃圾回收器,当前卡页存在跨代引用
kotlin
//定位卡页对应卡表项,将其标记为脏,
CARD_TABLE [this address >> 9] = 1
考虑到G1内存区域的特殊性,设计者在G1的记忆集上做了一些特殊的设计,它基于卡表的理念,为每个Region都分配一个记忆集,而该记忆集本质上是一个哈希表,key为指向当前Region对象的Region地址,value是一个集即对应Region的卡表项索引号,所以G1相比其他垃圾回收器有着更高的内存占用。根据经验推算来看,G1至少要耗费java堆容量的10%至20%支持垃圾收集器的工作。

写屏障
明确记忆集收集之后,我们再来探讨跨代信息写入的问题,卡表如何变脏,何时变脏?本质上来说变脏的时间点应该是赋值的那一刻,如果是字节码,那么指令就是有虚拟机解释再执行,那么虚拟机旧有充分的介入空间。一旦字节码变为编译后的机器码,此时指令就已经是存粹的机器码,我们就必须寻求一种机器码层面的手段,完成卡表维护的动作。
scss
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
于是这里就有了一个类似于Spring AOP的概念,即JVM在赋值器生成写前屏障和写后屏障,通过这些环绕通知,实现在写入前后实现无侵入式记忆集维护。
基于写后屏障的Minor gc跨代引用解决方案
通过上文我们先进的完成的针对新生代回收的跨代引用问题,对此我们讲这些概念串联一下,假设新生代A对象被老年代B所引用,对应的处理流程为:
- 通过写后屏障更新全局Card Table
- 为避免更新各个Region Rset 所带来哈希运算、扩容、所竞争的开销,Rset更新操作改为提交更新信息到日志缓冲区
- 日志缓冲区满后,会被淘汰,交由全局线程Concurrent Refinement Threads执行
- 后台异步线程Concurrent Refinement Threads实时消费,定位所有脏卡,更新Rset
可能读到这里读者难免有些疑问,既然跨代消息会提交到全局卡表(Card Table),为什么还要提交一份消息到日志缓冲区呢?实际上,这是实时性和准确性的折中考量:
- 准确性:假设局部日志缓冲区没满,触发Minor gc,gc线程就可以基于全局卡表进行引用关系订正
- 实时性:确保准确性之后,确保Rset更新的实时性和开销,G1进一步通过批攒日志异步消费的方式,提升跨代更新的效率

并发可达性问题
多标和漏标
明确Mixed GC工作机制后,我们再来探讨一个更深入的话题------跨代引用问题。在并发标记的第二个阶段,考虑到堆中对象的数量以及对响应时间和吞吐表现的考量,G1采用并行回收的方式,让GC线程和业务线程并行执行,此时就会出现多标或者漏标两种情况。
多标即原本可达分析阶段置为黑色的对象被解引用导致多标记,因为黑色对象无需重复扫描,导致分析结束后不能及时回收,成为浮动垃圾。这种情况相对较少,即时不干预对于程序整体也没有实质性的影响,下次收集清理即可。
而漏标则存在一定的业务风险,多标即原本可达性分析置为白色的对象,在可达性分析结束后,并行的业务线程将其引用,导致不该回收的对象被回收,如下图所示,Obj B在可达性分析视为可回收对象后,业务线程突然修改其应用关系,使之被Obj A引用:

写前屏障+STAB
从本质上来说,出现漏标的情况必须符合以下两个条件:
- 赋值器在已被标记过的黑色对象引用链上插入白色对象
- 赋值器删除全部灰色对象对该白色对象的引用
对应我们也可以回到上图印证这一点,即灰色对象扫描期间解除引用后,之前标记为黑色的对象对齐重新引用,导致漏标。
考虑上述条件是与关系,要解决跨代应用问题,本质就是要感知到这并发标记对象的变更,最简单的做法无非是在最终标记阶段针对待分析对象进行跨堆扫描,只不过这种做法扫荡成本非常高昂,与之对应的收益也是成反比。
所以我们必须找到一种高效的手段解决该问题,仔细分析问题触发的条件是逻辑与关系。所以我们不妨从这两个条件的角度出发,只要能破坏其中任意一个条件就能打破这层逻辑关系,从而确保正确的标记。
针对条件一,即黑色引用链上插入新对象,对应解决思路很简单,在赋值器感知到这个操作的时候,采用写后屏障将黑色对象信息标记下来,待并发标记结束后,以这些黑色对象为根,重新扫描订正一下即可。这就是所谓增量更新,也就是CMS垃圾回收器的处理手段。
破坏条件二原理也是类似,即灰色对象删除白色对象引用关系时,直接被删除的引用信息标记下来,待并发标记结束后,重新以灰色对象为根扫描(即使灰色和白色关系被删除,它们还是按照一开始的引用关系进行标记)。

这种方式我们也可以通俗的理解为,一旦感知到这个删除操作,无论关系图如何变化,都会按照刚刚开始扫描那一刻的对象图进行搜索,宁可出现漏标的浮动垃圾,也不愿意出现多标的误回收。
这也就是所谓的原始快照**(SATB,Snapshot-At-The-Beginning)**,也就是本文介绍的G1回收器的处理策略,相较于破坏条件一的感知删除操作,基于写前屏障将被删除的引用内存地址提交到线程本地的STAB,待队列满之后(或者最终标记阶段强制合并)提交到全局STAB,完成引用关系订正。
我们还是从实际上的场景入手,假设并行回收阶段,新生代经过可达性分析得到一个白色对象:

随后,老年代对象将其引用,此时,G1赋值器的处理步骤为:
- 灰色对象感知对象解引用,将解引用对象提交到STAB局部队列
- 局部队列已满或者最终标记阶段强制合并局部队列信息到全局队列
- 最终标记阶段,将被删除引用设置为灰色,宁可多标也不漏标

G1调优最佳实践
合理设置停顿时间
MaxGCPauseMillis是G1回收器的核心参数,该参数是作为Mixed GC动态回收算法的重要输入,所以,我们可以根据不同场景需要动态调整参数值:
- 高吞吐:追求单位时间内,数据处理的效率,多用于后台批处理任务(涉及一定时长的集合数据),对暂停不敏感,可适量增加暂停时间,释放更多的堆空间
- 高并发:并发场景着重于用户体验,所以我们要尽可能减少GC时间对业务线程的干扰,所以我们可以结合SLA业务进行反推:
ini
SLA TP 99 <= 200ms
接口平均耗时=100ms
# 设置最大暂停时间(默认200ms)
-XX:MaxGCPauseMillis=200-100=100ms
目标:尽量不让GC拖垮TP99
需要注意,不要盲目减小MaxGCPauseMillis,尽管MaxGCPauseMillis会结合Region动态分析进行混合回收。若极端的调小MaxGCPauseMillis极可能出现极低的GC收益,进而出现更频繁的GC,最终导致大量过期对象未能及时清理而引发严重的Full GC。
按照经验主意,对于MaxGCPauseMillis的设定笔者建议把期望停顿时间设置在100ms~300ms这个区间。
Region大小动态分析
默认情况下,G1会根据分配的堆内存空间动态调整Region大小,笔者除非明确需要,否则不要显示设置Region大小,以免干扰G1自适应能力。
当然,这里也有一个比较特殊的场景,即大对象分配,上文我们提到了一个Humongous Region(存放大小超过Region 50%的对象),在需要高吞吐的场景,我们批处理程序大部分都是一次性读取一个大对象(超长数组或者各种数据集),这类对象无论是分配亦或者回收成本都很高:
- 分配必须是连续Region,容易造成内存碎片
- GC只会发生在Mixed GC或者Full GC,容易导致GC抖动
所以,如果明确能够评估该场景的大对象集大小,且明确堆空间大小没有提升空间的情况下,建议通过调整G1HeapRegionSize提升分配器的分配效率。
以笔者个人经验为例,一般情况下,一个普通的Java Entity对象约840 字节,经过换算之后大约是8M左右,结合Region阈值进行推算,大约需要16.8,所以保守起见设置为32M:
ini
# 指定Region的内存大小,n必须是2的指数幂,其取值范围是从1M到32M
-XX:G1HeapRegionSize=32m
由此保证一个Region即可完成对象分配,从而确保
- 提升对象分配效率
- 降低Full GC 概率
- 避免Humongous Region跨区发分配和回收开销
老年代晋升和回收治理
针对高并发场景,我们还需要针对内存空间管理进行调整,本质要做到:
- 尽量让对象在新生代完成回收
- 尽可能保证高响应情况下,尽可能多进行GC,降低Full GC频率
针对第一点,我们可适当提升TargetSurvivorRatio或MaxTenuringThreshold,提升晋升老年代的S区阈值,避免过早进入老年代。
大部分晋升的老年代生命周期都相对较短,为避免并发场景过晚阈值回收导致更严总的Full GC,导致响应延迟升高,对此我们可以适当调低-InitiatingHeapOccupancyPercent尽早回收老年代对象来规避这个问题:
ini
-XX:InitiatingHeapOccupancyPercent=30
关于G1和CMS
可能因为JDK 9将G1作为默认回收器之后,使得大部分读者认为G1要先进于CMS。实际上,G1出色的回收算法和内存管理策略是建立在一定空间成本(记忆集)和CPU负载开销,所以对于CPU或者内存不宽裕的服务器,我们还是需要结合实际硬件条件,对二者进行充分的压测和分析,不要盲目的选择G1。 这里笔者本着经验主义说明一下所谓不宽裕的分界线,即CPU核心数小于8或内存空间低于6~8G这个范围,CMS的表现仍是由于G1。
当然,随着Hotspot开发者对G1的不断优化,相信将来的迭代,会使得回收器设置的理念向"无脑G1"这一看法倾斜。
小结
以上便是笔者关于G1垃圾回收器的基本介绍和使用建议,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
参考
深入JVM:详解G1垃圾回收器原理:blog.csdn.net/mm127488979...
高并发系统必看!G1如何让亿级JVM吞吐量提升300%? :mp.weixin.qq.com/s/uE8E90d_R...
探究JVM(五)一张图搞定G1垃圾回收器的记忆集:blog.csdn.net/Yao_ziwei/a...
JVM三色标记法详解,带"颜色"的JVM:zhuanlan.zhihu.com/p/431406707
《深入理解Java虚拟机》
Tips for Tuning the Garbage First Garbage Collector :www.infoq.com/articles/tu...
Better default for ParallelGCThreads and ConcGCThreads by using number of physical cores and CPU mask:mail.openjdk.org/pipermail/h...
6 The Parallel Collector:docs.oracle.com/javase/8/do...