【JVM原理详解】26-Parallel-Scavenge与吞吐量优先

📅 2026/8/1 0:44:14 👁️ 阅读次数 📝 编程学习
【JVM原理详解】26-Parallel-Scavenge与吞吐量优先

26-Parallel Scavenge 与吞吐量优先

上一篇讲了 Serial 和 ParNew,它们关注的是缩短单次 GC 停顿。但有一类应用并不在意某次停顿长短,而在意单位时间内完成的业务量——例如离线数据分析、批处理任务、ETL 作业。为这类场景,HotSpot 提供了Parallel Scavenge / Parallel Old收集器,它的设计目标是吞吐量优先。本篇将剖析它的原理、关键参数、自适应调节机制,以及它与 ParNew 的本质区别。

吞吐量的定义

GC 语境下的吞吐量(Throughput)有精确含义:

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 时间)

注意这不是"每秒处理请求数",而是用户代码运行时间占总时间的比例。一个 99% 吞吐量的系统,意味着 100 秒里有 99 秒在跑业务,1 秒在做 GC。

举个例子对比"延迟优先"与"吞吐量优先":

方案A(延迟优先):每 1 秒 GC 一次,每次 10ms → 吞吐量 = 990/1000 = 99% 方案B(吞吐优先):每 10 秒 GC 一次,每次 50ms → 吞吐量 = 9950/10000 = 99.5%

方案 B 单次停顿更长(50ms vs 10ms),但总 GC 时间更少,吞吐量更高。对于不与用户交互的后台任务,方案 B 更合适——这正是 Parallel Scavenge 的设计哲学。

Parallel Scavenge 收集器

Parallel Scavenge 是新生代收集器,基于复制算法多线程并行回收,回收时 STW。听起来和 ParNew 很像,但它有两个关键差异:

  1. 目标不同:ParNew 追求"降低单次停顿",Parallel Scavenge 追求"达到可量化的吞吐量"。
  2. 自适应:它能根据运行情况动态调整新生代大小、Eden/Survivor 比例、晋升老年代年龄等参数,无需人工调参。
新生代布局: ┌───────────────────────────────────┐ │ Eden │ S0 │ S1 │ │ (8/10) │(1/10)│(1/10)│ └───────────────────────────────────┘ ↑ 复制算法,多线程并行

与 ParNew 的本质区别

两者表面都是"多线程复制算法",但底层实现完全不同:

维度ParNewParallel Scavenge
设计目标降低停顿达到吞吐量目标
可搭配老年代CMS / Serial OldParallel Old
自适应调节有(UseAdaptiveSizePolicy)
代码框架与 CMS 共享独立实现
参数控制停顿为主吞吐量为主

最关键的差异是Parallel Scavenge 关注的是"吞吐量"这个全局指标,而不是单次停顿。它允许偶尔一次较长的 GC,只要总体 GC 时间占比足够低即可。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 的老年代搭档,多线程Mark-Compact 算法、STW。在 JDK 6 之前,Parallel Scavenge 只能搭配 Serial Old(单线程老年代),导致老年代 GC 成为瓶颈。Parallel Old 的出现让"全堆并行吞吐量优先"成为可能。

# JDK 8 默认收集器组合(Server 模式)java-XX:+UseParallelGC-XX:+UseParallelOldGC-cpMyApp com.example.Main

实际上 JDK 8 Server 模式下,这就是默认组合,无需显式指定。JDK 9 起,-XX:+UseParallelOldGC-XX:+UseParallelGC合并,开启一个即同时启用两者。

三大核心参数

Parallel Scavenge 之所以叫"吞吐量优先",靠的是下面三个参数的协同。

-XX:MaxGCPauseMillis

设置最大 GC 停顿时间目标(毫秒)。JVM 会尽量让单次 GC 停顿不超过这个值,方法是缩小新生代——新生代越小,回收越快。

java-XX:MaxGCPauseMillis=50-cpMyApp com.example.Main

但这里有个陷阱:停顿目标是软约束,不是硬保证。JVM 会努力逼近,但不保证每次都达标。更危险的是,盲目调小这个值会导致新生代被压缩,GC 频率上升,反而降低吞吐量。

