• 欢迎访问本站,本站记录博主日常编程遇到的问题,知识,惊奇软件等。如有问题还请留言


    Deprecated: strip_tags(): Passing null to parameter #1 ($string) of type string is deprecated in /www/wwwroot/gschaos.club/wp-content/themes/Git-alpha-6h0SRk/header.php on line 294

jvm的垃圾回收是如何暂停所有线程的

java mysticalycc 2天前 32次浏览 已收录 0个评论
文章目录[隐藏]

jvm的垃圾回收是如何暂停所有线程的

1. 为什么需要暂停线程?

GC 需要对堆内存中的对象做可达性分析(reachability analysis),判断哪些对象是垃圾。如果应用线程(Mutator Threads)还在继续运行,就可能:

  • GC 标记过程中修改对象引用,导致结果不一致;
  • 在对象回收时还在访问已被标记回收的对象。

因此,GC 必须让所有应用线程在某个点暂停,保证堆结构的一致性


2. jvm 如何让线程暂停?

jvm 内部没有类似操作系统“强制杀死/挂起线程”的方法,而是通过 安全点机制(Safepoint) 来实现的。

🔹 Safepoint(安全点)

  • jvm 在运行字节码时,会在一些特定的字节码指令位置(例如方法调用、循环回跳、异常处理、返回等)插入检查逻辑。
  • jvm 发出 "进入 Safepoint" 的请求时,各个线程不会立即停下,而是 等执行到下一个 Safepoint 时才停下
  • 这样保证线程在安全点停下时,堆和栈中的对象引用关系是一致且可扫描的

也就是说,jvm 的暂停并不是“立刻断电式停机”,而是协作式挂起

关于安全点的知识:安全点


3. Safepoint 的触发与实现

当 GC 需要执行时:

  1. VMThread(专门的 jvm 内部线程) 发起 Safepoint 请求;
  2. jvm 在全局设置一个标志位,表示 “需要进入 Safepoint”
  3. 所有线程在执行字节码时,都会周期性检查这个标志位;
  4. 一旦发现标志位已被设置,线程就会:
    • 停止执行;
    • 进入一个安全状态,等待 VMThread 通知恢复。

🔹 技术实现(HotSpot)

  • 轮询点(Polling Page)
    jvm 会在 JIT 编译生成的机器码中插入轮询指令,例如访问一个特殊的内存页(Polling Page)。
    当 GC 开始时,jvm 把这页标记为不可访问(修改页表属性),于是线程下一次访问时会触发缺页异常,JVM 捕获后把线程挂起。
  • 解释执行模式
    在解释器中,HotSpot 直接在解释执行循环中插入 safepoint 检查逻辑。

4. Safepoint 的等待

  • 一旦 GC 触发,JVM 会等待所有线程都到达 Safepoint。
  • 如果有线程长时间执行不含 safepoint 检查的本地代码(如 C/C++ JNI 调用),可能导致 Safepoint 停顿时间过长,这就是所谓的 Safepoint Bias 问题。

5. 除了 Safepoint,还有 Safe Region

有时线程可能处于 不执行字节码、不在 jvm 控制下 的状态,例如:

  • Thread.sleep()
  • 等待 IO;
  • 阻塞在锁上。

此时线程不可能主动轮询 safepoint。
解决办法是 Safe Region:线程在进入这种区域时,会声明“自己在安全区域”,JVM 就不用等它。等它离开时会再次检查 GC 是否正在进行,如果是,就挂起等待。

6. 小结

JVM 垃圾回收暂停所有线程的机制是 协作式的,流程大致如下:

  1. GC 线程发出 Safepoint 请求;
  2. 所有运行中的线程在下一个 Safepoint 停下;
  3. 阻塞或睡眠的线程通过 Safe Region 机制直接被认为安全;
  4. 等所有线程都挂起后,GC 执行;
  5. 完成后,唤醒所有线程继续运行。

