设想你是一家有 1000 个房间的大酒店经理。客人川流不息:有的住三天就退房,有的长住一年。每间房都会留下废弃的行李、过期的报纸、坏掉的家具——这就是“垃圾”。房间满了,新客人就进不来,于是你需要一支“清扫队”(GC)定期清理。 但清扫有代价:旧式酒店一打扫,就得让所有客人在走廊里原地站定,等打扫完才能继续走动——这叫“停顿世界(Stop-The-World,STW)”。停顿久了,客人体验极差。 于是酒店管理界出现了三位“剑客”: - G1:把酒店切成 2048 个小区,每次只挑最脏的小区扫,客人只需短暂停步(约 10ms)。 - ZGC:给每件行李贴彩色标签,清扫队趁客人转身的瞬间完成换房,客人几乎无感(< 1ms)。 - Shenandoah:搬家时在旧房间门口贴一张“已搬到 802”的告示,客人看到告示自己跟过去,同样几乎无感。 本篇就用这座“智慧酒店”的视角,带你认识三支清扫队的内部机制、调优手法与选型心法。

岗位关联

对 AI 从业者而言,模型推理、特征服务、向量检索大多跑在 JVM 上,GC 停顿直接决定 p99 延迟天花板。掌握三剑客,你就掌握了让在线 AI 服务既快又稳的底层密码。

目录
  1. 可视化演示
  2. GC背景与挑战
  3. G1区域化架构
  4. G1调优实践
  5. ZGC染色指针
  6. ZGC调优要点
  7. Shenandoah并发疏散
  8. Shenandoah调优要点
  9. 三剑客选型对比
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示G1 Region分区内存布局、G1/ZGC/Shenandoah停顿时间对比、GC算法选型依据。切换模式理解三种垃圾回收器的核心技术差异。

本节配有交互动效(进入视口后自动加载,约 3KB)。

G1 Region分区内存布局 G1将堆划分为大小相等的Region(默认约2048个),每个Region可动态切换为Eden/Survivor/Old Garbage-First策略优先回收垃圾最多的Region,实现可预测的停顿时间 -XX:MaxGCPauseMillis=200, Region=heap/2048 G1通过Region分区实现可预测停顿

GC背景与挑战

酒店越开越大,房间(堆内存)总会被住满。传统的“清场大扫除”有一个硬伤:清扫期间必须让所有客人在原地站定,任何人不能走动、不能取行李、不能办入住——这个全员静止的时刻就是“停顿世界”(Stop-The-World,STW)。清扫一分钟,酒店就停业一分钟,客人的怨气也就积累一分。

在 JVM 里,应用线程就是客人,GC 线程就是清扫队。堆中那些不再被引用的对象就是废弃行李,被引用的存活对象就是仍在使用的行李箱。GC 回收垃圾时,某些阶段必须暂停所有应用线程。停顿越久、次数越多,业务请求被“卡住”的时间就越长,尤其是高并发场景下,毫秒级的停顿都会被放大成可见的延迟毛刺。

衡量清扫队优劣,核心看两个指标:吞吐量(Throughput)和停顿时间(Pause Time)。吞吐量是指应用真正运行的时间占比——如果一次清扫 20ms,一天清扫 100 次,就相当于酒店每天“假性停业” 2 秒。对于延迟敏感的服务,更要盯住单次停顿时间和 p99 延迟。

G1、ZGC、Shenandoah 这三位“剑客”,本质上是三种不同的清场策略:有的缩小清扫范围,有的让清扫与营业并行,有的让客人自己跟着告示走。理解它们各自的取舍,就是本篇的主线。

Throughput=TappTapp+Tgc×100%\displaystyle \text{Throughput} = \frac{T_{\text{app}}}{T_{\text{app}} + T_{\text{gc}}} \times 100\%
吞吐量 = 应用运行时间 ÷(应用运行时间 + 所有 GC 停顿时间)。这个公式是评估一切 GC 策略的第一把尺子。
  1. 应用运行时间酒店正常营业的总时长,即应用线程真正执行业务逻辑的时间。
    TappT_{\text{app}}
  2. GC 停顿时间酒店闭门清扫的总时长,等于所有 STW 暂停的累加。
    Tgc=∑iPiT_{\text{gc}} = \sum_{i} P_i
  3. 吞吐量占比营业时间占全天的比例,越高说明 GC 浪费的时间越少。
    Throughput=TappTapp+Tgc\text{Throughput} = \frac{T_{\text{app}}}{T_{\text{app}} + T_{\text{gc}}}

