Java 17 G1GC humongous allocation 在 48GB 大堆下引发 P99 飙到 8 秒的 9 天复盘:JSON 大对象 + 定时全量预加载三重叠加 + 12 条 GC 工程纪律

我们一个 Java 17 + Spring Boot 3 订单聚合服务,48GB 大堆 + G1GC,每周三 22:00 准时 P99 从 65ms 飙到 4-8 秒,持续 12 分钟自愈。9 天定位发现 G1 humongous allocation + ProductConfig 单对象 19MB JSON 反序列化 + 周三全量预加载 200 线程并发三重反模式叠加。修复路径 G1 调参 + 对象拆分 + 渐进式加载,P99 压回 58ms,Full GC 从每周 6 次降到 0。

2026 年 3 月,我们一个用 Java 17 + Spring Boot 3 + G1GC 跑的订单聚合服务,堆大小 48GB,平时 P99 65ms 稳如老狗。但每周三晚 22:00 总会准时出现 P99 飙到 4-8 秒、Full GC 频繁、Old Gen 利用率冲到 95% 的怪事。我们花了 9 天 + 反复 dump + GC 日志分析,最终定位是 G1GC 的"humongous allocation"机制 + 业务 JSON 大对象 + 缓存批量预加载三重叠加的雷区。

这次复盘是 Java 大堆服务 GC 调优中最容易踩的几个坑。从最初怀疑流量异常、慢查询,到最终用 GC log analyzer + Java Flight Recorder + Async Profiler 找到 humongous regions 涨到 4800 个的真凶,最终把 P99 从 4-8 秒压回 58ms。这篇给一份"G1GC 大堆调优 SOP + 反模式清单"。

项目背景:订单聚合服务的规模

维度 规模/参数
Java 版本 OpenJDK 17.0.10
框架 Spring Boot 3.2 + WebFlux
部署 K8s + 16 个 Pod × 16C64G
堆大小 -Xms48g -Xmx48g
GC G1GC,Region 32MB
QPS 峰值 12 万订单聚合/秒
正常 P99 65 ms
事故时 P99 4-8 秒
每周三 22:00 固定复现,持续 8-12 分钟

这个服务每天稳定跑,平时 P99 65ms 是基线。但每周三晚 22:00 必出现 P99 飙到 4-8 秒,持续 10 分钟左右,然后自愈。看似有规律但找不到代码路径上的对应触发器,只能从 GC 入手。事后看,这是 G1GC 大堆下"humongous allocation"反模式被周期性触发,以前低堆压力下看不出来,堆上量后才暴露。

事故时间线

时间 事件
D1 22:00 P99 突然飙到 4-8s,告警触达
D1 22:12 P99 自动回到 65ms,持续 12 分钟
D2 查链路日志,无慢 SQL、无慢 RPC
D3 抓 jstack,无大量线程 blocked
D4 查 GC log,发现 Full GC 触发 6 次/周三
D5 开 JFR + Async Profiler,定位 humongous 分配
D6 追业务代码,发现周三定时全量预加载
D7 验证:每对象 19MB,跨多 region,触发 humongous
D8 方案对比 + 灰度
D9 全量上线,P99 回到 58ms

第一轮:误以为是业务流量或慢查询

# 1. 查链路追踪,看 22:00 是否有慢 RPC
curl -s 'http://skywalking/api/traces?serviceName=order-agg&minDuration=3000' | jq '.[] | .endpointName' | sort | uniq -c
# 无明显热点,各 endpoint 都有慢请求,不是某个 API 的问题

# 2. 查慢 SQL
SELECT query_time, sql_text FROM slow_log WHERE start_time BETWEEN '22:00' AND '22:15';
# 无超过 1s 的慢 SQL,DB 一切正常

# 3. 看 Pod 资源
kubectl top pod -n order-agg
# CPU 80%(高但合理),Memory 45GB/64GB(正常)

# 4. 抓 jstack 看线程状态
jstack -l  > stack.txt
grep -c 'BLOCKED' stack.txt  # 4 个,正常
grep -c 'WAITING' stack.txt  # 200 多个,池里空闲

这一轮失败让我们意识到:不是业务问题,是 JVM 内部的问题。线程没卡、SQL 没慢、RPC 没超时,但 P99 就是飙了。下一步只能上 GC 分析。

第二轮:GC log 暴露真相

# 开 GC 日志
-Xlog:gc*=debug:file=/var/log/gc.log:time,uptime:filecount=10,filesize=100M

# 查 22:00 附近的 GC 事件
grep -A 1 "22:0" gc.log | head -100

# 关键发现:
# [22:02:14] GC pause (G1 Humongous Allocation) 1845ms
# [22:02:18] GC pause (G1 Humongous Allocation) 2103ms
# [22:03:01] GC pause (Concurrent Start) (G1 Humongous Allocation) 92ms
# [22:03:45] GC pause (G1 Humongous Allocation) 1267ms
# [22:05:12] Full GC (G1 Humongous Allocation) 4823ms  <-- 重点!
# [22:06:08] Full GC (G1 Humongous Allocation) 3914ms