safepoint一般在耗时的地方检测像一些循环,方法调用等,每次读取缓存可能得到不是一致性的结果,这个如何解决

  • safepoint 依赖轮询(polling),线程会反复检查一个全局的标志(或内存页);
  • 但 CPU 有缓存层次结构(L1/L2/L3 + store buffer + MESI 协议),线程可能每次只是从自己的缓存读这个标志;
  • 如果 GC 线程在另一个 CPU 上修改了这个标志,那么 Mutator 线程怎么保证能“立刻”看到变化呢?

我们来分层解释:


1. Safepoint Polling 的本质

在 JIT 编译的机器码里,HotSpot 会生成一条类似的指令:

mov rax, [polling_page_address]

这条指令访问一个特殊地址(polling page),通常是一个只读内存页。
当 jvm 想触发 safepoint 时,会把这个页设为不可访问,于是下一次线程执行到这个指令时会触发 缺页异常 (page fault),由 JVM 捕获后进入 safepoint。

这有个好处:不依赖缓存可见性,而是依赖 MMU 的页表机制。


2. 那么缓存一致性问题怎么处理?

即便不用 page fault 的方式,单纯用“全局变量标志位”方案,也能成立,原因在于 CPU 的缓存一致性协议 (MESI) 和 JVM 的内存屏障:

  1. GC 线程设置 safepoint 标志时
    • HotSpot 会发出带有 lock 前缀的原子指令(例如 lock cmpxchg 或者内存屏障),
    • 这会触发 CPU 硬件协议(MESI),把对应的 cache line 从其他 CPU 的缓存里作废 (invalidate)
  2. Mutator 线程读取标志时
    • 如果 cache line 已失效,就会从内存重新加载,看到最新的值;
    • JVM 的轮询点读取一般是 volatile 语义,保证不会被编译器优化掉,并且读取时绕过寄存器缓存。

这样,即便不用 page fault,也能依赖硬件缓存一致性保证所有线程最终看到 safepoint 请求。


3. 为什么不会“每次都读内存”?

你担心的“很耗时”问题其实不会出现:

  • 正常情况下
    safepoint 标志位几乎总是 0(不需要停),这个 cache line 常驻 CPU 缓存,线程每次检查时成本接近 0。
  • 只有 GC 触发时
    GC 线程会修改标志,导致缓存失效,这时其他线程下一次读取就会 miss,从内存重新加载 → 发现需要进入 safepoint。
    (但这恰好是我们想要的!)
  • 因此 safepoint 检查的开销 ≈ 读一个 CPU 缓存中的变量,而不是频繁地强制访问主内存。

4. Polling Page 与 标志位 两种机制

实际上 HotSpot 用两种策略:

  • 早期/某些平台:使用“全局标志位 + volatile 读”,依赖 CPU 缓存一致性;
  • 现代 HotSpot JIT:使用 Polling Page 技术,通过触发缺页异常更快地“抢占”线程。

Polling Page 更加通用,不依赖 CPU 缓存协议细节,还能避免编译器优化掉轮询代码。


总结

  • Safepoint 检查不会每次都去主内存取值,正常时是缓存命中;
  • 当 GC 修改标志时,通过 MESI 协议让其他 CPU 的缓存失效,确保线程能看到最新值;
  • JIT 生成的 Polling Page 技术则绕过缓存一致性问题,直接用 MMU 的异常来中断线程,更加高效。

Polling Page 不耗时么

直观上看,Polling Page 需要在 JIT 生成的代码里插一条内存读指令(mov rax, [polling_page_address]),你会担心:

  • 这个读是不是每次都会走内存?
  • 会不会导致缓存失效,开销很大?

答案是:绝大多数时候几乎没有额外耗时。我详细解释一下。


1. Polling Page 的正常情况

  • jvm 会在启动时分配一块特殊的内存页(比如一页 4KB 的虚拟地址空间)。
  • 平时这块页是 可读的,里面的值 JVM 不关心,线程只是在每次循环/调用点“顺手”读一下。
  • 这块页和普通内存一样,会被 CPU 缓存到 L1/L2 Cache,因此访问代价 ≈ 读缓存里的一个 int(几纳秒)。

👉 所以在没有 GC 的绝大部分时间,Polling Page 访问非常便宜,不需要走主存。


