垃圾回收的停顿
垃圾回收停顿是什么
JVM 垃圾回收停顿,全称 Stop-The-World(STW),是垃圾回收器(GC)执行内存管理任务时触发的关键线程暂停状态。
垃圾回收器的核心职责是判定堆内存中对象的可达性、回收不可达的垃圾对象、整理内存碎片以优化内存分配效率。由于堆内存是 JVM 所有应用线程的共享资源,为保障 GC 操作的准确性与安全性,JVM 会在 GC 关键阶段暂停所有应用线程的执行,仅允许 GC 线程运行。此阶段应用程序对外呈现完全无响应状态,也是制约 Java 应用吞吐量与延迟稳定性的核心因素之一。


STW 触发的核心目的
STW 的核心目标是冻结堆内存的引用关系与内存布局,消除应用线程并发操作对 GC 流程的干扰,具体可拆解为两个维度:
- 保障 GC 标记结果的一致准确
阻断应用线程对堆内存对象的创建、引用关联更新、引用解除等写操作,使堆内所有对象的可达性状态固定于某一瞬时时间切片。消除 "一边标记存活对象、一边修改引用关系" 带来的不确定性,避免出现存活对象漏标、垃圾对象错标等问题。 - 保障 GC 内存操作的安全可控
在执行内存复制、内存压缩等核心操作时,规避应用线程与 GC 线程对同一内存区域的并发访问冲突。当 GC 线程移动存活对象地址或更新对象引用指针时,STW 可确保应用线程不会读取或写入处于 "迁移过程中" 的无效内存地址,防止内存访问错乱与程序崩溃。
STW 发生的核心阶段
不同 GC 算法的执行流程存在差异,但 STW 主要集中在以下三个核心阶段,且各阶段的停顿时长与影响因素差异显著:
- 初始标记(Initial Mark)
- 任务:标记 GC Roots 直接引用的对象,确立存活对象标记的起点边界。
- 必要性:GC Roots 是可达性分析的根基,若不暂停用户线程,用户线程可能在标记过程中创建、销毁或修改根引用,导致标记起点错乱,进而引发后续存活对象判断失误。
- 特点:停顿时间极短,通常为毫秒级甚至亚毫秒级。此阶段仅遍历 GC Roots 集合本身,不递归扫描整个堆的对象引用链,耗时仅取决于 GC Roots 的数量。
- 适用 GC:所有实现并发标记能力的收集器(CMS、G1、ZGC、Shenandoah)均会在此阶段触发短暂 STW;Serial GC、Parallel GC 虽无并发标记概念,但其标记流程的第一步本质也是类似的根扫描,且全程处于 STW 状态。
- 重新标记(Remark)
- 任务:修正并发标记阶段因用户线程持续运行导致的标记结果偏差,确保存活对象标记的完整性与准确性。
- 解决问题:并发标记期间,用户线程会执行对象创建、引用更新、对象销毁等操作,可能产生新的存活对象或使部分对象的引用失效,需通过重新扫描更新标记结果。
- 特点:停顿时间长于初始标记,短于内存复制 / 压缩阶段;停顿时长与两个因素强相关:一是并发标记阶段用户线程的引用修改频率,二是堆内存活对象的数量;通过优化缓冲区处理逻辑,可进一步缩短停顿。
- 适用 GC:
CMS、G1 的核心 STW 阶段之一,是保障标记准确性的关键环节;
ZGC/Shenandoah 通过染色指针等技术,将大部分标记修正工作转为并发执行,此阶段的 STW 被极度压缩,甚至可忽略不计;
Serial GC、Parallel GC 无独立的重新标记阶段,因全程 STW,标记过程本身就是精准的,无需额外修正。
- 内存复制 / 压缩(Evacuation/Compaction)
- 任务:对存活对象执行内存空间整理操作 ------ 或复制存活对象到新的内存区域,或移动存活对象以消除内存碎片,同时更新所有指向这些对象的引用地址,最终回收垃圾对象占用的内存。
- 必要性:内存空间的 复制 / 移动 / 压缩 属于独占性内存操作,若不暂停用户线程,用户线程可能访问正在被移动的对象,或修改对象引用,导致引用指向无效内存地址,引发程序崩溃或内存泄漏。
- 特点:传统收集器中停顿时间最长的阶段,停顿时长与存活对象的数量和大小直接正相关 ------ 存活对象越多、对象越大,复制 / 移动的耗时越久。
- 适用 GC:
Serial GC、Parallel GC:全程 STW,此阶段占总停顿时间的绝大部分,且需扫描整个堆;
G1:采用分区回收策略,仅选择存活对象占比低、回收收益高的 Region 集合(回收集 CSet)执行复制,复制过程同步完成内存压缩;可通过 -XX:MaxGCPauseMillis 参数控制单次回收的 Region 数量,实现延迟可控。
ZGC/Shenandoah:通过并发重定位、引用屏障动态更新地址技术,将此阶段的核心工作转为并发执行,仅保留极短的 STW 用于完成地址映射的最终确认,停顿时间与堆大小几乎无关。
示例代码
我们来看如下代码,直观观测 JVM 的 STW 现象:
java
import java.util.HashMap;
public class StopWorldTest {
public static class MyThread extends Thread {
HashMap map = new HashMap();
@Override
public void run() {
try {
while (true) {
if (map.size() * 512 / 1024 / 1024 >= 900) {
map.clear();
System.out.println("clean map");
}
byte[] b1;
for (int i = 0; i < 100; i++) {
b1 = new byte[512];
map.put(System.nanoTime(), b1);
}
//Thread.sleep(1);
}
} catch (Exception e) {
}
}
public static class PrintThread extends Thread {
public static final long starttime = System.currentTimeMillis();
@Override
public void run() {
try {
while (true) {
// 8722
long t = System.currentTimeMillis() - starttime;
System.out.println(t / 1000 + "." + t % 1000);
Thread.sleep(100);
}
} catch (Exception e) {
}
}
public static void main(String args[]) {
MyThread t = new MyThread();
PrintThread p = new PrintThread();
t.start();
p.start();
}
}
}
}
MyThread 线程(内存消耗线程):循环创建 512 字节的数组并放入HashMap,当总占用接近 900MB 时清空 map 释放内存,避免触发 OutOfMemoryError;
PrintThread 线程(时间戳输出线程):以 100ms 为固定间隔输出相对时间戳,正常情况下输出应该是连续的;若发生 STW,线程无法立即执行,会出现时间戳跳变,跳变的差值就是 STW 的大致时长。
使用如下参数执行:
bash
-Xmx1g -Xms1g -Xmn812k -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails
其中 +UseSerialGC 即使用 Serial 串行 GC,SerialGC 在 Minor GC 和 Full GC 都会暂停所有应用线程,且单线程执行 GC,停顿时间更长,STW 现象更易观察。