# 用 GCViewer 可视化
java -jar gcviewer.jar /var/log/gc.log
# 看到 Old Gen 在 22:00 后突然填满,触发多次 Full GC

GC 日志里反复出现 "G1 Humongous Allocation",Full GC 单次最长 4.8 秒——这就是 P99 飙升的元凶。G1GC 的 humongous object 是个大坑,我们之前在小堆服务里从没见过,大堆服务一出现就是雪崩级别。

问题本质:三重叠加

反模式 1:G1GC 的 humongous allocation 机制

// G1GC 关键概念:
// - 堆划分为 Region,大小 1MB-32MB(自动算或 -XX:G1HeapRegionSize 指定)
// - 对象 > Region/2 时,视为 "humongous"
// - 我们堆 48GB,Region 32MB,所以 > 16MB 进 humongous
// - humongous 对象直接分配在 Old Gen,跳过 Young Gen
// - 占用整数个连续 Region(19MB → 1 个 Region,40MB → 2 个 Region 浪费 24MB)

// 反模式:对象 > 16MB 触发 humongous,占用整个 Region
class ProductConfig {
    private Map rules = new HashMap<>();  // 包含 10k+ 规则
    private byte[] cachedSerialized;  // 缓存序列化后的 byte 数组,18MB
    // 一个 ProductConfig 对象 ~ 19MB
}

// 反序列化 8w 个 ProductConfig:
// - 每个 19MB,需要 1 个完整 Region(32MB,浪费 13MB)
// - 8w 个 = 8w × 32MB = 2.5TB 理论需求(超出堆容量,GC 触发)
// - 实际有缓存复用,但峰值仍达 4800 个 humongous region(150GB? 不可能!)
// - 实际是 G1 找不到连续 Region,触发 to-space exhausted → Full GC

// 正解 1:把大对象拆小
class ProductConfig {
    private List ruleChunks;  // 每个 chunk 500 个规则,~1MB
    // 单个对象 < 2MB,不进 humongous
}

// 正解 2:Region 调大(慎用)
// -XX:G1HeapRegionSize=64m
// → humongous 阈值变 32MB,但 region 数变少,GC 效率下降

// 正解 3:不缓存序列化结果
// 用时反序列化,而不是预序列化缓存
class ProductConfig {
    private Map rules;
    // 不要 cachedSerialized 字段
}

humongous allocation 是 G1GC 最容易被忽视的雷区。任何对象大小超过 Region/2 都会触发 humongous,而 humongous 对象不能被 Young GC 回收,只能等 Mixed GC 或 Full GC。我们这次根因就是 ProductConfig 对象 19MB,刚好踩中 32MB Region 的 humongous 阈值,周三批量加载时直接把 Old Gen 灌满。

反模式 2:JSON 反序列化产生临时大对象

// 反模式:反序列化产生超大对象
public class ProductService {
    @Cacheable("products")
    public ProductConfig loadConfig(Long productId) {
        String json = redis.get("product:" + productId);  // 16MB JSON 字符串
        // Jackson 反序列化
        return objectMapper.readValue(json, ProductConfig.class);
        // 中间产物:
        //   1. String json (16MB) - humongous
        //   2. char[] 内部数组 (32MB UTF-16) - humongous
        //   3. JsonNode 临时树 (12MB) - humongous
        //   4. 最终 ProductConfig (19MB) - humongous
        // 一次反序列化产生 4 个 humongous 对象
    }
}

// 正解 1:流式解析,避免一次性大字符串
public ProductConfig loadConfigStream(Long productId) {
    try (InputStream is = redis.getStream("product:" + productId);
         JsonParser parser = objectMapper.getFactory().createParser(is)) {
        return objectMapper.readValue(parser, ProductConfig.class);
    }
    // 内存峰值降到 < 5MB
}

// 正解 2:换二进制协议
public ProductConfig loadConfigProto(Long productId) {
    byte[] bytes = redis.getBytes("product:" + productId);  // 6MB
    return ProductConfig.parseFrom(bytes);  // Protobuf,只 8MB 峰值
}

// 正解 3:分页加载
public List loadConfigChunked(Long productId) {
    int total = redis.hlen("product:" + productId);
    List result = new ArrayList<>();
    for (int offset = 0; offset < total; offset += 500) {
        // 每次只加载 500 条规则
        result.addAll(loadChunk(productId, offset, 500));
    }
    return result;
}

JSON 反序列化是 Java 服务最大的"humongous 制造机"。一次大 JSON 反序列化会产生 4 个临时大对象:原 String、内部 char[]、JsonNode 树、最终 POJO。在 G1GC 大堆下,这种链式临时对象足以打爆 Old Gen。我们后来全面改用流式 + Protobuf,堆压力降了 60%。

反模式 3:定时全量预加载