MaxGCPauseMillis=50 → 新生代缩小 → 单次快但频繁 → 吞吐量下降 MaxGCPauseMillis=200 → 新生代扩大 → 单次慢但稀少 → 吞吐量上升

这是一个需要根据实际负载权衡的参数。对吞吐量优先的后台任务,适当放大停顿目标反而更优。

-XX:GCTimeRatio

直接以比例方式设定吞吐量目标。GCTimeRatio=N表示 GC 时间占总时间的1/(N+1)

GCTimeRatio=99 → 吞吐量目标 = 99/(99+1) = 99% GCTimeRatio=19 → 吞吐量目标 = 19/(19+1) = 95% GCTimeRatio=9 → 吞吐量目标 = 9/(9+1) = 90%(默认值)

默认值 9 意味着 JVM 默认目标是 90% 吞吐量。对于生产服务偏低,通常调到 19 或 99。GCTimeRatioMaxGCPauseMillis可能冲突——一个要低停顿(小新生代),一个要高吞吐(大新生代)。冲突时 JVM 以MaxGCPauseMillis优先,这可能导致吞吐量目标无法达成。

-XX:+UseAdaptiveSizePolicy

这是 Parallel Scavenge 最具特色的参数:自适应调节。开启后,JVM 根据运行历史动态调整:

  • 新生代 / 老年代大小比例
  • Eden / Survivor 比例
  • 对象晋升老年代的年龄阈值
java-XX:+UseParallelGC-XX:+UseAdaptiveSizePolicy\-XX:MaxGCPauseMillis=100-XX:GCTimeRatio=19\-cpMyApp com.example.Main

开启自适应后,你只需给出"目标"(停顿或吞吐量),JVM 自己找参数。这是"声明式调优"的早期实践,与 G1、ZGC 的设计思路一脉相承。

自适应的工作机制

JVM 内部维护一组运行统计:最近 N 次 GC 的停顿时长、吞吐量、各区域占用。每次 GC 后根据这些统计决定是否调整区域大小:

if (实测停顿 > MaxGCPauseMillis) { 缩小新生代 → 下次 GC 更快 } else if (实测吞吐量 < 目标吞吐量) { 扩大新生代 → 减少 GC 频率 } // 同时调整 Eden/Survivor 比例、晋升年龄

这种反馈机制让 Parallel Scavenge 在负载波动的场景下表现稳健——不需要人工介入,JVM 自己会找到平衡点。

代码示例:观察自适应调节

