第9章:OpenJDK堆分代与 Serial/Parallel GC 基础

1. 项目背景

业务场景:某电商的"每日推荐"批处理任务每天凌晨 2 点运行,处理约 2000 万条用户行为数据,计算推荐模型并写入缓存。该任务一直稳定运行在 JDK 8 的 Parallel GC 下,耗时约 45 分钟。团队为了使用虚拟线程特性,将 JDK 升级到 21,JVM 参数原封不动搬过来。结果批处理耗时从 45 分钟飙升至 70 分钟,运维排查发现 GC 停顿时间占比从 3% 变为 18%。

痛点:

  1. GC 算法选型的"惯性" :团队使用了 6 年的 Parallel GC 参数模板(-XX:+UseParallelGC),从未思考它是否适合当前负载。JDK 21 的默认 GC 已改为 G1,团队却"顺手"沿用旧参数------Parallel GC 在追求吞吐的批处理场景没问题,但年轻代回收的停顿时间随堆增大而线性增长。
  2. 分代假说的滥用:很多人背口诀"大多数对象朝生夕死",但对其落地的 Eden/Survivor/Old 之间的晋升机制一知半解。比如不清楚"对象何时从 Eden 晋升到 Survivor""经过几次 Minor GC 后进入 Old Gen""大对象直接分配在 Old Gen"的精确规则。
  3. GC 日志的读不懂 :运维拿到 -Xlog:gc* 的输出后,只能看"有没有 Full GC",不知道如何从日志中分析分配速率、晋升阈值、停顿分布。

本章从"为什么要分代"出发,用实验数据验证弱分代假说,深入 Serial GC 和 Parallel GC 的工作原理,并在最后用 Unified Logging 绘制"分配速率-停顿"曲线,建立一套读 GC 日志的系统方法。

2. 项目设计

(半夜两点,小胖被 PagerDuty 叫醒------批处理任务超时了。)

小胖:大师,为什么同样的代码、同样的参数,JDK 8 跑 45 分钟,JDK 21 跑 70 分钟?不是说 Java 越升级越快吗?

大师 (看了一下 GC 日志):你们用了 -XX:+UseParallelGC 对吧?JDK 8 的默认堆大小和 JDK 21 不一样,同一台机器的默认 GC 线程数也可能不同。但最关键的是------你不应该用 Parallel GC 跑一个会产生大量长期存活对象的批处理任务。咱们先退一步,从"为什么 GC 要分代"聊起。

你知道"弱分代假说"(Weak Generational Hypothesis)吗?这是所有分代 GC 的理论基石:绝大多数对象"朝生夕死",只有少数"长命百岁"

来自实际测量(IBM 的研究):98% 的对象在分配后很快变成垃圾,只有 2% 的对象能存活超过一次 GC。这就好比食堂的纸巾------每顿饭用一张,用完立刻扔掉(朝生夕死),但盛菜的盘子会反复用一整天(长命百岁)。

分代 GC 的策略

scss 复制代码
┌──────────────────────────────────────┐
│              堆 (Heap)               │
│  ┌─────────────────────────────────┐ │
│  │  新生代 (Young Generation)      │ │
│  │  ┌──────┬───────┬───────┐     │ │
│  │  │ Eden │ S0    │ S1    │     │ │ ← Minor GC
│  │  │ (8)  │ (1)   │ (1)   │     │ │   (频繁、快速)
│  │  └──────┴───────┴───────┘     │ │
│  │  默认比例 Eden:S0:S1 = 8:1:1  │ │
│  └─────────────────────────────────┘ │
│  ┌─────────────────────────────────┐ │
│  │  老年代 (Old Generation)       │ │ ← Major GC / Full GC
│  │  存活多次 GC 的对象              │ │   (不频繁、较慢)
│  └─────────────────────────────────┘ │
└──────────────────────────────────────┘

技术映射:Eden ↔ 食堂的"用餐区"(人来人往,大部分盘子很快回收),Survivor ↔ 食堂的"暂存区"(还没决定要不要长期保留),Old ↔ 厨房的储物柜(长期使用的锅碗瓢盆)。

小白:那对象是怎么从 Eden 升到 Old 的?什么叫"晋升"?

