本文的 原始地址 ,传送门
本文的 原始地址 ,传送门
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
听说你是高手,说说,你的ZGC 怎么调优?
说说,ZGC 垃圾回收器的底层原理?
说说,ZGC 的浮动垃圾,是怎么处理的?
说说,ZGC 垃圾回收器的调优过程?
最近有小伙伴在面试 阿里,又遇到了相关的面试题。小伙伴懵了,因为没有遇到过,所以支支吾吾的说了几句,面试官不满意,面试挂了。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V171版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,回复:领电子书
另外,此文的内容,作为 第14章,收入尼恩的《JVM 调优圣经》PDF。
尼恩三大 GC 学习圣经
第一大 gc 学习圣经:
《cms圣经:cms 底层原理和调优实战》
第二大 gc 学习圣经:
《G1圣经:G1 底层原理和调优实战》
接下来,咱们言归正传,开始讲 cms
第3大 gc 学习圣经:
《ZGC 圣经:ZGC 底层原理和调优实战》
ZGC解决了什么问题?
JVM 老大难问题: STW 卡顿(StopTheWorld)
对于Java的项目来说,JVM进行垃圾回收会有一个很大的问题,就是 STW (StopTheWorld)。
在很多业务场景中,STW时间太长是非常致命的,比如说手机系统 (Android ) 显示卡顿,通过对 GC 算法的不断演进,停顿时间控制在几个ms 级别;
再比如说一些实时证券交易系统以及一些大数据平台,大规模部署的情况下,STW太久会造成很严重的影响。
为了满足不同的业务需求,Java 的 GC 算法也在不停迭代,对于特定的应用,选择其最适合的 GC 算法,才能更高效的帮助业务实现其业务目标。
对于这些延迟敏感的应用来说,GC 停顿已经成为阻碍 Java 广泛应用的一大顽疾,需要更适合的 GC 算法以满足这些业务的需求。
首先,来看看大厂的 GC 垃圾回收器的 技术选型
主要 从 堆大小维度 进行技术选型
超小堆(小于 100M):这类场景下,Serial 串行收集器能有效减少额外开销,因为超小堆的垃圾回收任务本身不复杂,串行收集器按顺序执行回收操作,不会因多线程协调产生额外负担,能较好满足需求。
小堆1G - 4G 堆:可采用CMS 收集器。CMS 收集器以多线程并行方式进行垃圾回收,在这个堆大小范围内,能充分利用多核处理器性能,大幅提升垃圾回收效率,降低停顿时间。
中堆4G - 8G 堆:推荐使用 G1 收集器。G1 将堆划分为多个区域,能更精准地控制垃圾回收的范围和时间,在这个堆大小区间,其优势可有效平衡回收效率和停顿时间。
大堆8G - 16G 堆:推荐使用 G1 收集器。G1(Garbage-First)垃圾收集器在设计上旨在提供可预测的停顿时间,适用于大堆内存、低延迟需求的场景。对于8-16g内存的应用,G1可以通过设置-XX:MaxGCPauseMillis
参数来控制最大停顿时间,通常可以将停顿时间控制在200ms以内。 1ms以内低延迟场景,在JDK 16之后,ZGC的停顿时间可以优化至1ms以内。对于8-16g内存的应用,如果对延迟要求极高,例如金融交易系统、实时数据分析等场景,ZGC是一个很好的选择。
超大堆(百 G 以上):ZGC 是最佳选择。超大堆下,CMS 或 G1 发生 Full GC 时停顿会达分钟级别,严重影响业务,而 ZGC 能将停顿时间控制在毫秒级,满足超大堆的业务需求。
三大 经典 GC的STW平均时间分析
以下是Parallel、CMS、G1三大GC的STW平均时间分析:
- Parallel GC:在年轻代回收时,STW时间较短,通常在几毫秒到几十毫秒之间,但在发生Full GC时,停顿时间会显著增加,可能达到几百毫秒甚至更长,因为它采用的是标记-整理算法,且在多线程环境下工作,需要暂停所有应用程序线程来完成回收操作。
- CMS GC:STW时间主要集中在初始标记和重新标记阶段。初始标记阶段的停顿时间很短,通常在几毫秒内,但重新标记阶段在高负载情况下可能会有较长的停顿,一般在几十毫秒到几百毫秒不等。
- G1 GC:STW时间受多种因素影响,包括年轻代回收、混合回收以及Full GC等。在年轻代回收时,停顿时间相对较短,通常在几十毫秒左右;混合回收阶段的停顿时间会比年轻代回收稍长,但一般也能控制在几百毫秒以内
超大堆 百 G / TB场景下STW 卡顿难题
近些年来,服务器的性能越来越强劲,各种应用可使用的堆内存也越来越大,常见的堆大小从 10G 到百 G 级别,部分机型甚至可以到达 TB
级别。
在这类大堆应用上,传统的 GC,如 CMS、G1 的停顿时间也跟随着堆大小的增长而同步增加,即堆大小指数级增长时,停顿时间也会指数级增长。
特别是当触发 Full GC 时,停顿可达分钟级别(百GB级别的堆)
, 传统的 GC,如 CMS、G1 的停顿时间 很长,以大型金融交易系统为例,运行在 64 核 CPU、512GB 内存的服务器上,因业务数据量巨大且交易频繁,对象分配和回收极为频繁,G1 收集器在执行 Full GC 时,需花费约 1 分钟来完成对全堆对象的处理,对业务的实时性造成显著影响。
超大堆 百 G / TB场景下, 当业务应用需要提供高服务级别协议(Service Level Agreement,SLA),例如 99.99% 的响应时间不能超过 100ms,此时 CMS、G1 等就无法满足业务的需求。
为满足当前应用对于 超低停顿、并应对大堆和超大堆 带来的挑战,伴随着 2018 年发布的 JDK 11,A Scalable Low-Latency Garbage Collector - ZGC 应运而生。
ZGC介绍
ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求极致低延迟的垃圾收集器,它曾经设计目标包括:
- 支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。
- 最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。
- 奠定未来GC特性的基础。
- 最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。
另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!
也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。
ZGC(Z Garbage Collector) 是 Java 平台自 JDK 11 起引入的一款低延迟、可扩展的垃圾回收器,专为大堆内存(TB级)和亚毫秒级停顿场景设计。
其核心目标是通过完全并发操作,消除传统垃圾回收器(如 G1、CMS)在处理大堆内存时的长停顿问题,适用于对延迟极度敏感的实时系统(如金融交易、在线游戏、实时数据处理等)。
ZGC的优势
场景需求 | ZGC的优势 | 传统GC(如G1)对比 |
---|---|---|
低延迟要求(<10ms) | 所有回收阶段并发执行,无STW停顿(仅转移阶段极短同步) | G1的Remark阶段需STW,停顿时间随堆增大而增加 |
超大堆内存(TB级) | 染色指针减少内存占用,并发处理无堆大小限制 | G1的卡表维护导致内存和CPU开销剧增 |
实时性敏感业务 | 支持亚毫秒级响应(如高频交易、游戏服务器) | 传统GC的长STW可能导致业务超时或中断 |
长期运行稳定性 | 无内存碎片风险,避免Full GC触发 | CMS可能因碎片触发Full GC,导致分钟级停顿 |
ZGC的劣势
CPU开销略高:
由于全并发操作依赖读屏障和染色指针,可能导致 5%~15% 的吞吐量下降(对比Parallel GC)。
早期版本兼容性限制:
JDK 15 前需 Linux x64 环境,后续版本逐步支持Windows/macOS和ARM架构。
无分代优化(JDK 21前):
JDK 21 引入 分代ZGC(ZGC Generational) 前,无法利用分代架构,去优化年轻代回收效率。
对吞吐量优先的场景,ZGC可能并不适合。
ZGC的设计目标是实现低延迟和高吞吐量,但在实际应用中,它在吞吐量优先的场景中可能并不适合。这是因为ZGC是单代垃圾回收器,每次处理的对象更多,更耗费CPU资源。此外,ZGC使用读屏障,读屏障操作需要耗费额外的计算资源。
例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。
究其原因有二:
第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;
第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
因此,对于吞吐量优先的场景,可能需要考虑其他垃圾回收器。
ZGC 的吞吐量损失 分析
为什么 ,对于吞吐量优先的场景,可能需要考虑其他垃圾回收器比如 CMS,而不是ZGC。
ZGC专注于 低延迟场景,在设计上需牺牲部分吞吐量。
根据实际生产环境测试,CMS迁移至ZGC后吞吐量通常下降约10%-25%。
例如:
- 美团风控服务从CMS切换至ZGC后,单节点吞吐量下降约18%,但延迟从40ms降至10ms以内 ;
- 某日志处理集群(堆内存64GB)升级ZGC后,任务处理速率(TPS)下降22%,主要因ZGC并发标记阶段占用额外CPU资源 。
ZGC 的核心影响因素与量化关系
-
堆内存规模:
中小堆(<32GB)下ZGC因内存管理开销更大,吞吐量降幅可达15%-25%;大堆(>100GB)场景因ZGC并发优势,降幅收窄至5%-10% ;
-
CPU资源竞争:ZGC的并发线程数(-XX)与业务线程争抢CPU,若集群CPU利用率原已超70%,吞吐量降幅可达20%以上 ;
-
对象分配模式:高频率短生命周期对象分配(如离线计算的中间数据)会加剧ZGC的染色指针维护开销,导致吞吐量额外损失5%-8% 。
ZGC 的优化建议与折中方案
- 吞吐量敏感场景:启用ZGC的分代模式(JDK21),可减少年轻代回收压力,预计提升吞吐量8%-12% ;
- 资源分配调优:通过-XX=4(限制并发线程)和-XX=N(N=CPU核数×0.5)平衡CPU争抢,可降低吞吐损失至10%以内;
- 混合部署策略:对延迟不敏感的离线计算模块保留CMS,仅对实时接口类任务启用ZGC,整体集群吞吐量损失可控制在5%以下。
老架构师 尼恩 大白话分析:为啥ZGC的“低延迟”和“吞吐量”会打架?
可以把垃圾回收(GC)想象成一个打扫房间的阿姨:
- ZGC 是个“轻手轻脚”的阿姨:她趁你工作的间隙(比如你喝水时)偷偷打扫,虽然你几乎感觉不到她停顿(低延迟),但她得一直轻手轻脚干活,打扫效率低(吞吐量低),整体打扫时间反而更长。
- Parallel/CMS 是个“大扫除”阿姨:她会突然喊你停下手头工作(STW),然后哐哐一顿猛扫,虽然打断你工作很烦(延迟高),但打扫得又快又干净(吞吐量高)。
ZGC的核心矛盾是:
它为了实现“随时能打断阿姨打扫”(低延迟),必须用更复杂的方式记录垃圾位置(比如染色指针),还要频繁检查哪里脏了。
这些额外操作就像让阿姨一边打扫一边写日记,当然干活更慢(吞吐量下降)。
老架构师尼恩,用大白话 进行 “低延迟”和“吞吐量” 介绍
两大核心指标的 大白话介绍
- 吞吐量 = 阿姨一天能打扫多少平米(干活的“总量”)
- 延迟 = 阿姨打扫时让你停下手头工作的最长时间(“打断你的程度”)
ZGC:
牺牲总量(吞吐量),保证不打断你(低延迟)
CMS:
允许偶尔大打断(高延迟),但总量更大(高吞吐量)
所以:
- 需要快速完成大量计算(离线任务)→ 选CMS(要总量)
- 需要随时响应请求(实时接口)→ 选ZGC(要不打断)
为啥离线计算用CMS,实时接口用ZGC?
- 离线计算(比如报表生成):
这类任务就像你周末在家加班,不怕阿姨突然大扫除打断你(延迟高无所谓),但希望阿姨一次打扫干净,别总磨蹭(高吞吐量优先)。CMS的“哐哐猛扫”模式更合适。
- 实时接口(比如支付系统):
这类任务就像你接客服电话,绝对不能容忍阿姨突然打断你说“稍等2分钟我扫个地”(延迟必须低)。哪怕阿姨干活慢点(吞吐量低),也要保证你随时能接电话。ZGC的“偷偷打扫”模式是刚需。
ZGC 的设计目标
超低延迟(亚毫秒级停顿)
ZGC 主打将垃圾回收的停顿时间控制在亚毫秒级(通常 < 10ms),尤其适用于对延迟敏感的大规模堆内存场景。其停顿时间不会因堆内存增大或活跃对象数量增加而显著上升13。
强可扩展性
支持从数百 MB 到 TB 级别的堆内存(未来规划扩展至 16TB),且堆内存规模扩大时,停顿时间几乎无增长。这种特性使其在大内存应用中表现稳定13。
高并发性
垃圾回收的标记、转移、重定位等核心操作均与应用线程并发执行,最大限度减少 STW(Stop-The-World)对应用性能的影响。
无分代设计(JDK 21 前)
早期版本未采用传统分代模型,而是通过 Region 分区动态管理内存,结合染色指针和读屏障技术实现高效并发回收。
硬件架构适配
通过 NUMA 感知优化非统一内存访问架构,优先在本地内存分配对象,提升内存访问效率。
为什么 zgc 毫秒级stw, 而 g1、cms 需要100毫秒级stw?
1 根扫描优化(减少停顿源头)
ZGC通过染色指针直接在指针中存储对象状态(如标记、转移状态) ,GC线程无需遍历内存即可获取元数据,将根扫描时间压缩至1ms以内。
对比传统回收器:
G1/CMS需遍历栈、寄存器等所有GC Roots,堆越大扫描路径越长。例如,64GB堆下G1的初始标记阶段STW可达50ms。
2 全阶段 并发 (消除长STW)
ZGC的标记、转移、重定位全程并发:
并发标记 阶段:染色指针自动记录存活对象,用户线程仅短暂暂停(仅处理 线程栈 扫描)
并发转移 阶段 :通过内存映射表(Forwarding Table)实时更新对象地址,无需暂停线程更新引用
对比起来,G1/CMS 转移阶段 需要STW :
- G1转移阶段必须STW(防止用户线程访问旧对象地址),64GB堆下转移停顿可达200ms
- CMS重新标记阶段需STW修正并发标记的误差,复杂引用场景停顿超100ms
3 零内存碎片设计(避免Full GC)
ZGC通过并发压缩(染色指针指引对象移动)消除内存碎片 ,无需触发Full GC。
对比传统回收器:
- G1/CMS使用标记-清除算法,内存碎片积累后触发Serial Old Full GC(16GB堆下可达2秒)
- CMS需预留20%内存防止并发失败,但突发内存分配仍可能触发STW Full GC
关键差异总结表
维度 | ZGC | G1/CMS |
---|---|---|
根扫描耗时 | <1ms(染色指针直接读状态) | 10-100ms(堆越大耗时越长) |
转移阶段STW | 0ms(并发转移) | 50-200ms(必须STW更新引用) |
内存碎片处理 | 自动并发压缩 | 需Full GC(秒级STW) |
最大停顿触发场景 | 始终<1ms(设计目标) | Full GC可达秒级 |
ZGC、CMS、G1 技术演进对比
- ZGC:用空间换时间(染色指针占用部分内存带宽)实现全并发
- G1:区域划分+优先回收降低延迟,但核心阶段仍需STW
- CMS:仅部分阶段并发,内存碎片和浮动垃圾导致最终STW
注:ZGC在JDK11后成熟,适用于大内存低延迟场景;G1/CMS更适用于中小堆且允许适度停顿的业务。
老架构师 尼恩 大白话介绍:ZGC 空间换时间 的思想
ZGC 的核心思想就是“用空间换时间”,它通过一种叫做“染色指针” +“转发表” 的技术,巧妙地利用了内存的一部分空间,来实现更高效的垃圾回收,让程序跑得更顺畅。
- 通过 染色指针(Colored Pointers) 标记对象状态(如是否存活)。
- 结合 读屏障(Load Barrier) + “转发表” , 动态修正引用状态,确保并发标记的准确性。
下面,尼恩用大白话给大家 秒懂一下ZGC。
其实很简单。
可以想象一下,就像快递分拣员用标签来加速分拣包裹一样:
1. 染色指针——给内存地址「贴颜色标签」
想象你是一个快递分拣员,面对 仓库里 无数包裹(内存对象),需要快速判断哪些是垃圾(需回收),哪些是客户还要的(存活对象)。
传统做法(G1/CMS):
需要一个一个的挨个拆开包裹,查看里面的单据(遍历对象头部的标记位),耗时费力(STW时间长)。
ZGC的做法(染色指针):
给每个包裹的快递单号(内存地址)直接印上颜色标签(例如红=垃圾,绿=存活),这样不用拆包裹,扫一眼单号颜色就能判断状态。
2. 转发表—— 并发转移 用「转发表」避免搬运混乱
想象仓库需要整理包裹,把有用的包裹搬到新货架(内存压缩),同时允许客户继续取件(应用线程运行)。
传统做法(G1/CMS):
必须让所有人暂停取件(STW),等搬运工把包裹搬到新地址,再批量更新所有客户的取件地址(引用更新),否则客户会拿到错误包裹。
ZGC的做法(染色指针+转发表):
- 搬运时贴新地址:搬运工偷偷把包裹搬到新货架,用一个 映射表(转发表),记录 旧货架贴「旧地址」到新货架地址的映射,同时 染色指针记录转移状态, 根据状态判断要不要走 地址转发。
- 客户自助查地址:客户来取件时,如果发现旧货架有纸条,自动去新地址取件,通过 染色指针+转发表 自动跳转 到 新货架的地址 。
- 零停机:搬运工和客户同时工作,无需暂停仓库运营(全并发)。
3. 空间换时间的本质——用「硬件成本」换「业务不卡顿」
ZGC的代价:
单号原本能写10位数字,现在要留2位给颜色标签,实际可用数字变少(占用了42位指针中的4位,可用地址空间减少到4TB,但 现代服务器 内存足够大,牺牲这点空间是值得的 。
ZGC的取舍逻辑:
空间成本:
- 染色指针占用内存地址空间(42位可用导致最大堆内存4TB,但对大多数服务器足够)。
- 转发表需要额外内存记录新旧地址映射关系。
时间收益:
- 省去了传统GC遍历对象、暂停更新引用的时间(STW从百毫秒降到毫秒级)。
- 内存压缩和标记完全后台完成,用户无感知。
现实场景类比
假设你开了一家24小时便利店(低延迟服务),既要卖货(处理请求),又要理货(垃圾回收):
- 传统理货(G1/CMS): 每天凌晨停业2小时,把过期商品下架,重新摆货(用户会投诉停业太久)。
- ZGC理货: 店员边卖货边偷偷给商品贴标签(染色指针),另一个店员趁顾客不注意把旧商品换到新货架(并发转移),顾客拿到的永远是最新商品(无感知)。
总结:ZGC的设计哲学
- 核心目标:宁可多用一点内存地址空间,也要让用户线程几乎不停顿。
- 实现关键:染色指针相当于给内存地址「加注释」,用硬件资源(内存带宽)换软件流畅性。
- 适用场景:适合对延迟敏感的应用(如实时交易、游戏服务器),容忍内存稍大的成本,但绝不接受卡顿。
什么是染色指针(Colored Pointers)
之前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。
比如 64 位的 JVM,对象头的 Mark Word 中保存的信息如下图:
这种记录方式在有对象访问的场景下, 是很自然流畅的,不会有什么额外负担。
CMS和G1的垃圾回收,需要用到 对象头数据
- 对象头部元数据:
CMS和G1等垃圾回收器将标记信息存储在对象头部的元数据中。
对象头部的元数据包含了对象的类型、锁信息、垃圾回收状态等。
在垃圾回收过程中,回收器需要访问对象头部的标记位来判断对象是否存活、是否需要回收等。
- 标记过程:
在标记阶段,回收器会遍历对象图,访问每个对象的对象头部,标记存活对象。例如,G1使用基于BitMap的标记方式,将标记信息记录在与对象独立的数据结构上。
能不能不用对象头?
有些场景,根本就不会去访问对象 本身,但又希望得知该对象的某些信息?
比如: 我们就有这样的场景——追踪式收集算法的标记阶段,可能存在只跟指针打交道, 而不必涉及指针所引用的对象本身的场景。
能不能不用对象头?
答案是可以的。
染色指针 就是 不去动 对象头部 , 而是动 对象的指针。
ZGC采用染色指针的方式,将标记信息直接存储在引用对象的指针上 , 而不是 对象头上。
染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。
在64位操作系统中,对象指针的长度是64位,ZGC利用其中的高4位来保存标志位,低42位用于保存对象的实际地址。
指针 这些标志位,包括用于表示对象三色标记状态的Marked 0和Marked 1,以及表示对象是否已经进入重分配集的Remapped等。
在垃圾标记阶段,ZGC通过遍历“引用图”来标记垃圾,而不是遍历对象图。
由于标记信息存储在指针上,ZGC可以快速判断对象的垃圾回收状态,而无需访问对象头部,从而减少了标记阶段的停顿时间。
染色指针
染色指针是一种直接将少量额外的信息存储在指针上的技术。
在 64 位 Linux 中,对象指针是 64 位,如下图:
对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果。
在这个 64 位的指针上,高 18 位都是 0,暂时不用来寻址。
剩余的 46 位指针所能支持内存可以达到 64TB ,这可以满足多数大型服务器的需要了。
不过 ZGC 并没有把 46 位都用来保存对象信息,而是用高 4 位保存了四个[标志位],导致 ZGC 可以管理的最大内存不超过 4 TB 。
通过这四个标志位,JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)等信息。
我们再来看下源码中的注释,非常的清晰直观:
0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB (理论上可以16TB)的内存,因为就 42 位用来表示地址。
也因此 ZGC 不支持 32 位指针,也不支持指针压缩。
然后用 42-45 位来作为标志位,其实不管这个标志位是啥指向的都是同一个对象。
这是通过多重映射来做的,很简单就是多个虚拟地址指向同一个物理地址,不过对象地址是 0001.... 还是0010....还是0100..... 对应的都是同一个物理地址即可。
具体这几个标记位怎么用的,待下文回收流程分析再解释。
不过这里先提个问题,为什么就支持 4TB,不是还有很多位没用吗?
首先 X86_64 的地址总线只有 48 条 ,所以最多其实只能用 48 位,指令集是 64 位没错,但是硬件层面就支持 48 位。
因为基本上没有多少系统支持这么大的内存,那支持 64 位就没必要了,所以就支持到 48 位。
那现在对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位可以用吗?
是的,理论上可以支持 16 TB,不过暂时认为 4TB 够了,所以暂做保留,仅此而已没啥特别的含义。
“引用图” V S “对象图” 大PK
在垃圾回收领域,ZGC 遍历 “引用图”,而 G1 等传统 GC 遍历 “对象图”,
“引用图” V S “对象图” 在结构特性、垃圾回收流程、对应用的影响等方面存在显著差异。
“对象图” VS “引用图” 结构特性差异
“对象图” 以对象为核心节点,节点间的边表示对象之间的引用关系。
每个对象在图中都有对应的节点,全面展示对象间复杂的引用层级。
比如,一个电商系统中,订单对象可能引用用户对象、商品对象,这些对象构成错综复杂的对象图。
而 “引用图” 以引用为核心节点,弱化对象本身。它将对象间的引用抽象出来作为节点,着重关注引用的传递和状态。
以刚才电商系统为例,“引用图” 更聚焦订单到用户、订单到商品的引用关系本身。
“对象图” VS “引用图” 垃圾回收流程差异
G1 等传统 GC 遍历 “对象图” 时,从根对象出发,深度或广度优先搜索整个对象图,标记存活对象,未被标记的即为垃圾对象,之后进行清除或整理。
这过程可能需暂停应用线程,以确保对象状态稳定。
而 ZGC 遍历 “引用图” 时,借助染色指针,实时标记引用状态,与应用线程并发进行垃圾回收。
如在处理大规模数据的应用中,ZGC 可在应用运行时标记垃圾引用,避免长时间停顿。
“对象图” VS “引用图” 对应用的影响差异
遍历 “对象图” 时,由于需扫描大量对象,在堆空间较大时,停顿时间长,影响应用性能。如在大型企业级应用中,G1 触发 Full GC 时,停顿可能达数百毫秒甚至秒级。
ZGC 遍历 “引用图” 采用全并发方式,将停顿时间控制在毫秒级,对应用性能影响极小。像对响应时间要求极高的游戏服务器、金融交易系统,ZGC 的优势能确保业务流畅运行。
“对象图” VS “引用图” 空间与维护成本差异
“对象图” 因包含所有对象信息,内存占用大,且对象状态变化时,更新对象图成本高。
“引用图” 以引用为中心,相对轻量化,空间占用少。
同时,ZGC 利用染色指针等技术,实时更新引用状态,维护成本更低,进一步提升垃圾回收效率
HotSpot虚拟机的 几种 不同的对象状态标记实现方案
HotSpot虚拟机的几种收集器有不同的对象状态标记 标记实现方案:
- 把标记直接记录在对象头上(Serial收集器)
- 把标记记录在与对象相互独立的数据结构上(G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息)
- ZGC的染色指针直接把标记信息记在引用对象的指针上(这个时候,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。)
ZGC指针染色与 JVM 指针压缩(Compressed Oops)的冲突
JVM指针压缩是一种在64位系统中减少指针占用内存的技术,注意 , JVM指针压缩 是 减少指针占用内存。
通过将64位指针压缩为32位(实际可用36位),在 保持堆内存寻址能力的同时降低内存消耗。
- 关键机制:利用对象对齐(默认8字节)和地址偏移计算,将指针的高位隐含在对象地址中,从而减少存储空间。
- 触发条件:堆内存≤32GB时自动生效(超过则失效)。
可以通过JVM参数XX:+UseCompressedOops
开启,当然默认是开启的。
为什么要使用压缩指针??
假设我们现在正在准备将32位系统切换到64位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的。
在JVM中导致性能下降的最主要原因就是64位系统中的 对象引用 (对象指针)。在前边我们也提到过,64位系统中对象的引用以及类型指针占用64 bit
也就是8个字节。
这就导致 :在64位系统中的对象引用占用的内存空间,是32位系统中的两倍大小, 就是对象的引用变大了,那么CPU可缓存的对象相对就少了 。
JVM指针压缩 作用场景
- 内存敏感型应用:需要减少内存占用的服务(如大数据处理、微服务集群),压缩后对象引用空间减少约40%。
- 优化缓存效率:压缩后的指针减少CPU缓存行占用,提升数据局部性。
ZGC染色指针 与 JVM 指针压缩的冲突
ZGC染色指针的底层设计
ZGC通过染色指针(Colored Pointers)将对象状态(如标记、转移状态)直接编码到指针的高4位,实现并发垃圾回收。
- 指针位占用:ZGC使用64位指针中的高4位存储元数据,剩余42位用于地址寻址(最大支持4TB堆内存)。
- 核心冲突:JVM指针压缩需要利用高32位计算地址偏移,与染色指针的高位元数据编码存在位冲突。
实际兼容性结论
- ZGC默认禁用指针压缩:由于染色指针已占用高位,ZGC运行时无法同时启用指针压缩 。
ZGC指针染色与 JVM 指针压缩的 差异:
- 指针压缩 适用堆内存≤32GB。
- ZGC染色指针支持最大4TB堆内存(受42位地址限制)。
技术选型建议
场景 | 推荐技术 | 原因 |
---|---|---|
中小堆内存(≤32GB) | G1/CMS + 指针压缩 | 节省内存, 且无染色指针冲突 |
大堆内存+低延迟需求(32GB到4TB) | ZGC(禁用指针压缩) | 染色指针实现毫秒级STW,牺牲压缩能力换取全并发回收 |
超大堆内存(>4TB) | 其他方案(如分代架构) | ZGC受42位地址限制,需分治或硬件扩展 |
关键差异总结
(1) 指针压缩:空间优化技术,降低内存占用,适合中小堆场景。
(2) ZGC染色指针:时间优化技术,通过元数据编码实现低延迟,牺牲部分地址空间。
(3) 互斥性:两者因指针位占用冲突无法共存,需根据堆大小和延迟需求选择。
ZGC的内存管理机制
Java虚拟机是一个普普通通的进程,如何 重新定义内存中某些指针的其中几位,操作系统是否支持?
处理器是否支持?
答案是: 程序代码最终都要转换为机器指令流交付给处理器去执行, 处理器可不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。
在Solaris/SPARC平台上,硬件层面直接支持虚拟地址掩码,能够轻松忽略染色指针中的标志位,从而简化了ZGC的设计。
而在 x86-64平台 上,没有类似的硬件支持,ZGC设计者必须依赖其他的技术手段,解决虚拟地址 到 物理地址映射的问题, 以弥补这一缺陷。
ZGC 主要是虚拟内存映射技术。 ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间。
当创建对象时,首先在堆空间申请一个虚拟地址,该虚拟地址并不会映射到真正的物理地址。
同时,ZGC 会在 M0、M1、Remapped 空间中为该对象分别申请一个虚拟地址,且三个虚拟地址都映射到同一个物理地址。
ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收。
ZGC 虚拟内存映射技术
视图空间的切换:
ZGC 通过 M0、M1、Remapped 三个视图空间的切换,来完成并发的垃圾回收。在垃圾回收过程中,当需要转移对象时,会更新指针的“颜色”,让其指向新的视图空间,从而实现对象引用的自动更新。
物理地址的共享:
三个视图空间中的虚拟地址都映射到同一个物理地址,这样既节省了物理内存,又保证了对象数据的一致性。在对象被访问时,处理器只关心整个指针对应的内存地址,不会区分其中的标志位和实际寻址地址。
M0、M1、Remapped 空间的作用
在ZGC中常见的虚拟空间有[0,4TB)、[4TB,8TB)、[8TB,12TB)、[16TB,20TB),
其中
-
[0,4TB)对应的是Java的堆空间;
-
[4TB,8TB)、[8TB,12TB)、[16TB,20TB)分别对应M0、M1和Remapped这3个视图。
最为关键的是M0、M1和Remapped这3个视图会映射到操作系统的同一物理地址。
这几个空间的关系如图所示。
1. M0 和 M1 空间
M0 和 M1 是 ZGC 创建的两个额外的虚拟地址空间。
当创建对象时,ZGC 会在堆空间申请一个虚拟地址,同时在 M0 和 M1 空间中分别为该对象申请一个虚拟地址。这三个虚拟地址都映射到同一个物理地址。
M0 和 M1 空间用于在垃圾回收过程中提供临时的存储视图。
通过在 M0 和 M1 之间切换,ZGC 可以在不暂停应用程序线程的情况下进行对象的标记和转移。
2. Remapped 空间
Remapped 空间是 ZGC 在垃圾回收过程中用于存储存活对象的新位置。
当对象从 M0 或 M1 空间转移时,其新的虚拟地址会映射到 Remapped 空间。
Remapped 空间确保在垃圾回收完成后,应用程序线程可以访问到对象的新位置,而无需暂停来更新所有引用。
M0、M1、Remapped 空间的工作原理
1. 对象创建
当创建一个新对象时,ZGC 在堆空间分配一个虚拟地址。
同时,ZGC 在 M0、M1 和 Remapped 空间中分别为该对象分配虚拟地址,这些虚拟地址都映射到同一个物理地址。
2. 垃圾回收过程
标记阶段:ZGC 通过遍历引用图来标记存活对象。
在这个过程中,M0 和 M1 空间提供了一个稳定的视图,允许应用程序线程继续运行。
转移阶段:
存活对象被复制到 Remapped 空间。
ZGC 使用虚拟内存映射技术来 更新对象的引用,确保应用程序线程在访问对象时能够自动跳转到新的地址。
ZGC 的地址 视图切换 机制
通过切换视图,ZGC 避免了在垃圾回收过程中暂停应用程序线程来更新所有对象引用的需要。
该图是ZGC在运行时虚拟地址和物理地址的转化。
1)4TB是的堆空间,其大小受限于JVM参数。
2)0~4TB的虚拟地址是ZGC提供给应用程序使用的虚拟空间,它并不会映射到真正的物理地址。
3)操作系统管理的虚拟内存为M0、M1和Remapped这3个空间,且它们对应同一物理空间。
4)在ZGC中,这3个空间在同一时间点有且仅有一个空间有效。 这就是ZGC的高明之处,利用虚拟空间换时间。这3个空间的切换由垃圾回收的不同阶段触发。
5)应用程序可见并使用的虚拟地址为0~4TB,经ZGC转化,真正使用的虚拟地址为[4TB,8TB)、[8TB,12TB)和[16TB,20TB),操作系统管理的虚拟地址也是[4TB,8TB)、[8TB,12TB)和[16TB,20TB)。
应用程序可见的虚拟地址[0,4TB)和物理内存直接的关联关系,由ZGC来管理。
使用地址视图的好处就是加快标记和转移的速度。
比如对于对象在标记阶段只需要转换地址视图。
而地址视图的转化非常简单,只需要设置地址中第42~45位中相应的标志位即可。
而在以前的垃圾回收器中,要修改对象的对象头,把对象头的标记位设置为已标记,这就会产生内存存取访问。
而在ZGC中无须任何的对象访问。这就是ZGC在标记和转移阶段速度更快的原因。
ZGC用了操作系统的 mmap 内存映射的系统调用。
以下是一个简单的 Linux 多视图 内存映射示例,演示了如何使用 mmap 将同一块物理内存映射到多个虚拟地址:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdint.h>int main() {// 创建一个共享内存的文件描述符int fd = shm_open("/example", O_RDWR | O_CREAT | O_EXCL, 0600);if (fd == -1) return 0;// 防止资源泄露,需要删除。执行之后共享对象仍然存活,但是不能通过名字访问shm_unlink("/example");// 将共享内存对象的大小设置为4字节size_t size = sizeof(uint32_t);ftruncate(fd, size);// 3次调用mmap,把一个共享内存对象映射到3个虚拟地址上int prot = PROT_READ | PROT_WRITE;uint32_t *remapped = mmap(NULL, size, prot, MAP_SHARED, fd, 0);uint32_t *m0 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);uint32_t *m1 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);// 关闭文件描述符close(fd);// 测试,通过一个虚拟地址设置数据,3个虚拟地址得到相同的数据*remapped = 0xdeafbeef;printf("48bit of remapped is: %p, value of 32bit is: 0x%x\n", remapped, *remapped);printf("48bit of m0 is: %p, value of 32bit is: 0x%x\n", m0, *m0);printf("48bit of m1 is: %p, value of 32bit is: 0x%x\n", m1, *m1);return 0;
}
ZGC 的视图切换与对象引用修正
m0 本次GC 标识过
m1 上次GC 标识过
Remapped 对象被重新映射到新的内存位置(对象被移动过)
下面通过一个简单的例子来演示这个问题,假定对象引用关系 的 初始状态如图 所示。
假设标记开始前地址视图为Remapped,GC工作线程将Obj1和Obj3标记,首先从一个Remapped 视图(移动过之后的地址)映射到另外一个M1 视图(本次GC视图)。
初始状态
在ZGC垃圾回收开始之前,对象间的引用关系如图示。此时,所有对象都处于Remapped视图中。
假定对象引用关系的初始状态如下:
- Obj1、Obj2、Obj3 是堆中的对象。
- Obj1 引用 Obj3,Obj2 也引用 Obj3。
- 在垃圾回收开始前,所有对象的地址视图均为 Remapped
这时候:
- Obj1中指向的Obj3其地址位为M1+Address
- Obj2指向的Obj3其地址位为Remapped+Address。
标记过程
标记开始
- GC 工作线程开始标记对象。
- 首先,将 Obj1 和 Obj3 标记为存活对象。
- 在标记过程中,Obj1 和 Obj3 的地址视图从 Remapped 切换到 M1。
部分对象标记完成
- 此时,Obj1 和 Obj3 的地址视图为 M1,而 Obj2 尚未完成标记,其地址视图仍为 Remapped。
- Obj2 的成员变量中指向 Obj3 的引用仍然指向 Remapped 视图中的地址。
部分对象标记后的地址视图如图 所示。
当垃圾回收开始后,ZGC会切换视图,将部分对象(如Obj1和Obj3)从Remapped视图映射到M1视图,并标记这些对象为存活对象。
此时Obj1和Obj3的地址视图为M1,而Obj2尚未完成标记,Obj2 地址视图仍然为Remapped,并且Obj2中成员变量也没有更新,所以它指向的Obj3仍然是老的地址视图。
视图切换与引用修正
完成 Obj2 的标记
- 当 Obj2 完成标记后,其地址视图也切换到 M1。
- 但是,Obj2 中指向 Obj3 的引用仍然指向 Remapped 视图中的地址。
修正 Obj2 的引用
- 由于 Obj3 已经被标记为存活对象,无需再次标记。
- 对于 Obj2 中指向 Obj3 的过时引用,ZGC 需要进行修正。
- 修正方法是通过 Obj2 的引用获取 Obj3 的过时指针,然后访问 Obj3 的 oop(普通对象指针)对象。
- 由于底层 oop(普通对象指针) 只有一个,通过 oop(普通对象指针) 获取的 Obj3 地址视图可能为 Remapped 或 M1。
- ZGC 确保通过 oop 获取的 Obj3 地址视图为 M1,以反映最新的标记状态。
引用 修正具体操作
访问 oopDesc
- 在 JVM 中,每个对象都有一个 oopDesc 信息(对象头),位于 oop 的头部。
- 通过 oop 获取 oopDesc 的地址,判断 Obj3 所在的视图。
- 如果 oopDesc 的地址视图为 M1,则说明 Obj3 已经被标记,无需再次处理。
修正引用
- 根据 oopDesc 的地址视图,修正 Obj2 中指向 Obj3 的引用,使其指向 M1 视图中的地址。
oop(普通对象指针)和oopDesc信息(也就是对象头)
在Java虚拟机(JVM)中,oop(Ordinary Object Pointer) 是对象在内存中的内部表示形式。
你可以把它理解为一个对象的“物理身份证”,它直接对应到内存中的一块数据区域。
下面用通俗的语言和结构图来解析一个Java对象在内存中的布局。
一个Java对象在内存中的oop的整体结构 分为三部分:对象头(Header) + 实例数据(Instance Data) + 对齐填充(Padding)。
用伪代码表示如下:
c++Copy Codeclass oop {Header header; // 对象头(存储元数据)InstanceData data; // 实例数据(对象的字段值)byte padding[]; // 对齐填充(可选,用于内存对齐)
};
ZGC 在视图切换过程中,通过修正对象引用,确保所有对象引用指向最新的视图。
这一过程利用了 oop (普通对象指针) 和 oopDesc 的信息,避免了重复标记和引用错误。
在JVM中,oop (普通对象指针) 有一个oopDesc信息(也就是对象头),oopDesc在oop的头部,所以可以通过oop获取oopDesc的地址,这个是一个 和对象 绑定了的地址, 通过oopDesc 地址视图判断Obj3处于哪个视图中。
oop (普通对象指针) 与 oopDesc信息(也就是对象头) 之间的关系, 这就好比一本书的封面,封面包含了书籍的关键信息,而对象头包含了对象的关键元数据。
因为 oopDesc 在 oop 头部,只要获取了 oop,就能轻松得到 oopDesc 的地址,这个地址与对象紧密绑定,在对象的生命周期内保持稳定。
相关伪代码如下:
inline uintptr_t ZOop::to_address(oop o) {
return cast_from_oop<uintptr_t>(o);
}
template
inline T cast_from_oop(oop o) { return (T)((oopDesc*)o);
}
oopDesc:在 JVM 中,每个对象都有一个 oopDesc 信息,它就像是对象的“身份证”,包含了对象的基本信息,例如对象头等。
获取 oopDesc:通过对象的引用,获取该对象的 oopDesc 信息。oopDesc 位于对象的头部,可以通过对象的引用获取。
这一步就像是通过一个指向对象的指针,找到对象的“身份证”。
在 oopDesc 中,有一个地址视图的信息。这个地址视图会标识该对象当前处于哪一个视图空间(M0、M1 或 Remapped)。
根据 oopDesc 中的地址视图信息,就可以判断出该对象当前处于哪一个视图空间中。
例如:
-
如果地址视图为 M0,则说明该对象在当前垃圾回收周期中被标记为活跃对象;
-
如果地址视图为 Remapped,则说明该对象可能在上一次垃圾回收中被转移过,或者在本次垃圾回收中未被标记为活跃。
什么是读屏障(Load Barrier)
在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。
- 写屏障 是 在 修改 对象引用 时 的 AOP
- 读屏障 是 在 读取 对象引用 时的 AOP。
比如 Object a = obj.foo;
这个过程就会触发读屏障。
也正是用了读屏障,ZGC 可以并发转移对象。而 G1 用的是写屏障,所以转移对象时候只能 STW。
简单的说就是 GC 线程转移对象之后,应用线程 读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移。
如果是的话修正对象的引用,按照上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。
当程序尝试读取一个对象时,读屏障会触发以下操作:
- 检查指针染色: 读屏障首先检查指向对象的指针的颜色信息。
- 处理移动的对象: 如果指针表示对象已经被移动(例如,在垃圾回收过程中),读屏障将确保返回对象的新位置。
- **确保一致性: **通过这种方式,ZGC 能够在并发移动对象时保持内存访问的一致性,从而减少对应用程序停顿的需要。
// 伪代码示例,展示读屏障的概念性实现
Object* read_barrier(Object* ref) {//如果对象已经被移动,返回新地址if (is_forwarded(ref)) {return get_forwarded_address(ref); // 获取对象的新地址}return ref; // 对象未移动,返回原始引用
}
读屏障可能被GC线程和业务线程触发,并且只会在访问堆内对象时触发,访问的对象位于GC Roots时不会触发,这也是扫描GC Roots时需要STW的原因。
下图展示了读屏障的效果,其实就是转移的时候, 找地方记一下(即 forwardingTable),然后读的时候触发引用的修正。
这种也称之为“自愈”,不仅赋值的引用时最新的,自身引用也修正了。
染色指针和读屏障是 ZGC 能实现并发转移的关键所在。
在ZGC中,因为在回收时需要把一个分区中的存活对象转移进另外一个空闲分区中,而ZGC的转移又是并发执行的,
因此,一条用户线程访问堆中的一个对象时,该对象恰巧被转移了,那么这条用户线程根据原本的指针是无法定位对象的,所以在ZGC中引入了转移表forwardingTable
的概念。
转移表可以理解为一个Map<OldAddress,NewAddress>
结构的集合,当一条线程根据指针访问一个被转移的对象时,如果该对象已经被转移,则会根据转移表的记录去新地址中查找对象,并同时会更新指针的引用。
何谓转移表/集(ForwardingTable)?
转移表ForwardingTable
是ZGC确保转移对象后,其他引用指针能够指向最新地址的一种技术,每个页面/分区中都会存在,其实就是该区中所有存活对象的转移记录,一条线程通过引用来读取对象时,发现对象被转移后就会去转移表中查询最新的地址。
同时转移表中的数据会在发生第二次GC时清空重置,也包括会在第二次GC时触发重映射/重定位操作。
通过读屏障+ ForwardingTable, ZGC的指针拥有“自愈”的能力。
GC发生后,堆中一部分存活对象被转移,当应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移,如果读取的对象已经被转移,那么则修正当前对象引用为最新地址(去ForwardingTable 转移表中查)。
这样做的好处在于:下次其他线程再读取该转移对象时,可以正常访问读取到最新值。 当然,这种情况在有些地方也被称为:ZGC的指针拥有“自愈”的能力。
ZGC - 读屏障解决对象漏标
引起漏标问题的原因
在垃圾回收的标记过程中,漏标问题可能导致某些存活对象被错误地回收。漏标问题的发生需要同时满足以下两个条件:
- 条件一:灰色对象断开了与白色对象的引用(直接引用或间接引用都可)。这会导致多标, 浮动垃圾。
- 条件二:已经标为黑色的对象重新与白色对象建立了引用关系。这会导致少标, 误删掉需要的对象。
只有当一个对象同时满足了这两个条件时,才会发生漏标问题。
简单代码案例
假设 obj
是一个灰色对象。objA 是一个黑 色对象
Object X = obj.fieldX; // 获取obj.fieldX成员对象
obj.fieldX = null; // 将原本obj.fieldX的引用断开
objA.fieldX = X; // 将断开引用的X白色对象与黑色对象objA建立引用
此时,先获取 obj 成员 fieldX
并将其赋值给变量 X
,让其堆中实例与变量 X
保持着引用关系。`
紧接着再将 obj.fieldX
置空,断开与 obj
对象的引用关系,最后再与黑色对象 objA
建立起引用关系。
最终关系如下:
- 灰色对象
obj
- 白色对象
obj.fieldX/X
- 黑色对象
objA
白色对象 X
在 GC 机制标记灰色对象 obj
成员属性之前,与灰色对象断开了引用,然后又与黑色对象 objA
建立了引用关系。
此时,白色对象 X
就会被永远停留在白色集合中,直至清除阶段到来,被“误判”为垃圾回收掉,发生 致命错误。
CMS 解决漏标 的手段
在 CMS 中,为解决该问题采用了写后屏障 + 增量更新的方式。
写后屏障记录了更改引用的对象,然后通过溯源对发生改动的节点进行了重新扫描。
G1 解决漏标 的手段
G1 则是通过 STAB(Snapshot At The Beginning)+ 写前屏障解决该问题。
G1 的核心思路是保留 GC 开始时的对象图关系,即原始快照。
并发标记过程会以最初的对象图关系进行访问,即使并发标记过程中某个对象的引用信息发生了改变,G1 会通过写前屏障记录原有的对象引用关系,依旧按照最初的对象图快照进行标记。
G1 写屏障的实现逻辑
void pre_write_barrier(oop* field) {// 处于 GC 并发标记阶段且该对象没有被标记(访问)过if ($gc_phase == GC_CONCURRENT_MARK && !isMarked(field)) { oop old_value = *field; // 获取旧值remark_set.add(old_value); // 记录原来的引用对象}
}
G1 中的这种做法思路是:并发标记过程中,不管引用关系怎么改变,反正就跟着最开始的对象图关系进行标记。这样就破坏了条件一,即灰色对象断开了与白色对象的引用。
ZGC 解决漏标 的手段
ZGC 通过读屏障的手段解决了对象漏标问题。读屏障相当于在读取引用时的 AOP(面向切面编程),在读取成员变量时记录下来,这种做法是保守的,但也是安全的。
ZGC 读屏障的实现逻辑
oop oop_field_load(oop* field) {pre_load_barrier(field); // 读屏障-读前操作return *field;
}void pre_load_barrier(oop* field) { if ($gc_phase == GC_CONCURRENT_MARK && !isMarked(field)) {oop old_value = *field;remark_set.add(old_value); // 记录读取到的对象}
}
读屏障的作用是,在读取成员变量时,记录下读取的对象。
这样,即使白色对象与黑色对象建立了新的引用关系,读屏障也能记录下来,确保在再次标记时能够重新标记这些对象,从而避免漏标问题。
读屏障是在读取成员变量时,统统记录下来,这种做法是保守的,但也是安全的。
ZGC 的读屏障和 G1 的写屏障有以下区别:
触发时机不同
ZGC 的读屏障是在从 GC 堆里的对象的引用类型字段里读取指针时触发。就好像你去仓库(GC 堆)拿东西(指针)的时候,有个检查人员(读屏障)会出来看看这个东西有没有问题,需不需要调整。
G1 的写屏障是在改变特定内存的值,也就是写入内存时触发。相当于你往仓库里放东西(写入数据)的时候,写屏障会起作用,记录一些相关信息。
作用不同
ZGC 读屏障主要有两个作用。
ZGC 读屏障的第一个作用:就是在标记阶段把指针标记上,并将堆里的指针 “修正” 到新的标记后的值;
老架构师 尼恩 大白话说明:想象一下 GC 堆就像一个大仓库,里面放着各种各样的物品(对象),每个物品都有一个标签(指针)来表示它的位置。在标记阶段,ZGC 不是直接去标记物品本身,而是去标记这些标签(指针)。
所谓 “把指针标记上”,就是给指针做个记号,告诉系统,这个指针所指向的物品正在被检查或者已经被检查过了。
ZGC 读屏障的第二个作用: 是在移动对象阶段,把读出的指针更新到对象的新地址上,同时将堆里的指针 “修正” 到原本的字段里,保证应用代码持有的是更新后的有效指针。
老架构师 尼恩 大白话说明: 当要把仓库里的某些物品(对象)从一个位置移动到另一个位置时,就进入了移动对象阶段。在这个阶段,系统会去读取物品原来的标签(指针),然后把这个标签上记录的位置信息更新为物品新的存放位置,这就是 “把读出的指针更新到对象的新地址上”。
同时,仓库里还有一些记录物品信息的本子(原本的字段),这些本子上也记录着物品的位置(指针)。系统还要把更新后的指针信息 “修正” 到这些本子上,让本子上记录的位置也变成物品的新位置。这样做的目的是保证应用代码在使用这些信息去查找物品时,拿到的是更新后的、能正确找到物品的有效指针,就好像你去仓库找东西,看的标签和本子上写的都是物品最新的位置,肯定能找到东西,不会因为物品位置变了而找不到。
在 ZGC 中,无论一个对象被赋值给多少个变量,从底层实现角度来看,实际上都是这些变量持有指向该对象的同一个指针。
ZGC 采用染色指针技术,对象的标记信息直接存储在指针上。例如,ZGC 将 64 位指针中的高 4 位取出来用于存储染色标识信息(如 Finalizable、Remapped、Marked1、Marked0),剩余 42 位用于表示对象地址,以此来管理 4TB 内存空间。
当对象被赋值给多个变量时,这些变量存储的都是这个带有染色标识的指针,它们都指向同一个对象在内存中的地址。
所以,一个对象即使被赋值给多个变量,也只有一个指针来标识它在内存中的位置和相关状态信息。当对象在 GC 过程中被移动或标记状态发生变化时,通过这个指针的染色信息和相关的 Load Barriers 机制,可以保证所有引用该对象的变量都能及时获取到对象的最新地址和状态。
G1 的写屏障主要用于在运行时探测并记录 相关指针,在回收器只回收堆中部分区域时,捕获来自该区域外的指针,这些指针会在垃圾回收时作为标记开始的根。
例如,老年代对象引用指向年轻代对象时,写屏障会捕获并记录下来,这样在年轻代回收时,就不用扫描整个老年代找根了。
实现方式及带来的影响不同
- ZGC 的读屏障具有 “self healing” 性质。如果堆上有指针处于 “尚未更新” 状态,经过读屏障会被就地更新,在同一个 GC 周期内再次访问该字段就无需再修正,所以读屏障带来的性能开销短暂。
- G1 的写屏障采用双重过滤来提高效率,过滤掉同一个 region 内部引用和空引用,并且使用一种两级的 logbuffer 结构来记录信息。不过,相比 ZGC 读屏障,它没有类似 “self healing” 这样能减少性能开销的特性。
ZGC 工作流程
ZGC 工作流程的 三大阶段
ZGC 的步骤大致可分为三大阶段分别是标记、转移、重定位。
- 标记:从根开始标记所有存活对象
- 转移:选择部分活跃对象,转移到新的内存空间上
- 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上。
并且这三个阶段都是并发的。
这是理论上的三个阶段划分 ,具体的实现上重定位其实是糅合在标记阶段的。
在标记的时候,如果发现引用的还是老的地址,则会修正成新的地址,然后再进行标记。
简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。
在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。
所以说重定位是糅合在下一步的标记阶段中。
ZGC标记阶段:初始标记
为什么需要初始标记
1 确定标记起点
通过短暂的STW停顿(通常1-2毫秒),
快速扫描并记录所有GC Roots直接引用的对象(如线程栈、静态变量等),为后续并发标记提供准确的遍历起点,避免并发阶段因动态引用变化导致对象遗漏或误判。
2 初始化元数据状态
借助染色指针技术,为根对象设置标记位等元数据,为后续并发标记、转移阶段提供状态标识基础,并与读屏障机制协同,高效跟踪并发过程中的引用变化。
该阶段是ZGC唯一需要STW的阶段,但其耗时极短且与堆大小无关,
确保整体垃圾回收过程的停顿时间控制在亚毫秒级,满足低延迟需求。
什么是初始标记
这个阶段其实大家应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中。
当然还有其他动作,比如重置 TLAB、判断是否要清除软引用等等,不做具体分析。
标记前:
标记后:
ZGC标记阶段:并发标记
ZGC 为什么需要 并发标记?
1 消除长时间停顿
并发标记阶段与应用线程并行执行,无需暂停应用(非STW),在后台遍历并标记所有存活对象。
这种设计避免了传统GC因全堆扫描导致的长时间停顿,确保应用延迟始终控制在亚毫秒级。
2 动态处理引用变化
在并发标记过程中,应用线程可能修改对象引用关系。
ZGC通过读屏障(Load Barrier)实时捕获新的引用变更,动态更新标记状态,确保标记结果的准确性,避免漏标或误标。
3 支持并发转移阶段
并发标记阶段输出的对象存活信息,直接用于后续并发转移阶段(即对象移动阶段)。
存活对象的精准标记使ZGC能在不暂停应用的情况下,安全地压缩堆内存,解决内存碎片问题。
4 染色指针的高效协同
通过染色指针存储标记状态(如Marked标记位),并发标记阶段可直接在指针元数据中记录对象状态,无需额外内存操作。
这种硬件级优化大幅降低了并发标记的开销,提升吞吐量。
什么是 ZGC 并发标记?
就是根据初始标记的对象开始, 并发遍历对象图,还会统计每个 region 的存活对象的数量。
这个并发标记其实有个细节,标记栈其实只有一个,但是并发标记的线程有多个。
为了减少之间的竞争每个线程其实会分到不同的标记带来执行。
你就理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的segment 一样。
那肯定有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。
看到这有没有想到啥?
没错就是 ForkJoinPool 的工作窃取机制!
ZGC标记阶段:再标记阶段
为什么需要 再标记阶段?
1 修正并发标记阶段的遗漏标记
在并发标记阶段,应用线程可能修改对象引用关系(如新增或删除引用),导致部分存活对象未被标记。再标记阶段通过短暂的全局停顿(STW2),结合读屏障记录的引用变更信息,快速补全遗漏标记,确保所有存活对象被准确识别。
2 确保标记结果的全局一致性
并发标记阶段与应用线程并行执行,可能导致标记状态存在时间差(如部分对象未完成标记)。再标记阶段通过全局同步,统一所有线程的引用关系视图,避免后续转移阶段因标记错误而误删存活对象。
3 为并发转移阶段提供可靠输入
再标记阶段的最终标记结果直接用于并发转移阶段(对象移动阶段)。通过染色指针(如将存活对象标记为Marked1
),ZGC能精准确定需要转移的对象,实现不暂停应用的安全内存压缩,解决内存碎片问题。
4 优化低延迟设计
再标记阶段的停顿时间极短(通常小于1毫秒),且与堆大小无关。这一设计通过染色指针和读屏障技术,仅需扫描少量变更引用(而非全堆),在保证标记精度的同时,维持ZGC的亚毫秒级停顿承诺。
什么是再标记阶段?
这一阶段是 STW 的,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况。
因此需要个再标记阶段来标记漏标的那些对象。
如果这个阶段执行的时间过长,就会再次进入到并发标记阶段,因为 ZGC 的目标就是低延迟,所以一有高延迟的苗头就得扼制。
这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。
有些非强根可以并发,有些不行,具体不做分析。
ZGC再标记阶段:非强引用并发标记 和 引用并发处理
为什么需要 非强引用并发标记和引用并发处理
1 减少 STW 停顿时间
ZGC 的核心目标是实现亚毫秒级停顿(通常 <10ms)。
传统垃圾回收器(如 G1)处理非强引用(软引用、弱引用等)时需在 STW 阶段完成可达性判断和队列注册,导致停顿时间增加。
ZGC 通过并发处理这些操作,避免 STW 时间被复杂逻辑拖累。
2 应对复杂引用类型的高效管理
Java 的非强引用类型(如软引用依赖内存压力策略回收)需动态判断回收时机。
若在 STW 阶段集中处理,需遍历所有引用对象,性能开销大。
ZGC 通过分阶段并发处理,既保证策略灵活性,又避免阻塞应用线程。
3 适应大堆内存场景
ZGC 支持 TB 级堆内存。
随着堆规模增大,非强引用数量可能激增,若在 STW 阶段处理,停顿时间将不可控。
并发标记和处理机制将任务分散到多个阶段,确保堆规模扩大时停顿时间仍保持稳定。
什么是 非强引用并发标记与引用并发处理
就是上一步非强根的遍历,然后引用 就软引用、弱引用、虚引用的一些处理。
这个阶段是并发的。
ZGC 通过 非强引用并发标记 和 引用并发处理阶段,将传统 GC 中需 STW 的操作转为并发执行,核心目标是实现亚毫秒级停顿。
两阶段分别解决非强引用的可达性判断和内存回收问题,结合染色指针、读屏障等技术保障并发安全,适用于对延迟和堆规模要求极高的场景。
1 非强引用并发标记
在 并发标记阶段,ZGC 遍历所有非强引用对象(如 SoftReference
、WeakReference
),判断其目标对象是否可达,并记录需回收的引用。
- 通过 染色指针(Colored Pointers) 标记对象状态(如是否存活)。
- 结合 读屏障(Load Barrier) 动态修正引用状态,确保并发标记的准确性。
2 引用并发处理阶段
在并发标记完成后,ZGC 对已标记为不可达的非强引用进行最终处理:
- 解除引用关系,释放内存;
- 被回收的引用对象注册到对应的
ReferenceQueue
。
ZGC转移阶段:重置转移集
为什么需要 重置转移集阶段?
重置转移集是 ZGC 维护 内存一致性、资源效率 和 低延迟性 的关键机制。通过清除失效数据、释放资源并为新周期初始化,ZGC 在 TB 级堆内存下仍能实现亚毫秒级停顿目标
1 维护内存访问正确性
转移集记录了待迁移对象的旧地址与新地址的映射关系。在一次 GC 周期结束后,所有存活对象已迁移至新地址,旧转移集中的数据失效。若保留旧数据,后续 GC 可能误用无效地址,导致内存访问错误或数据不一致。
2 释放无效资源
转移集依赖 Forwarding Table 存储地址映射信息。重置转移集会清理该表中的旧条目,避免无效数据占用内存资源,防止内存泄漏17。
对于大型 Region(如 ≥4MB 的大对象存储区),ZGC 不会迁移其内容,重置转移集可明确区分此类对象,避免误迁移。
3 为新一轮 GC 周期初始化
ZGC 的 Region 布局动态变化(小型/中型/大型 Region),每次 GC 需根据当前堆状态重新选择待回收的 Region18。重置转移集可初始化数据结构,确保新周期转移集与目标 Region 完全匹配。
提前释放资源可降低下一轮 GC 的延迟,符合 ZGC 亚毫秒级停顿目标。
4 保障并发转移安全性
ZGC 的转移阶段与应用线程并发执行。重置转移集能隔离不同周期的迁移任务,防止并发线程误读旧转移集数据,确保对象访问的正确性。
结合 染色指针(Colored Pointers) 机制,重置后指针状态与新周期同步,避免历史状态干扰内存操作。
什么是 重置转移集?
什么是转移集合和转移表
转移集合(Relocation Set):转移集合是ZGC垃圾回收周期中需要迁移存活对象的Region集合,用于并发转移阶段的高效内存整理。
转移表(Forwarding Table):记录对象旧地址到新地址的映射关系,确保并发转移过程中业务线程能通过读屏障快速定位对象新位置。
两个工作:
- Forwarding Table的清理(移除旧映射条目);
- 转移集本身的清空(准备下一轮GC)
还记得标记时候的重定位么?在写读屏障时候提到的 forwardingTable 就是个映射集,你可以理解为 key 就是对象转移前的地址,value 是对象转移后的地址。
不过这个映射集在标记阶段已经用了,也就是说标记的时候已经重定位完了,所以现在没用了。
但新一轮的垃圾回收需要还是要用到这个映射集的。
因此在这个阶段对那些转移分区的地址映射集做一个复位的操作。
参考源码如下:
// 示例代码(参考 OpenJDK 源码)
void ZHeap::reset_relocation_set() {ZRelocationSetIterator iter(&_relocation_set);for (ZForwarding* forwarding; iter.next(&forwarding);) {_forwarding_table.remove(forwarding); // 清理旧条目}_relocation_set.reset(); // 重置转移集结构
}
(1) 清理 Forwarding Table:移除所有旧迁移条目。
(2) 重置数据结构:释放转移集占用的内存,初始化空数据集。
ZGC转移阶段:回收无效分区
为什么需要 回收无效分区?
1 避免内存碎片,提升内存利用率
ZGC将堆划分为多个大小不同的 Region(小型、中型、大型分区),对象转移后,原Region变为无效分区。若长期保留无效分区,会导致内存碎片化,降低后续内存分配效率。通过回收无效分区,ZGC能重新整合空闲内存,保证堆内存的紧凑性,提升整体利用率。
2 支持动态内存管理需求
ZGC的Region分配和回收是动态进行的。在并发转移阶段,存活对象被迁移到新Region,原Region变为无效分区。回收无效分区可为新对象分配提供连续可用空间,确保动态扩容或缩容时堆布局的灵活性,适配不同内存负载场景。
3 保障低延迟目标
ZGC的核心设计目标是将最大停顿时间控制在 10ms以内。若无效分区未及时回收,后续GC周期可能因内存碎片被迫执行更耗时的压缩操作,导致停顿时间不可控。回收无效分区通过轻量级操作(如标记空闲Region)提前释放资源,避免此类延迟累积45。
4 释放系统资源,支持大堆场景
ZGC支持 TB级堆内存,无效分区长期占用内存会浪费资源,甚至导致内存耗尽。回收阶段通过操作系统接口(如madvise
)主动释放物理内存,降低内存占用压力,确保大堆场景下系统资源的合理分配。
什么是 回收无效分区 ?
1 无效分区识别
当存活对象被并发转移至新 Region 后,原 Region 被标记为“无效分区”,其内存空间不再包含有效对象57。
2 轻量级元数据更新
Region 空闲列表维护:将无效分区加入 ZGC 的空闲 Region 池,供后续对象分配使用。
动态 Region 类型调整:根据应用的内存分配特征(如对象大小分布),动态调整小型(2MB)、中型(32MB)、大型(Humongous)Region 的比例,优化内存利用率。
3 物理内存资源释放
操作系统级内存回收:对长期未使用的无效分区,调用系统接口(如 Linux 的 madvise(MADV_FREE)
)主动释放物理内存,减少内存占用。
自动内存返还机制:自 JDK13 起,ZGC 增强了对未使用堆内存的自动返还能力,确保无效分区的物理内存及时释放给操作系统。
4 并发回收与屏障技术
读屏障(Read Barrier)支持:在应用线程访问对象时,读屏障实时修正指向无效分区的指针,确保引用指向新迁移的有效地址,避免访问已回收内存58。
染色指针状态同步:通过染色指针的元数据(如 Marked/Remapped 状态)标识 Region 有效性,实现回收过程与应用线程的并发执行。
5 NUMA 感知优化
本地化内存分配:在 NUMA 架构下,优先将回收后的 Region 分配给同一 CPU 节点的线程使用,减少跨节点内存访问延迟。
ZGC转移阶段:选择待回收的分区
这和 G1 一样,因为会有很多可以回收的分区,会筛选垃圾较多的分区,来作为这次回收的分区集合。
为什么需要 选择待回收分区?
1 减少内存碎片
ZGC 采用 Region 分区管理(如 2MB/32MB 的小型/中型 Region 和 Humongous Region),通过回收无效分区整合碎片化内存,提升堆内存利用率56。若跳过此阶段,频繁分配和回收可能导致内存碎片,影响大对象分配效率。
2 优化回收效率
优先回收 低存活率的分区(如转移后无存活对象的 Region),可减少并发转移阶段的负载,降低处理延迟56。同时避免对高存活率分区的无效扫描,减少计算资源浪费。
3 避免误回收有效对象
通过染色指针(Colored Pointers)和读屏障(Read Barrier)的协同,精准识别无效分区,防止误回收仍在使用的对象或未完成转移的分区。
4 支持大堆与低延迟
在 TB 级堆内存场景下,分区筛选机制确保 ZGC 仅处理必要区域,避免全堆扫描带来的性能损耗,维持亚毫秒级停顿目标。
什么是 选择待回收分区?
这和 G1 一样,因为会有很多可以回收的分区,会筛选垃圾较多的分区,来作为这次回收的分区集合。
ZGC 的“选择待回收分区阶段”通过 并发标记筛选、动态分区管理、屏障技术保障 等机制,在避免误回收的同时提升内存利用率,确保低延迟和高吞吐量。
该阶段是 ZGC 实现 TB 级堆内存高效管理的核心环节
1 并发标记与分区状态判定
存活对象跟踪:通过 并发标记阶段 的染色指针(如 Marked0/Marked1 状态)标记所有存活对象,记录其所属 Region 的存活状态。
分区有效性判定:当存活对象被转移至新 Region 后,原 Region 被标记为“无效”,加入待回收队列。
2 动态分区筛选策略
存活率优先回收:优先选择 存活率最低的 Region(如无存活对象的小型 Region)进行回收,减少内存碎片和后续转移压力。
分区类型适配:根据应用对象分布动态调整小型、中型、Humongous Region 的回收优先级,优化内存分配效率。
3 元数据更新与资源管理
空闲 Region 池维护:将无效分区加入空闲池,供后续对象分配使用,仅需更新染色指针的元数据(如 Remapped 状态),无 STW 停顿。
物理内存释放:调用系统接口(如 madvise
)释放长期未使用的无效分区物理内存,降低系统资源占用。
4 屏障技术与并发保障
读屏障(Read Barrier):在应用线程访问对象时,实时检测指针状态,确保引用指向有效分区,防止误回收仍在使用的 Region。
染色指针状态同步:通过指针元数据标识分区有效性,实现回收决策与业务线程的完全并发执行。
ZGC转移阶段:初始化待转移集合的转移表
为什么需要 初始化 待转移集合的 Forwarding Table 转移表?
1 并发转移的原子性保障
ZGC采用并发转移机制,需确保对象从旧Region迁移到新Region的过程中,应用线程能正确访问对象。转移表(Forwarding Table)记录旧地址与新地址的映射关系,避免转移过程中因地址变更导致数据不一致或访问错误,维持并发操作的原子性。
2 支持染色指针与读屏障协作
ZGC通过染色指针(Colored Pointers)的标记位(如Remapped状态)标识对象是否已转移。转移表作为元数据基础,与读屏障(Read Barrier)配合,在应用线程访问对象时实时修正引用指向新地址,实现无STW的并发转移。
3 优化大堆内存管理效率
对于TB级堆内存,ZGC需动态筛选高回收价值的分区(如低存活率Region)。转移表记录待转移分区的元数据(如对象分布、目标Region地址),避免全堆扫描,提升转移阶段的执行效率。
什么是 初始化 待转移集合的转移表?
ZGC通过转移表初始化阶段提前构建并发转移所需的核心元数据,结合染色指针和读屏障技术,实现了大内存场景下的低延迟与高吞吐,主要工作如下
1 分区筛选与元数据构建
存活对象统计:基于并发标记阶段的染色指针标记结果,筛选存活率低的Region作为待转移集合 。
转移表初始化:为每个待转移分区创建映射条目,记录源Region的地址范围、目标Region的分配位置及对象引用关系 。
2. 内存分配与状态预置
目标Region预分配:根据待转移对象的总大小,提前从空闲Region池分配目标分区,避免转移过程中因内存不足触发额外GC。
染色指针状态更新:将待转移分区的指针标记为“转移中”状态,后续通过读屏障检测并触发实时修正。
3. 并发转移准备
线程任务划分:将待转移分区的对象按线程数拆分为多个任务块,确保并发转移的负载均衡。
屏障指令注入:在读屏障中插入转移表查询逻辑,使应用线程访问对象时自动触发地址修正或协助转移操作。
这一步就是初始化待回收的分区的 forwardingTable。
ZGC转移阶段:初始转移
为什么需要 初始转移阶段?
1 并发转移的元数据准备
ZGC的并发转移机制需预先构建转移表(Forwarding Table),记录旧地址与新地址的映射关系。初始转移阶段负责初始化这些元数据,确保后续并发转移时应用线程能通过读屏障(Read Barrier)快速修正引用,避免因地址变更导致数据不一致或访问错误。
2 低存活率分区的筛选与资源预分配
分区筛选:初始转移阶段基于并发标记结果,筛选存活率低的分区(如无存活对象的小型Region)作为待转移集合,减少转移负载并提升内存利用率。
目标Region预分配:提前从空闲池分配目标Region,避免转移过程中因内存不足触发额外GC停顿,保障大堆场景下的高效内存管理。
3 染色指针状态同步
初始阶段将待转移分区的指针标记为“转移中”状态,后续通过染色指针的Marked/Remapped标识与读屏障协同,确保应用线程在访问对象时自动触发地址修正,维持并发操作的原子性。
4 任务划分与负载均衡
初始阶段将待转移对象按线程数拆分为多个任务块,确保并发转移阶段的负载均衡,降低多线程竞争带来的性能损耗。
什么是 初始转移阶段?
这个阶段其实就是从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间。
如果不在转移分区集合中,则将对象标记为 Remapped。
注意这个阶段是 STW,只转移根直接可达的对象。
ZGC转移阶段:并发转移
为什么需要 并发转移
1 维持低延迟目标
ZGC的核心设计目标是将最大停顿时间控制在亚毫秒级(JDK21+ <1ms)。通过并发转移阶段,ZGC在移动存活对象时无需暂停应用线程,避免传统GC因全堆STW(Stop-The-World)导致的长延迟。
2 支持TB级大堆管理
ZGC需处理16TB级别堆内存25,若采用同步转移(如G1的STW压缩阶段),对象迁移耗时会随堆容量线性增长。并发转移通过多线程协作与分区粒度控制,确保转移效率与堆大小无关45。
3 保障内存访问一致性
并发转移阶段依赖染色指针(Colored Pointers)和读屏障(Read Barrier):
染色指针的Remapped标记位标识对象是否已完成迁移;
读屏障在应用线程访问对象时实时检测指针状态,若发现未完成转移的旧地址,自动触发地址修正或协助迁移操作。
该机制确保即使转移未完成,应用线程仍能正确访问对象,避免数据错乱。
4 减少内存碎片
并发转移通过动态筛选低存活率Region,将存活对象紧凑迁移至新Region,整合碎片化内存空间,提升后续分配效率。
什么是 并发转移?
这个阶段和并发标记阶段就很类似了,从上一步转移的对象开始遍历,做并发转移。
这一步很关键。
G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多。
至此十个步骤就完毕了,一次 GC 结束。
主要工作如下:
1 对象迁移与地址映射
根据转移表(Forwarding Table),将存活对象从旧Region复制到预分配的新Region,并更新对象引用关系;
染色指针的Marked0/Marked1状态标识对象迁移进度,确保并发标记与转移阶段的协同47。
2 读屏障驱动的并发协作
应用线程访问对象时,读屏障检测指针的Remapped标记,若对象未迁移则触发以下操作:
- 查询转移表获取新地址;
- 协助完成对象复制并更新引用14;
该机制将部分转移负载分摊到应用线程,降低GC线程压力。
3 分区状态同步
迁移完成后,旧Region被标记为“可回收”,加入空闲Region池供后续分配;
调用系统接口(如madvise
)释放长期未使用的物理内存,降低资源占用。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
ZGC 染色指针在标记阶段的使用分析
ZGC通过染色指针的Marked0
、Marked1
和Remapped
三位状态,在标记阶段实现:
- 初始标记:STW阶段快速定位根对象并切换视图;
- 并发标记:读屏障动态修正漏标错标,CAS操作保证线程安全;
- 状态隔离:交替标记位与视图切换隔离周期,支持大规模堆的高效回收。这一设计使ZGC在TB级堆内存下仍能保持亚毫秒级停顿,成为低延迟GC的标杆。
三色标记对应染色指针状态:
状态 | 染色指针表现 | 语义 |
---|---|---|
白色(未标记) | Remapped 视图且M0/M1 未置位 |
未被标记或不可达对象 |
灰色(待处理) | M0/M1 视图且未完成子引用处理 |
活跃对象,需递归处理其引用链 |
黑色(已处理) | M0/1 视图且完成子引用处理,不在灰色队列 |
活跃对象,引用链已完全扫描 |
ZGC通过染色指针的Marked0
、Marked1
和Remapped
三位实现高效并发标记,其状态切换逻辑与标记阶段深度耦合。
以下是各阶段详细分析:
初始标记阶段(STW阶段)
- 初始标记前,内存地址视图为
Remapped
(表示对象未参与本次GC周期)。 - 进入初始标记时,视图切换为
Marked0
或Marked1
(交替使用,上面以Marked0
为例)。 - 根对象标记:从GC Roots出发,将直接引用的对象指针的
Marked0
位设为有效,标识为灰色(活跃对象待处理)。比如上图0,2,注意黑色对象是,不再灰色队列中的M0置位对象 - 不活跃对象对象指针Remapped位置位有效,比如上图对象1
- 新分配的对象在此阶段直接使用
Marked0
视图,避免后续重复标记。比如对象3 - 此阶段需短暂STW(<1ms),仅处理根对象引用链,避免并发干扰。
并发标记阶段
注意:
- 灰色对象:M0=1,同时在灰色队列中存在的对象
- 黑色对象:M0=1,同时在灰色队列中不存在的对象
1 灰色对象递归处理
GC线程并发遍历Marked0
视图下的灰色对象,递归标记其子引用对象(同样置位Marked0
),并将当前对象转为黑色(完成标记)。
状态更新通过CAS原子操作修改指针的Marked0
位实现,确保线程安全。
2 读屏障的错标修正
用户线程访问对象时触发读屏障,若发现指针为Remapped
视图(非活跃状态),则触发标记逻辑,将其转换为Marked0
视图并加入待处理队列。
屏障通过检查指针的Marked0
和Remapped
位动态维护标记状态,防止漏标。
举个例子
初始状态:对象A已标记为黑色(Marked1
置位),对象B未标记(白色)。
用户线程操作:执行A.field = B
(黑色对象A引用白色对象B)。
读屏障动作:
- 访问
A.field
时发现B为白色,触发修正。 - 置位B的
Marked1
位(转为灰色),并加入灰色队列。 - 后续标记流程确保B及其引用链存活。
3 新对象处理
并发标记期间新分配的对象直接使用当前活跃视图(如Marked0
),其指针的Marked0
位自动置位,避免重复扫描。
为什么有M0和M1两个视图
这里采用两个视图是为了区分前一次标记和这一次标记。
如果这次标记的视图是 Marked0,那下一次并发标记就会把视图切换到 Marked1。
这样做可以配合 ZGC 按照页回收垃圾的做法。如下图:
第二次标记的时候,如果还是切换到 Marked0,那么 2 这个对象区分不出是活跃的还是上次标记过的。如果第二次标记切换到 Marked1,就可以区分出了。
这时 Marked0 这个视图的对象就是上次标记过程被标记过活跃,转移的时候没有被转移,但这次标记没有被标记为活跃的对象。Marked1 视图的对象是这次标记被标记为活跃的对象。
Remapped 视图的对象是上次垃圾回收发生转移或者是被 Java 应用线程访问过,本次垃圾回收中被标记为不活跃的对象。
Remapped视图的作用:
- 跨周期标识:未参与当前GC的对象保持
Remapped
视图,表示未被标记或已转移完成。 - 转移阶段衔接:标记完成后,活跃对象进入转移阶段,转移后的对象指针切换回
Remapped
视图。
ZGC 染色指针在转移阶段的使用分析
ZGC通过染色指针的Remapped位和Marked0/Marked1交替视图,实现以下特性:
1 初始转移:
短暂STW处理根对象,更新转发表与指针状态。
2 并发转移:
用户线程与GC线程协作,动态修正未转移对象的指针。
3 转移后清理:
视图切换隔离周期状态,再标记修正漏标对象。该机制在TB级堆内存下实现了亚毫秒级停顿,同时确保转移过程的高效与安全。
第一次GC
第二次GC
初始转移阶段(Initial Relocation)
操作目标
- 快速完成根集合直接引用对象的转移,减少首次停顿时间。
- 通过染色指针的Remapped位标识对象是否完成转移(0表示未转移,1表示已转移)。
染色指针流程
1 根对象处理:
GC线程在短暂STW(Stop-The-World)中扫描根集合,将直接引用的对象标记为Remapped=0(需转移),并记录旧地址到新地址的映射到转发表(Forward Table)。
2 指针状态更新:
转移完成后,对象指针的Remapped位从0置为1,表示已转移至新地址,视图切换为活跃状态。
3 示例:
对象A旧地址为0x1000,转移后新地址为0x2000,转发表记录0x1000→0x2000
,指针颜色更新为Remapped=1。
并发转移阶段(Concurrent Relocation)
操作目标
- 用户线程与GC线程并发执行转移,处理非根集合对象,避免全局停顿。
- 利用染色指针的Marked0/Marked1位辅助判断对象是否需要转移。
染色指针流程
1 用户线程触发转移:
用户线程访问对象时,若指针的Remapped=0(未转移),触发读屏障:
- 通过转发表查询新地址,原子更新指针至新位置。
- 更新染色指针的Remapped=1,并清除转发表中的旧记录。
2 GC线程协作:
GC线程并发扫描堆内存,批量转移未被用户线程处理的对象,通过染色指针的视图状态(Remapped或Marked位)过滤已处理对象46。
3 示例:
对象B的旧地址0x2000未被转移,用户线程访问时触发屏障,查询转发表得到新地址0x3000,更新指针并标记Remapped=1。
转移后阶段(Post-Relocation)
1 漏标对象处理
- 再标记(Remark):在下一轮GC中,通过染色指针的Marked0/Marked1交替视图,识别并发转移期间新增的引用(如黑色对象引用白色对象),强制修正漏标问题。
- 视图切换:下一GC周期切换活跃标记位(如Marked1→Marked0),确保历史转移状态与当前周期隔离。
2 指针状态清理
- 转发表回收:转移完成后,清除已处理对象的转发表记录,释放内存。
- 染色指针重置:Remapped位保持为1,Marked位根据新周期重置(如从Marked1切换为Marked0)。
染色指针的标记位总结
来分析下几个标记位,M0、M1、Remapped。
先来介绍个名词,地址视图:指的就是此时地址指针的标记位。
比如标记位现在是 M0,那么此时的视图就是 M0 视图。
在垃圾回收开始前, 视图是 Remapped 。
在进入标记标记时。
标记线程访问发现对象地址视图是 Remapped 这时候将指针标记为 M0,即将地址视图置为 M0,表示活跃对象。
如果扫描到对象地址视图是 M0 则说明这个对象是标记开始之后新分配的或者已经标记过的对象,所以无需处理。
应用线程 如果创建新对象,则将其地址视图置为 M0,如果访问的对象地址视图是 Remapped 则将其置为 M0,并且递归标记其引用的对象。
如果访问到的是 M0 ,则无需操作。
标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0。
并发转移阶段,地址视图被置为 Remapped 。
也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped 。
如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理。
应用线程此时创建新对象,地址视图会设为 Remapped 。
此时访问对象如果对象在活跃表中,且地址视图为 Remapped 说明转移过了,不做处理。
如果地址视图为 M0,则说明还未转移,则需要转移,并将其地址视图置为 Remapped 。
如果访问到的对象不在活跃表中,则不做处理。
那 M1 什么用?
M1 是在下一次 GC 时候用的,下一次的 GC 就用 M1来标记,不用 M0。
再下一次再换过来。
简单的说就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,但是没有被转移,在本次回收中也没有被标记活跃的对象。
其实从上面的分析以及得知,如果没有被转移就会停留在 M0 这个地址视图。
而下一次 GC 如果还是用 M0 来标识那混淆了这两种对象。
所以搞了个 M1。
至此染色指针这几个标志位应该就很清晰了,我在用图来示意一下。
ZGC 解决了什么问题?
ZGC通过全并发回收、染色指针、动态Region等创新技术,解决了传统GC在大堆内存场景下的高延迟、内存碎片化、Full GC稳定性风险以及内存与吞吐量难以兼得的痛点,成为实时系统、大数据处理等场景的首选垃圾收集器。
主要解决了以下关键问题:
- 超大堆内存下的高延迟问题
- 内存碎片化导致的性能劣化
- Full GC引发的服务稳定性风险
- 传统GC内存利用率与吞吐量的矛盾
ZGC尤其适合以下场景:
- 金融交易、实时监控等毫秒级响应要求的系统
- 大数据分析、云原生应用等TB级堆内存场景
- 7x24小时运行且需长期稳定性保障的服务
ZGC调优
ZGC调优需结合监控工具定位瓶颈,针对性调整参数(如分配容忍度、GC间隔),并通过场景化策略(低延迟/高吞吐/大内存)平衡性能。生产环境中推荐固定堆大小、启用大页与NUMA优化,辅以动态Region管理,可稳定支持TB级堆与亚毫秒级延迟。
ZGC 调优工具与监控
实时监控工具:
- JFR(JDK Flight Recorder):监控GC事件、内存分配速率、停顿时间等核心指标;
- Prometheus+Grafana:通过JMX Exporter采集ZGC指标(如
jvm_gc_pause_seconds
、jvm_memory_used_bytes
),实现可视化监控; - ZGC日志分析:启用
-Xlog:gc*
日志,结合GCViewer或GCEasy工具解析停顿时间分布与内存回收效率。
关键监控指标:
- 停顿时间(Pause Time):关注
MaxPauseMillis
是否超过目标阈值(如<1ms); - 内存利用率:通过
ZHeapUsed
与ZHeapCommitted
评估堆压力; - 分配速率(Allocation Rate):高分配速率需触发更频繁GC,需结合
ZAllocationSpikeTolerance
优化。
ZGC 关键调优动作和参数调整
核心参数调整:
参数 | 作用 | 典型配置 | 适用场景 |
---|---|---|---|
-XX:ZAllocationSpikeTolerance |
控制内存分配峰值的容忍度(默认5) | 2~5(降低容忍度以更快触发GC) | 高分配速率场景 |
-XX:ZCollectionInterval |
设置GC触发间隔(秒) | 60(默认)或根据流量调整 | 周期性流量波动场景 |
-XX:ZProactive |
禁用主动GC(-XX:-ZProactive ) |
关闭以减少无效GC | 内存压力稳定场景 |
-XX:ConcGCThreads |
并发GC线程数(建议≤CPU核心数的1/4) | 4~8(避免CPU竞争) | 多核服务器环境 |
堆内存配置:
- 固定堆大小:通过
-Xmx
与-Xms
设为相同值(如-Xmx16g
),避免动态扩容引发性能抖动; - 大页支持:启用
-XX:+UseLargePages
减少TLB未命中,提升内存访问效率。
ZGC 场景化调优建议
高吞吐量场景(如批量处理):
- 优化目标:降低GC频率,提升吞吐量;
- 策略:增大
ZAllocationSpikeTolerance
(如8),延长ZCollectionInterval
(如120秒)。
低延迟场景(如实时交易):
- 优化目标:保障亚毫秒级停顿;
- 策略:降低
ZAllocationSpikeTolerance
(如2),启用NUMA亲和性(-XX:+UseNUMA
)。
大内存场景(堆≥4TB):
- 优化目标:避免内存碎片与转移失败;
- 策略:启用动态Region(
-XX:ZUncommitDelay=300
),限制最大Region数量(-XX:ZMaxHeapSize
)。
混合负载场景:
- 优化目标:平衡吞吐与延迟;
- 策略:启用自适应模式(
-XX:+ZUncommit
),动态回收空闲内存。
ZGC 生产场景参考参数配置
bashCopy Code// 基础配置
-XX:+UseZGC
-Xmx16g -Xms16g
-XX:+UseLargePages //调优参数
-XX:ZAllocationSpikeTolerance=3
-XX:ZCollectionInterval=60
-XX:ConcGCThreads=4
-XX:ParallelGCThreads=8 // 高级优化
-XX:+UseNUMA
-XX:-ZProactive
-XX:ZUncommitDelay=300
典型生产表现:
- 延迟:平均停顿<200μs,最大停顿<1ms;
- 内存占用:常态内存利用率70%~80%,峰值容忍度内无OOM;
- 吞吐量:与G1相当,CPU利用率降低20%~30%。
遇到问题,找老架构师取经
借助此文,尼恩给解密了一个高薪的 秘诀,大家可以 放手一试。保证 屡试不爽,涨薪 100%-200%。
后面,尼恩java面试宝典回录成视频, 给大家打造一套进大厂的塔尖视频。
通过这个问题的深度回答,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,里边有大量的大厂真题、面试难题、架构难题。
很多小伙伴刷完后, 吊打面试官, 大厂横着走。
在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
另外,如果没有面试机会,可以找尼恩来改简历、做帮扶。
遇到职业难题,找老架构取经, 可以省去太多的折腾,省去太多的弯路。
尼恩指导了大量的小伙伴上岸,前段时间,刚指导一个40岁+被裁小伙伴,拿到了一个年薪100W的offer。
狠狠卷,实现 “offer自由” 很容易的, 前段时间一个武汉的跟着尼恩卷了2年的小伙伴, 在极度严寒/痛苦被裁的环境下, offer拿到手软, 实现真正的 “offer自由” 。