G1区域化架构

G1 的管理员做了一个关键决定:不再把整栋酒店当成一个必须全员停摆的大仓库,而是把它切成上千个大小相等的“小区”(Region),每个小区独立评估脏乱程度。清扫时不必全楼停业,只需在极短时间内处理几个小区,客人只感到“被叫停了一瞬”。

“Garbage First“(垃圾优先)这个名字正是由此而来:每次清扫前,管理员会估算每个小区的”垃圾量 / 清扫成本“比值,优先处理比值最高的小区。就像保洁主管拿着脏乱报表,专挑”垃圾最多又最好扫”的楼层先下手,而不是从一楼到顶楼无论脏净挨个扫。

要实现“只扫一个区”,难点在于:其他区的客人可能还引用着这个区里的行李。如果不清查全楼,搬走行李就会导致引用断裂。G1 用 RSet(记忆集)记录“有哪些外部区的引用指向本区”,清扫时只核对账本,不必全楼排查;并发标记阶段则用 SATB(起始快照)保证标记开始后新产生的对象也不会漏网。代价是维护这些结构需要额外内存与 CPU。

这种“分区治理 + 脏乱优先”的策略,让 G1 自 JDK 9 起成为官方默认回收器,也是今天绝大多数 Java 后端服务开箱即用的选择——它不追求极致,却足够均衡可靠。

Pi=GiCi\displaystyle P_{i} = \frac{G_{i}}{C_{i}}
第 i 个 Region 的回收价值 = 可回收垃圾量 ÷ 回收成本。G1 每次清扫前都按这个比值排序,先收“性价比”最高的区域。
  1. 垃圾量第 i 个 Region 中可回收垃圾对象占用的字节数,垃圾越多越值得扫。
    Gi=trash bytes in RegioniG_{i} = \text{trash bytes in Region}_{i}
  2. 回收成本清扫该 Region 需要复制存活对象、更新 RSet 的总工作量,存活对象越多成本越高。
    Ci=copy cost+RSet update costC_{i} = \text{copy cost} + \text{RSet update cost}
  3. 价值排序每个周期把各 Region 的 P 值从大到小排序,优先回收 Top N 个小区。
    Pi=Gi/Ci,sort descendingP_{i} = G_{i} / C_{i}, \quad \text{sort descending}

G1调优实践

G1 提供了一个“期望停顿目标”参数 -XX:MaxGCPauseMillis(默认 200ms)。它的自适应调节器(Ergonomics)会依据历史 GC 数据,动态调整每轮回收多少个 Region,尽量让停顿不超过这个目标——就像保洁主管根据过去一周的清扫速度,决定这轮让保洁员扫几个楼层的房间。

大对象(Humongous)是 G1 的一个特殊难题:超过 Region 大小一半的对象(如超大数组、大缓存)会被放进连续的“巨物区”。巨物区回收时机被动,且容易造成堆碎片——好比酒店走廊里横着几个超大衣柜,保洁队只能绕着走,平时还搬不动。实践中可通过 -XX:G1HeapRegionSize 调节 Region 大小,让它与业务对象体量匹配。

另一个关键机制是混合回收(Mixed GC):当老年代占用率达到 IHOP(默认 45%)时触发并发标记,标记完成后 G1 把年轻代 Region 和“垃圾占比高”的老年代 Region 放在一起回收。调优时重点观察:并发标记周期是否过于密集、Mixed GC 是否频繁发生,以及 GC 日志中的停顿是否稳定。

但千万别把目标设成 1ms——就像要求保洁员每次只扫一个房间,客人确实只停一瞬,可一天被叫停几十次,整体体验反而更差。实践中 10~20ms 往往是最佳平衡点。