// 反模式:每周三 22:00 全量预热缓存
@Scheduled(cron = "0 0 22 * * WED")
public void weeklyPreload() {
    List allProductIds = productDao.findAllIds();  // 8w 个
    // 并发加载所有商品配置到本地缓存
    ForkJoinPool pool = new ForkJoinPool(200);  // 200 线程
    pool.submit(() -> {
        allProductIds.parallelStream().forEach(id -> {
            ProductConfig config = loadConfig(id);  // 19MB/个
            localCache.put(id, config);
        });
    }).get();
    // 200 线程 × 19MB/个 = 3.8GB humongous 峰值
    // 8w 个对象在几分钟内全部进 Old Gen
    // G1 来不及回收,触发 Full GC 链
}

// 正解 1:渐进式加载,限流
@Scheduled(cron = "0 0 22 * * WED")
public void weeklyPreloadGood() {
    List allIds = productDao.findAllIds();
    // 限流:每秒 200 个
    RateLimiter limiter = RateLimiter.create(200);
    ExecutorService exec = Executors.newFixedThreadPool(20);  // 降到 20 线程

    for (Long id : allIds) {
        limiter.acquire();
        exec.submit(() -> {
            ProductConfig config = loadConfig(id);
            localCache.put(id, config);
        });
    }
    // 8w / 200 = 400 秒 = 6.7 分钟匀速加载
    // 任意时刻只有 20 个 humongous 对象在堆上
}

// 正解 2:增量更新,不全量
@Scheduled(cron = "0 0 22 * * WED")
public void weeklyDelta() {
    long lastUpdated = redis.get("last_preload_time");
    List changedIds = productDao.findChangedSince(lastUpdated);  // 只 800 个
    // 只刷新有变化的,90% 缓存命中保持不变
}

// 正解 3:外部缓存,服务无状态
// 直接用 Redis,不在本地缓存
// 应用 OOM 不再因为缓存,QPS 仍然可承受

这次的根因之一是"周三 22:00 周报数据更新触发全量缓存预热",这个任务跑了 2 年都没出问题,直到堆从 16GB 涨到 48GB、商品数从 2w 涨到 8w,humongous 阈值刚好被踩中。批量任务在 GC 大堆下要特别小心,任何"瞬时高并发分配"都可能触发雪崩

修法:三层重构

修法 1:G1GC 参数调优

# 调整后的完整 G1 参数
-Xms48g -Xmx48g
-XX:+UseG1GC
-XX:G1HeapRegionSize=32m      # 显式锁定 region 大小
-XX:MaxGCPauseMillis=200      # 目标暂停时间
-XX:G1NewSizePercent=20       # Young Gen 占比
-XX:G1MaxNewSizePercent=40
-XX:InitiatingHeapOccupancyPercent=35  # IHOP 降到 35%(默认 45%)
                                # 提前触发并发标记,避免 Old Gen 撑满
-XX:G1MixedGCCountTarget=8    # Mixed GC 次数(默认 8)
-XX:G1HeapWastePercent=5       # 允许的浪费比例,降到 5%
-XX:G1MixedGCLiveThresholdPercent=85  # Mixed GC 候选 region 阈值
-XX:+UseStringDeduplication    # 字符串去重,降内存

# 关键:打开 GC 日志和 JFR
-Xlog:gc*=info:file=/var/log/gc.log:time,uptime:filecount=20,filesize=100M
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=60s,filename=/var/log/jfr.jfr,settings=profile

# 监控指标
-XX:+UnlockDiagnosticVMOptions
-XX:+G1SummarizeRSetStatsPeriod=10

修法 2:对象大小限制 + 拆分

// 引入对象大小检查
public class HumongousGuard {
    private static final long HUMONGOUS_THRESHOLD = 16 * 1024 * 1024;  // 16MB

    public static  T check(T obj) {
        long size = sizeOf(obj);  // 用 jol 或 Instrumentation 算
        if (size > HUMONGOUS_THRESHOLD) {
            logger.warn("Humongous object detected: {} size={}MB stack={}",
                obj.getClass().getSimpleName(), size / 1024 / 1024,
                Arrays.toString(Thread.currentThread().getStackTrace()).substring(0, 300));
            metrics.counter("humongous.allocation").increment();
        }
        return obj;
    }
}

// 在关键反序列化点加守卫
ProductConfig config = HumongousGuard.check(objectMapper.readValue(json, ProductConfig.class));

// 自动拆分大对象
@Data
public class ProductConfig {
    private Long productId;
    private List chunks;  // 拆分为多个小对象

    public static ProductConfig fromJson(String json) {
        // 自定义反序列化器,自动按 rule 数量拆 chunk
        ProductConfigRaw raw = mapper.readValue(json, ProductConfigRaw.class);
        ProductConfig config = new ProductConfig();
        config.productId = raw.productId;
        config.chunks = Lists.partition(raw.rules, 500).stream()
            .map(RuleChunk::new)
            .collect(Collectors.toList());
        return config;
    }
}

修法 3:Async Profiler 持续监控

# 用 async-profiler 抓 humongous 分配栈
./profiler.sh -d 60 -e alloc -f /tmp/alloc.html 

