Java GC 全系列一小时速通教程

GC一小时速通教程

JVM GC 之 "垃圾" 是什么?从用户注册业务说起

在讲解 JVM 中的 GC(垃圾回收)之前,我们首先要搞清楚一个核心问题:什么是 "垃圾"? 为了更直观地理解,我们先从一个常见的业务场景 ------ 用户注册业务入手,逐步剖析业务执行过程中 JVM 内存的变化,进而引出 "垃圾" 的定义。

一、用户注册业务流程梳理

用户注册是我们日常开发中非常基础的业务,完整的业务流程大致如下:

  1. 用户操作层:用户在前端页面输入注册信息,包括用户名、真实姓名、昵称、密码等,然后点击 "注册" 按钮。
  2. 请求传输层:前端将用户输入的注册信息封装成请求,发送到后端的 Controller 层。
  3. 业务逻辑层(Service 层) :Controller 层接收请求后,调用对应的 Service 层方法处理业务逻辑。在 Service 层中,会对用户信息进行校验、组装等操作(这一步是我们关注的重点,涉及对象创建和内存分配)。
  4. 数据访问层(DAO 层) :Service 层处理完业务逻辑后,调用 DAO 层方法,将用户信息最终持久化到数据库中。
  5. 结果返回层:数据库存储成功后,通过 DAO 层、Service 层、Controller 层逐层返回结果给前端,告知用户注册成功。后续用户就可以使用数据库中存储的用户名和密码进行登录。

二、Service 层核心操作:对象创建与内存分配

为了简化讲解,我们假设前端传递到 Service 层的是零散的用户信息(而非完整的 User 对象),因此在 Service 层需要手动组装这些信息,创建一个 User 对象。这一步操作直接关联到 JVM 的内存分配,也是理解 "垃圾" 的关键。

\1. 代码示例:Service 层创建 User 对象

首先定义一个简单的 User 实体类,用于封装用户信息:

arduino 复制代码
// User实体类
public class User {
    // 成员变量:对应用户注册信息
    private String username;   // 用户名
    private String realName;   // 真实姓名
    private String nickname;   // 昵称
    private String password;   // 密码
​
    // 无参构造器
    public User() {}
​
    // 有参构造器(用于组装用户信息)
    public User(String username, String realName, String nickname, String password) {
        this.username = username;
        this.realName = realName;
        this.nickname = nickname;
        this.password = password;
    }
​
    // Getter和Setter方法(省略,用于访问和修改成员变量)
    public String getUsername() {
        return username;
    }
​
    public void setUsername(String username) {
        this.username = username;
    }
​
    // 其他成员变量的Getter/Setter方法省略...
}

然后在 Service 层中,通过new关键字创建 User 对象,组装用户信息:

typescript 复制代码
// 用户注册Service层实现类
@Service
public class UserRegisterService {
​
    // 注入DAO层对象(用于后续操作数据库)
    @Autowired
    private UserDao userDao;
​
    // 用户注册核心方法
    public boolean register(String username, String realName, String nickname, String password) {
        // 1. 业务校验(省略,如校验用户名是否已存在、密码格式是否合法等)
        // 2. 组装用户信息:创建User对象(关键操作:new User())
        User user = new User(username, realName, nickname, password);
​
        // 3. 调用DAO层方法,将User对象存入数据库
        boolean saveResult = userDao.save(user);
​
        // 4. 返回注册结果
        return saveResult;
    }
}

三、JVM 内存视角:对象存放在哪里?

当执行User user = new User(...)这行代码时,JVM 会在不同的内存区域分配空间,这涉及到 JVM 内存模型中的栈内存堆内存

  1. 栈内存(Stack):存放引用变量
  • 变量user的存储 :代码中的user是一个引用变量(并非真正的 User 对象),它会被存储在栈内存中。
  • 引用的本质:栈内存中的user变量存放的是一个 "内存地址",这个地址指向堆内存中真正的 User 对象。
  1. 堆内存(Heap):存放对象实例
  • User 对象的存储 :通过new User(...)创建的 User 对象(实例),会被存储在堆内存中。
  • 堆内存的归属:堆内存是 "线程共享" 的,所有线程创建的对象实例都统一存放在堆内存中。
  • 对象的内容:堆内存中的 User 对象会存储其成员变量(username、realName等)的值,以及对象的元数据信息(如对象类型、类的引用等)。