Nyoung=⌊PtargetTˉregion⌋\displaystyle N_{\text{young}} = \left\lfloor \frac{P_{\text{target}}}{\bar{T}_{\text{region}}} \right\rfloor
G1 用“目标停顿 ÷ 每 Region 平均回收耗时”来估算下轮 Young GC 应该收集多少个小区,这是它控制停顿的核心算法。
  1. 目标停顿用户通过 MaxGCPauseMillis 参数设定的期望最大停顿时间,单位毫秒。
    Ptarget=MaxGCPauseMillisP_{\text{target}} = \text{MaxGCPauseMillis}
  2. 单区平均回收耗时G1 根据历史 GC 记录统计出的平均清理一个 Region 的时间。
    Tˉregion=∑pausei∑Region counti\bar{T}_{\text{region}} = \frac{\sum \text{pause}_{i}}{\sum \text{Region count}_{i}}
  3. 年轻代 Region 数量两者相除向下取整,决定下一轮 Young GC 收集的 Region 数,从而把停顿控制在目标内。
    Nyoung=⌊PtargetTˉregion⌋N_{\text{young}} = \left\lfloor \frac{P_{\text{target}}}{\bar{T}_{\text{region}}} \right\rfloor

ZGC染色指针

ZGC 的酒店不切小区,而是给每件行李贴一张“颜色标签”。在 64 位指针中,低 42 位是真正的房间号(堆内地址),中间 4 位是颜色(M0、M1、Remapped、Finalizable),高位全部置零。颜色状态告诉清扫队:这件行李是旧的、新的,还是已经搬过家,从而实现在客人完全无感的情况下完成标记和搬迁。

读屏障(Load Barrier)是这套机制的执行者:客人每次弯腰取行李时,系统都会快速检查颜色标签。如果发现行李已被标记为“需要搬迁”,就当场顺着指针拿到新房间的地址。这意味着“搬迁”可以完全并发进行——清扫队在前面搬,客人照常在后面取,两不耽误。

ZGC 的完整流程分为并发标记、并发准备转移、并发转移等大阶段,STW 只出现在标记开始与结束的两次极小暂停中,停顿时间与堆大小基本无关。因此 ZGC 特别适合超大堆(TB 级)和极低延迟场景,JDK 15 起生产可用,JDK 21 引入分代 ZGC 后吞吐量进一步提升。

这就像酒店服务员在你递咖啡的瞬间完成换房——标签一变,客人手里的钥匙卡依然有效,真正的门牌号更新在后台悄悄完成,全程不到 1 毫秒。

ptr=zero⏟63:46  M1 M0 R F⏟45:42  addr⏟41:0\displaystyle \text{ptr} = \underbrace{\text{zero}}_{63:46} \; \underbrace{M_1 \, M_0 \, R \, F}_{45:42} \; \underbrace{\text{addr}}_{41:0}
ZGC 的指针结构:高 18 位留空,中间 4 位是颜色元数据,低 42 位是真实堆地址。颜色就是扫码枪里的标签,决定了清扫队和客人如何协作。
  1. 地址位低 42 位可寻址 4TB(后续 JDK 版本更高),存放对象在堆中的真实偏移。
    [41:0]=heap address[41:0] = \text{heap address}
  2. 颜色标记位第 42~45 位共 4 个元数据位,分别表示 Marked0、Marked1、Remapped、Finalizable 状态。
    [45:42]=M1 M0 R F[45:42] = M_1 \, M_0 \, R \, F
  3. 保留位第 46~63 位必须全零,这也是指针染色合法性的前提。
    [63:46]=0[63:46] = 0

ZGC调优要点

ZGC 的调优哲学是“少动参数,规划好堆”。由于并发转移要求“旧房间”和“新房间”同时存在,堆必须同时装下“存活对象”和“一个 GC 周期内新产生的对象”。如果空间不足,转移就会因放不下而退化,停顿随之飙升——好比搬家公司到了却发现新仓库只有一半大,只能把家具堆在走廊里。

并发线程数由 -XX:ConcGCThreads 控制,默认由 JVM 根据 CPU 核数和堆大小自动推算。并发线程过多会跟业务线程抢 CPU,导致吞吐量下降;过少则清扫速度跟不上分配速度,堆迅速膨胀。一般无需手工调整,除非你清楚机器的 CPU 余量。

