G1垃圾回收器详细分析:从最简单的标记-清除到分代策略与记忆集

261 阅读6分钟

起点:最朴素的标记-清除

G1再牛,它的基础还是垃圾回收的老祖宗——标记-清除。想象内存里一堆对象,GC的任务是清垃圾。简单点就是:从根(栈变量、全局对象)开始,标记活对象,没标记的扫掉。

Demo举例

假设内存2GB,20万个对象,每个占10KB,总共2GB。根引用1000个,这1000个连着5000个活对象。GC标记这6000个(60MB),剩下19.4万个(1.94GB)清掉,内存回收。

问题1:停顿时间长得离谱

标记20万个对象得扫描所有引用链,假设每次检查1微秒,总共200毫秒,程序全停(Stop-The-World)。用户那边卡0.2秒,游戏都得掉帧,体验直接崩。

问题2:碎片一大堆

清掉1.94GB后,60MB活对象散在内存里,像个破麻袋。假设下次要分配50MB连续空间,可能愣是找不到,得挪半天,效率惨兮兮。

优化方向

得让GC停顿短点,别全扫;碎片也得收拾。这俩目标就是G1的灵魂,咱们一步步逼近。


第一步进化:分代收集加点料

朴素的全扫太笨,咱们引入分代收集。大部分对象活不久(幼年死亡假设),那就分新生代和老年代,新生代快清,老年代慢点。

怎么搞?

新生代用标记-复制,活的挪走,死的不留痕迹;老年代用标记-清除,悠着来。假设2GB内存,新生代400MB,老年代1.6GB。新生代20万个对象,GC一跑,18万死(90%),2万活(20MB)挪到Survivor,花40毫秒。老年代晚点再清。

问题1:老年代还是拖后腿

新生代快了,但老年代1.6GB,4万个活对象(40MB),标记-清除全扫得150毫秒,停顿还是长。

问题2:碎片没跑

老年代清完,40MB活对象散成1000块,下次分配30MB大对象,照样卡壳。

优化方向

老年代得再细分,别一次全清;停顿得可控,碎片得收拾。这就指向G1的分区思路了。


G1的真面目:分区+优先回收+停顿控制

G1(Garbage-First)把内存切成小块(Region),每个区域独立管理,按“垃圾最多”的优先级回收,目标是停顿时间可预测,碎片也少。咱们细拆一下。

内存布局

假设2GB内存分成2000个1MB区域。新生代占400个(400MB),老年代1600个(1.6GB)。新生代再细分:

  • Eden:新生对象出生地,占300MB(300个区域)。
  • Survivor:活下来的待观察,占100MB(100个区域)。 老年代就是1600个1MB区域,动态调整。

回收流程

G1分两类回收:Young GC(新生代)和Mixed GC(混合回收)。

  1. Young GC

    • Eden满时触发,比如300MB塞满30万个对象(10KB/个)。
    • 标记活对象,假设3万活(30MB,10%存活率),复制到Survivor。
    • 耗时:标记30万对象约60毫秒,复制3万对象约10毫秒,总共70毫秒停顿。
    • 结果:300MB回收,碎片全无。
  2. Mixed GC

    • 当老年代占用超一定比例(默认45%,720MB)时,触发混合回收。
    • G1挑垃圾最多的区域清。比如老年代1600个区域,选20个,每区域活200KB(20%),垃圾800KB。
    • 标记+复制:20个区域共4MB活对象挪走,16MB垃圾回收,停顿50毫秒。
    • 优势:只清局部,停顿可控。

停顿控制

G1有个“目标停顿时间”(默认200毫秒)。假设程序每秒分配100MB对象,G1得每秒回收100MB。200毫秒内回收20MB(20个区域),一秒跑5次,刚刚好。用户几乎无感。

碎片管理

G1用标记-复制,活对象挪到新区域,旧区域全清,碎片自然没了。比如20个区域回收,4MB活对象挤一块,16MB连续空间直接可用。

数字敏感点

  • 2GB内存,2000区域,每区域1MB。
  • 新生代400MB,存活率10%,每次Young GC回收360MB。
  • 老年代1.6GB,Mixed GC挑20区域,回收16MB,停顿50毫秒。
  • 每秒分配100MB,G1跑5次Mixed GC,稳住内存。

G1的内在机制:细节拉满

G1不简单,靠几个关键设计撑场面:

1. 记忆集(Remembered Set)

区域化后,对象跨区域引用咋办?G1给每个区域配个记忆集,记着谁指向自己。比如区域A有对象指向区域B,B的记忆集记下A。Young GC时,只扫新生代的根+记忆集,不用全扫老年代。假设老年代1600区域,平均每区域50个跨引用,总共8万指针,扫起来也就16毫秒。

2. 并发标记

老年代垃圾多时,G1跑并发标记:

  • 初始标记:停顿,标记根,10毫秒。
  • 并发标记:和程序一起跑,标记全老年代,500毫秒,用户无感。
  • 重新标记:短停顿,修正漏标,20毫秒。
  • 结果:知道哪些区域垃圾多,下次Mixed GC有的放矢。

3. 动态调整

G1会根据停顿目标调整新生代大小。比如目标50毫秒,但Young GC超了,就缩Eden到200MB,降负载。


G1的坑和优化空间

G1很强,但也有软肋。

问题1:记忆集维护成本

跨区域引用多,记忆集就膨胀。假设老年代1600区域,每区域100个跨引用,记忆集得记16万指针,更新时CPU忙不过来,拖慢程序。

问题2:吞吐量短板

G1追求低停顿,频繁小回收。假设每50毫秒跑一次,一秒20次,CPU花30%时间在GC。而Parallel GC可能一秒跑一次,吞吐量更高。G1适合Web服务,高吞吐任务就吃力。

优化方向

  • 并发更强:标记、清理全并发,记忆集优化,像ZGC那样极致低停顿。
  • 碎片再压:大对象分配更顺畅,朝超大内存优化。

总结:从朴素到G1的推演

  • 标记-清除 → 停顿长、碎片多,得改。
  • 分代收集 → 效率高,但老年代坑。
  • G1分区+优先 → 停顿可控、碎片少,现代GC标杆。
  • 未来趋势 → 并发更牛、内存更优,跟ZGC接轨。