给 AI 服务调 JVM,我原来的经验全部失效了
我做 JVM 调优大概六年,给订单、支付、库存这些服务调过不下三十次,形成了一套肌肉记忆:Young 区给 1/3,MaxGCPauseMillis 设 100,开字符串去重,老年代增长超过 50 MB/min 就要警惕。
2025 年下半年开始接 AI 平台,我发现这套经验用不上。同样是 Java 服务,GC 的行为模式完全不一样。这篇是我这两年重新建立起来的一套认知,以及我们在生产上跑的配置。
AI 负载的三个反直觉特征
先说清楚「AI 负载」和我们熟悉的传统服务到底差在哪。我把三个服务的 JFR 采样数据放一起对比(同机型 16C32G,同 QPS 500):
| 特征 | 订单服务 | 推荐服务 | RAG 问答服务 |
|---|---|---|---|
| 分配速率 | 210 MB/s | 480 MB/s | 1.9 GB/s |
| 对象平均存活时间 | < 50ms | 2~8s | 双峰:<10ms 与 >30min |
| 大对象占比(>1KB) | 3% | 11% | 34% |
| 单请求内存峰值 | 28 KB | 96 KB | 1.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 分钟稳态。
| GC | Young/小周期频率 | 最大停顿 | P99 | P999 | CPU | 堆峰值 |
|---|---|---|---|---|---|---|
| G1(默认参数) | 4.1 次/秒 | 380ms | 1,240ms | 4.1s | 58% | 6.2 GB |
| G1(调优后) | 1.2 次/秒 | 74ms | 310ms | 640ms | 61% | 4.8 GB |
| ZGC(JDK 21) | 并发周期 0.4 次/秒 | 1.8ms | 196ms | 410ms | 74% | 7.4 GB |
| ZGC(分代,JDK 25) | 并发周期 0.2 次/秒 | 0.9ms | 182ms | 380ms | 63% | 6.1 GB |
| Shenandoah(JDK 25) | 0.6 次/秒 | 3.2ms | 204ms | 455ms | 71% | 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 + 分代 ZGC | P999 从 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 |
| Metaspace | 120 MB | 180 MB | 动态代理、Lambda 多 |
| Code Cache | 80 MB | 140 MB | JIT 编译量大 |
| 线程栈(虚拟线程) | 320 MB | 680 MB | StackChunk 按并发数增长 |
| Direct Buffer | 60 MB | 1.9 GB | 向量堆外缓存 + Netty |
| JIT / GC 原生 | 180 MB | 420 MB | ZGC 的元数据更多 |
| 其他(glibc arena 等) | 40 MB | 310 MB | 高频分配导致的 arena 碎片 |
| 合计 | 4.1 GB | 8.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 调优的新范式》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。