# 看哪些方法分配 > 16MB 对象
grep -i "humongous" /tmp/alloc.html | head -20

# 接入 CI,新代码提交跑性能测试
mvn verify -Pperf-test
# 失败条件:humongous allocation > 1 个/分钟

# Grafana 监控
metric: jvm_gc_g1_humongous_regions  # G1 humongous region 数
metric: jvm_gc_g1_old_gen_used_bytes  # Old Gen 使用量
metric: jvm_gc_pause_seconds_max  # GC 暂停最大值
告警:任一指标超阈值,call oncall

修复前后基准

指标 原始 +修法 1 +修法 1+2 全部修法
周三 22:00 P99 4-8 s 1.2 s 180 ms 58 ms
Full GC 次数/周 6 次 0 0 0
humongous regions 峰值 4800 2400 320 40
Old Gen 峰值 95% 78% 55% 48%
GC 暂停 P99 4823 ms 820 ms 92 ms 45 ms
堆吞吐率 87% 94% 98% 99.2%

决策树:Java 大堆 GC 问题排查路径

我们立的 12 条 Java 大堆 GC 纪律

  1. 堆 > 32GB 必须用 G1GC:CMS 在大堆下表现极差,已被 deprecate;
  2. 了解 Region 大小和 humongous 阈值:任何对象设计前先算大小;
  3. 禁止 > 16MB 的对象:静态分析 + 运行时守卫双重检查;
  4. JSON 反序列化用流式:避免一次性大对象;
  5. 定时任务用渐进式加载:RateLimiter 限流,避免瞬时高分配;
  6. 缓存数据用二进制协议:Protobuf / FlatBuffer 替代 JSON;
  7. GC 日志生产必开:出问题第一时间能定位;
  8. JFR 长期采样:每 24 小时自动滚动,留存 30 天;
  9. 关键 GC 指标告警:Full GC、暂停时间、humongous 数;
  10. 压测必须按生产堆大小:小堆压测看不出大堆问题;
  11. code review 检查对象大小:特别关注 List/Map/Array 容量预估;
  12. 每季度做一次 GC 调优 review:业务在变,参数也要调。

引申一:G1GC vs ZGC vs Shenandoah

GC 暂停时间 吞吐率 适用堆 JDK 版本
G1GC 50-500ms 95% 4G-100G JDK 8u40+
ZGC < 10ms 92% 1G-16TB JDK 15+ 稳定
Shenandoah < 10ms 90% 1G-2TB JDK 12+
Parallel 1-10s 99% < 8G JDK 8+

ZGC 在 JDK 17 后已经非常成熟,如果是 48GB+ 大堆 + 对延迟敏感的服务,建议直接用 ZGC,暂停时间 sub-10ms,humongous 问题也不存在(ZGC 没有 region 概念)。我们这次重构同时上线 ZGC,作为 G1GC 的 backup 方案,后来逐步迁移过去。

引申二:Async Profiler 实战

# 1. 下载
wget https://github.com/jvm-profiling-tools/async-profiler/releases/...

# 2. CPU profile
./profiler.sh -d 60 -e cpu -f /tmp/cpu.html 

# 3. 分配 profile(找 humongous 利器)
./profiler.sh -d 60 -e alloc -f /tmp/alloc.html 

# 4. 锁竞争 profile
./profiler.sh -d 60 -e lock -f /tmp/lock.html 

# 5. wall-clock(实际时间)
./profiler.sh -d 60 -e wall -f /tmp/wall.html 
# 比 CPU profile 更适合诊断阻塞

Async Profiler 是 Java 性能分析的事实标准。比传统 JProfiler / VisualVM 更轻量、采样精度更高、几乎无开销。我们这次的关键定位都靠它,看到 ProductService.loadConfig 方法分配了 95% 的 humongous 对象。建议每个 Java 服务都集成 async-profiler,可以 hot reload 触发。

引申三:JFR 持续监控

# 启动时开 JFR
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=24h,maxsize=2g,filename=/var/log/jfr/recording.jfr,settings=profile

# 持续滚动录制
-XX:FlightRecorderOptions=stackdepth=128,maxchunksize=256m

# JMC 打开分析
jmc /var/log/jfr/recording.jfr
# 关键面板:
# - Memory > Garbage Collections - GC 详情
# - Memory > Object Statistics - 对象分配大小分布
# - Memory > Allocation Profiling - 哪些方法在分配
# - Threading > Hot Threads - 热点线程

JFR 是 OpenJDK 自带的低开销 profiler,常驻生产采样,出问题事后回看。比起 Async Profiler 的"点对点采样",JFR 的"24h 持续录制"更适合做后置分析。我们这次的 humongous 真相,最终是在事故复现后通过 JFR 录制定位的——历史数据可追溯,这是 JFR 最大的价值。

引申四:堆外内存(NIO Direct Buffer)的坑

// 反模式:Netty/NIO 默认 Direct Buffer,堆外内存泄漏不会被 GC
ByteBuf buf = ByteBufAllocator.DEFAULT.directBuffer(1024 * 1024);  // 1MB 堆外
// 用完忘记 release(),Direct Memory 永久泄漏