2. GC 要触发 Safepoint 时

  • JVM 把这个页的 页表属性改为不可访问(例如 PROT_NONE)
  • 下一次某个线程执行到 polling 指令时,就会触发 缺页异常 (Page Fault)
  • 内核陷入 → JVM 的异常处理器接管 → JVM 把线程挂起进入 safepoint。

这里真正“耗时”的只有 触发 safepoint 那一次,因为要经历异常处理(内核态切换)。


3. 为什么 Polling Page 更高效?

对比一下:

  • 如果只用标志位 (volatile 读)

    • JIT 编译后仍然要多一条内存读指令;
    • 在 GC 请求时,GC 线程写标志会触发 MESI 协议,导致所有线程的缓存失效;
    • 线程会从内存重新加载 → 进入 safepoint。

    性能问题主要在于所有线程同时 cache miss,瞬间压力比较大。

  • Polling Page

    • 平时没有任何 MESI 失效的广播;
    • 只有 GC 时,才通过修改页表 + 缺页异常的方式让线程停下;
    • 每个线程只会在“下一次碰到 polling 指令”时被中断,不需要全局缓存同步。

👉 换句话说,Polling Page 把“频繁的内存一致性检查”变成了“偶尔的 page fault”,绝大多数时间没有额外代价。


4. 开销对比

  • 普通情况(无 GC):访问 Polling Page ≈ 访问一个常驻缓存的 int,开销接近 0。
  • GC 触发时:触发一次缺页异常,代价大概几千纳秒(微秒级),但这是稀有事件,而且 GC 本身就要 stop-the-world,这点额外开销可以忽略。

结论
Polling Page 本身几乎不耗时,因为访问的是缓存;
只有在 GC 真正触发 safepoint 时才会有一次缺页异常,作为中断手段。
相比“全局标志位 + 缓存一致性协议”,Polling Page 更加简洁、低开销、可控。


其他的线程不是在读取L1/L2中的Polling Page么,修改页能立马触发么

1. 内存访问的两条关键路径

一次内存读要经过两个“翻译层”:

  1. 虚拟地址 → 物理地址:依赖 页表 (Page Table),CPU 用 TLB (Translation Lookaside Buffer) 做缓存。
  2. 物理地址 → 数据内容:依赖 L1/L2/L3 Cache 和主存。

也就是说:

  • 页表权限检查发生在地址翻译阶段(TLB 命中时也要检查权限)
  • 缓存只负责存取数据,不负责权限

2. jvm 修改页表权限时会做什么?

当 JVM 想触发 safepoint,会调用操作系统接口(如 Linux 上的 mprotect(polling_page, PROT_NONE)),这会修改该页的 页表条目 (PTE)

同时,OS 会发出 TLB shootdown

  • 清除所有 CPU 上对应地址的 TLB 缓存;
  • 确保下一次访问该页时,必须重新走页表检查;
  • 此时会发现权限不允许 → 触发 缺页异常 (page fault)

3. 为什么缓存(L1/L2)读不到旧数据?

这是关键点:

  • CPU 访问内存时的顺序是 先查 TLB(地址翻译 + 权限检查),再查数据缓存
  • 如果 TLB 里已经失效(因为 mprotect 触发了 shootdown),CPU 必须重新加载页表;
  • 新的页表标记为不可访问 → 根本不会进入缓存读阶段,而是直接抛异常。

👉 换句话说:修改页表权限时,操作系统会同步刷新 TLB,保证线程不可能绕过权限检查去用旧的缓存行。


4. 所以 Polling Page 的触发过程是这样的:

  1. GC 线程调用 mprotect,把 polling page 标记为不可访问;
  2. 内核触发 TLB shootdown,所有 CPU 的 TLB 中对应条目失效;
  3. Mutator 线程下一次执行 polling 指令时:
    • TLB miss → 查页表;
    • 发现该页不可访问 → 触发缺页异常;
    • jvm 捕获异常 → 把线程挂起进入 safepoint。

整个过程根本不会走到“用缓存里旧数据”的情况。


