理解停顿时间与吞吐量

聊到GC抑制提升启动性能,咱们得先把两个核心指标搞清楚:停顿时间和吞吐量。这俩词在JVM调优里就像“鱼和熊掌”,想兼得?嗯,基本不现实。

我个人习惯,每接手一个启动性能优化项目,第一件事就是先看GC日志里的停顿时间分布。你想想看,如果每次停顿都超过几百毫秒,那启动过程就像在泥潭里走路,一步一停,用户早跑了。

停顿时间(Pause Time)

说白了,就是GC线程“暂停一切”搞清理的那段时间。对用户线程来说,这就是一段“死寂期”。

  • STW(Stop-The-World):所有应用线程都被冻结,直到GC完成。
  • 影响因素:堆大小、GC算法、存活对象数量。
  • 启动场景的痛:应用刚启动,类加载、对象初始化正高频发生,一次Young GC如果停顿过久,直接拖慢整体初始化节奏。

我记得有个项目,线上服务启动要50秒,其中GC停顿累加占了12秒。当时用G1 GC,默认目标停顿时间设成200ms,但实际Young GC经常飙到400ms以上。调了`-XX:MaxGCPauseMillis`之后,虽然吞吐量降了一点,但启动总时间降到了38秒——这笔买卖划算。

关键指标:平均停顿时间、最大停顿时间、停顿频率。启动阶段更关注最大停顿时间,而不是平均。

吞吐量(Throughput)

吞吐量衡量的是“干活时间”占“总时间”的比例。公式很简单:

Throughput = (总时间 - GC总耗时) / 总时间

比如,应用运行了100秒,GC花了5秒,吞吐量就是95%。这个值越高,说明GC对系统的影响越小。

但这里有个容易踩的坑:吞吐量高不等于启动快。为什么?因为启动阶段很多时间是花在类加载、字节码执行上,GC本身占比可能不高。如果为了追求高吞吐量,把堆设得特别大,结果每次GC的停顿时间反而变长——你想想看,那启动速度不升反降。

我的建议:在启动性能优化场景下,优先控制停顿时间,而不是盲目追求吞吐量。启动阶段的“总时间”里,用户线程执行时间本来就短,GC停顿时间一旦放大,相对占比就会特别刺眼。

停顿时间 vs 吞吐量:经典权衡

我经常在团队里用下面这张表格说明二者的取舍关系:

维度 停顿时间(Pause Time) 吞吐量(Throughput)
关注点 单次GC的停顿时长 GC消耗的总CPU时间占比
优化目标 降低最大/平均停顿 提高CPU有效利用率
典型GC算法 G1、ZGC、Shenandoah Parallel Scavenge、Serial
启动场景倾向 低停顿优先(减少冻结时间) 高吞吐量可能带来更长停顿
副作用 可能导致更多次GC(吞吐下降) 单次GC更长(停顿增加)

我曾经在一个微服务启动优化项目里,初始用Parallel GC,吞吐量很高,但每次Young GC停60ms左右,启动时频繁GC,总累加停顿接近8秒。改用G1后,最大停顿控制在15ms以内,虽然吞吐量从99%掉到96%,但启动时间缩短了30%。这也证明了:启动性能的瓶颈往往在停顿时间,而不是吞吐量。

从GC日志看停顿与吞吐

怎么分析?最简单的方式是看GC日志。例如Parallel Scavenge的日志片段:

[GC (Allocation Failure) [PSYoungGen: 32768K->512K(37888K)] 32768K->2512K(124416K), 0.0461010 secs] [Times: user=0.05 sys=0.00, real=0.05 secs]

这里的 real=0.05 secs 就是停顿时间(50ms)。如果启动阶段每小时能看到几十次这样的日志,总停顿时间就很可观了。我自己的做法是:用jstat或者GCViewer工具,直接看GC总耗时和GC次数,算出平均停顿。

注意:启动阶段的GC日志往往被类加载日志淹没。建议加上 -Xlog:gc*:file=gc.log(JDK 9+)单独输出,别跟应用日志混在一起。

知识结构图

为方便你理解二者对启动性能的影响路径,我画了一张关系图:

停顿时间与吞吐量对启动性能的影响 停顿时间 vs 吞吐量 — 启动性能权衡 ⏸️ 停顿时间 STW暂停 最大停顿 ≥ 200ms → 启动变慢 用户感知“冻结” ⚡ 吞吐量 CPU有效利用率 >95% 算优秀 高吞吐 ≠ 快启动 大堆 → 更长停顿 此消彼长 权衡关键 启动优化:优先控制停顿时间,可适度牺牲吞吐量

实践中如何平衡?

说了这么多,具体到代码和参数上怎么搞?我一般遵循三步:

  1. 先测量:用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps 采集启动过程的GC日志,计算平均停顿和总停顿。
  2. 定目标:比如“启动阶段每个GC停顿不超过30ms”,然后选合适的GC算法(G1默认200ms目标,需要手动调低)。
  3. 微调:调整堆大小、新生代比例、并行线程数,观察停顿时间变化。不要一次调太多参数,每次只改一个。

举个例子,用G1时设置:

-XX:+UseG1GC -XX:MaxGCPauseMillis=50 -Xms256m -Xmx256m -XX:G1NewSizePercent=5

这个配置把最大停顿目标压到50ms,同时新生代初始占比设小,减少Young GC扫描范围。我在一个Spring Boot应用启动优化中用过,启动总停顿从2.3秒降到了0.8秒。

一个小技巧:如果启动阶段有大量对象快速晋升到老年代,可以考虑用 -XX:ConcGCThreads=2 增加并发标记线程,避免Mixed GC停顿过长。当然,这需要你根据CPU核数权衡。

关于停顿时间与吞吐量的权衡,核心就一句话:启动性能优化,停顿时间是第一性原理。 记住这个,后面的章节我们会在具体算法里反复验证这个观点。