四、从业务结束到 "垃圾" 产生:引用的生命周期

当用户注册业务执行完成后(即register()方法执行结束),JVM 内存会发生什么变化?这是判断 "垃圾" 的关键。

  1. 方法执行结束:栈帧出栈,引用消失

当register()方法执行完成(即 DAO 层将 User 对象存入数据库后),该方法对应的栈帧会从线程栈中 "出栈"。此时,栈帧中的引用变量user会被销毁 ------ 也就是说,栈内存中不再有指向堆内存中 User 对象(地址 0x0012FF3A)的引用。

  1. 堆内存中的对象:失去引用后成为 "垃圾"

堆内存中的 User 对象(地址 0x0012FF3A)原本是通过user引用被访问的。当user引用被销毁后,这个 User 对象就再也无法被程序中的任何部分访问到了(因为没有任何引用指向它)。

此时,这个无法被程序访问的对象,就是 JVM GC 中所说的 "垃圾"。

核心结论:"垃圾" 的定义

在 JVM 中,"垃圾" 指的是堆内存中那些失去所有引用(或没有任何引用指向它)的对象实例------ 这些对象无法再被程序使用,继续占用堆内存会造成内存浪费,因此需要通过 GC 将它们回收,释放内存空间。

五、扩展思考:哪些情况会导致对象成为 "垃圾"?

除了上述 "方法执行结束,引用出栈" 的情况,还有其他场景会导致对象成为 "垃圾",例如:

引用变量被显式赋值为 null:如user = null;,此时user不再指向堆内存中的 User 对象,该对象成为垃圾。

引用变量被重新赋值 :如User user = new User(...); user = new User(...);,第一个 User 对象的引用被覆盖,成为垃圾。集合中的对象被移除:如List list = new ArrayList<>(); list.add(new User(...)); list.remove(0);,被移除的 User 对象若没有其他引用,成为垃圾。

JVM GC 之如何识别垃圾:从活跃线程到 GC Roots

上一部分我们理解了 "垃圾" 是堆内存中失去所有引用的对象实例。但堆内存是所有线程共享的区域,里面可能存在成千上万的对象,如何准确区分 "存活对象" 和 "垃圾对象" 呢?这就需要从线程的运行机制和 GC Roots 说起。

一、线程与堆内存的关系:共享与隔离

当我们的项目部署到 Tomcat 等服务器运行时,会涉及到多个线程与堆内存的交互:

  1. 服务器线程池的作用

    • Tomcat 启动后会维护一个线程池,当用户请求(如注册、登录、操作等)到来时,会从线程池中分配一个线程处理该请求
    • 每个请求对应一个独立的线程,这些正在处理任务的线程称为 "活跃线程"
  2. 线程与堆内存的交互

    • 所有线程共享堆内存,不同线程创建的对象都存放在堆中
    • 每个线程有自己独立的栈内存(线程私有),用于存储当前执行方法的局部变量(包括对象引用)

二、栈帧与对象引用:活跃线程中的关键线索

每个活跃线程的栈内存中,会为正在执行的方法创建对应的 "栈帧":

  1. 栈帧的作用

    • 存储方法的局部变量(包括基本类型变量和对象引用)
    • 记录方法的执行状态(如返回地址、操作数栈等)
  2. 以用户注册业务为例

    • register() 方法执行时,线程会创建对应的栈帧
    • 栈帧中存储 user 引用变量,指向堆内存中的 User 对象
    • 方法执行过程中,这个引用始终有效(活跃状态)
    java 复制代码
    // 栈帧中存储的引用关系
    public boolean register(...) {
        // 栈帧中创建user引用,指向堆内存的User对象
        User user = new User(...);  // 引用有效(活跃状态)
        // ...业务逻辑...
        return saveResult;
    }
    // 方法执行结束,栈帧出栈,user引用消失
  3. 多线程场景下的栈帧状态

    • 系统运行时存在多个活跃线程
    • 每个线程有多个嵌套执行的方法,形成栈帧链
    • 这些栈帧中存储的对象引用,是判断对象是否存活的关键线索

三、GC Roots:垃圾回收的 "起点集合"