大师:晋升规则有精确的条件:

  1. 年龄晋升 :对象在 Survivor 区每经历一次 Minor GC 而没有被回收,年龄 +1。当年龄超过 -XX:MaxTenuringThreshold(默认 15),晋升到 Old。
  2. 动态年龄判断 :如果 Survivor 区中"同龄对象的大小"超过了 Survivor 区的一半,年龄 ≥N 的对象直接晋升------不管 MaxTenuringThreshold 是多少。
  3. 空间担保:如果 Survivor 区放不下了(触发 Minor GC 后存活对象太多),对象绕过 Survivor 直接进入 Old。
  4. 大对象直通 :超过 -XX:PretenureSizeThreshold 的对象直接在 Old 分配(仅 Serial/Parallel 有效,G1 不适用)。
java 复制代码
// 对象的"逃亡之旅"
byte[] data = new byte[1024 * 1024 * 10]; // 10MB 大对象
// → 如果 PretenureSizeThreshold=1MB → 直接进入 Old Gen

List<byte[]> cache = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    cache.add(new byte[1024]); // 小对象在 Eden 分配
}
System.gc(); // 触发 GC → cache 中的对象被标记为存活 → 进入 Survivor
// 15 次 GC 后 → 这些对象晋升到 Old Gen

技术映射:对象在 Eden ↔ 新生婴儿在育婴室;Survivor ↔ 幼儿园(一次一次"年级"升级);Old ↔ 成年人社会(养老也要在这里)。

小胖:那 Serial GC 和 Parallel GC 有什么区别?为什么一个叫"串行"一个叫"并行"?

大师:名字起得很直白------"Serial"就是单线程做 GC,"Parallel"就是多线程做 GC。

  • Serial GC (-XX:+UseSerialGC):年代最久远的 GC,年轻代和老年代回收都用单线程。适合客户端应用和单核 CPU 环境。当应用暂停时只做 GC,没有任何业务线程在运行。
  • Parallel GC (-XX:+UseParallelGC):JDK 8 及之前的服务端默认 GC。年轻代回收用多线程并行,老年代默认也是多线程(-XX:+UseParallelOldGC)。追求的是吞吐量------单位时间内业务运行时间占比最大,停顿次数少但单次停顿时间长。

两者的核心差异:

特性 Serial GC Parallel GC
年轻代回收线程 1 -XX:ParallelGCThreads=N(默认=CPU 核数)
老年代回收线程 1 多线程
目标 最小化内存和 CPU 开销 最大化吞吐量(吞吐 = 业务时间 / 总时间)
适用场景 客户端、单核、<100MB 堆 批处理、科学计算、对停顿不敏感的服务
优点 实现简单,无线程切换开销 多核机器上吞吐极高
缺点 大堆停顿时间长 停顿随堆增大而线性增长,延迟不可控

技术映射:Serial GC ↔ 办公室只有一个保洁阿姨(一个人打扫整个办公室),Parallel GC ↔ 一个保洁团队(8 个人同时打扫不同区域)。

小白:等一下,你刚才说 JDK 21 的默认 GC 是 G1,那 Serial 和 Parallel 还有学习的必要吗?

大师:必须学!原因有三:

  1. Serial/Parallel 是理解 GC 的基础:许多 GC 概念(Eden/Survivor/Old、晋升、卡表、跨代引用)在 Serial/Parallel 中最纯粹、最直观。G1 和 ZGC 是这些概念上的演进,不是推翻。
  2. Serial GC 是极端场景的救命稻草:当你的堆只剩 64MB、系统只剩 1 个核时,Serial GC 是唯一的选择。容器化时代这并不罕见。
  3. Parallel GC 在特定场景仍然最优:批处理、离线计算------这些对停顿不敏感但对吞吐敏感的场景,Parallel GC 至今仍然是吞吐最高的选择。

3. 项目实战

3.1 环境准备

组件 版本 用途
JDK OpenJDK 21 运行 GC 体验程序
GC 日志分析 GCViewer / gceasy.io 可视化 GC 日志
压测工具 JMH 或手写测试 模拟不同分配模式

3.2 分步实现