5. 对比总结

  • 标志位方案 (volatile)
    • 依赖 MESI 协议来让缓存行失效;
    • 本质是 数据一致性 的传播。
  • Polling Page 方案
    • 依赖 TLB shootdown 来保证权限变化立即生效;
    • 本质是 地址翻译权限 的传播。

Polling Page 不靠数据一致性协议(MESI),而是靠地址翻译机制(TLB),因此修改页表后能立刻生效


结论
其他线程不会一直从 L1/L2 里读到旧的 polling page,因为访问顺序是 先查 TLB 再读缓存
mprotect 修改页表会触发 TLB shootdown,让所有 CPU 下次访问时重新检查权限,从而立刻触发异常。


这个时候不是所有的线程都会触发异常(活跃的),跟之前的全局变量不是一样么

  • Polling Page 触发 safepoint
  • 全局变量(volatile 标志位)触发 safepoint

看起来两者好像都是“所有活跃线程都会在下一个检查点被拦住”,你会觉得差别不大。
但其实它们在 触发机制、代价和实现简洁性 上有很大的区别。


1. 相同点

  • 两种方式都需要等所有活跃线程执行到下一个检查点(Safepoint Polling)才能停下;
  • 都是 协作式挂起,不能像操作系统那样强制把线程中断在任意点;
  • 最终效果一样:所有 Mutator 线程进入 safepoint,GC 才能开始。

2. 不同点

🔹 全局变量(volatile 标志位)

  • 机制:jvm 在 JIT 代码中插入 mov eax, [global_safepoint_flag]
  • GC 触发时:GC 线程写 flag=1,硬件通过 MESI 协议 让所有 CPU 的缓存行失效;
  • 线程执行到 polling 点时:读 flag,发现=1,就主动进入 safepoint。

👉 依赖 数据一致性 (cache coherence),需要广播 invalidation 消息给所有 CPU。


🔹 Polling Page

  • 机制:JVM 在 JIT 代码中插入 mov eax, [polling_page]
  • GC 触发时:JVM 调用 mprotect 把 polling page 标记为 PROT_NONE
  • 操作系统会触发 TLB shootdown,所有 CPU 清除对应虚拟页的 TLB 映射;
  • 线程执行到 polling 点时:访问这个页 → TLB 查不到 → 发现页不可访问 → 触发缺页异常 → JVM 捕获并挂起线程。

👉 依赖 地址翻译 (TLB 权限检查),不依赖缓存一致性协议。


3. 为什么 Polling Page 更优?

虽然两种方式看起来都要“所有线程都被拦住”,但 Polling Page 在实现和性能上更有优势:

  1. 无需内存屏障 & 编译器约束

    • 全局变量方案需要 volatile 语义,编译器/JIT 必须保证读写不可消除、不可重排序;
    • Polling Page 是一个普通的 load 指令,编译器/JIT 不会优化掉,简洁可靠。
  2. 更低的 steady-state 开销

    • 全局变量每次访问时都要参与 MESI 协议,虽然大多数时候是缓存命中,但依旧带来 cache coherence 消息流;
    • Polling Page 是普通的内存读(命中缓存),没有 coherence 流量,GC 触发前完全没有额外负担
  3. 更强的可移植性

    • 全局变量方案依赖 CPU 的缓存一致性模型(MESI、内存屏障语义),不同 CPU 架构(x86、ARM、RISC-V)差异较大;
    • Polling Page 只依赖操作系统的页表和 TLB 机制,几乎所有平台都一致。
  4. 触发时的中断方式不同

    • 全局变量方案:线程“主动”发现标志位变化 → 自愿进入 safepoint;
    • Polling Page 方案:线程在访问时触发 缺页异常,相当于被“硬中断” → 进入 safepoint。

    这使得 Polling Page 更像“抢占式”的停顿,更容易保证线程不会因为编译器/缓存优化而漏掉 safepoint。


4. 回答你的问题

这个时候不是所有的线程都会触发异常(活跃的),跟之前的全局变量不是一样么?