JVM 如何确定哪些对象是存活的?核心就是通过 GC Roots(垃圾回收根集):

  1. GC Roots 的定义

    • 是一组 "活跃的引用" 的集合
    • 主要包括:活跃线程栈帧中的局部变量、静态变量、常量引用、JNI 引用等
    • 在我们的业务场景中,主要关注活跃线程栈帧中的局部变量引用
  2. GC Roots 的收集过程

    • JVM 扫描所有活跃线程的栈内存
    • 收集所有栈帧中正在使用的对象引用
    • 这些引用共同构成 GC Roots集合

四、标记 - 清除:垃圾回收的基本流程

有了 GC Roots 后,JVM 就能通过 "标记 - 清除" 过程识别并回收垃圾:

  1. 标记阶段

    • 从 GC Roots 集合中的引用出发,遍历所有可达的对象(可达性分析算法)
    • 被遍历到的对象会被标记为 "存活对象"(在堆内存中设置标记位)
    • 未被标记的对象就是 "垃圾对象"
  2. 清除阶段

    • 回收所有未被标记的垃圾对象
    • 释放其占用的堆内存空间
  3. 标记的周期性

    • 每次 GC 执行时,都会重新进行标记(上一次的标记会被清零)
    • 保证标记结果反映当前对象的存活状态

五、GC Roots 的通用性

虽然不同垃圾回收器(如 SerialGC、ParallelGC、G1、ZGC 等)的实现细节不同:

  • 传统回收器在堆内存中标记对象
  • 新一代回收器(如 ZGC)采用染色指针技术,不在堆内存中标记

GC Roots 作为垃圾回收的起点这一核心机制,贯穿了所有垃圾回收器的设计。它是 JVM 判断对象存活状态的根本依据,也是理解垃圾回收原理的关键概念。

下一部分我们将深入讲解不同的垃圾器,回收算法及其实现原理。

垃圾回收算法:基于分代的处理策略(CMS为例)

一、堆内存中垃圾的分类与分代处理

在垃圾清理的时候,每次都要扫描整个堆内存,是非常耗时的,所以,每一代都会对堆内存进行分代处理。

在堆内存中,垃圾主要分为两类:

  1. 短期存活的垃圾

    • 例如用户注册流程,当注册完成后,线程中的相关信息就不再需要,这些对象很快就会变成垃圾。
  2. 长期存活的垃圾

    • 比如用户登录后,将用户对象信息存放在 Session 中,这些对象在很长一段时间内都不会被回收。

即:

  • 年轻代:存放那些很快就会被回收的对象,例如在用户注册等操作完成后就没用的对象。
  • 老年代:存放长期存活的对象,比如存储在 Session 中的用户对象信息。

二、年轻代的特点与标记复制算法

(一)年轻代的特点

年轻代的显著特点是每次回收时需要处理的对象数量非常多。

(二)标记复制算法

  1. 垃圾回收的主要操作

    • 垃圾回收主要有两个关键操作:标记和回收。标记是指找出存活的对象,回收则是处理那些未被标记的对象。
  2. 标记复制算法的原理

    • 年轻代采用标记复制算法。可以把年轻代想象成有两个 "箱子"。每次创建对象时,都将对象放入其中一个 "箱子"。当进行垃圾回收时,把存活的对象复制到另一个空 "箱子" 中,并排列整齐。这样做的好处是不需要考虑碎片清理的问题。

1.未标记

2.标记存活对象

3.先复制对象到S1

4.清空

5.第二次标记

6.第二次复制

7,清空Eden和S1

8.下一次复制后清空Eden和S2,周而复始。连续存活到一定次数的对象会被移动到老年代。

三、老年代的特点与标记清理、标记整理算法

(一)老年代的特点

老年代中的对象大部分是长期存活的,每次需要回收的对象相对较少。

(二)标记清理算法

  1. 标记清理算法的原理

    • 标记清理算法适用于老年代。它的操作比较直接,首先标记出那些不再存活的对象,然后直接将这些未被标记的(即无用的)对象清理掉。
  2. 碎片问题

    • 在堆内存中,可以将内存空间想象成 Excel 中的小格子。每个对象会占用若干个小格子。当对象被清理后,会留下一些空格子,这些空格子就是碎片。例如,原来内存中的对象是紧密连续排列的,经过一次清理后,出现了一些零散的空格子。当有新对象需要分配内存时,如果新对象所需的内存空间大于当前零散的空格子,就会出现无法分配的情况,这就需要进行内存整理。