步骤一:观察 Young GC 的完整生命周期

目标:创建大量短命对象 + 少量长命对象,用 GC 日志验证对象晋升路径。

java 复制代码
// YoungGCDemo.java ------ 观察新生代回收
import java.util.ArrayList;
import java.util.List;

public class YoungGCDemo {
    // 每 1MB = 1024 * 1024 / 4 = 262144 个 int
    private static final int _1MB = 1024 * 1024 / 4;

    public static void main(String[] args) throws Exception {
        System.out.println("PID: " + ProcessHandle.current().pid());
        System.out.println("开始分配,观察 Young GC...\n");

        List<int[]> youngRefs = new ArrayList<>(); // 存活对象(会被晋升)

        // 第一轮分配:填满 Eden
        for (int i = 0; i < 30; i++) {
            int[] arr = new int[_1MB]; // 约 4MB per allocation
            if (i < 5) youngRefs.add(arr); // 前 5 个存活
            System.out.println("分配第 " + (i + 1) + " 个 4MB 数组");
        }
        System.out.println("第 1 次 Young GC 应该发生了\n");

        Thread.sleep(1000);

        // 第二轮分配:触发晋升
        for (int i = 0; i < 30; i++) {
            int[] arr = new int[_1MB];
            if (i < 3) youngRefs.add(arr);
            System.out.println("分配第 " + (i + 1) + " 个 4MB 数组");
        }
        System.out.println("第 2 次 Young GC,部分对象应晋升到 Old\n");

        Thread.sleep(3000);
        System.out.println("youngRefs 大小: " + youngRefs.size());
    }
}
bash 复制代码
# 用 Serial GC 运行,Eden=80M, S0=S1=10M, Old=100M
javac YoungGCDemo.java
java -XX:+UseSerialGC \
     -Xms200m -Xmx200m \
     -Xmn100m \
     -XX:SurvivorRatio=8 \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -Xlog:gc*:file=serial_gc.log \
     YoungGCDemo

# 查看 GC 日志
cat serial_gc.log

日志解读

scss 复制代码
[2026-01-01T12:00:00.000+0800] GC(0) Pause Young (Allocation Failure)
  [DefNew: 81920K->10240K(92160K)] DefNew是Serial的年轻代实现
  对象从81920K降到10240K → 回收了约70MB

步骤二:对比 Serial vs Parallel 的吞吐量

目标:同一个分配密集程序,分别用 Serial 和 Parallel 运行,对比吞吐量差异。

java 复制代码
// GCBenchmark.java ------ 对比 GC 吞吐量
public class GCBenchmark {
    private static final int _1MB = 1024 * 1024 / 4;

    public static void main(String[] args) throws Exception {
        long startTime = System.currentTimeMillis();
        int totalWork = 0;

        // 模拟批处理:分配大量对象并进行计算
        for (int round = 0; round < 10; round++) {
            int[][] data = new int[50][_1MB]; // 50 * 4MB = 200MB per round
            // 模拟"计算"
            for (int i = 0; i < data.length; i++) {
                for (int j = 0; j < 1000; j++) {
                    data[i][j] = i * j;
                }
                totalWork += data[i][0];
            }
            // 大部分数组在循环结束后变成垃圾(朝生夕死)
            if (round % 2 == 0) {
                System.out.println("Round " + round + " 完成");
            }
        }

        long endTime = System.currentTimeMillis();
        long elapsed = endTime - startTime;
        System.out.println("总耗时: " + elapsed + " ms");
        System.out.println("总工作量: " + totalWork + " (防编译器优化)");
    }
}
bash 复制代码
# 编译
javac GCBenchmark.java

echo "=== Serial GC ==="
time java -XX:+UseSerialGC -Xms512m -Xmx512m \
     -Xlog:gc*:file=serial_bench.log GCBenchmark

echo ""
echo "=== Parallel GC ==="
time java -XX:+UseParallelGC -Xms512m -Xmx512m \
     -XX:ParallelGCThreads=4 \
     -Xlog:gc*:file=parallel_bench.log GCBenchmark

