G1:把堆切成 2048 块后,GC 就变灵活了
一句话结论(30s)
G1 的本质是把堆切成最多 2048 个等大 Region 让分代不再物理连续,因为 Region 角色可随 GC 动态切换,回收时只需挑部分 Region 而非全堆。关键设计是 RSet——每个 Region 记录”哪些外部 Region 的哪些 Card 引用了我的对象”,回收时只扫引用方而非全堆,再配合 SATB 快照保证并发标记不丢对象。权衡在于 RSet 要靠写屏障 + 后台 Refinement 线程维护,SATB 产生浮动垃圾,Free Region 耗尽时还会退化为 Serial Old 单线程 Full GC。
核心原理(2min)
主流程:G1 把堆切成 2048 个等大 Region,对象按代晋升(Eden→Survivor→Old),大对象作为 Humongous 直接分配连续 Region;回收某 Region 时查它的 RSet 定位所有外部引用。关键机制:应用线程写引用时写后屏障检查是否跨 Region,是则把对应 Card 标脏并写入 Dirty Card Queue,由后台 Refinement 线程异步更新目标 Region 的 RSet,不占用 GC 停顿;并发标记用 SATB 保留快照,代价是浮动垃圾。
底层深入(5-10min)
G1 的堆布局
G1 不要求物理连续的老年代和新生代。而是把整个堆切成最多 2048 个大小相等的 Region(1-32MB)。每个 Region 的角色动态变化:
堆 = [Eden][Eden][Survivor][Old][Old][Humongous]...[Free]
↑ 不要求连续!角色随 GC 切换
晋升:一次 GC 后,存活对象从 Eden → Survivor → Old 晋升,源 Region 释放后标记为 Free。
一个很大的对象(≥ 半个 Region)作为 Humongous 对象直接分配到一组连续的 Region 中,跳过新生代。这是为了避免大对象在 Eden 和 Survivor 之间反复复制。
为什么 G1 要把堆切成 Region,而不是像传统收集器那样物理上固定分代?因为固定分代里各代比例写死、调整不灵活,而 Region 的角色可以随 GC 动态切换,回收时「只挑部分 Region」就能把停顿压在目标值内。大对象跳过新生代同理——避免庞然大物在 Eden/Survivor 间反复复制。
Region 的大小在源码里是一组静态常量,来自 g1HeapRegion.hpp:
static uint LogOfHRGrainBytes;
static uint LogCardsPerRegion;
static size_t GrainBytes;
static size_t GrainWords;
static size_t CardsPerRegion;
// Returns whether a field is in the same region as the obj it points to.
template <typename T>
static bool is_in_same_region(T* p, oop obj) {
assert(p != nullptr, "p can't be null");
assert(obj != nullptr, "obj can't be null");
return (((uintptr_t) p ^ cast_from_oop<uintptr_t>(obj)) >> LogOfHRGrainBytes) == 0;
}
GrainBytes 是单个 Region 的字节大小,LogOfHRGrainBytes 是它的 log2 值——所以判断两个指针是否落在同一 Region,只需把二者的地址做异或再右移 LogOfHRGrainBytes 位,结果为 0 即同区。这个 is_in_same_region 正是”跨 Region”判断的内核,后面 Refinement 线程决定要不要维护 RSet 时靠的就是它。Region 大小由参数 G1HeapRegionSize 控制:
product(size_t, G1HeapRegionSize, 0, \
"Size of the G1 regions.") \
range(0, NOT_LP64(32*M) LP64_ONLY(512*M)) \
默认值 0 表示交给 JVM 按堆大小自动推算(1-32MB),保证 Region 总数落在约 2048 这个量级。
Region 类型在源码里的编码
每个 Region 的”角色”被编码成一个整数 Tag,来自 g1HeapRegionType.hpp:
// We encode the value of the heap region type so the generation can be
// determined quickly. The tag is split into two parts:
//
// major type (young, old, humongous) : top N-1 bits
// minor type (eden / survivor, starts / cont hum, etc.) : bottom 1 bit
//
// 00000 0 [ 0] Free
//
// 00001 0 [ 2] Young Mask
// 00001 0 [ 2] Eden
// 00001 1 [ 3] Survivor
//
// 00010 0 [ 4] Humongous Mask
// 00010 0 [ 4] Starts Humongous
// 00010 1 [ 5] Continues Humongous
//
// 00100 0 [ 8] Old Mask
// 00100 0 [ 8] Old
//
typedef enum {
FreeTag = 0,
YoungMask = 2,
EdenTag = YoungMask,
SurvTag = YoungMask + 1,
HumongousMask = 4,
StartsHumongousTag = HumongousMask,
ContinuesHumongousTag = HumongousMask + 1,
OldMask = 8,
OldTag = OldMask
} Tag;
类型位的高位区分 Young / Old / Humongous 三大类,低位区分 Eden / Survivor 或 StartsHumongous / ContinuesHumongous 等子类。因此 is_young() 只要一次按位与 (get() & YoungMask) != 0 就能判定代际,比 switch 快得多。注意 Humongous 对象跨多个连续 Region 时,第一个是 StartsHumongous、后续是 ContinuesHumongous,二者靠类型位区分。
RSet:谁会引用我?
一个 Region 被回收时,需要知道”谁引用了我的对象”。G1 的做法是 RSet(Remembered Set)——每个 Region 的 RSet 记录了”哪些外部 Region 的哪些 Card 引用了我的对象”。
Region X 的 RSet:
Region A, Card 3 → 引用了 X 中的对象
Region B, Card 7 → 引用了 X 中的对象
...
回收 Region X 时,只需扫描 X 的 RSet(而非全堆),找到外部引用位置并更新。
为什么 G1 需要 RSet,而像 Serial 这种整堆收集的 GC 却不需要?因为 G1 回收的是「单个 Region」而非全堆——回收 X 时若不知道谁引用了 X,就只能扫全堆才能找到所有指向 X 的引用。RSet 就是把这个「谁引用我」的答案预先缓存起来,让局部回收成为可能。
RSet 的实际数据结构
现代 JDK(本仓库这份源码)里,RSet 的 card 集合不再用老的 SparsePRT/PerRegionTable 设计,而是 G1CardSet。它底层是一张 ConcurrentHashTable,把”每一个含引用的外部 Region”映射到一组 Card:
// Set of card indexes comprising a remembered set on the Java heap. Card
// size is assumed to be card table card size.
//
// Technically it is implemented using a ConcurrentHashTable that stores a card
// set container for every region containing at least one card.
class G1CardSet : public CHeapObj<mtGCCardSet> {
每一组的 Card 用一个 ContainerPtr 表示,容器类型编码在指针的最后两位,密度越高容器越”粗”:
// ContainerPtr represents the card set container type of a given covered area.
// It encodes a type in the LSBs, in addition to having a few significant values.
//
// Possible encodings:
//
// 0...00000 free (Empty, should never happen on a top-level ContainerPtr)
// 1...11111 full All card indexes in the whole area this ContainerPtr covers are part of this container.
// X...XXX00 inline-ptr-cards A handful of card indexes covered by this ContainerPtr are encoded within the ContainerPtr.
// X...XXX01 array of cards The container is a contiguous array of card indexes.
// X...XXX10 bitmap The container uses a bitmap to determine whether a given index is part of this set.
// X...XXX11 howl This is a card set container containing an array of ContainerPtr, with each ContainerPtr
// limited to a sub-range of the original range.
//
// Coarsening happens in the order below:
// ContainerInlinePtr -> ContainerArrayOfCards -> ContainerHowl -> Full
Card 少时直接内联在指针里(inline-ptr),多了升级成数组、再升级成 bitmap、最后是”满”(full)。这种按密度逐级粗化(coarsen)的设计,让稀疏的跨 Region 引用几乎不占内存,而稠密引用退化成一张全量位图,扫描反而更快。读取容器类型只需 ptr & 0x3,非常廉价。
💭 思考:为什么 RSet 的 Card 集合要按密度逐级粗化,而不是一刀切用数组或位图?——跨 Region 引用绝大多数是稀疏的:如果每个 Region 都预分配一张覆盖全堆的 bitmap,内存会按”Region 数 × Card 数”爆炸;反过来,如果统一用数组,引用密集时又要逐个比对、扫描变慢。所以 G1 让容器跟着密度走——稀疏时几个 Card 直接塞进指针里、几乎零开销,密度上去再升级数组/位图,最满就退化成一个 “full” 标记,扫描反而最快。这种”为每种密度准备最合适的表示”的思路,本质是用一点转换开销,换内存和扫描速度的双赢。
每个 Region 的 RSet 对象
G1HeapRegionRemSet 是挂在每个 Region 上的 RSet 门面,来自 g1HeapRegionRemSet.hpp:
class G1HeapRegionRemSet : public CHeapObj<mtGC> {
// A set of nmethods whose code contains pointers into
// the region that owns this RSet.
G1CodeRootSet _code_roots;
// The collection set groups to which the region owning this RSet is assigned.
G1CSetCandidateGroup* _cset_group;
G1HeapRegion* _hr;
// Cached value of heap base address.
static HeapWord* _heap_base_address;
...
private:
enum RemSetState {
Untracked,
Updating,
Complete
};
RemSetState _state;
它其实包含两部分:记录外部对象引用的 card set(经 _cset_group 间接访问),以及记录指向本 Region 的 nmethod(JIT 代码常量里的指针)的 code root set。它还有一个三态 _state:Untracked(不跟踪)、Updating(正在建)、Complete(已完整)。只有老年代 / 回收候选 Region 才需要跟踪 RSet——Young Region 每次 GC 都被全量扫描,根本不需要 RSet。
💭 思考:为什么 Young Region 不需要 RSet,只有老年代/回收候选才建?——RSet 的存在意义是”回收单个 Region 时不用扫全堆”。但 Young Region 本来每轮 GC 就被整体扫描(新生代对象大多短命、存活少,全扫成本很低),给它建 RSet 反而要额外维护跨区引用、得不偿失。老年代不一样——Mixed GC 只挑”部分”老年代 Region 回收,这时才必须靠 RSet 知道”谁引用了我”。所以 RSet 本质是为”局部回收”服务的:全量扫描的地方不需要它,只有”挑着收”的地方才需要。
RSet 的维护
应用线程每次写引用字段时,G1 的写后屏障检查新引用是否跨 Region → 是则将对应的 Card 标记为 dirty → 记录到 Dirty Card Queue(DCQ)→ Refinement 线程异步处理 DCQ → 更新目标 Region 的 RSet。
整个流程不在 GC 停顿期内完成——写屏障只负责把 Card 入队,由后台线程负责更新 RSet,对应用吞吐影响极小。
为什么写屏障只标脏、不直接做跨 Region 判断和更新 RSet?因为写屏障运行在应用线程最热的路径上,必须极轻——把脏 Card 入队是 O(1) 的廉价操作,而「判断是否跨区 + 更新 RSet」成本高,留给后台 Refinement 线程异步做,才不影响应用吞吐。
写后屏障:只标脏,不做跨 Region 判断
当前源码里,mutator 的写后屏障其实只做一件事——把 Card 标脏,来自 g1BarrierSet.inline.hpp:
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_post(T* field) {
// Make sure that the card table reference is read only once. Otherwise the compiler
// might reload that value in the two accesses below, that could cause writes to
// the wrong card table.
CardTable* local_card_table = card_table();
CardValue* byte = local_card_table->byte_for(field);
if (*byte == G1CardTable::clean_card_val()) {
*byte = G1CardTable::dirty_card_val();
}
}
注意:这里没有任何”是否跨 Region”的判断,判断被下沉到了 Refinement 阶段。而且当前实现用两张 Card Table,来自 g1BarrierSet.hpp 的注释:
// This barrier set is specialized to manage two card tables:
// * one the mutator is currently working on ("card table")
// * one the refinement threads or GC during pause are working on ("refinement table")
//
// The card table acts like a regular card table where the mutator dirties cards
// containing potentially interesting references.
//
// When the amount of dirty cards on the card table exceeds a threshold, G1 swaps
// the card tables and has the refinement threads reduce them by "refining"
// them.
mutator 写一张表、Refinement 线程读另一张表,脏卡超阈值就交换。这样 mutator 与 Refinement 之间几乎不需要逐次写入的细粒度同步,写屏障因此能做得极轻——这正是”对应用吞吐影响极小”的底层来源。
Refinement:真正的跨 Region 判断在这里
后台 Refinement 线程扫到脏卡时,逐个引用做跨 Region 判断并更新 RSet,来自 g1OopClosures.inline.hpp:
template <class T>
inline void G1ConcurrentRefineOopClosure::do_oop_work(T* p) {
// Early out if we already found a to-young reference.
if (_has_ref_to_cset) {
return;
}
T o = RawAccess<MO_RELAXED>::oop_load(p);
if (CompressedOops::is_null(o)) {
return;
}
oop obj = CompressedOops::decode_not_null(o);
if (G1HeapRegion::is_in_same_region(p, obj)) {
// Normally this closure should only be called with cross-region references.
// But since Java threads are manipulating the references concurrently and we
// reload the values things may have changed.
return;
}
G1HeapRegion* to_region = _g1h->heap_region_containing(obj);
if (to_region->is_young()) {
_has_ref_to_cset = true;
return;
}
G1HeapRegionRemSet* to_rem_set = to_region->rem_set();
if (to_rem_set->is_tracked()) {
G1HeapRegion* from = _g1h->heap_region_containing(p);
if (from->rem_set()->cset_group() != to_rem_set->cset_group()) {
to_rem_set->add_reference(p, _worker_id);
_has_ref_to_old = true;
}
}
}
同一个 Region 内的引用直接跳过(不值得记 RSet);跨区但目标是 Young 的也跳过——Young Region 下轮 GC 必然全量扫描,RSet 只对老年代有意义。只有”跨区 + 目标老年代 + 目标被跟踪”才真正调用 add_reference 写入 RSet。所以”是否跨 Region”这个判断实际发生在后台 Refinement 阶段,而不是 mutator 的写屏障里——这是对本文开头那句简化的准确澄清。
💭 思考:为什么”判断是否跨 Region”要下沉到 Refinement 线程,而不是在写屏障里当场做完?——写屏障跑在应用线程每一条引用赋值的最热路径上,出现频率极高;而”判跨区 + 更新 RSet”要读 Region 边界、查哈希表,成本远高于一次标脏。把重活留给后台线程,mutator 只做 O(1) 的标脏,才能把对应用吞吐的拖累压到最低。如果不拆、全塞进写屏障,等于让每次对象赋值都背上”维护 GC 元数据”的完整成本,应用会明显变慢。
真正写入 RSet 的动作在 g1HeapRegionRemSet.inline.hpp:
uintptr_t G1HeapRegionRemSet::to_card(OopOrNarrowOopStar from) const {
return pointer_delta(from, _heap_base_address, 1) >> CardTable::card_shift();
}
void G1HeapRegionRemSet::add_reference(OopOrNarrowOopStar from, uint tid) {
assert(has_cset_group(), "pre-condition");
assert(_state != Untracked, "must be");
uint cur_idx = _hr->hrm_index();
uintptr_t from_card = uintptr_t(from) >> CardTable::card_shift();
if (G1FromCardCache::contains_or_replace(tid, cur_idx, from_card)) {
// We can't check whether the card is in the remembered set - the card container
// may be coarsened just now.
return;
}
card_set()->add_card(to_card(from));
}
to_card 把引用指针换算成”相对堆基址的 Card 序号”,add_reference 先查 FromCardCache 去重(同一线程对同一 Card 的重复写不重复入 RSet),再调用 card_set()->add_card() 真正写入。整个写入是并发的:多个 Refinement 线程可同时向同一个 G1CardSet 加 Card,靠 ConcurrentHashTable 与原子操作保证安全。
SATB:保留快照
G1 的并发标记基于 SATB(Snapshot-At-The-Beginning)。标记开始时保留所有引用的”快照”,标记过程中新分配的、被覆盖的引用都基于快照判断存活。代价是产生浮动垃圾——快照时还活着但标记完成时已死亡的对象本轮 GC 不回收。
为什么 SATB 一定会产生浮动垃圾?因为它保留了「标记开始时刻」的快照——一个对象在快照时还可达,即使标记过程中引用被断掉,本轮仍按存活处理,等下一轮才回收。用「少回收一点」换「并发标记不漏标活对象」的安全。
SATB 的核心是写前屏障,来自 g1BarrierSet.inline.hpp:
template <class T>
inline void G1BarrierSet::enqueue(T* dst) {
G1SATBMarkQueueSet& queue_set = G1BarrierSet::satb_mark_queue_set();
if (!queue_set.is_active()) return;
T heap_oop = RawAccess<MO_RELAXED>::oop_load(dst);
if (!CompressedOops::is_null(heap_oop)) {
SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(Thread::current());
queue_set.enqueue_known_active(queue, CompressedOops::decode_not_null(heap_oop));
}
}
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_pre(T* field) {
if (HasDecorator<decorators, IS_DEST_UNINITIALIZED>::value ||
HasDecorator<decorators, AS_NO_KEEPALIVE>::value) {
return;
}
enqueue(field);
}
write_ref_field_pre 在覆盖引用前执行:先把”旧值”读出来,若非空则压入线程本地的 SATB 队列。这样并发标记期间即使某个对象被改掉了引用,标记器仍能看到标记开始时刻的快照引用,从而不漏标活对象。队列只在并发标记激活时(is_active())才真正入队——所以平时 SATB 屏障几乎零开销,只有在标记周期内才承担成本。
💭 思考:为什么 SATB 写前屏障要加
is_active()开关,而不是永远开着?——写前屏障比写后屏障更”重”:它要在覆盖引用前先把旧值读出来压队列。但这个快照只在并发标记期间才有用,平时压了也没人消费。于是加一个开关:非标记期直接返回、零开销,标记期才入队。若拿掉开关,等于让所有引用写都永久承担这份成本,而收益只存在于一小段时间内——纯属浪费。
Full GC 退化
正常 G1 永不做 Full GC——只做 Young GC(仅回收新生代 Region)+ Mixed GC(回收新生代 + 部分老年代 Region)。但如果对象分配速度远超 GC 回收速度,G1 的 Free Region 耗尽 → Concurrent Mode Failure → 退化为 Serial Old 单线程 Full GC → 数秒 STW。
为什么 G1 说「永不做 Full GC」却又存在 Full GC 分支?因为「正常情况不做」不等于「永远不做」——当分配速度远超回收速度、Free Region 耗尽时,兜底的 Serial Old Full GC 是保住 JVM 不崩溃的最后手段,代价就是数秒 STW。理解这一点,就能明白为什么 IHOP 调太高会「晚一步」而陷入 Full GC。
根因:-XX:InitiatingHeapOccupancyPercent(默认 45%)设太高 → 并发标记启动太晚 → Mixed GC 来不及释放 Region → Free Region 耗尽。降低此值让并发标记更早启动。
收集阶段的状态机
G1 的收集阶段在 g1CollectorState.hpp 里显式编码为一个枚举:
// State of the G1 collection.
//
// The rough phasing is Young-Only, Mixed / Space Reclamation and
// Full GC "phase".
//
// We split the Young-only phase into three parts to cover interesting
// sub-phases and avoid separate tracking.
class G1CollectorState {
enum class Phase {
// Indicates that the next GC in the Young-Only phase will (likely) be a "Normal"
// young GC.
YoungNormal,
// We are in a concurrent start GC during the Young-Only phase. This is only set
// during that GC because we only decide whether we do this type of GC at the start
// of the pause.
YoungConcurrentStart,
// Indicates that we are about to start or in the prepare mixed gc in the Young-Only
// phase before the Mixed phase. This GC is required to keep pause time requirements.
YoungPrepareMixed,
// Doing extra old generation evacuation.
Mixed,
// The Full GC phase (that coincides with the Full GC pause).
FullGC
} _phase;
YoungConcurrentStart 是一次”顺带启动并发标记”的 Young GC,YoungPrepareMixed 是进入 Mixed 前那次负责筛候选集、满足停顿预算的 Young GC。正常流程只在这几个状态间流转,FullGC 是并发标记来不及、Free Region 耗尽时的兜底分支。
触发 Mixed GC 的关键参数
“什么时候启动并发标记""回收哪些老年代 Region”都由下面这些参数决定,来自 gc_globals.hpp 和 g1_globals.hpp:
product(uint, InitiatingHeapOccupancyPercent, 45, \
"The percent occupancy (IHOP) of the current old generation " \
"capacity above which a concurrent mark cycle will be initiated " \
product(uint, G1MixedGCLiveThresholdPercent, 85, EXPERIMENTAL, \
"Threshold for regions to be considered for inclusion in the " \
"collection set of mixed GCs. " \
"Regions with live bytes exceeding this will not be collected.") \
range(0, 100) \
product(uint, G1HeapWastePercent, 5, \
"Amount of space, expressed as a percentage of the heap size, " \
"that G1 is willing not to collect to avoid expensive GCs.") \
range(0, 100) \
product(uintx, G1MixedGCCountTarget, 8, \
"The target number of mixed GCs after a marking cycle.") \
InitiatingHeapOccupancyPercent(默认 45)就是 IHOP:老年代占用超过该比例就启动并发标记,给后面的 Mixed GC 留足时间。标记结束后,G1 按 G1MixedGCLiveThresholdPercent(85)筛掉存活率过高的 Region、按 G1MixedGCCountTarget(8)把回收分摊到多次 Mixed GC、G1HeapWastePercent(5)控制愿意浪费多少堆空间以换取不再回收。IHOP 设太高 → 标记启动晚 → Mixed GC 释放不及 → Free Region 耗尽 → 退化为 Full GC。
总结
| 机制 | 作用 | 代价 |
|---|---|---|
| Region | 灵活分代,无需物理连续 | 需要 RSet 管理跨 Region 引用 |
| RSet | 回收时只扫引用方而非全堆 | 写屏障 + 后台 Refinement 维护 |
| SATB | 并发标记不丢对象 | 浮动垃圾(待下轮回收) |
章末提问
-
G1 和 CMS 在堆布局上的本质区别? — 结论:G1 用等大 Region 动态分代,CMS 是物理连续分代。因为 Region 角色可随 GC 切换、回收可挑部分 Region,从而把停顿压在目标值内;CMS 固定分代难以动态调整。
-
RSet 是什么、为什么需要它? — 结论:RSet 是每个 Region 缓存「谁引用了我」的结构,避免回收时全堆扫描。因为 G1 回收的是单个 Region 而非全堆,必须预先知道所有指向它的跨 Region 引用位置。
-
写后屏障为什么只标脏、不做跨 Region 判断? — 结论:为了把应用线程热路径开销压到最低。因为标脏是 O(1) 廉价操作,跨区判断和 RSet 更新成本高,下沉给后台 Refinement 线程异步完成。
-
SATB 的代价是什么? — 结论:浮动垃圾。因为快照保留了标记开始时刻的引用状态,快照时可达、中途死亡的活对象本轮不回收,等下一轮才回收。
-
G1 什么时候会退化为 Full GC,怎么避免? — 结论:分配远超回收导致 Free Region 耗尽时退化为 Serial Old。因为 IHOP 设太高会让并发标记启动太晚、Mixed GC 来不及释放 Region,应调低 IHOP 让标记更早启动。