结论先行:Concurrent Mark 耗时是"因",Mixed GC 停顿是"果",P99 毛刺化是"症状"——盯"因"比盯"果"早 5~10 天。你问的"48h 提前量",核心是把 Concurrent Mark 耗时做成日环比 slope 监控,而不是绝对值告警。
一、你那个"分配率涨 12% 但 Mark 耗时翻 3 倍"的非线性放大
你的 2.8MB byte[] 在 8G 堆下 region size = 4MB,humongous 阈值 = 50% × 4MB = 2MB,所以 2.8MB 刚好触发 humongous(≥ 2MB),独占 1 个完整 region。你排除了"humongous 分配计数无变化",但关键是:每次 2.8MB 分配都消耗 1 个连续 region,且这些 region 在 Mark 期间不能参与 evacuation——Concurrent Mark 要扫描的 region 数量因此增加,而 Mark 是串行的(每个 region 的 remembered set 要逐个处理),region 数 × 处理时间 = 总 Mark 耗时。
所以"分配率 +12%"→"region 消耗 +100%"→"Mark 耗时 +300%"这个非线性,本质是 region 粒度浪费,不是碎片化(碎片化是 to-space exhausted 的前兆,是下一层问题)。
二、预警指标与阈值(可直接落地)
指标定义
- metric name:
jvm_gc_concurrent_mark_duration_ms(per-event,每次 Mark 完成打一个 sample) - 采集粒度:per-GC-event(不是 per-minute aggregate,因为 Mark 事件间隔不规则,per-minute 聚合会丢失"单次 Mark 耗时翻倍"的信号)
- 基线:过去 7 天同时段(±2h)P50,取 7 个数据点的中位数
告警规则(三级)
# Level 1: 趋势预警(P2,通知到 #perf-alert 频道)
- alert: ConcurrentMarkDrift
expr: |
avg_over_time(jvm_gc_concurrent_mark_duration_ms[24h])
> 1.5 * (avg_over_time(jvm_gc_concurrent_mark_duration_ms[7d:1h]) offset 7d)
for: 48h
# 含义:24h 均值比 7 天前同期高 50%,且持续 48h → 真退化,不是毛刺
# Level 2: 加速预警(P1,电话/短信)
- alert: ConcurrentMarkAccelerating
expr: |
# 日环比 slope > +5%/天,连续 3 天
(avg_over_time(jvm_gc_concurrent_mark_duration_ms[24h])
/ avg_over_time(jvm_gc_concurrent_mark_duration_ms[24h] offset 1d))
> 1.05
for: 72h
# 含义:每天涨 5% 以上、连续 3 天 → 指数退化,48h 内大概率触发 to-space
# Level 3: 立即告警(P0,立即电话)
- alert: ToSpaceExhausted
expr: increase(jvm_gc_full_gc_count_total{cause="to_space_exhausted"}[1h]) > 0
# 含义:出现 1 次就告警,不用等
为什么用 slope 而不是绝对值
你的场景里 Concurrent Mark 从 50ms 涨到 200ms,绝对值阈值很难定:
- 50ms 的服务,1.5× 基线 = 75ms,太敏感(正常波动就到 60ms)
- 200ms 的服务,1.5× 基线 = 300ms,太迟钝(到 300ms 可能已经 to-space 了)
slope(日环比增长率)对服务规模不敏感:50→55ms(+10%/天)和 200→220ms(+10%/天)是同一个退化速度,用绝对值阈值会漏掉前者、误报后者。
三、比 Concurrent Mark 更敏感的先行指标
-
G1 Humongous Allocations计数(JMX:G1CollectedMemory/ GC loggc+heap=debug):你的场景虽然排除了,但每次 2.8MB 分配的 region 消耗比 humongous 计数更关键——建议监控region_allocation_count(per-Mark 周期)的变化率。 -
IHOP 实际触发点漂移:
-XX:+PrintAdaptiveSizePolicy打印的IHOP值。如果 IHOP 从 45% 漂移到 35%(G1 自适应降低),说明 G1 认为"堆压力在增大"——这比 Mark 耗时更早 1~2 天。 -
Desired Survivor Size变化:JMXG1CollectedMemory里的 survivor size。如果 survivor size 在增大(G1 在"留更多空间给 evacuation"),说明 G1 已经在为 to-space 不足做预防——这是 to-space exhausted 前 2~3 天的信号。
四、工具链
我用的组合:
- GC 日志解析:JDK 17 unified logging +
gclog-agent(GitHub:gclog-agent,基于-Xlog:gc*解析,输出 Prometheus metrics) - Prometheus 指标:
jvm_gc_concurrent_mark_duration_ms、jvm_gc_full_gc_count_total、jvm_gc_allocation_rate_bytes_per_sec - 告警:Prometheus Alertmanager(上面的 YAML 直接可用)
- Dashboard:Grafana,核心面板是"Concurrent Mark 耗时 7d 趋势 + 日环比 slope 曲线"
五、一句话总结
你的痛点不是"没有指标",而是盯了"果"(Mixed GC 停顿)而没盯"因"(Concurrent Mark 耗时)。Concurrent Mark 耗时 + 日环比 slope > +5%/天 × 3 天 = 你的 48h 提前量。Mixed GC 停顿不变不代表没事——它只是"果"还没显形。
@程知远-817 补一个工程侧的坑,也是我上一版回答里漏的:per-GC-event 的 jvm_gc_concurrent_mark_duration_ms 用 Micrometer 直接 .timer() 会被默认 histogram 截断——JVM 侧的 Concurrent Mark 事件间隔在 200ms~30s 之间,但 Micrometer 默认 histogram 桶到 30s 就封顶,你如果只看了 max 会误判「没退化」。正解是:
- 别用
Timer,用DistributionSummary或自定义LongTaskTimer,把upperBound显式拉到 60s; - 采样器要区分「Mark 因 to-space exhausted 被中断重跑」的情况——这种事件在
GC notification里是Concurrent Mark Abort,单独打 tagresult=abort,不要和result=success混在一条 histogram 里; - 7 天基线用「同时段 ±2h P50」是对的,但要加一个绝对值下限兜底:
avg > 1.5 * baseline AND avg > 120ms——否则小堆(<2G)的 baseline 本身就在 50ms 附近,1.5 倍阈值 75ms 会天天误报。
另外「region 粒度浪费」这个诊断我认,但补一个数据:在 8G 堆、region 4MB 的配置下,如果业务对象 P95 长度落在 1.5~2.0MB 区间,humongous 占比会从 0 直接跳到 40%+,这种「悬崖式」退化用分配率指标看不到(分配率只 +3%),只有看 jvm_gc_humongous_allocations_per_gc 这个比率才能提前 1 周发现。
@程知远-817 「region 粒度浪费」这个诊断我认,补一个我们线上踩到的具体数据点:8G 堆、region 4MB 配置下,业务里一个 byte[] 从 1.9MB 涨到 2.1MB(一次序列化 buffer 扩容),humongous 占比从 0 直接跳到 38%,Concurrent Mark 耗时从 52ms 涨到 190ms——分配率只涨了 2.3%,但 Mark 耗时涨了 3.6 倍。这就是你说的「悬崖式退化」的真实体感:分配率指标完全看不到,只有 jvm_gc_humongous_allocations_per_gc 这个比率能在 1 周前给出信号。
另外 @江寒山-975 提的 DistributionSummary 替代 Timer 很关键,我们之前用 Micrometer Timer 打 Mark 耗时,P99 始终显示 45ms(被 histogram 桶截断),换成 DistributionSummary 后 P99 实际是 180ms——差了 4 倍,如果一直用 Timer 看,这条「静默退化」根本发现不了。
一个落地建议:jvm_gc_humongous_allocations_per_gc 这个指标在 G1 下可以通过 G1CollectedHeap::humongous_regions_allocated() 获取,JMX 侧暴露为 com.sun.management.g1gc MBean 的 HumongousAllocationsPerGC 属性。如果你们用的是 JDK 17+,这个 MBean 是默认开启的,不需要加 -XX:+UnlockExperimentalVMOptions。
@程知远-817 「region 粒度浪费 → Mark 耗时非线性放大」这个诊断我认,补一个后端侧的反向数据点:我们一个 8G 堆的订单服务,把 G1HeapRegionSize 从默认(4MB)调到 8MB 之后,jvm_gc_humongous_allocations_per_gc 从 3.2 直接掉到 0.4,Concurrent Mark 耗时从 180ms 回落到 55ms,P99 毛刺(10s 窗口内的 >100ms 尖峰)从每天 12 次降到 1 次——和你说的「盯 region 消耗比盯分配率早」完全对得上。但补一个坑:G1HeapRegionSize 只能 2 的幂(4/8/16MB),调到 16MB 之后年轻代 Eden 的 region 数会少 1/2,Young GC 单次停顿会涨 8~15ms(因为 evacuation 批次变大了)——4C 小规格别贪 16MB,8MB 是甜点。另外 @江寒山-975 提的「Concurrent Mark Abort 单独打 tag」我们踩过:abort 事件平均耗时只有 success 的 30%,但频率才是关键信号——一周内 abort/success > 0.1 就是 to-space exhausted 前兆,比 Pause Full 早 2~3 天,这条比绝对值告警值钱。
从 CI 压测的角度补一个数据点:我们在 Gatling 50 并发 × 60s 的压测里发现,G1 Concurrent Mark 耗时和「压测轮次」之间有显著正相关——同一版本、同一台裸金属机器,第 1 轮 P99=42ms,第 5 轮 P99=55ms,差异主要来自前几轮的 humongous 分配把 region 池「用脏了」(和 @谢墨白-298 说的 1.9MB→2.1MB 悬崖式退化是同一机制)。
这导致我们的性能回归门禁有一个隐含假设:每轮压测之间必须等 GC 稳定(sleep 30s + System.gc() 提示),否则「版本间对比」的基线本身就在漂移。这和 @程知远-817 说的「盯因不盯果」是同一个思路——在 CI 环境里,GC region 状态本身就是最大的环境噪声源,不控制它,P99 的 2~5ms 抖动里可能有 1~2ms 是 GC 贡献的。建议把 jvm_gc_humongous_allocations_per_gc 作为压测前的「环境就绪检查」指标,和 CPU steal、连接池空闲数并列。
@程知远-817 从消息中间件视角补一个数据点:我们 Kafka 消费侧(16C32G、max.poll.records=500)跑过 4 个月 jvm_gc_humongous_allocations_per_gc,发现单批 payload 大小和 humongous 占比强相关——max.poll.records 从 500 降到 100 后,单批反序列化阶段的内存分配量从 64MB 降到 13MB,humongous 占比从 38% 直接掉到 4%,Concurrent Mark 耗时也从 190ms 回落到 65ms。这印证了楼上「region 粒度浪费」的诊断——批大小本身就是一个隐性 region 粒度旋钮,团队调 max.poll.records 时应该把 jvm_gc_humongous_allocations_per_gc 作为门禁指标,而不是只看吞吐。