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

第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 的策略

┌──────────────────────────────────────┐ │ 堆 (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 不适用)。
// 对象的"逃亡之旅"byte[]data=newbyte[1024*1024*10];// 10MB 大对象// → 如果 PretenureSizeThreshold=1MB → 直接进入 Old GenList<byte[]>cache=newArrayList<>();for(inti=0;i<100;i++){cache.add(newbyte[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 GCParallel 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 环境准备

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

3.2 分步实现

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

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

// YoungGCDemo.java —— 观察新生代回收importjava.util.ArrayList;importjava.util.List;publicclassYoungGCDemo{// 每 1MB = 1024 * 1024 / 4 = 262144 个 intprivatestaticfinalint_1MB=1024*1024/4;publicstaticvoidmain(String[]args)throwsException{System.out.println("PID: "+ProcessHandle.current().pid());System.out.println("开始分配,观察 Young GC...\n");List<int[]>youngRefs=newArrayList<>();// 存活对象(会被晋升)// 第一轮分配:填满 Edenfor(inti=0;i<30;i++){int[]arr=newint[_1MB];// 约 4MB per allocationif(i<5)youngRefs.add(arr);// 前 5 个存活System.out.println("分配第 "+(i+1)+" 个 4MB 数组");}System.out.println("第 1 次 Young GC 应该发生了\n");Thread.sleep(1000);// 第二轮分配:触发晋升for(inti=0;i<30;i++){int[]arr=newint[_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());}}
# 用 Serial GC 运行,Eden=80M, S0=S1=10M, Old=100Mjavac YoungGCDemo.javajava-XX:+UseSerialGC\-Xms200m-Xmx200m\-Xmn100m\-XX:SurvivorRatio=8\-XX:+PrintGCDetails\-XX:+PrintGCDateStamps\-Xlog:gc*:file=serial_gc.log\YoungGCDemo# 查看 GC 日志catserial_gc.log

日志解读

[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 运行,对比吞吐量差异。

// GCBenchmark.java —— 对比 GC 吞吐量publicclassGCBenchmark{privatestaticfinalint_1MB=1024*1024/4;publicstaticvoidmain(String[]args)throwsException{longstartTime=System.currentTimeMillis();inttotalWork=0;// 模拟批处理:分配大量对象并进行计算for(intround=0;round<10;round++){int[][]data=newint[50][_1MB];// 50 * 4MB = 200MB per round// 模拟"计算"for(inti=0;i<data.length;i++){for(intj=0;j<1000;j++){data[i][j]=i*j;}totalWork+=data[i][0];}// 大部分数组在循环结束后变成垃圾(朝生夕死)if(round%2==0){System.out.println("Round "+round+" 完成");}}longendTime=System.currentTimeMillis();longelapsed=endTime-startTime;System.out.println("总耗时: "+elapsed+" ms");System.out.println("总工作量: "+totalWork+" (防编译器优化)");}}
# 编译javac GCBenchmark.javaecho"=== Serial GC ==="timejava-XX:+UseSerialGC-Xms512m-Xmx512m\-Xlog:gc*:file=serial_bench.log GCBenchmarkecho""echo"=== Parallel GC ==="timejava-XX:+UseParallelGC-Xms512m-Xmx512m\-XX:ParallelGCThreads=4\-Xlog:gc*:file=parallel_bench.log GCBenchmarkecho""echo"=== 对比 GC 日志统计 ==="echo"--- Serial GC 统计 ---"grep-c"Pause Young"serial_bench.logecho"--- Parallel GC 统计 ---"grep-c"Pause Young"parallel_bench.log

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

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

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

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

// AllocationRateDemo.java —— 分配速率与 GC 停顿的关系publicclassAllocationRateDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{System.out.println("PID: "+ProcessHandle.current().pid());System.out.println("分配速率 (MB/s) | 每轮分配 MB");int[]sizes={1,2,5,10,20,50,100};// 每秒分配不同大小的内存for(intsizeMB:sizes){intsize=sizeMB*1024*1024/8;// 字节数组大小(long=8 bytes)longstartTime=System.nanoTime();longbytesAllocated=0;int[]keepRefs=newint[10];// 保留 10 个引用,其余可回收// 分配 2 秒while(System.nanoTime()-startTime<2_000_000_000L){long[]arr=newlong[size];// 快速分配keepRefs[(int)(Math.random()*10)]=(int)arr[0];// 随机保留引用bytesAllocated+=size*8L;}doublerate=bytesAllocated/(1024.0*1024.0)/2.0;// MB/sSystem.out.printf(" %3d MB | %.1f MB/s%n",sizeMB,rate);System.gc();// 清理后重新开始下一轮Thread.sleep(2000);}}}
# 用不同 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。

// FullGCDemo.java —— 触发老年代 Full GCimportjava.util.ArrayList;importjava.util.List;publicclassFullGCDemo{privatestaticfinalint_1MB=1024*1024/4;publicstaticvoidmain(String[]args)throwsException{System.out.println("PID: "+ProcessHandle.current().pid());System.out.println("制造 Full GC 场景...");List<int[]>liveList=newArrayList<>();// 持续分配长命对象,逐渐填满老年代for(inti=0;i<100;i++){liveList.add(newint[_1MB*10]);// 40MB per objectSystem.out.println("已分配 "+(i+1)+" 个 40MB 对象 → 约 "+((i+1)*40)+" MB");Thread.sleep(200);}// 当 Old Gen 满时会触发 Full GC// Full GC 将尝试回收,如果 liveList 持有所有引用,则无法回收 → OOM}}
# 堆=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 吞吐分别跑 GCBenchmarkParallel 总时间更短(多核)
Full GC 触发分配堆满对象且持有引用Full GC 出现且 Old Gen 无法回收
大对象直接进 Old分配 > PretenureSizeThreshold 的对象GC 日志显示直接在 Tenured 分配
#!/bin/bashecho"=== 完整 GC 验证 ==="echo"1. Young GC 演示"java-XX:+UseSerialGC-Xms128m-Xmx128m-Xmn64m\-Xlog:gc*=info-XX:+PrintGC\YoungGCDemo2>&1|grep-E"Pause|GC"echo""echo"2. Full GC 演示"java-XX:+UseSerialGC-Xms128m-Xmx128m-Xmn32m\-Xlog:gc*=info FullGCDemo2>&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
SurvivorRatioEden:S0:S1 = 8:1:1 是默认值。如果 Survivor 太小→过早晋升,太大→ Eden 太小→ GC 频繁
PretenureSizeThreshold仅对 Serial 和 Parallel GC 有效;G1 有独立的-XX:G1HeapRegionSize决定大对象阈值
AdaptiveSizePolicyParallel 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"。

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析