Administrator
发布于 2026-02-28 / 196 阅读
4

AI 负载下 JVM 调优的新范式

给 AI 服务调 JVM,我原来的经验全部失效了

我做 JVM 调优大概六年,给订单、支付、库存这些服务调过不下三十次,形成了一套肌肉记忆:Young 区给 1/3,MaxGCPauseMillis 设 100,开字符串去重,老年代增长超过 50 MB/min 就要警惕。

2025 年下半年开始接 AI 平台,我发现这套经验用不上。同样是 Java 服务,GC 的行为模式完全不一样。这篇是我这两年重新建立起来的一套认知,以及我们在生产上跑的配置。

AI 负载的三个反直觉特征

先说清楚「AI 负载」和我们熟悉的传统服务到底差在哪。我把三个服务的 JFR 采样数据放一起对比(同机型 16C32G,同 QPS 500):

特征订单服务推荐服务RAG 问答服务
分配速率210 MB/s480 MB/s1.9 GB/s
对象平均存活时间< 50ms2~8s双峰:<10ms 与 >30min
大对象占比(>1KB)3%11%34%
单请求内存峰值28 KB96 KB1.4 MB
CPU 用户态/内核态71% / 29%68% / 32%44% / 56%

三个反直觉的点:

一、对象存活时间是双峰的,没有中间态

传统服务的对象存活时间是连续分布的:有毫秒级的临时对象,有请求级的(几十毫秒),有会话级的(几秒),有缓存级的(几十分钟)。GC 的分代假设之所以有效,就是因为这个分布。

AI 服务不是。它要么极短命(拼 prompt 的中间 String、JSON 解析缓冲),要么极长命(向量缓存、模型配置、知识库元数据)。中间态几乎没有。这意味着分代 GC 的整个前提被削弱了一半——我们既需要 Young 区足够大来吃掉短命对象,又需要 Old 区足够稳定来承载长命对象,而中间的晋升地带是浪费的。

二、单请求内存峰值高一个数量级

1.4 MB vs 28 KB,差 50 倍。一个 RAG 请求要装下:20 个召回片段(每个 2~4 KB)、query 向量和候选向量(20 × 6 KB)、拼好的 prompt(40~80 KB)、模型响应的 JSON(30 KB)、流式输出的缓冲区(几十 KB)。

这些对象大部分是同时活着的,不是先后产生再释放。所以 Young 区要能同时容纳几百个请求 × 1.4 MB = 几百 MB,否则就会提前 GC。

三、内核态 CPU 占比反超用户态

44% / 56%,这个比例在传统服务里几乎见不到。原因是大量堆外内存的读写(向量、网络缓冲、堆外缓存)和频繁的系统调用(流式响应的小包写入)。这直接影响调优方向:减少系统调用和堆外拷贝,比减少堆分配更值得做

GC 选择:我们测了 G1、ZGC、Shenandoah

在 JDK 21 和 JDK 25 上,我们对同一个问答服务做了完整对比。压测条件:QPS 800,堆 8G,持续 30 分钟,指标取后 20 分钟稳态。

GCYoung/小周期频率最大停顿P99P999CPU堆峰值
G1(默认参数)4.1 次/秒380ms1,240ms4.1s58%6.2 GB
G1(调优后)1.2 次/秒74ms310ms640ms61%4.8 GB
ZGC(JDK 21)并发周期 0.4 次/秒1.8ms196ms410ms74%7.4 GB
ZGC(分代,JDK 25)并发周期 0.2 次/秒0.9ms182ms380ms63%6.1 GB
Shenandoah(JDK 25)0.6 次/秒3.2ms204ms455ms71%6.8 GB

几个结论:

ZGC 的停顿数据确实漂亮,1.8ms 的 Max Pause 是 G1 怎么调都调不到的。 但代价是 CPU 涨了 16 个百分点(JDK 21 非分代版本)。分代 ZGC 在 JDK 25 里把这个差距缩小到了 2 个百分点,这是个巨大的改进。

ZGC 的堆占用更高,7.4 GB vs G1 调优后的 4.8 GB。原因是 ZGC 需要更多的堆余量来做并发回收(它的并发标记和转移需要「空间换时间」),加上着色指针和读屏障本身的元数据开销。在容器里这意味着要么给更多内存,要么接受更频繁的 GC。