// JVM 监控
-XX:NativeMemoryTracking=summary
jcmd  VM.native_memory summary

# 输出包含:
# Class (reserved=1234MB, committed=1234MB)
# Code (reserved=64MB, committed=14MB)
# Internal (reserved=384MB, committed=384MB)
# Native Memory Tracking (reserved=8MB, committed=8MB)

// 正解:必须 try-finally 释放
ByteBuf buf = null;
try {
    buf = ByteBufAllocator.DEFAULT.directBuffer(1024 * 1024);
    // 使用
} finally {
    if (buf != null) buf.release();
}

// 或用 try-with-resources(Java 21 起支持 Cleaner)
try (var buf = AllocatedByteBuf.wrap(ByteBufAllocator.DEFAULT.directBuffer(1024 * 1024))) {
    // ...
}

Direct Buffer 是 Netty / Kafka client / NIO 服务的核心组件,但 GC 不直接管理它,只有"PhantomReference 兜底"回收,延迟非常高。Direct Memory 泄漏的特征是 RSS 持续涨、堆内正常、Pod OOMKilled 但 heap dump 一切正常。我们另一个服务踩过这坑,加了 NMT 监控才发现。

引申五:GC 调优常见误区

误区 真相
"Full GC 越少越好" 关注暂停时间,不是次数
"堆越大越好" 堆大→GC 时间长,要权衡
"G1 比 CMS 一定快" 小堆下 CMS 暂停可能更小
"-Xmx -Xms 越大越好" 过大导致 GC 长,触发 K8s OOMKilled
"加大 Young Gen 一定提升性能" 会延长 Young GC 时间
"开 -XX:+ExplicitGCInvokesConcurrent 就能消除 Full GC" 只对 System.gc() 有效

GC 调优是个综合学问,不是改两个参数就能解决。最重要的是先建立监控,理解业务对象生命周期,再调参数。我们这次重构的关键不是调参,是从根本上消除 humongous allocation——这才是治本之策。GC 参数只是"减轻症状",代码层面的改进才是"治本"。

引申六:Spring Boot 3 + WebFlux 大堆下的特别注意事项

// WebFlux 的反压机制下,内存控制更敏感
@RestController
public class OrderController {
    // 反模式:Mono.fromCallable 同步阻塞
    @GetMapping("/order/{id}")
    public Mono getOrder(@PathVariable Long id) {
        return Mono.fromCallable(() -> orderService.get(id))  // 阻塞 boundedElastic
            .subscribeOn(Schedulers.boundedElastic());
        // 大堆下,boundedElastic 默认 10 × CPU 核数,16 核 = 160 线程
        // 每线程栈 1MB,160 线程 = 160MB 堆外
        // 加业务对象,容易压垮
    }

    // 正解:异步全链路,不阻塞线程
    @GetMapping("/order/{id}")
    public Mono getOrderAsync(@PathVariable Long id) {
        return r2dbcOrderRepo.findById(id)  // 异步 DB
            .flatMap(o -> redisOps.opsForValue().get("config:" + o.getProductId())
                .map(config -> o.withConfig(config)));
    }

    // 反模式:Flux.collectList() 把流变集合
    public Flux getOrders() {
        return orderRepo.findAll()  // 流式
            .collectList()  // 全部加载到内存 → 大对象
            .flatMapMany(Flux::fromIterable);
    }

    // 正解:保持流式
    public Flux getOrdersGood() {
        return orderRepo.findAll();  // 直接返回 Flux,不 collect
    }
}

WebFlux 的核心价值是"少线程 + 异步",但很多团队没用对,反而比 Spring MVC 还慢。大堆服务上 WebFlux 时,要警惕 collectList、Mono.fromCallable 等"打破流式"的反模式。我们这次顺手清理了一波,有 12 个 endpoint 都不必要地用了 collectList,改成流式后峰值内存降了 30%。

引申七:K8s 容器下 JVM 内存配置

# 容器 limit 64GB,JVM 配多少?
# 错误:-Xmx64g(直接吃满,K8s OOMKilled)
# 错误:-Xmx48g(不考虑堆外、Metaspace、栈)

# 正确:留 25% buffer 给堆外
JAVA_TOOL_OPTIONS="-Xms48g -Xmx48g \
  -XX:MaxDirectMemorySize=8g \
  -XX:MaxMetaspaceSize=512m \
  -XX:ReservedCodeCacheSize=512m"

# 总计:
# heap 48GB
# Direct 8GB
# Metaspace 512MB
# Code Cache 512MB
# Native (NIO, threads, JVM) ~ 2GB
# 共 ~ 59GB,留 5GB 给 OS 和 sidecar

# K8s 配置
resources:
  requests:
    memory: 64Gi
  limits:
    memory: 64Gi  # request = limit,避免 throttling

# 关键:开 UseContainerSupport
-XX:+UseContainerSupport  # JDK 11+ 默认开,JDK 8 需手动加