对,最终效果一样:所有活跃线程都会在 safepoint 停下。
但不同点在于触发机制

  • 全局变量 = 逻辑检查(主动发现标志位变化)
  • Polling Page = 硬件异常(被动触发中断)

而且 Polling Page 避开了缓存一致性开销,更通用、稳妥。


结论
Polling Page 和全局变量方案最终效果相同:活跃线程都会停下。
不同在于:Polling Page 通过 TLB 权限异常 来中断线程,避免了缓存一致性开销和编译器优化问题,所以 HotSpot 更倾向使用 Polling Page。


被动的中断比读取内存要快么

  • Polling Page 触发异常(被动中断)
  • 全局变量(volatile 读标志位)

哪个更快?

答案是要分场景来看,因为这两种机制的 常态成本触发时成本 是完全不同的。


1. 常态下(GC 没触发的时候)

  • 全局变量 (volatile)
    • 每次都会生成一条内存读指令,读的是缓存中的值;
    • 因为缓存命中,所以开销 ~ 1–3 CPU cycles(几纳秒级)。
    • 但:这条 volatile load 不能被优化掉,会占据一个寄存器,还会有 内存屏障(load fence) 的语义 → 带来轻微流水线开销。
  • Polling Page
    • 也是一条普通的内存读指令,读缓存;
    • 没有 volatile 语义,也不需要内存屏障,可以作为普通 load,开销更低(单纯是 1 cycle 的 L1 读)。

👉 所以 常态下 Polling Page 更快,因为它更“干净”,不会触发编译器/CPU 的额外约束。


2. GC 触发时(需要进入 Safepoint)

  • 全局变量 (volatile)
    • GC 线程写 flag=1 → 触发 MESI 广播,所有 CPU 对应 cache line 失效;
    • 线程下一次读时:cache miss → 访问主内存(几十到几百 cycles);
    • 读到 1 后,线程主动进入 safepoint。
    • 开销主要是:全局缓存同步 + 主存访问
  • Polling Page
    • GC 线程 mprotect → 修改页表权限 → OS 做 TLB shootdown;
    • 线程下一次读时:TLB miss → 检查页表 → 发现不可访问 → 触发 缺页异常
    • 缺页异常 → 内核态陷入 → jvm 捕获 → 线程进入 safepoint;
    • 开销主要是:一次 trap/异常处理(几百到几千 cycles,微秒级)

👉 触发时,全局变量方案更快,因为它只是一次主存访问;Polling Page 走了异常路径,代价更高。


3. 为什么 HotSpot 还是选择 Polling Page?

因为 HotSpot 的设计是 优化常态成本(99.999% 的时间线程都不在 GC),而不是优化 GC 触发瞬间。

对比一下:

  • volatile 全局变量:常态下每个 safepoint 检查都要付出额外的内存屏障成本(虽然很小,但循环里几十亿次就很大了)。
  • Polling Page:常态下就是一条最轻量的 load 指令,几乎零开销。

触发时虽然更贵(异常处理),但触发 safepoint 的频率相对很低(比如几百毫秒甚至几秒一次)。
👉 所以 JVM 选择了 让大多数时间更快,而牺牲稀有路径的性能


4. 类比总结

  • 全局变量
    • 优点:触发 safepoint 代价低(一次主存访问);
    • 缺点:常态下每次检查都有额外内存屏障,循环里代价累积大。
  • Polling Page
    • 优点:常态下几乎零开销(普通 L1 load);
    • 缺点:触发时要处理异常,代价高。

HotSpot 的策略:牺牲稀有路径,优化常态路径


结论

  • 平时(无 GC) → Polling Page 比读 volatile 标志位更快;
  • GC 触发时 → volatile 标志位更快;
  • JVM 选择 Polling Page,是为了优化大多数时间的性能。

MysticalYcc , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:jvm的垃圾回收是如何暂停所有线程的
喜欢 (0)
mysticalycc
关于作者:
简短的个人签名

Warning: Attempt to read property "comment_author_email" on null in /www/wwwroot/gschaos.club/wp-content/themes/Git-alpha-6h0SRk/comments.php on line 47
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到