JDK 21+ 引入分代 ZGC:把对象按年龄分成年轻代和老年代两个区域,年轻代频繁回收短命小对象,老年代很少被打扰。与不分代版本相比,分代 ZGC 的吞吐量提升显著,适合既想要 ZGC 低延迟、又在意吞吐的线上服务。

这就像未来酒店虽然清扫不打扰客人,但必须备好“两套房间”周转——床位规划(堆大小)比扫把数量(并发线程)更决定成败。

Mheap≥Llive+Ralloc×Tcycle\displaystyle M_{\text{heap}} \geq L_{\text{live}} + R_{\text{alloc}} \times T_{\text{cycle}}
堆容量必须 ≥ 存活集 + 分配速率 × GC 周期时长。这是 ZGC 堆规划的“底线公式”:留足周转空间,并发转移才跑得起来。
  1. 存活集一个 GC 周期开始时仍然存活的对象总量,是堆的“底仓”。
    LliveL_{\text{live}}
  2. 周期内新增分配应用分配速率乘上 GC 周期时长,是清理期间新产生的对象量。
    Ralloc×TcycleR_{\text{alloc}} \times T_{\text{cycle}}
  3. 堆容量下界两者之和构成堆容量的下限,规划 -Xmx 时必须留出余量。
    Mheap≥Llive+Ralloc×TcycleM_{\text{heap}} \geq L_{\text{live}} + R_{\text{alloc}} \times T_{\text{cycle}}

Shenandoah并发疏散

Shenandoah 的酒店走了另一条技术路线:不贴标签,而是给每个对象预留一个 Brooks 指针——相当于在房间门口钉一块小牌子:“本房间行李已搬到 802”。客人取行李时,门前的保安(读屏障)扫一眼牌子:若显示已搬迁,就指引到新房间;若没有,则直接放行。

这套“转发指针”机制让对象搬迁可以完全并发:清扫队推着推车在走廊里把旧房间的行李一件件搬到新房间,与此同时客人照常住店、取物。整个疏散(Evacuation)阶段的暂停时间只与根集合大小相关,与堆大小基本无关——堆再大,停顿也不随之增长。

为了在并发搬迁后高效地更新所有指向旧地址的引用,Shenandoah 使用 连接矩阵(Connection Matrix):一个位图,记录哪两个 Region 之间存在引用关系。更新引用时只需处理“真正有联系的区域对”,不用全堆扫描,就像酒店管家手里有一张“谁认识谁”的关系图,通知搬家事宜只需通知相关楼层。

客人会察觉吗?最多一瞬间的疑惑:“咦,行李怎么不见了?”——抬头看到告示,顺着指引走过去,整个过程微不足道。这就是并发疏散的优雅之处。