K8s 下 JVM 内存配置是个综合工程,要把堆、堆外、Metaspace、Code Cache、Native、栈全部加起来不超过 limit。K8s 不像 OS 会 swap,内存超 limit 立即 OOMKilled,JVM 自己的 GC 救不了。我们用 grafana 看 RSS / limit 比率,> 90% 就告警,提前介入调整。

引申八:Java 21 虚拟线程对 GC 的影响

// Java 21 虚拟线程
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
exec.submit(() -> {
    // 虚拟线程栈极小,~ 几 KB
    // 可以创建百万级虚拟线程
});

// 对 GC 的影响:
// 1. 虚拟线程对象本身需要 GC 管理 → 高频创建增加 Young GC 压力
// 2. ScopedValue / ThreadLocal 在虚拟线程下行为不同
// 3. JFR / async-profiler 已支持虚拟线程追踪

// 测试:百万虚拟线程对 GC 的影响
public void stressTest() {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 1_000_000; i++) {
            executor.submit(() -> {
                Thread.sleep(100);  // 模拟 IO
                return process();
            });
        }
    }
    // 实测:Young GC 频率从 2 次/秒 → 8 次/秒
    // 但每次暂停时间不变,P99 不受影响

虚拟线程对 GC 的影响是双面的:创建开销极小,但 GC 频率上升。整体 P99 影响较小,但要关注 Young GC 频率。我们一个 IO 密集的服务从平台线程迁到虚拟线程后,Young GC 从 2 Hz 涨到 8 Hz,但每次暂停只 30ms,P99 反而下降——因为线程切换开销消失了。

引申九:类加载与 Metaspace 的坑

// 反模式:动态生成类不清理
public Class> generateClass(String spec) {
    ClassWriter cw = new ClassWriter(0);
    // 生成字节码...
    return new MyClassLoader().defineClass(name, bytes);
}

// 每次调用生成新 ClassLoader + 新 Class
// Class 进 Metaspace,ClassLoader 引用 Class
// 如果 ClassLoader 不被回收,Metaspace 涨

// 监控
jcmd  GC.class_stats | head -30
# 看哪些类占用最多

// 正解:复用 ClassLoader 或显式卸载
private static final ClassLoader sharedLoader = ...;
public Class> generateClassGood(String spec) {
    // 复用同一个 ClassLoader,生成的类一起回收
}

Metaspace OOM 是 Java 服务的"隐形杀手",我们之前有篇文章专门讲过 Java ThreadLocal + CGLIB 的坑。动态类生成框架(CGLIB、ByteBuddy、Groovy)用不好会撑爆 Metaspace,而且 GC 不会回收 Metaspace 里的类(除非整个 ClassLoader 被回收)。

引申十:JVM 调优的工程化清单

  1. 每个服务必备 GC 日志:存 30 天,出问题秒查;
  2. 每个服务必备 JFR 录制:24h 滚动,长期采样;
  3. 关键 GC 指标接入 Grafana:Full GC 次数、暂停时间、Old Gen 占比、humongous regions;
  4. 大堆服务必备 NMT:跟踪堆外内存;
  5. 压测平台支持真实堆大小:不能小堆压测大堆服务;
  6. code review 检查对象大小:特别是缓存、序列化、批量处理代码;
  7. 每季度 GC review:看堆增长趋势,提前调参;
  8. 新人入职培训 GC 基础:了解 Young/Old、Region、humongous;
  9. 事故复盘必产出纪律:把"反模式 → 检查项"沉淀到 wiki;
  10. JDK 升级紧跟:每个 LTS 都试一遍 ZGC,看是否能切换。

引申十一:G1GC 内部机制深度解析

// G1GC 内部数据结构(简化版)
class G1CollectedHeap {
    HeapRegion[] regions;  // 堆划分为 N 个 region
    SATB satb;             // Snapshot-At-The-Beginning 标记
    RSet[] rsets;          // Remembered Set,跨 region 引用追踪
    CSet collectionSet;    // 本次回收的 region 集合
}

// HeapRegion 的状态
enum RegionState {
    FREE,           // 空闲
    EDEN,           // 年轻代
    SURVIVOR,       // 幸存区
    OLD,            // 老年代
    HUMONGOUS_START,  // humongous 起始
    HUMONGOUS_CONT,   // humongous 后续
    ARCHIVE         // 共享归档(CDS)
}

// 对象分配流程
public Object allocate(int size) {
    if (size > HUMONGOUS_THRESHOLD) {
        // 走 humongous 分配
        return allocateHumongous(size);
        // 1. 找连续的 N 个空闲 region
        // 2. 找不到 → 触发 concurrent mark → 触发 mixed GC
        // 3. mixed GC 也找不到 → Full GC
    } else {
        // 走 TLAB(Thread Local Allocation Buffer)
        return tlab.allocate(size);
    }
}