echo ""
echo "=== 对比 GC 日志统计 ==="
echo "--- Serial GC 统计 ---"
grep -c "Pause Young" serial_bench.log
echo "--- Parallel GC 统计 ---"
grep -c "Pause Young" parallel_bench.log

典型结果对比(4 核虚拟机):

指标 Serial GC Parallel GC
总耗时 ~8500 ms ~5200 ms
GC 停顿次数 42 次 38 次
总 GC 时间 ~2200 ms ~1200 ms
吞吐量 ~74% ~81%

步骤三:绘制"分配速率-停顿"曲线

目标:通过改变分配速率,观察 GC 停顿之间的关系。

java 复制代码
// AllocationRateDemo.java ------ 分配速率与 GC 停顿的关系
public class AllocationRateDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("PID: " + ProcessHandle.current().pid());
        System.out.println("分配速率 (MB/s) | 每轮分配 MB");

        int[] sizes = {1, 2, 5, 10, 20, 50, 100}; // 每秒分配不同大小的内存

        for (int sizeMB : sizes) {
            int size = sizeMB * 1024 * 1024 / 8; // 字节数组大小(long=8 bytes)
            long startTime = System.nanoTime();
            long bytesAllocated = 0;
            int[] keepRefs = new int[10]; // 保留 10 个引用,其余可回收

            // 分配 2 秒
            while (System.nanoTime() - startTime < 2_000_000_000L) {
                long[] arr = new long[size]; // 快速分配
                keepRefs[(int)(Math.random() * 10)] = (int)arr[0]; // 随机保留引用
                bytesAllocated += size * 8L;
            }

            double rate = bytesAllocated / (1024.0 * 1024.0) / 2.0; // MB/s
            System.out.printf("  %3d MB     | %.1f MB/s%n", sizeMB, rate);

            System.gc(); // 清理后重新开始下一轮
            Thread.sleep(2000);
        }
    }
}
bash 复制代码
# 用不同 GC 运行并记录日志
java -XX:+UseSerialGC -Xms256m -Xmx256m \
     -Xlog:gc*=info:file=alloc_rate_serial.log::filecount=0 \
     AllocationRateDemo

# 分析日志中的停顿时间与分配速率的关系
grep "Pause" alloc_rate_serial.log | awk '{print $NF}' | head -20

步骤四:Serial GC 的 Full GC 触发条件演示

目标:制造一个"老年代满"的场景,触发 Full GC。

java 复制代码
// FullGCDemo.java ------ 触发老年代 Full GC
import java.util.ArrayList;
import java.util.List;

public class FullGCDemo {
    private static final int _1MB = 1024 * 1024 / 4;

    public static void main(String[] args) throws Exception {
        System.out.println("PID: " + ProcessHandle.current().pid());
        System.out.println("制造 Full GC 场景...");

        List<int[]> liveList = new ArrayList<>();

        // 持续分配长命对象,逐渐填满老年代
        for (int i = 0; i < 100; i++) {
            liveList.add(new int[_1MB * 10]); // 40MB per object
            System.out.println("已分配 " + (i + 1) + " 个 40MB 对象 → 约 " + ((i + 1) * 40) + " MB");
            Thread.sleep(200);
        }
        // 当 Old Gen 满时会触发 Full GC
        // Full GC 将尝试回收,如果 liveList 持有所有引用,则无法回收 → OOM
    }
}
bash 复制代码
# 堆=256MB,其中老年代约 160MB(-Xmn100m → 新生代100MB + 老年代156MB)
java -XX:+UseSerialGC -Xms256m -Xmx256m -Xmn100m \
     -XX:+PrintGCDetails \
     -Xlog:gc*=info:file=fullgc_demo.log \
     FullGCDemo

# 在日志中查找 "Full GC"
grep "Full GC" fullgc_demo.log

可能遇到的坑

  1. -Xmn 设置过大:新生代设太大→老年代空间不够→对象频繁提前晋升→ Full GC 频繁。新生代大小建议先测量再设置,不要盲设。
  2. Survivor 空间太小:如果 Survivor 空间放不下 Minor GC 后存活的对象 → 对象直接晋升 Old(叫"过早晋升")→ Old 快速被填充 → Full GC 增多。
  3. System.gc() 会触发 Full GC :在生产代码中禁用 System.gc() 调用------用 -XX:+DisableExplicitGC
  4. Parallel GC 的 GC 线程数 :默认 ParallelGCThreads = CPU 核数。在容器中如果 limit 是 2C,但宿主机 64C → JVM 可能创建 64 个 GC 线程,上下文切换开销巨大。JDK 8u191+ 的容器感知会纠正这个行为。