我们最终的选择是分场景

服务类型GC理由
同步问答(用户等待,对尾延迟敏感)JDK 25 + 分代 ZGCP999 从 4.1s 降到 380ms,用户能感知到
离线批处理(embedding、文档解析)G1(调优后)吞吐优先,ZGC 的 CPU 开销不划算
流式对话(SSE 长连接)JDK 25 + 分代 ZGC长生命周期对象多,ZGC 的并发转移优势明显
网关 / 路由(无状态转发)G1堆小(2G),G1 足够

G1 调优:AI 负载下的参数不一样

批处理这类还用 G1 的服务,我们的参数模板和传统服务差别很大。贴出来:

# 传统订单服务(对比用)
-Xms4g -Xmx4g -XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:InitiatingHeapOccupancyPercent=45

# AI 问答服务(批处理,8G 堆)
-Xms8g -Xmx8g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=150              # 比传统服务更宽松
-XX:G1NewSizePercent=40               # Young 区下限 40%(传统是 5~10%)
-XX:G1MaxNewSizePercent=60            # 上限 60%
-XX:G1HeapRegionSize=8m               # 大 region,配合大对象
-XX:G1ReservePercent=25               # 高预留防 to-space exhausted
-XX:InitiatingHeapOccupancyPercent=30 # 更早启动并发标记
-XX:G1MixedGCCountTarget=12           # 更多次 Mixed GC,每次少收一点
-XX:G1MixedGCLiveThresholdPercent=80
-XX:+UseStringDeduplication
-XX:-UseCompressedOops                # 堆 >32GB 时才需要,8G 不要关

解释几个和传统认知冲突的地方:

G1NewSizePercent=40:传统服务给 5~10% 就够,AI 服务必须给大。因为单请求内存峰值高,Young 区小了会导致对象还没死就被迫 GC,然后晋升到 Old 区。我们试过 20%,老年代增长速率是 310 MB/min;提到 40% 后降到 34 MB/min,差了 9 倍。

MaxGCPauseMillis=150 而不是 100:这个反直觉。AI 服务单次 GC 要处理的对象更多,把目标压到 100ms 会导致 G1 疯狂缩小 Young 区去满足目标,反而 GC 频率暴涨。我们测过 100ms,Young GC 频率从 1.2 次/秒涨到 3.8 次/秒,总停顿时间反而涨了 40%。在 AI 负载下,降低 GC 频率比降低单次停顿更重要。

G1HeapRegionSize=8m:默认在 8G 堆下是 4 MB,一半是 2 MB。我们的向量矩阵、批量 embedding 的结果常常在 1~4 MB 之间,会进 humongous 区。调大 region 到 8 MB(一半是 4 MB)后,这些对象变成普通对象,走常规分配路径,humongous 分配次数从 1.2 万次/分钟降到 340 次/分钟。

容器资源分配的新思路

这一节是我觉得最有价值的部分,也是踩坑最多的地方。

坑:容器内存限制不能只算堆

我们最初的 K8s 配置:

resources:
  requests:
    memory: "8Gi"
    cpu: "4"
  limits:
    memory: "8Gi"      # 堆设了 -Xmx6g,觉得还有 2G 余量,很安全
    cpu: "8"

结果就是不停被 OOMKilled。原因是用 AI 服务时,堆外内存远超预估:

内存区域传统服务AI 服务(实测)说明
Java 堆3.2 GB(占 80%)4.8 GB(占 48%)-Xmx6g
Metaspace120 MB180 MB动态代理、Lambda 多
Code Cache80 MB140 MBJIT 编译量大
线程栈(虚拟线程)320 MB680 MBStackChunk 按并发数增长
Direct Buffer60 MB1.9 GB向量堆外缓存 + Netty
JIT / GC 原生180 MB420 MBZGC 的元数据更多
其他(glibc arena 等)40 MB310 MB高频分配导致的 arena 碎片
合计4.1 GB8.4 GB

堆只占总内存的一半多一点。按「堆 × 1.5」来设 limit 的传统做法,在 AI 服务上会死得很惨。我们现在的规矩是:

resources:
  requests:
    memory: "12Gi"     # = 堆 6G + 堆外 4G + 余量 2G
    cpu: "6"
  limits:
    memory: "14Gi"     # 留 2G 缓冲,防止瞬时峰值被 kill
    cpu: "10"

# 关键:显式限制堆外,别让它无限涨
env:
  - name: JAVA_TOOL_OPTIONS
    value: >
      -Xmx6g -Xms6g
      -XX:MaxDirectMemorySize=2g
      -XX:MaxMetaspaceSize=384m
      -XX:ReservedCodeCacheSize=256m
      -XX:NativeMemoryTracking=summary
      -XX:+ExitOnOutOfMemoryError

-XX:MaxDirectMemorySize=2g 这条最重要。默认它等于 -Xmx,也就是 6G,堆外加上堆就是 12G,直接超容器限制。显式设成 2G 之后,堆外超限会抛 OutOfMemoryError: Direct buffer memory,至少是 Java 层的异常,能被监控捕获,而不是被 K8s 静默 kill。

-XX:NativeMemoryTracking=summary 开着有点开销(约 3~5%),但我们只在预发环境开,用来定期核对内存账:

$ jcmd 1 VM.native_memory summary scale=MB
Native Memory Tracking:
Total: reserved=11208MB, committed=8764MB
- Java Heap (reserved=6144MB, committed=6144MB)
- Class     (reserved=1088MB, committed=196MB)
- Thread    (reserved=2048MB, committed=682MB)
- Code      (reserved=256MB, committed=142MB)
- GC        (reserved=1108MB, committed=428MB)
- Other     (reserved=560MB, committed=312MB)
- Symbol    (reserved=48MB, committed=41MB)

这个命令我们现在加进了周巡检脚本,每周一自动跑一次,填到表里做趋势对比。上次就是靠这个发现了 Code Cache 缓慢上涨的问题(最后定位到一个动态生成类的三方库)。

CPU request 要给足,limit 可以放开

AI 服务的 CPU 使用模式和传统服务不同:它不是持续高负载,而是「突发计算 + 长等待」交替。拼 prompt、算向量的时候 CPU 打满,等模型响应的时候 CPU 接近 0。

K8s 的 CFS 配额是按 100ms 周期分配的。如果 CPU limit 设得太紧,突发计算阶段会被 throttle,导致延迟尖刺。我们吃过这个亏:

$ cat /sys/fs/cgroup/cpu.stat
nr_periods 4820134
nr_throttled 184201        # 3.8% 的周期被限流
throttled_time 94213782214 ns    # 累计限流 94 秒

3.8% 的限流率看着不高,但它集中在突发计算阶段,直接导致 P99 从 310ms 涨到 890ms。

我们的做法:CPU request 给到稳态值(比如 6 核),limit 设为 request 的 2 倍或者干脆不设 limit。不设 limit 在很多人看来很激进,但在 AI 负载下是合理的——因为突发是短暂的,平均下来不会真的吃满,而限流的代价(尾延迟)远大于超用的代价。前提是节点要有足够的余量,我们在 AI 服务的节点池上把超卖比控制在 1.5:1。

JDK 25 带来的两个变化

最后提一下 JDK 25 上我们验证过的两个东西。

紧凑对象头(JEP 519):AI 服务对象小、数量多,这个特性收益特别明显。同一个服务在 JDK 21 和 JDK 25(开启紧凑对象头)下的对比:

堆峰值:  4.8 GB  →  3.9 GB   (-18.7%)
GC 频率: 1.2 次/秒 → 0.9 次/秒
P99:    310ms    →  268ms

降幅比官方宣称的平均 10% 高不少,符合「小对象多」的特征。

分代 ZGC:JDK 21 的 ZGC 是非分代的,JDK 23 开始分代成为默认。上面表格里 74% → 63% 的 CPU 差距就是分代带来的。这是 JDK 25 上 AI 服务最值得启用的一项。

-XX:+UseZGC
-XX:+ZGenerational        # JDK 23+ 默认开启,JDK 21 需要显式加
-XX:ZCollectionInterval=5   # 后台 GC 间隔,AI 负载下调小
-XX:ZAllocationSpikeTolerance=3   # 默认 2,分配突增时调高更稳

留个问题

关于《AI 负载下 JVM 调优的新范式》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考