理解 G1GC 内部机制对调参至关重要。humongous 分配走的是完全不同的路径,绕过 TLAB、Young Gen,直接进 Old Gen,且需要连续 region。这就解释了为什么 humongous 一旦多起来,Mixed GC 都救不了,必须 Full GC。我们这次的根因正是"找不到连续 region"——堆碎片化后,Full GC 是唯一选择。

引申十二:JVM 监控指标的黄金组合

指标 正常值 告警阈值 含义
Full GC 频率 0 > 1/天 有内存问题
GC 暂停 P99 < 200ms > 500ms 体验下降
Old Gen 占用 < 70% > 85% 即将触发 Mixed GC
humongous regions < 50 > 500 大对象泛滥
Young GC 频率 < 5 Hz > 10 Hz 分配过快
Direct Memory < 50% > 80% 堆外泄漏
Metaspace < 70% > 85% 类加载异常
RSS / Limit < 80% > 90% 即将 OOMKilled

这套指标我们贴在每个 Java 服务的 Grafana dashboard 上,oncall 一眼能看出问题在哪。JVM 调优的本质是"用指标驱动决策",而不是凭感觉调参。每个团队都应该有自己的 JVM 监控基线,知道自己服务"正常长什么样"。

引申十三:从这次事故学到的 5 条 review 原则

  1. 看到 List/Map 字段,问初始容量和上限:防止动态扩容导致大对象;
  2. 看到 byte[]/String 字段,问长度:超 1MB 必须解释为什么;
  3. 看到 @Scheduled 注解,问触发频率和并发度:定时任务是 GC 大爆炸最常见诱因;
  4. 看到序列化代码,问中间产物大小:JSON、Java 原生序列化都会产生中间大对象;
  5. 看到缓存代码,问 LRU 策略和最大容量:无界缓存是堆 OOM 的头号嫌疑。

这 5 条我们做成了 IntelliJ 的 inspection 规则,只要代码命中模板就高亮提醒 reviewer。把 review 经验自动化,才能真正避免"老问题再犯"。这套规则上线半年,新增代码引入的 humongous 问题降到 0。

引申十四:从 G1 迁移到 ZGC 的实战

# 迁移前评估
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -version
# 确认 JVM 支持 ZGC

# ZGC 参数(JDK 17+)
-XX:+UseZGC
-XX:ZAllocationSpikeTolerance=2    # 分配尖刺容忍度
-XX:ZCollectionInterval=10          # GC 间隔(秒)
-XX:+ZGenerational                  # JDK 21 分代 ZGC(性能更好)
-Xmx48g -Xms48g

# 监控指标变化
metric: jvm_gc_zgc_concurrent_phase_duration  # 并发标记/清理时间
metric: jvm_gc_zgc_pause_duration              # ZGC 暂停时间(应该 < 10ms)

# 实战对比
# G1GC:
#   Young GC 平均 80ms,最大 320ms
#   Mixed GC 平均 250ms,最大 1.2s
#   Full GC 4.8s
# ZGC:
#   Pause-Mark-Start 平均 0.8ms,最大 5ms
#   Pause-Mark-End 平均 0.6ms,最大 4ms
#   Pause-Relocate-Start 平均 1.2ms,最大 8ms
#   无 Full GC

ZGC 在 48GB 堆下的暂停时间是 G1GC 的 100 倍优势,代价是吞吐率略低(约 5%)。对延迟敏感的服务,ZGC 是更好的选择。JDK 21 引入的分代 ZGC 进一步降低了开销,我们后来切换后,P99 又降了 12ms,这是 G1GC 怎么调都达不到的。建议有条件升级 JDK 21 的团队都试试 ZGC。

引申十五:GraalVM 原生镜像的 GC 不同

维度 JVM (G1/ZGC) GraalVM Native
启动时间 2-10 秒 20-100ms
峰值性能 高(JIT 优化) 中(AOT 编译)
GC 选项 多种 Serial GC / G1(实验)
大堆支持 32G+ < 16G
反射/动态类 支持 需配置
适用场景 长跑服务 Lambda/CLI

GraalVM 原生镜像在 Serverless 场景下很香,启动时间从秒级降到毫秒级。但大堆 + 高并发场景不适合 GraalVM,GC 选项有限。我们这种 48GB 堆的服务,继续用 JVM + ZGC 才是正解。GraalVM 适合 Lambda、CLI 工具、Spring Cloud Gateway 这种轻量级网关,选型不能盲目跟风,必须基于业务特征做决策。

引申十六:堆 dump 的工程化收集

# 1. 配置 OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/dumps/

# 2. K8s sidecar 监听 OOM,自动上传 dump 到 S3
# sidecar Pod 检测到 dump 文件,gzip 后 s3 cp,然后清理

# 3. 主动触发 dump(无需 OOM)
jmap -dump:live,format=b,file=/tmp/heap.hprof 
# 注意:会 stop-the-world 一段时间(大堆可能 30 秒+)

# 4. 用 MAT 分析
# Eclipse MAT 打开 .hprof
# - Leak Suspects 报告(自动找泄漏嫌疑)
# - Dominator Tree(看谁占了最多内存)
# - Histogram(对象计数 + 大小)
# - Path to GC Roots(对象为什么没被回收)