3.3 测试验证

GC 验证矩阵

验证点 命令 预期观察
Young GC 触发 分配填满 Eden → DefNew... 日志 Minor GC 后 Eden 清空,存活对象→Survivor
对象晋升到 Old 分配后观察 Old Gen 使用量增长 Old Gen 中的 used 值增大
Serial vs Parallel 吞吐 分别跑 GCBenchmark Parallel 总时间更短(多核)
Full GC 触发 分配堆满对象且持有引用 Full GC 出现且 Old Gen 无法回收
大对象直接进 Old 分配 > PretenureSizeThreshold 的对象 GC 日志显示直接在 Tenured 分配
bash 复制代码
#!/bin/bash
echo "=== 完整 GC 验证 ==="

echo "1. Young GC 演示"
java -XX:+UseSerialGC -Xms128m -Xmx128m -Xmn64m \
     -Xlog:gc*=info -XX:+PrintGC \
     YoungGCDemo 2>&1 | grep -E "Pause|GC"

echo ""
echo "2. Full GC 演示"
java -XX:+UseSerialGC -Xms128m -Xmx128m -Xmn32m \
     -Xlog:gc*=info FullGCDemo 2>&1 | grep "Full GC"

echo ""
echo "3. 大对象直通 Old Gen"
java -XX:+UseSerialGC -Xms128m -Xmx128m \
     -XX:PretenureSizeThreshold=1048576 \
     -Xlog:gc*=info -cp . \
     -c 'byte[] b=new byte[10*1024*1024]; Thread.sleep(5000);' \
     2>&1 | grep -i "tenured\|old"

4. 项目总结

4.1 优点与缺点

维度 优点 缺点
分代模型 大幅减少了 GC 每次需要扫描的对象数量(只扫新生代) 跨代引用需要维护"卡表",增加 minor GC 开销
Serial GC 实现极简、无 GC 线程调度开销,适合小堆和单核 大堆停顿时间不可接受(200MB 堆停顿可达 2-3 秒)
Parallel GC 多核机器上吞吐极高,对非交互式批处理最优 停顿时间随堆大小线性增长,无法保证延迟上限
Unified Logging 统一的 -Xlog 标签体系,日志结构化、可检索 GC 标签多(gc+heap、gc+ergo、gc+age...),初学容易混淆
晋升机制 灵活的动态年龄判断和空间担保 动态年龄判断在边缘情况下行为非确定性------同一程序在不同 JVM 版本间晋升行为可能不同

4.2 适用场景

  1. Serial GC 适用:客户端桌面应用、内存 < 100MB 的微服务、嵌入式开发板(如 Raspberry Pi)。
  2. Parallel GC 适用:批处理、ETL 数据处理、科学计算------只要对停顿不敏感,Parallel GC 的吞吐无人能敌。
  3. 学习 GC 基础:Serial 和 Parallel 的简单实现是理解 G1/ZGC 等复杂 GC 的必经之路。
  4. 容器极简环境:单核 + 128MB 内存的容器中,Serial GC 是唯一可靠的选择。
  5. GC 日志训练:用 Serial/Parallel 的清洁日志学习 GC 日志解读,标签少、逻辑清晰。

不适用场景

  • 交互式 Web 服务(要求 P99 < 100ms)------Serial/Parallel 无法保证低延迟,应选 G1 或 ZGC。
  • 大堆(> 4GB)------Parallel GC 的停顿可达 5-10 秒,不可接受。
  • 混部环境------Serial/Parallel 的 Stop-The-World 会暂停所有业务线程,严重影响多租户体验。

4.3 注意事项