标记(绿色存活)

清理,但清理后会留下小面积的空格,无法存入大一点的对象,这些小空格就叫做碎片

(三)标记整理算法

  1. 标记整理算法的原理

    • 当老年代中碎片过多或者没有足够的连续空间来存放新对象时,就需要使用标记整理算法。该算法首先标记出存活的对象,然后将这些存活对象移动到一起,使它们在内存中排列得更加紧凑,从而腾出连续的空间。

标记

整理,将所有对象向左上角对齐排列,之前的垃圾位置会被直接覆盖

清理剩下的内存空间

JVM 垃圾回收器

什么是 STW(Stop The World)?

STW 全称 "Stop The World",即全局停顿。垃圾回收器有专属的 GC 线程,日常业务由工作线程(或用户线程)处理,理想状态下两者各干各的。但垃圾回收时,若工作线程继续往堆内存写入新实例,会干扰回收(比如 GC 刚标记垃圾,工作线程又给垃圾加引用),所以早期 GC 会先停掉所有工作线程,只让 GC 线程单独完成回收,回收完再恢复工作线程,这个过程就是 STW。

早期小型项目中,STW 影响不大;但现在互联网行业发展到顶峰,高并发场景下,STW 时间需越短越好,最好能实现 "回收与业务并行",让工作不受回收影响。

一、先看俩早期 GC:Serial 和 Parallel,实战里的 "卡脖子" 问题

这俩是 JVM 早期的默认 GC,特点鲜明,但在高并发场景下,痛点特别明显。

1. Serial GC(串行垃圾回收器)

  • 工作方式:全程就一个线程干垃圾回收的活儿。

  • 实战痛点:全程 STW,高并发直接崩

    比如电商大促,用户疯狂下单,突然触发 Serial GC 回收。这时候所有处理下单的线程全被暂停(Stop The World,STW),用户点 "提交订单" 没反应,页面卡个几百毫秒甚至几秒要是卡 1 秒,用户可能直接刷新或换平台,订单就丢了。

    这种 GC 现在只适合本地跑小 Demo,生产环境基本见不着了。

2. Parallel GC(并行垃圾回收器,JDK 1.8 默认)

  • 改进点:多线程一起回收,比 Serial 快。
  • 实战痛点:还是 STW,只是短了点,但高并发下仍致命

二,里程碑:并发垃圾回收器CMS

CMS(老年代垃圾回收器,年轻代还是使用早期的GC,因为年轻代使用标记复制算法效率极高,所以停顿几乎感知不到)(Concurrent Mark-Sweep,并发标记 - 清除)是 JVM 中首个以 "低停顿" 为目标的垃圾回收器,核心设计是让 "标记" 和 "清除" 阶段与工作线程并行.

cms执行流程图,源:zhuanlan.zhihu.com/p/34921587

流程:

  1. 初始标记:短暂 STW,仅标记 GC Roots 直接引用的老年代对象,建立标记起点。
  2. 并发标记:GC 与工作线程并行,从初始标记的对象出发,遍历并标记老年代所有存活对象。
  3. 并发预清理:并发处理 "写屏障" 标记的脏卡片,提前修正引用变更,减少下一阶段的 STW 时间。
  4. 重新标记:短暂 STW,最终修正漏标,确保所有存活对象都被正确标记。(主要处理脏卡片和并发标记中工作线程新增的但是未标记的对象)
  5. 并发清理:GC 与工作线程并行,清理未标记的垃圾对象,释放内存(会产生碎片)。(清理过程中新产生的对象会放在并发清理之前设定好的"非清理区域")
  6. 并发重置:并发重置内部状态(如标记位、卡片表),为下一轮回收做准备。
  7. 新生代联动:回收期间若年轻代满,暂停并发阶段优先执行 Minor GC,完成后再恢复 CMS。(解释一下MinorGC MajorGC FullGC)

并发标记过程中存在一个问题,GC标记和用户线程同时执行,如果被标记的对象引用发生改变怎么办?GC Roots中的A引用了B,B已经被标记为存活,但是线程执行的时候A被重新引用了C怎么办?