如图时间戳产生大幅跳变
线程每次循环创建 100 个 byte512 数组,因 while(true) 无休眠机制,会以极高频率持续创建对象;而 JVM 新生代内存仅配置 812KB,新生代空间远小于对象创建速率,很快被占满并触发高频 Minor GC(新生代垃圾回收)。Serial GC 为单线程回收器,其新生代回收阶段会触发 STW 暂停所有用户线程;由于所有 byte512 数组均被 HashMap 强引用,GC 可达性分析判定这些对象为存活状态,因此 Minor GC 几乎无法回收任何内存,存活对象会被持续晋升至老年代。老年代内存随对象持续晋升快速耗尽,进而触发 Full GC,Full GC 需单线程扫描并回收整个堆内存,且因测试中 map.clear() 的触发阈值过高,未等该方法执行断开引用 Full GC 无法有效回收内存,仅能遍历所有存活对象,导致 STW 时长从 Minor GC 的毫秒级飙升至数百毫秒,仅当执行 map.clear() 断开 HashMap 对数组的强引用后,这些数组才会被标记为不可达对象,且必须等下一次 GC 执行,内存才会真正被释放。最终老年代完全耗尽,JVM 无法为新 byte512 数组分配内存,触发 "无法创建字节数组" 异常,程序无法继续正常执行。
高频 Minor GC 的短时长 STW 会造成时间戳轻微抖动,而单次 Full GC 的长时长 STW 是时间戳大幅跳变的核心原因。
从此例中可以看到,新生代GC进行较为频繁,但每一次GC耗时较短,而老年代GC发生次数较少,但每一次所消耗的时间较长,下面通过修改虚拟机参数改变这种现象:
bash
-Xmx1g -Xms1g -Xmn900m -XX:SurvivorRatio=1 -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails
-Xmn900m:新生代扩容至 900MB,解决新生代过小导致高频 Minor GC 的问题;
-XX:SurvivorRatio=1:新生代中 Eden : From : To = 1:1:1,各占 300MB,增大 Survivor 区,减少对象过早晋升到老年代。
同时,修改第8行代码为:
java
if (map.size() * 512 / 1024 / 1024 >= 550) {
将阈值设为 550MB,意味着:
当 map 占用 550MB 时就清空,此时新生代还有约 350MB 空闲空间,使得对象主要集中在新生代;当内存清空后,这些对象的引用被断开,Full GC 能一次性回收大部分内存,同时能避免老年代溢出。
执行结果如下:

修改 JVM 参数后,程序执行流程及 GC 日志呈现出清晰且可控的规律:程序运行初期,因-Xmn900m将新生代扩容至堆内存的 90%,极大提升了新生代的内存缓冲能力,对象高频创建不再瞬间占满新生代,使得 Minor GC 触发频率降低;但新生代单次回收的内存量从几百 KB 增至数百 MB,因此单次 Minor GC 耗时从毫秒级升至 0.23 秒左右。当新生代存活对象向老年代晋升时,因老年代被压缩至仅约 100MB,出现空间不足,JVM 触发保护机制立即执行 Full GC;后续程序进入稳定循环:老年代因对象持续晋升逐步占满,触发周期性 Full GC,且由于 map.clear() 阈值已降至 550MB(提前断开 HashMap 对 byte 数组的强引用),GC 能有效识别并回收不可达对象,使得每次 Full GC 可在 0.18-0.31 秒内完成回收。整个过程中程序依靠 "Minor GC(晋升失败)→ 触发 Full GC → 有效回收内存 → 对象重新创建" 的循环稳定运行,未出现内存耗尽的 OOM 异常。