类型 详细说明
MaxTenuringThreshold 默认值 15 是最大值(4 bits 存储),但实际晋升由"动态年龄判断"决定------不要以为设 0 就能让所有对象不进 Old
SurvivorRatio Eden:S0:S1 = 8:1:1 是默认值。如果 Survivor 太小→过早晋升,太大→ Eden 太小→ GC 频繁
PretenureSizeThreshold 仅对 Serial 和 Parallel GC 有效;G1 有独立的 -XX:G1HeapRegionSize 决定大对象阈值
AdaptiveSizePolicy Parallel GC 默认开启自适应的新生代大小调整-------XX:-UseAdaptiveSizePolicy 可关闭此行为

4.4 常见踩坑经验

案例 1:Survivor 区溢出导致"名存实亡"的晋升

某缓存服务用 Parallel GC,配置 -XX:MaxTenuringThreshold=15 期望对象在 Survivor 区待足够久才晋升。但生产上 GC 日志显示大量对象在 1-2 次 GC 后就晋升了。根因 :Survivor 区太小(默认 SurvivorRatio=8,S0+S1=1/10 堆),存活对象超过了 S0/S1 容量→ 触发"空间担保"直接晋升。修复 :调大 SurvivorRatio 或者增大整体 -Xmn

案例 2:Parallel GC 的 GC 线程数爆炸

微服务部署在 K8s 中,Pod 限制 2C/4Gi,但 jstat 显示 GC 线程数为 32------等于宿主机物理核数。根因 :JDK 8u191 之前的版本不感知容器 cgroup 限制,ParallelGCThreads 读的是物理机的 CPU 核数。修复 :升级到 JDK 8u191+ 或显式设置 -XX:ParallelGCThreads=2

案例 3:大对象直接进 Old 造成 Full GC 螺旋

某数据导入工具用 byte[10MB] 数组读取文件并解析。PretenureSizeThreshold 设为 1MB,所有文件缓冲区直接分配在老年代。1000 个文件导入后 Old Gen 满 → Full GC → 老年代释放部分 → 继续导入 → 再次 Full GC → "Full GC 螺旋"。根因 :大对象不该走堆------应用 ByteBuffer.allocateDirect() 堆外分配或按批次流式处理而非全量加载。

4.5 思考题

  1. 进阶题MaxTenuringThreshold 和"动态年龄判断"有什么区别?如果 Survivor 区中所有同龄对象总大小超过了 Survivor 区 50%,年龄多少的对象会被直接晋升?请通过修改 GC 日志级别(-Xlog:gc+age=trace)来验证你的结论。

  2. 实战题 :你的服务目前使用 -XX:+UseParallelGC,堆大小 8GB。用户投诉偶发性响应超时(2-3 秒)。怀疑是 GC 停顿造成的。请设计一个方法(不依赖第三方工具),在线上验证停顿时间是否贡献了超时的大头,并给出切换到 G1 GC 的参数推荐。

答案提示:思考题 1 答案见本章晋升机制部分;思考题 2 答案见第 20 章 G1 GC 原理与调优。


下一章预告:第 10 章将系统梳理 Java 的异常体系------Throwable 家族、受检/非受检争议、hs_err_pid 日志解读,以及一份可用于生产的"线上异常分级与处置 SOP"。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

相关推荐
君顾11 小时前
西安 24 小时自助健身房系统开发实战指南
java·开发语言·健身房
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之RBAC与角色分层实践
java·网络·spring
caoerzhong1 小时前
JeeWMS 开源仓库管理系统多租户架构解析:一套 Java WMS 如何同时服务多个货主与多个仓库
java·架构·开源·vue
sunshine22 girl1 小时前
Java学习四 面向对象原理
java
干到60岁退休的码农1 小时前
15.创建获取用户接口与JWT认证器
java·spring boot·mybatis
小羊没烦恼!2 小时前
Memory 记忆设计讨论:Agent Memory 的数据模型可以怎么设计
java·开发语言·前端·c++·c#
SL_staff2 小时前
战略落地的最后一公里:从目标到个人日历的自动化链路实现
java·github·全栈
用户3721574261352 小时前
Java 设置 Excel 数据验证:整数、日期、文本长度、下拉列表和时间
java
白远山2 小时前
智慧场馆解决方案软件开发实战:从架构设计到落地部署指南
java·开发语言·架构·需求分析