解决方案:脏卡片 。线程执行的时候,如果发生引用变更,Jvm会感知(学名叫做写屏障),会给变更后的C对象所在的区域特别标记(学名脏卡片),等到第3和第4阶段时,被脏卡片标记的对象会被重新标记为存活。注意,之前失去引用但依然被标记的B对象不会被清理,依然标记存活,这就是GC的核心原则之一:宁可多标不可漏标的原则

CMS确实可以大大缩短STW的时间,但是还存在以下问题

1.全堆扫描,大对象直接丢到老年代,标记清理算法导致内存碎片问题严重,易触发 "致命 Full GC"

2.无法主动控制停顿时间,延迟稳定性差

3.CPU 资源占用高,吞吐量损耗明显

CMS通常适合中小堆内存,比如8G左右的堆内存。

三.G1垃圾回收器

为了解决上面的问题,推出了新一代的垃圾回收器:G1

优势:

针对于cms全堆扫描以及采用的标记清理算法进行了升级:分成若干region的方式可以很方便的采用标记复制算法(可以参考cms中年轻代的处理方式,非常快,几乎无感),大大优化了碎片问题以及清理速度。

cms中的大对象存入老年代,每次标记都要扫描大对象,最主要的还是cms是一块连续的大内存区,多次回收后大量碎片导致无法存入大对象从而引发频发的Full GC.

G1中直接创建了大对象区,每个大对象区只存入一个大对象,扫描标记时如果发现已经死亡直接回收即可。

CMS 是全堆连续内存,多线程得 "瓜分整块内存" 来扫描,容易出现 "有的线程扫完没事干、有的线程还在啃大块" 的负载不均,效率受影响;

而 G1 把堆拆成了小 Region 分片,多线程能 "各管一片 Region",不用抢着扫同一块大内存,天然能均匀分担任务,还能针对性优先扫垃圾多的分片,线程利用效率比 CMS 高得多。

cms老年代清理需要全堆扫描,无法控制扫描时间,G1 能通过 -XX:MaxGCPauseMillis 设目标停顿时间,会挑垃圾多的 Region 优先回收,尽量贴合设定时间,这也是 "Garbage-First" 的意思。

G1 执行流程(含 SATB 与 RSet 作用时机)

  1. 初始标记(STW) :暂停用户线程,标记 GC Roots 直接可达的对象。此阶段会初始化部分 RSet 基础信息,为后续并发标记做准备。

  2. 并发标记

    :用户线程恢复运行,GC 线程并行标记所有可达对象。

    • 此阶段通过 SATB(初始快照) 配合写屏障,记录并发中引用的变化(如 A 从指向 B 改为指向 C 时,记录 B 到 SATB 旧地址列表;A 新增指向 C 时,直接标灰 C)。
    • 同时,RSet 会实时维护外部 Region 对本 Region 对象的引用(如其他 Region 对象引用本 Region 对象时,更新 RSet 记录 Region + Card 信息),支撑后续回收时的存活判断。
  3. 最终标记(STW) :暂停用户线程,处理并发标记中 SATB 日志记录的旧引用,将旧地址列表中的对象标黑;同时扫描标灰对象及其子引用,批量标黑,确保标记完整。

  4. 筛选回收(STW)

    :暂停用户线程,统计各 Region 存活对象占比,优先选择垃圾率高的 Region 回收。

    • 回收时,通过查询目标 Region 的 RSet,快速定位外部引用来源,判断本 Region 对象是否存活;将存活对象复制到新 Region,清理垃圾并压缩内存。

部分核心原理:G1中通过Rset集合,解决不同Region之间的引用关系。比如有三个Region区,R1,R2,R3,分别存放了三个对象,A,B,C。

R1的A对象被B对象和C对象引用。

  • 它就像一个 "便签本",上面写着:

    "R2 的 Card 0x0A 有人引用我"

    "R3 的 Card 0x05 有人引用我"

  • 这个便签本不是给你平时看的,是GC 回收时备用的

  1. 这表示:

    • R2 的 0x0A 卡页里有对象引用了 R1 里的对象;
    • R3 的 0x05 卡页里有对象引用了 R1 里的对象。
  2. 去这些 Card 里找引用对象

    • 到 R2 的 0x0A 卡页,找到里面的对象(比如 b1);
    • 到 R3 的 0x05 卡页,找到里面的对象(比如 c1)。
  3. 检查这些引用对象

    • 看 b1、c1 本身是不是存活的(可达的);
    • 如果它们存活,那它们引用的 R1 内的对象(比如 a)也必须标记为存活。
  4. 回收未被引用的对象

    • 遍历完 RSET 里的所有外部引用源,再加上 R1 内部引用链,就能确定哪些对象还活着;
    • 把存活对象复制到新 Region,清空 R1。