read(X)={Xif Xforward=nullXforwardotherwise\displaystyle \text{read}(X) = \begin{cases} X & \text{if } X_{\mathrm{forward}} = \mathrm{null} \\ X_{\mathrm{forward}} & \text{otherwise} \end{cases}
读对象时先检查 Brooks 指针:为空说明没搬家直接读;非空说明已搬迁,顺着指针去新地址。这就是 Shenandoah 并发疏散的“指路牌”逻辑。
  1. Brooks 指针每个对象头部预留一个字长的转发指针,指向搬迁后的新对象。
    XforwardX_{\mathrm{forward}}
  2. 条件读取读屏障检查转发指针,据此决定是直接读取还是跳转到新地址。
    read(X)={Xif Xforward=nullXforwardotherwise\text{read}(X) = \begin{cases} X & \text{if } X_{\mathrm{forward}} = \mathrm{null} \\ X_{\mathrm{forward}} & \text{otherwise} \end{cases}
  3. 并发收益搬迁全程并发执行,STW 仅保留在根扫描等极短阶段,且与堆大小无关。
    TSTW∝root set sizeT_{\text{STW}} \propto \text{root set size}

Shenandoah调优要点

Shenandoah 提供多种启发式模式,通过 -XX:ShenandoahGCHeuristics 选择:adaptive(自适应,默认)根据分配速率动态调节 GC 周期;static(静态)按固定周期触发;compact(紧凑)适合堆很满、需要紧急回收的场景。选模式就像选保洁排班:客流量大时动态排班,淡季按固定时点扫,紧急大扫除集中突击。

堆规划同样至关重要。Shenandoah 的并发转移依赖足够的周转空间,若堆太满、疏散来不及完成,会触发 Degenerated GC(退化回收)甚至 Full GC,性能断崖式下跌。一般建议堆容量为存活集的 2~3 倍,并持续监控疏散完成率,确保并发转移始终跟得上分配速度。

适用场景上,Shenandoah 常见于中等规模堆(数十 GB 级别)+ 延迟敏感的服务,如交易、搜索、网关等。它在对象头上多了转发指针,每次访问多一次间接跳转,吞吐量略低于 ZGC,但调优参数更直观、内存布局更简单,社区评价“对运维更友好”。

就像叫搬家队之前先盘点家当、估算搬运速度,才能决定需要多大周转仓库——Shenandoah 的调优,本质是“先算账,再搬家”。

Tgc≈SliveRcollect\displaystyle T_{\text{gc}} \approx \frac{S_{\text{live}}}{R_{\text{collect}}}
GC 周期耗时 ≈ 存活对象总量 ÷ 并发回收速率。先算清“要搬多少家当、搬得多快”,才能决定需要多大周转仓库。
  1. 存活对象总量需要被疏散的存活对象字节总和,即待搬迁的家当总量。
    Slive=∑live bytesS_{\text{live}} = \sum \text{live bytes}
  2. 并发回收速率清扫线程单位时间能疏散的字节数,取决于 CPU 核数与并发线程数。
    Rcollect=f(CPU cores,ConcGCThreads)R_{\text{collect}} = f(\text{CPU cores}, \text{ConcGCThreads})
  3. GC 周期耗时两者相除得到一次回收周期的大致时长,用于反推堆的周转空间需求。
    Tgc≈SliveRcollectT_{\text{gc}} \approx \frac{S_{\text{live}}}{R_{\text{collect}}}

三剑客选型对比

三位剑客,三种哲学:G1 是“性价比之王”——分区治理、脏乱优先,吞吐量最高,停顿在几十毫秒量级;ZGC 是“未来旗舰”——染色指针、读写无感,停顿亚毫秒、可支撑 TB 级超大堆;Shenandoah 是“务实新贵”——转发指针、连接矩阵,低延迟且配置直观,在中等规模堆上尤为顺手。

从数据看,G1 的停顿通常在 10~50ms(取决于堆大小与参数),吞吐量可达 99% 以上;ZGC 与 Shenandoah 的停顿低于 1ms 或在数毫秒内,但由于读屏障的 CPU 开销,吞吐量略低(通常也在 98% 上下)。堆规模上,ZGC 对超大堆更有优势,Shenandoah 在中等堆上更容易调优,G1 则通吃绝大多数场景。

选型建议可以浓缩为三句话:不知道选什么,就用 G1(JDK 9+ 默认,均衡可靠);压测发现 p99 被 GC 停顿拖垮、堆又大,上 ZGC;堆中等、想要低延迟且希望调参简单,上 Shenandoah。关键动作是压测先行——用 GC 日志分析工具量化停顿和吞吐,用数据代替直觉做决策。

选对清扫队,客人从不察觉深夜的酒店曾悄悄大变样;选错,则每天多出几秒“闭门谢客”。工程心法六个字:先测量,再选择。测量 GC 日志,明确延迟目标,然后让三剑客各归其位。

PG1≈10 ms  ≫  PZGC≈PSH<1 ms\displaystyle P_{\text{G1}} \approx 10\,\text{ms} \; \gg \; P_{\text{ZGC}} \approx P_{\text{SH}} < 1\,\text{ms}
三者的停顿梯队:G1 约 10ms 级,ZGC 与 Shenandoah 都在 1ms 以内。这个不等式就是选型的第一张滤网。
  1. G1 停顿量级约 10ms 上下,受堆大小、参数与负载影响。
    PG1≈10 msP_{\text{G1}} \approx 10\,\text{ms}
  2. ZGC 与 Shenandoah 停顿量级两者均低于 1ms(Shenandoah 视配置可达数 ms)。
    PZGC≈PSH<1 msP_{\text{ZGC}} \approx P_{\text{SH}} < 1\,\text{ms}
  3. 梯队对比用不等号直观呈现三者延迟差异,延迟敏感场景只有后两者能满足。
    PG1≫PZGC≈PSHP_{\text{G1}} \gg P_{\text{ZGC}} \approx P_{\text{SH}}

AI 应用场景

在线推理服务低延迟优化

阶段:模型部署与服务化

解决的问题:推理网关、特征服务是典型的延迟敏感型 JVM 服务。传统 G1 每次停顿 10~30ms,直接击穿 p99 延迟水位线,高峰时超时率上升。ZGC 的亚毫秒停顿让“酒店清场”几乎不可感知,p99 显著回落。

具体案例:某 LLM 对话服务的网关模块,堆 48GB,切换 ZGC 并预留足够周转空间后,STW 从平均 18ms 降到 0.6ms,p99 从 25ms 降至 8ms,超时率下降 60%。

离线批量特征加工吞吐优化

阶段:数据预处理与特征工程

解决的问题:批量任务对延迟不敏感,只关心 12 小时窗口内能否跑完。ZGC 的读屏障会额外消耗 CPU,反而拖慢这类“吞吐优先”的任务;保持 G1 并合理调大年轻代,能在吞吐上拿到最优解。

具体案例:某推荐系统的离线特征平台,Spark Executor 保持 G1 默认并设置 20ms 停顿目标,配合大页与年轻代调优,整体任务耗时缩短 12%,GC 停顿时间占比从 8% 降到 4%。

向量检索近线索引服务

阶段:检索与召回

解决的问题:向量检索服务需要在查询的同时平滑重建索引,GC 抖动会拉高召回超时。Shenandoah 的并发疏散让索引搬迁与查询并发执行,停顿与堆大小无关,适合中等堆的低延迟检索场景。

具体案例:某搜索系统的近线向量索引更新服务,堆 16GB,选用 Shenandoah adaptive 模式,索引重建期间查询 p99 稳定在 3ms,无一次因 GC 导致的超时。

Python 演示

python31 行
def simulate_gc(name, pause_ms, interval_ms, total_ms):    # 模拟单个GC策略: 每经过interval_ms触发一次停顿, 统计吞吐量    gc_count = 0    total_pause = 0.0    t = 0    while t < total_ms:        t += interval_ms        if t >= total_ms:            break        gc_count += 1        total_pause += pause_ms    app_time = total_ms - total_pause    throughput = app_time / total_ms * 100    return gc_count, total_pause, throughput  def show_comparison():    configs = [        ("G1", 15.0, 6000),        ("ZGC", 0.4, 12000),        ("Shenandoah", 0.7, 10000),    ]    print(f"{'回收器':<12}{'GC次数':>8}{'总停顿ms':>10}{'吞吐量%':>10}")    print("-" * 44)    for name, pause, interval in configs:        count, pause_sum, throughput = simulate_gc(name, pause, interval, 120000)        print(f"{name:<12}{count:>8}{pause_sum:>10.1f}{throughput:>10.2f}")  if __name__ == "__main__":    show_comparison()
目的:简化模拟三种 GC 策略在同一工作负载下的停顿次数、总停顿时间与吞吐量,直观验证“停顿时间短不等于吞吐低损耗”的对比关系。
  1. while t < total_ms:模拟一个完整运行周期,时间不断向前推进直到任务结束。
  2. gc_count += 1每到一次 GC 间隔就记录一次停顿发生,对应一次“酒店闭门清扫”。
  3. total_pause += pause_ms把该策略单次停顿时间累加,得到总停顿时长。
  4. app_time = total_ms - total_pause应用真正可用的时间 = 总时长 − 所有 GC 停顿。
  5. throughput = app_time / total_ms * 100吞吐率 = 应用可用时间占总时间的百分比。
  6. configs = [("G1", 15.0, 6000), ...]三支清扫队的参数:名称、单次停顿、触发间隔。

常见误区

误区听说 ZGC 暂停不到 1ms,就把所有服务都切到 ZGC,以为延迟问题全部解决。反正参数少,切过去就不用管了。

正解ZGC 的并发标记和读屏障要占用额外 CPU(通常带来约 5%~10% 的吞吐损失)。如果业务对延迟不敏感,比如离线批量任务,或对吞吐有强要求,G1 反而更好。选 GC 的第一步永远是明确目标:延迟优先还是吞吐优先,然后再选剑。

为何常见:只盯着“停顿时间”一个指标,忽略了并发屏障带来的 CPU 成本与更大的内存占用,属于典型的单指标决策。

误区既然 G1 有停顿目标参数,那把 `-XX:MaxGCPauseMillis=1` 设成 1ms,停顿不就一定小于 1ms 了吗?调优也太简单了。

正解MaxGCPauseMillis 只是“期望目标”而非“硬性承诺”。G1 通过缩小每轮回收的 Region 数量来逼近目标,设得太小会导致年轻代被压得极小,GC 频率暴增,整体吞吐和延迟反而恶化。实践中 10~20ms 是常见合理区间,且必须配合 GC 日志持续观测。

为何常见:把“调节器的目标参数”误当成“绝对的硬限制”,这是对 G1 自适应调节机制最常见的误解。

误区反正内存便宜,把 `-Xmx` 设到物理内存上限,GC 就不会频繁触发了,系统自然更快。

正解堆越大,标记和转移的扫描范围越大,G1 的单次停顿通常更长;ZGC 需要为并发转移预留周转空间,堆过大反而加重并发周期的内存压力。正确做法是先测量存活集,再按存活集的 2~3 倍留出余量,而不是盲目撑满物理内存。

为何常见:直觉上“空间多 = 少清理”,忽略了 GC 的扫描与复制成本与堆大小成正比这一事实。

误区都是并发收集、停顿都不到 1ms,那 Shenandoah 和 ZGC 随便选一个就行,没什么差别。

正解两者底层机制完全不同:ZGC 靠染色指针在指针里存元数据,转移时对象本身不动;Shenandoah 在对象头加 Brooks 转发指针,读屏障检查后跳转。由此带来内存布局、屏障成本、调优参数和适用堆规模的差异,选型时要区分看待。

为何常见:只从“停顿时间”这个结果看,两者高度相似,掩盖了底层实现机制的显著差异。

误区ZGC 停顿 0.5ms,G1 停顿 15ms,那 ZGC 的效率肯定比 G1 高,跑同样的任务一定更快。

正解停顿时间和吞吐量是两个正交的指标。并发收集器用额外 CPU 换取低停顿;G1 用可控停顿换取较少 CPU 开销。实测中 G1 的吞吐量往往略高于 ZGC。延迟敏感型业务看停顿,计算密集型业务看吞吐,两者不可混为一谈。

为何常见:把“用户体验好坏”(停顿)直接等同于“机器效率”(吞吐),混淆了延迟与吞吐这两个维度的评价标准。

面试题

Q1. G1 的 "Garbage First" 核心思想是什么?

单选题 · easy

  • A.优先回收垃圾占比最高的区域← 正确答案
  • B.优先回收年轻代中的对象
  • C.优先回收体积最大的对象
  • D.优先回收地址靠前的区域

答案:A。G1 每次清扫前按“垃圾量/回收成本”排序,优先回收垃圾最多、回收价值最高的 Region,因此得名 Garbage First。B 是年轻代回收的常规行为而非核心思想,C 和 D 与 G1 的选择策略不符。

Q2. ZGC 用染色指针将元数据存放在哪里?

单选题 · mid

  • A.对象头中开辟的专用字段位
  • B.指针中单独开辟的元数据位← 正确答案
  • C.堆外独立的标记位图文件
  • D.JIT 编译器生成的局部变量

答案:B。ZGC 在 64 位指针中单独划出第 42~45 位存放 M0/M1/Remapped/Finalizable 四个颜色位,而不是存进对象头或位图文件。这就像把行李标签贴在钥匙牌(指针)上而非行李箱(对象)上,读取时无需访问对象本身。

Q3. 下列属于 ZGC 并发执行阶段的有哪些?

多选题 · mid

  • A.并发标记所有存活对象← 正确答案
  • B.并发转移对象到新地址← 正确答案
  • C.并发重映射所有旧引用← 正确答案
  • D.完全停止应用的根扫描

答案:ABC。ZGC 的标记、转移、重映射都可以与应用线程并发执行,这正是它停顿 <1ms 的原因。D 的根扫描虽然很短,但属于 STW 阶段——就像酒店开门营业前,保安要花极短时间清点一下门口人员。

Q4. Shenandoah 读屏障依赖什么机制找到搬迁后的对象?

单选题 · mid

  • A.对象头中的 Brooks 转发指针← 正确答案
  • B.染色指针中的颜色元数据位
  • C.全局哈希表记录新旧地址
  • D.操作系统虚拟内存页映射

答案:A。Shenandoah 在每个对象头部预留 Brooks 转发指针,读屏障检查该指针是否为空,非空则跳转到新对象。ZGC 才用染色指针(B),Shenandoah 不用全局哈希表(C)和虚拟内存映射(D)。

Q5. G1 中 RSet 记忆集记录的主要内容是什么?

单选题 · mid

  • A.外部 Region 指向本区的引用← 正确答案
  • B.本 Region 内部对象年龄顺序
  • C.GC 线程运行时的详细日志
  • D.JIT 编译产生的热点方法表

答案:A。RSet 记录“谁在引用我”——即其他 Region 指向本 Region 的跨区引用。清扫一个 Region 时只需核对 RSet,不必全堆扫描。这就像酒店管家的小本子记着“哪些楼层的客人知道这个房间的行李”。

Q6. 关于三剑客的实现机制,下列说法正确的有哪些?

多选题 · extreme

  • A.G1 使用 RSet 维护跨区引用← 正确答案
  • B.ZGC 依赖染色指针和读屏障← 正确答案
  • C.Shenandoah 使用 Brooks 指针← 正确答案
  • D.三种 GC 都完全避免 STW 停顿

答案:ABC。G1 的 RSet、ZGC 的染色指针+读屏障、Shenandoah 的 Brooks 指针都是各自的核心机制。但三者都无法完全消除 STW,只是把停顿压到极短。D 的错误在于“完全避免”过于绝对,连 ZGC 也有毫秒级以下的根扫描暂停。

Q7. 某 AI 推理服务堆 64GB,要求 p99 延迟小于 5ms,最合理的 GC 选择是?

单选题 · hard

  • A.G1 默认参数直接运行
  • B.ZGC 并配置合适的堆容量← 正确答案
  • C.Shenandoah 强制关闭并发
  • D.Serial GC 单线程串行回收

答案:B。64GB 堆 + 5ms 内的 p99 要求,G1 的 10ms 级停顿难以满足,Serial GC 会带来秒级停顿。ZGC 的亚毫秒停顿最适合超大堆低延迟场景。Shenandoah 理论上可行,但“强制关闭并发”(C)会使其丧失核心优势。

Q8. 对 G1 进行调优时,下列哪些实践是合理的?

多选题 · hard

  • A.设置适度的 MaxGCPauseMillis 目标← 正确答案
  • B.监控 IHOP 触发的并发标记周期← 正确答案
  • C.用 RegionSize 匹配超大对象的分配← 正确答案
  • D.关闭全部 GC 日志以节省开销

答案:ABC。设定合理停顿目标、监控 IHOP 并发标记、调节 RegionSize 匹配大对象都是常见且有效的 G1 调优手段。D 的错误在于 GC 日志开销极小,它是定位调优问题的基础依据,关闭日志得不偿失。

Q9. Stop-The-World(停顿世界)的含义是什么?

单选题 · easy

  • A.应用线程全部暂停等待 GC 完成← 正确答案
  • B.应用线程降低速度继续运行
  • C.只暂停分配内存的新生线程
  • D.GC 线程等待应用线程让出 CPU

答案:A。STW 指 GC 的某些阶段需要暂停所有应用线程,就像酒店大扫除时所有客人必须原地站定。B 的“降速运行”不是 STW,C 只暂停部分线程也不对,D 把等待方向完全搞反了。

Q10. 下列哪些垃圾回收器能够实现并发转移?

多选题 · easy

  • A.ZGC 并发转移对象到新区← 正确答案
  • B.Shenandoah 并发疏散对象← 正确答案
  • C.Parallel Scavenge 并行回收
  • D.Serial GC 串行回收对象

答案:AB。ZGC 的并发转移和 Shenandoah 的并发疏散都是“搬家不闭店”的典型代表。Parallel Scavenge 和 Serial GC 的转移阶段都需要 STW,属于传统“清场式”回收器,无法并发搬迁对象。