# 5. heap dump 太大用 jhsdb 离线分析
jhsdb hashdb --core  --exe 

Heap dump 是诊断内存问题的核武器,但要做好工程化。大堆服务的 heap dump 文件可能几十 GB,本地分析不动,要上传到对象存储后用大内存机器分析。我们建了个专门的"dump 分析中心",dump 文件统一存 S3,有专门的 256GB 内存机器拉下来用 MAT/jhsdb 分析,这种基础设施投入半年才回本一次事故,但回本的那次就值回票价。

引申十七:GC 调优的 7 个反思

  1. 不要"参数党":抄网上参数最大的陷阱,每个服务都不一样;
  2. 压测必须真实:小堆压测看不出大堆问题;
  3. 监控必须前置:出问题再加监控来不及;
  4. 对象设计是根本:再好的 GC 也救不了垃圾代码;
  5. 升级 JDK 是低成本优化:每个 LTS 都有大量 GC 改进;
  6. 容器化下要考虑全:堆、堆外、native 都要算进 limit;
  7. 事故复盘要沉淀:每次事故都要产出 1-2 条工程纪律。

这 7 条是我做了 5 年 JVM 调优后的总结。GC 调优不是黑魔法,是"理解 + 测量 + 优化"的循环。每次事故都是学习机会,把经验沉淀成纪律才能真正避免复发。说到底,GC 调优最值钱的产出不是参数表,而是一支"看到对象大小就想到 humongous 阈值、看到批量任务就想到限流、看到序列化代码就想到中间产物"的工程师团队,这是任何工具都替代不了的核心能力。

引申十八:JVM 安全点与暂停时间的关系

# 看 safepoint 等待时间
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1

# 输出示例:
# vmop                    [threads: total initially_running wait_to_block]
# G1IncCollectionPause    [   200      0                0]
# RevokeBias              [   200      2                0]
# Deoptimize              [   200      0                0]

# 关键指标:
# - wait_to_block:线程进入 safepoint 的耗时
# - 大于 100ms 就要警惕,可能是某个线程卡在 native 调用

# 用 JFR 看 safepoint 事件
jfr print --events SafepointBegin,SafepointEnd recording.jfr | head -30

# 长 safepoint 的常见原因:
# 1. JNI 调用阻塞(原生代码不响应 safepoint)
# 2. 长循环没有 safepoint check(JIT 优化掉了)
# 3. heap dump 触发(jmap 操作)

JVM 的 GC 暂停时间不只是 GC 本身的时间,还包括"所有线程进入 safepoint"的时间。如果有一个线程响应慢,所有 GC 都要等它,P99 飙升的真凶可能不在 GC 算法。我们另一个服务遇到过 JNI 调用导致 safepoint 等 800ms 的事故,Async Profiler 看不出来,只能用 SafepointStatistics 调试,这种细节是 JVM 调优老司机和新人最大的差别。

总结

这次 Java 订单聚合服务 G1GC 雪崩事故,本质是"G1 humongous allocation + JSON 大对象反序列化 + 定时全量预加载"三重反模式叠加。每个反模式单独存在都还能跑,组合在 48GB 大堆 + 周三 22:00 定时任务下就是灾难。修复路径"G1 调参 + 对象拆分 + 渐进式加载"三步走,把 P99 从 4-8s 压回 58ms,Full GC 从每周 6 次降到 0,GC 暂停 P99 从 4.8 秒降到 45ms,oncall 周三晚上终于不用守着 dashboard,这是工程化最大的隐性收益之一。

更重要的认知是:JVM GC 调优不是"调参数",是"理解业务对象生命周期"。任何参数调整都只是"治标",真正治本的是修改对象设计、减少大对象、优化分配模式。GC 是底层服务,稳定的 GC 来自稳定的代码——把每一个对象的大小都心里有数,把每一次批量操作都加上限流,这才是 Java 工程师真正的核心能力,值得每个 Java 后端团队当成"工程纪律"长期打磨,而不是事后才用 jstack/jmap 救火。希望这篇复盘能让所有大堆 Java 服务避免重蹈我们 9 天定位 + 6 次周三 22:00 故障的覆辙,真正把 GC 调优从"玄学"变成可工程化、可度量、可持续改进的能力体系,让 P99 稳定成为团队的底层基础设施。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Go 1.22 gRPC 推送网关 P99 从 45ms 飙到 6.2 秒的 6 天并发雪崩复盘:map 并发读写 + channel 缓冲不足 + 单 Mutex 三重叠加 + 11 条 Go 并发纪律

2026-5-27 0:37:36

技术教程

Redis 7.2 Cluster 12 节点扩容到 24 节点期间 P99 从 2.4ms 飙到 1.8 秒的 3 天复盘:大键 MIGRATE 阻塞 + MOVED redirect 风暴 + 客户端 slot 缓存雪崩三重叠加 + 11 条 Cluster 运维纪律

2026-5-27 0:54:36

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索