并发标签期间,如果发生类似CMS中的脏卡片处理,采用G1专用的SATB处理方式,即:

G1 并发标记:SATB 与写屏障的核心逻辑

G1 和 CMS 一样,并发标记时用户线程(工作线程)不会停,但为了避免漏标,G1 靠 SATB(Snapshot-At-The-Beginning,初始快照) 和写屏障配合,核心原则还是 "宁可多标活,不可漏标",具体分两种关键场景:

一、先简单说:三色标记法基础

为了清晰跟踪对象存活状态,GC 会给对象 "涂色":

  • 黑色:已扫描完,对象本身存活,且它引用的子对象也都标记过了;
  • 灰色:对象本身存活,但它引用的子对象还没扫描;
  • 白色:未被扫描,默认是 "待回收垃圾"(如果最后还是白色,就会被回收)。

二、场景 1:引用 "断旧连新"(A 原本→B,改成 A→C)

  1. 并发标记前:初始快照(SATB)记录下 "A→B" 这个引用,此时 A、B 还没被 GC 线程扫描;
  2. 并发标记中:用户线程先动手,把 A 的引用改成 "A→C"(断旧连新);
  3. 写屏障触发 :因为改了引用,写屏障会把 "B 的旧引用" 记录到 SATB 旧地址列表(相当于给 B 发 "保护令");
  4. 后续处理 :不管 B 之后有没有其他引用,GC 到了 重新标记阶段(STW 短停顿) ,会把旧地址列表里的 B 直接标为 黑色(多标活)------ 哪怕 B 其实已经没引用了,也先保它一次,避免漏标。

三、场景 2:已标黑对象 "新增引用"(A指向B 已黑,B 新→C)

  1. 并发标记中 :GC 已经扫描完 A 和 B,两者都标为 黑色(默认它们的子引用都处理完了);
  2. 用户线程操作:程序继续跑,B 突然新增一个引用 "B→C"(C 之前是白色,没被扫描);
  3. 写屏障触发 :因为 B 是已标黑的对象,新增引用会触发写屏障,把 C 直接标为 灰色(不让 C 一直是白色,避免被当垃圾);
  4. 后续处理:到了重新标记阶段,GC 会优先扫描灰色的 C,把 C 及其子引用都标为黑色(补全标记)------ 确保 C 不会因为 "新增引用没被扫描" 而漏标。

总结:SATB + 写屏障的核心作用

  • 场景 1 处理 "断旧引用":靠 SATB 旧地址列表保旧对象(B),避免误杀;
  • 场景 2 处理 "新增引用":靠写屏障把新对象(C)标灰,避免漏标;
  • 本质都是用 "多标活"(比如场景 1 的 B 可能多标)换 "无漏标",同时不用长时间停用户线程,平衡了停顿和安全性。

图例:G1直接将内存分成了一个一个的region(区),如下图,分别用不同的字母标出每个区域的用途。除了大对象区,每个region的大小是一样的,只是数量不同。

  • Eden 区:由若干个 Region 组成,数量动态变化(比如 50 个 → 100 个)。
  • 老年代区:由若干个 Region 组成,数量随对象晋升逐渐增加。
  • 大对象会占用连续多个 Region,比如一个 5MB 的对象会占用 3 个连续的 2MB Region(最后一个只用 1MB,剩下 1MB 浪费)

G1 是非常优秀的垃圾收集器,日常工作里,不管是单体中小型项目,还是负载均衡做得好的微服务,只要单机堆内存控制在 16G 左右,它的性能完全够用,压根不用换。但像美团这类企业,在 TB 级海量内存的大型服务器上,就优先用 ZGC 了.