/** * 演示 Parallel Scavenge 自适应调节 * 适用 JDK 8/11/17 * * 运行: * java -Xms256m -Xmx256m -XX:+UseParallelGC * -XX:+UseAdaptiveSizePolicy * -XX:MaxGCPauseMillis=50 -XX:GCTimeRatio=19 * -Xlog:gc*=info,gc+heap=debug -cp MyApp AdaptiveGcDemo */publicclassAdaptiveGcDemo{privatestaticfinalint_1MB=1024*1024;publicstaticvoidmain(String[]args)throwsException{// 模拟波动的分配速率for(inti=0;i<100;i++){byte[]block=newbyte[_1MB];// 偶尔保留引用,制造长期存活对象if(i%10==0){cache(block);}Thread.sleep(20);}}staticfinaljava.util.List<byte[]>CACHE=newjava.util.ArrayList<>();staticvoidcache(byte[]block){CACHE.add(block);}}

观察日志中新生代大小的变化:

[0.234s][info][gc,heap] GC(0) PSYoungGen total 76288K, used 65536K ... [1.567s][info][gc,heap] GC(5) PSYoungGen total 152576K, used 131072K ... [3.891s][info][gc,heap] GC(12) PSYoungGen total 101376K, used 81920K ...

PSYoungGen是 Parallel Scavenge 新生代的内部名(PS = Parallel Scavenge)。可以看到total大小在多次 GC 后发生了变化——这正是自适应调节在起作用。

与 ParNew 的选择

实际项目中如何在两者间选择?决策核心是对停顿还是吞吐更敏感

对用户交互的 Web 服务 → 停顿敏感 → ParNew + CMS(JDK 8)或 G1(JDK 9+) 对后台批处理 / ETL → 吞吐敏感 → Parallel Scavenge + Parallel Old 对延迟敏感且堆大 → G1 / ZGC

一个实际案例:某公司夜间跑离线报表,每天凌晨 2 点启动一个 Java 进程处理 50GB 数据,跑 2 小时。这种场景:

  • 不与用户交互,停顿 200ms 也无所谓。
  • 但 2 小时内 GC 总时间越少越好。
  • Parallel Scavenge + Parallel Old是最优解。

反过来,一个在线 API 服务,P99 延迟要求 < 50ms,此时即使吞吐量 99%,一次 200ms 的停顿也会打爆 SLA——应选 G1 或 ZGC。

适用场景

后台计算与批处理

  • Hadoop/Spark 的 JVM 进程:MapReduce 任务不与用户交互,吞吐量第一。
  • ETL 数据管道:定时跑数据清洗,只看总耗时。
  • 离线训练 / 模型推理批处理:对单次延迟不敏感。

历史服务(JDK 8 默认)

JDK 8 Server 模式默认就是 Parallel Scavenge + Parallel Old。很多遗留系统跑在这套组合上,运行多年没出大问题——说明对"吞吐不敏感"到"吞吐重要"的中间地带,它是可靠的选择。

不适用场景

  • 交互式 Web 服务:停顿不可控,可能突然出现 200ms+ 的长尾。
  • 低延迟交易系统:单次停顿超 10ms 就会影响交易。
  • 大堆(> 8GB):Parallel Old 整理老年代时全堆 STW,堆越大停顿越长。

实践要点

1. 不要同时设 MaxGCPauseMillis 和 GCTimeRatio 且相互矛盾

# 矛盾配置:要 50ms 停顿又要 99% 吞吐-XX:MaxGCPauseMillis=50-XX:GCTimeRatio=99

JVM 会以MaxGCPauseMillis优先,GCTimeRatio形同虚设。选一个作为主目标即可。

2. 自适应开启后不要手动固定新生代

# 错误:开了自适应又固定新生代-XX:+UseAdaptiveSizePolicy-Xmn100m

-Xmn-XX:NewRatio会和自适应冲突。开启自适应后,让 JVM 自己管区域大小。

3. 监控 GC 频率而非单次停顿

吞吐量优先场景下,单次停顿波动正常。关注单位时间内 GC 总时间吞吐量百分比,这两个指标更能反映健康度。JDK 11+ 日志会在每次 GC 后输出User/Sys/Real时间,可据此计算。

4. Parallel Old 的 Full GC 是 STW 的

[12.345s][info][gc]GC(20)Pause Full(Ergonomics)800M->600M(1G)234.567ms

(Ergonomics)表示触发原因是自适应策略决定该做 Full GC 了。注意 Full GC 停顿随堆线性增长,4GB 堆可能轻松到秒级。大堆场景应迁移到 G1。

5. JDK 8 升级到 JDK 11 时的迁移

JDK 11 默认 G1,若你的批处理任务在 JDK 8 用 Parallel Scavenge 表现良好,可以显式保留:

java-XX:+UseParallelGC-cpMyApp com.example.BatchJob

对于批处理任务,不必盲目切 G1——Parallel Scavenge 的吞吐量优势在纯计算场景下依然存在。

小结

  • Parallel Scavenge / Parallel Old是"吞吐量优先"的收集器组合,新生代复制算法、老年代 Mark-Compact,全多线程并行。
  • 吞吐量 = 用户代码时间 / (用户代码时间 + GC 时间),与"低停顿"是不同的优化目标,后者允许总 GC 时间更多。
  • 三大核心参数:MaxGCPauseMillis(停顿目标)、GCTimeRatio(吞吐量目标)、UseAdaptiveSizePolicy(自适应开关),三者协同让 JVM 自行寻优。
  • 与 ParNew 的本质区别在于设计目标自适应能力,代码实现也相互独立,二者不可混搭。
  • 适用场景:后台批处理、离线数据分析、ETL 作业等不与用户交互的吞吐量敏感型任务;交互式 Web 服务应选 G1 或 ZGC。

下一篇我们将进入垃圾收集器的"并发"时代——CMS 收集器,它是第一款真正让老年代回收与应用线程并发的收集器,也是理解 G1、ZGC 并发回收思路的关键阶梯。

更多内容:JVM调优实战