因为 G1 有个坎:堆内存一旦超 64G,问题就全来了。首先,G1 的 Region 是初始化时就固定大小的,堆越大,划分出的 Region 数量越多,管理这些 Region 以及维护 RSet、处理 SATB 日志的开销会暴增。其次,G1 延续了 CMS 的思路,标记对象时要去内存里找对象,改对象头的标记位 ------ 单次寻址开销不大,但堆大了,寻址范围广,来回折腾的时间就扛不住了,吞吐量直接掉下来。

而 ZGC 正好解决了这些问题:

第一,它不用固定 Region,改成动态页面管理。就像按物品大小选箱子,小对象给 2MB 小页面,大对象给 32MB 甚至更大的页面,不用一次性把 1T 内存全分成小格子,管理负担自然轻。

第二,最关键的是染色指针技术。这里可以用 Redis 类比:原来项目读数据要直接查数据库,慢;加个 Redis 缓存常用数据,速度就快了。CPU 里的寄存器和缓存也一样,是内存的'临时 Redis',会存当前常用的指针。ZGC 直接在这些指针的高位加标记(比如用 00、01 表示对象状态),不用去内存里改对象头 ------ 就像给快递单号加前缀表示状态,不用拆快递看单子。尤其在 TB 级内存里,不用频繁往内存寻址、改对象头,速度和效率直接拉满。

所以总结下来:中小堆用 G1 性价比最高,超大堆场景,ZGC 的动态页面 + 染色指针就是降维打击。

什么是染色指针:

先说指针,我们在学习Java的入门课的时候,都会在创建对象的时候学到如下的知识点,比如:

ini 复制代码
User user = new Uesr();

其中前面的user叫做引用变量,后面的new User()叫做对象创建的表达式,可以简单理解为就是一个方法(函数),该方法(函数)会返回在内存中创建User类实例所在的内存地址。也就是说,引用变量user就是指针,它保存了User实例的内存地址。

而在64位的Linux系统中,使用64位中的低46位用来寻址,剩余的高18位是闲置的,ZGC利用了这个规则,从闲置的前18位中取出来4位用来标识该地址的对象实例的具体状态:

sql 复制代码
// 1. 栈上的user是指针,指向堆中User实例的内存地址(如0x000123456789ABCD)
User user = new User(); 
// 2. ZGC在指针高位嵌入"颜色标记",假设高4位为0001 表示"已标记存活"
//    此时实际使用的指针为:0001 + 000123456789ABCD → 0x500123456789ABCD
User anotherUser = user; // 引用传递时,指针高位的"颜色"也随之传递

G1 标记时要修改堆中User实例的mark word,而 ZGC 只需修改栈上user指针的高位 ------ 由于栈上的指针(尤其是频繁访问的引用)会被 CPU 缓存到寄存器或 L1/L2 缓存中,修改操作可在 CPU 内部完成,无需访问堆内存,速度比对象头修改快数百倍。

染色指针的核心 "颜色" 类型(状态标记)

ZGC 通过指针高位的二进制组合定义对象状态,当前主要包含三类核心标记(不同 JDK 版本可能微调位定义):

ZGC 64 位指针高 4 位元数据含义表

指针高 4 位标识 对应状态 状态含义
0000 Marked0 对象的三色标记状态之一
0001 Marked1 对象的三色标记状态之一
0010 Remapped 表示对象已进入重分配集,即被移动过
0011 Finalizable 表示对象只能通过 finalize () 方法才能被访问到

指针类型:明确 Java 中与染色指针相关的指针形态

Java 开发中虽不直接操作指针,但 ZGC 的染色指针设计需结合 JVM 层面的指针类型理解,核心分为两类:

栈指针(Stack Pointer)

  • 即方法栈中局部变量对应的引用(如User user),是 GC Roots 的核心组成部分。
  • 特点:频繁被访问,大概率被 CPU 缓存,ZGC 初始标记阶段仅需扫描栈指针,通过高位染色标记根对象,无需遍历堆。

堆内指针(Heap Pointer)

  • 堆中对象内部的引用(如User实例中private Address addr对应的指针)。
  • 特点:数量庞大,但 ZGC 通过 "读屏障" 实时拦截堆内指针的访问 ------ 若指针高位标记为 "已转移",读屏障会自动将指针重定向到对象的新地址(即 "引用自愈"),无需暂停用户线程。

新的问题来了,写屏障我们在了解cms,G1时已经非常清楚了,这个读屏障又是什么呢?

GC 线程和用户线程可能 "抢着" 访问对象 ------ 若用户线程先访问未标记的对象,而 GC 线程还没扫描到它,就可能被误判为垃圾回收。读屏障通过实时标记 "未标记" 的指针,确保所有被用户线程访问的对象都会被标记为存活,避免漏标。

scss 复制代码
// 读屏障伪代码(嵌入在引用读取操作中)
Address readBarrier(Address pointer) {
    // 1. 检查指针高位的颜色标记
    if (pointer.color == 未标记) {
        // 2. 将指针颜色改为"已标记(0000)",标记该对象为存活
        pointer.color = 已标记;
        // 3. 将该对象加入GC线程的"待扫描队列",后续遍历其引用链
        addToMarkQueue(pointer);
    }
    // 4. 返回原始指针,用户线程正常访问对象
    return pointer;
}

场景二:并发转移阶段 ------ 实现 "引用自愈"

当 ZGC 处于 "并发转移" 阶段(GC 线程正在将存活对象复制到新内存地址),用户线程可能访问 "已转移" 的对象(假设指针高位为0010)------ 此时旧地址已无效,新地址存储在 "转发表(Forwarding Table)" 中。读屏障会触发 "引用自愈" 逻辑:

scss 复制代码
// 读屏障伪代码(并发转移阶段)
Address readBarrier(Address oldPointer) {
    // 1. 检查指针高位的颜色标记
    if (oldPointer.color == 已转移(0010)) {
        // 2. 从转发表中查询旧地址对应的新地址
        Address newPointer = forwardingTable.get(oldPointer);
        // 3. 关键:将用户线程的旧指针"更新为新指针"(引用自愈)
        updateUserPointer(oldPointer, newPointer);
        // 4. 返回新地址,用户线程访问新对象
        return newPointer;
    }
    // 若未转移,直接返回旧指针
    return oldPointer;
}

好的,接下来看一下ZGC具体的执行流程:

ZGC执行流程图,源:zhuanlan.zhihu.com/p/585254683

ZGC 执行流程以 "极致低延迟" 为核心,仅 2 个极短 STW 阶段(均 <1ms),其余步骤全与用户线程并发执行,核心流程可简述为 7 步:

  1. 初始标记(STW) :快速标记 GC Roots 直接可达的对象,仅处理 "根→对象" 的第一层引用,耗时与堆大小无关;
  2. 并发标记:GC 线程与用户线程并行,通过染色指针遍历引用链,标记所有存活对象(仅修改指针颜色,不碰对象头);
  3. 再标记:并发处理标记遗漏(如用户线程新增的引用),极少场景需微秒级 STW;
  4. 并发转移准备:筛选垃圾率高的页面,创建转发表(记录对象转移后新地址),锁定待回收页面;
  5. 初始转移(STW) :快速转移 GC Roots 直接指向的对象,更新根引用指针,耗时极短;
  6. 并发转移:GC 线程与用户线程并行转移剩余存活对象,用户线程访问已转移对象时,读屏障自动重定向到新地址(引用自愈);
  7. 并发重映射:并发更新所有旧指针为新地址,删除转发表,释放旧页面供后续分配。

全程靠染色指针管理对象状态、读屏障处理并发冲突,实现 "超大堆下低延迟回收"。

相关推荐
却尘2 小时前
Vite 炸裂快,Webpack 稳如山,Turbopack 想两头要:谁才是下一个王?
前端·面试·vite
子夜master2 小时前
玩转EasyExcel,看这一篇就够了!!(合并导入 自定义导出 动态表头 合并单元格)
后端
武子康2 小时前
大数据-131 Flink CEP 实战 24 小时≥5 次交易 & 10 分钟未支付检测 案例附代码
大数据·后端·flink
Postkarte不想说话3 小时前
Cisco配置PIM-DM
后端
BingoGo3 小时前
PHP 8.5 新特性 闭包可以作为常量表达式了
后端·php
SimonKing3 小时前
Komari:一款专为开发者打造的轻量级服务“看守神器”
后端
间彧3 小时前
Spring Security如何解析JWT,并自行构造SecurityContex
后端
Tech_Lin3 小时前
前端工作实战:如何在vite中配置代理解决跨域问题
前端·后端
间彧3 小时前
在Spring Cloud Gateway中如何具体实现JWT验证和用户信息提取?
后端