把 AI 服务压到 3000 QPS 之后,GC 曲线完全变了
我们有个文档问答服务,跑在 JDK 21 + G1 上,原来堆 4G,Young 区 1.2G,日均 800 万次调用,GC 表现平平无奇:Young GC 每 8 秒一次,单次 25ms 左右,Full GC 一周见不到一次。
八月份业务量涨了,QPS 从 600 冲到 3000,同一个服务开始频繁出现 P99 抖动。监控上 CPU 才 55%,但延迟每隔几分钟就有一个尖刺。这篇是完整的分析过程和最终的参数配置。
现象:不是 GC 时间长,是 GC 次数多
先看监控。这是我拉的一小时数据:
| 指标 | QPS 600 时 | QPS 3000 时 |
|---|---|---|
| Young GC 频率 | 0.12 次/秒 | 4.7 次/秒 |
| Young GC 单次耗时 | 25ms | 41ms |
| GC 总停顿占比 | 0.3% | 19.3% |
| 老年代增长速率 | 3 MB/min | 340 MB/min |
| Full GC | 几乎为零 | 每 42 分钟一次 |
| 请求 P99 | 210ms | 1,340ms |
单次 GC 时间只涨了 16ms,但频率涨了 39 倍。GC 总停顿占比接近 20%——这个数字要知道,一般认为超过 5% 就该优化了,20% 已经是在烧钱。
老年代增长速率从 3 MB/min 到 340 MB/min 是最刺眼的一项。这说明有对象在被提升到老年代,而且提升速度很快。4G 堆里老年代约 2.7G,除以 340 MB/min,理论上是 8 分钟触发一次 Full GC,实际 42 分钟(因为有部分对象会死掉),但结论一样:这是不正常的。
用 JFR 看看到底在分配什么
jcmd <pid> JFR.start name=ai-prof duration=120s \
filename=/tmp/ai.jfr settings=profile
拿到火焰图之后,分配热点排前五的:
| 分配来源 | 占比 | 说明 |
|---|---|---|
String.substring / split / formatted | 31.4% | prompt 拼接、文档切分 |
byte[](Jackson 反序列化) | 24.8% | JSON 解析模型响应 |
float[] / double[] | 18.2% | embedding 向量 |
HashMap$Node / ArrayList | 11.6% | 工具参数、检索结果容器 |
char[](StringBuilder 扩容) | 7.9% | 流式响应的缓冲拼接 |
分配速率是 1.8 GB/s。作为对比,我们同规格的订单服务(纯 CRUD + Redis)是 210 MB/s,差了近 9 倍。
逐个看。
字符串:切分和拼接是大头
文档切分的代码是这么写的,每次请求要处理 5 份文档,每份 1400 字:
// 问题版本:每次切分产生大量中间 String
List<String> chunk(String text, int size, int overlap) {
List<String> out = new ArrayList<>();
for (int i = 0; i < text.length(); i += size - overlap) {
out.add(text.substring(i, Math.min(i + size, text.length())).trim());
}
return out;
}
JDK 7u6 之后 substring 不再共享 char[],每次调用都拷贝一份。5 份文档 × 平均 8 个分片 = 40 次拷贝,每次约 2800 字节。单请求就是 112 KB 纯拷贝,3000 QPS 下是 336 MB/s。
更要命的是这个切分结果根本没有被缓存——每次请求都重新切一遍同样的文档。加个 Caffeine 缓存之后,这部分分配直接归零:
private final Cache<String, List<String>> chunkCache = Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofHours(2))
.build();
List<String> chunk(String docId, String text, int size, int overlap) {
return chunkCache.get(docId + ":" + size + ":" + overlap,
k -> doChunk(text, size, overlap));
}
命中率 87%(我们的文档访问有明显的热点分布,前 200 篇文档贡献了 71% 的检索量)。
prompt 拼接那边改用了预分配容量的 StringBuilder,并且把模板里的静态部分提到常量:
// 改前:每次 formatted() 都要解析模板,产生多个中间 String
String prompt = """
<context>
%s
</context>
请基于以上内容回答:%s
""".formatted(context, question);
// 改后:StringBuilder 预分配,避免扩容拷贝
StringBuilder sb = new StringBuilder(256 + context.length() + question.length());
sb.append(CONTEXT_PREFIX).append(context).append(CONTEXT_SUFFIX)
.append(QUESTION_PREFIX).append(question);
单看这两个优化,分配速率从 1.8 GB/s 降到 1.15 GB/s。
JSON:流式解析别整棵反序列化
模型响应是个大 JSON,尤其是带工具调用的时候,单个响应能有 30 KB。我们原来无脑 objectMapper.readValue(...),Jackson 内部会先把整个响应读成 byte[] 再解析,峰值内存是原始大小的 3~4 倍。
改成用 Jackson 的流式 API 边读边处理,只在真正需要某个字段时才构造对象:
try (JsonParser p = jsonFactory.createParser(responseStream)) {
while (p.nextToken() != null) {
if ("content".equals(p.currentName())) {
p.nextToken();
delta = p.getText(); // 只取这一个字段,不构造完整树
break;
}
}
}
这个改动让单请求的 JSON 分配从 96 KB 降到 12 KB。代价是代码可读性下降,我们只在最热的路径上这么做,其他地方还是用 readValue。
另外把 ObjectMapper 的 JsonFactory 复用起来(ObjectMapper 本身是线程安全的,但每次新建 JsonFactory 会有额外的缓冲区分配)。这算意外收获,我们代码里有三处每次请求都 new ObjectMapper(),属于低级错误。
向量:float[] 的内存账要算清楚
我们的 embedding 维度是 1024。一个 float[1024] 是 4 KB,加上对象头 16 字节,实际 4112 字节。
单次请求要算 1 个 query 向量 + 召回 20 个候选做 rerank 时的向量读取。但真正吃内存的是本地缓存的那份向量索引:我们为了降低延迟,把热点文档的向量缓存在堆里。
// 缓存 5 万条向量,账是这么算的
// 50,000 × (4112 bytes 数组 + 16 bytes 对象头 + 8 bytes 引用) ≈ 207 MB
207 MB 常驻老年代,而且因为是长生命周期对象,会不断在 GC 之间被复制(在 G1 里是 humongous 对象,分配路径还不一样——超过 region 一半的对象会直接进 humongous 区)。
这里有个关键判断:float[1024] 是 4112 字节,而 G1 默认 region size 在 4G 堆下是 2 MB,一半是 1 MB,所以 4 KB 不算 humongous。但如果我们用 double[] 或者维度更高(比如 3072 维的 float[] 是 12 KB,仍不算),要小心。真正会踩坑的是批量矩阵,比如一次算 512 条文档的 embedding,float[512][1024] 就是 2 MB,直接进 humongous 区。
我们做的优化是把缓存外置——用堆外内存(ByteBuffer.allocateDirect)存向量,堆里只留 ID 映射:
// 5 万条 × 1024 维 × 4 字节 = 200 MB 堆外
ByteBuffer vecStore = ByteBuffer.allocateDirect(50_000 * 1024 * 4)
.order(ByteOrder.LITTLE_ENDIAN);
float[] load(int docId) {
float[] v = new float[1024];
vecStore.position(docId * 1024 * 4);
((FloatBuffer) vecStore.slice().limit(1024)).get(v);
return v; // 短期使用,Young 区即可回收
}
这样老年代少了 207 MB 常驻,代价是每次读取有一次堆外到堆内的拷贝(约 1.8 微秒)。我们评估下来划算,因为 GC 压力的下降远大于这点开销。
顺带一提,批量 embedding 我们改成了分批处理,每批 64 条,避免大矩阵进 humongous 区。
流式响应:StringBuilder 忘记 reset
这个是最隐蔽的一个。SSE 流式输出时我们把每个 chunk 拼进一个 StringBuilder:
// 问题代码:sb 是共享的,且没有限制大小
private final StringBuilder buffer = new StringBuilder();
public void onChunk(String delta) {
buffer.append(delta); // 长对话能涨到几百 KB
sink.tryEmitNext(delta);
}
// 请求结束忘了 buffer.setLength(0)
两个问题:一是共享实例在并发下数据错乱(这个我们早发现了),二是 StringBuilder 内部的 char[] 只增不减,长对话场景下单个 buffer 能涨到 400 KB,而这些 buffer 挂在长生命周期的对象上,全部进老年代。
修复很简单但要改结构:改成每请求一个实例,且限制上限。
public class StreamAccumulator implements AutoCloseable {
private static final int MAX = 64 * 1024;
private StringBuilder sb = new StringBuilder(4096);
public void append(String delta) {
if (sb.length() < MAX) sb.append(delta); // 超过就不再累积
}
public void close() { sb = null; } // 显式释放引用
}
G1 参数调整
代码改完之后重跑压测,分配速率从 1.8 GB/s 降到 620 MB/s。然后调参数。
G1 的默认 MaxGCPauseMillis=200ms 在我们的场景下太宽松也太严格——太宽松是因为 200ms 的停顿对交互式 AI 服务已经很难受了,太严格是因为设置更低会导致 GC 过于频繁。
最终配置:
-Xms6g -Xmx6g # 固定堆,避免动态扩容带来的抖动
-XX:+UseG1GC
-XX:MaxGCPauseMillis=80 # 目标压到 80ms
-XX:G1NewSizePercent=30 # Young 区下限从默认 5% 提到 30%
-XX:G1MaxNewSizePercent=50 # 上限 50%,给大流量留空间
-XX:G1ReservePercent=20 # 提高预留,降低 to-space exhausted 风险
-XX:InitiatingHeapOccupancyPercent=35 # 提前启动并发标记
-XX:G1MixedGCLiveThresholdPercent=75 # 老年代回收更积极
-XX:+UseStringDeduplication # 字符串去重
-XX:MaxTenuringThreshold=3 # 降低晋升阈值,短命对象尽快回收
-Xlog:gc*,safepoint:file=/var/log/gc.log:time,uptime:filecount=10,filesize=64m
G1NewSizePercent=30 是最有效的一项。Young 区大了,大部分临时对象在 Young 区就被回收掉,晋升到老年代的数量大减。老年代增长速率从 340 MB/min 降到 21 MB/min。
UseStringDeduplication 在我们的场景下贡献了约 8% 的堆节省(文档片段、服务名这些重复率很高),代价是约 2% 的 CPU。
关于 MaxTenuringThreshold=3:这个参数和直觉相反。通常认为降低晋升门槛会让对象更快进老年代,是坏事。但在 AI 服务里,我们的对象要么极短命(一次请求内),要么极长命(缓存),中间态很少,所以让短命对象快速晋升反而是错的——应该让它在 Young 区就被回收。最终我们把它调回了默认值,测试下来没有明显差异,这里列出来是因为我试过,结论是「不要动它」。
结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 分配速率 | 1.8 GB/s | 620 MB/s |
| Young GC 频率 | 4.7 次/秒 | 0.9 次/秒 |
| Young GC 单次 | 41ms | 23ms |
| GC 停顿占比 | 19.3% | 2.1% |
| 老年代增长 | 340 MB/min | 21 MB/min |
| Full GC | 每 42 分钟 | 0(一周观察) |
| P99 | 1,340ms | 186ms |
| P999 | 4.2s | 520ms |
| CPU | 55% | 61% |
CPU 涨了 6 个百分点,主要是字符串去重和堆外内存拷贝。但同样的 QPS 我们从 8 个实例缩到了 5 个,总体是省了。
关于 JDK 25 的紧凑对象头
JDK 25 这个月中旬刚发布,JEP 519 把对象头从 96 位压到 64 位。我在测试环境跑了一下这个服务:
-XX:+UseCompactObjectHeaders # JDK 25 中默认是开启的(实验特性转正)
堆占用从 6G 峰值的实际使用 3.9G 降到 3.3G,降了 15%。这个数字和我们对象小、数量多的特征吻合——我们的堆里有大量 float[]、小 HashMap、短 String。GC 频率也降了约 11%。
但我暂时不打算上生产。两个原因:一是 JDK 25 才发布两周,长期支持版本我们一般观望到第一个补丁版(25.0.1);二是这个功能改变了对象布局,如果用了某些依赖对象头大小的 JOL 分析或者 unsafe 代码需要回归测试。计划下个月在非核心服务上灰度。
几条经验
- AI 服务的分配速率比传统服务高一个数量级,别用给订单服务调的参数套过来。核心矛盾是 Young 区太小导致 GC 过频,不是单次 GC 太慢;
- 先优化分配,再调 GC 参数。我们第一轮只调参数,最好也就是把停顿占比从 19% 压到 14%;改完代码降到 3.4%,再调参数才到 2.1%;
- 缓存是 AI 服务里性价比最高的优化,文档切分、embedding、rerank 结果都可以缓存,而且命中率通常很高(内容型访问的局部性很强);
- 向量这种大数组要考虑堆外。堆内放几十万条向量,等于给老年代埋了个定时炸弹;
- 流式响应的缓冲区最容易泄漏,因为它的生命周期和请求绑在一起,而请求生命周期在异步代码里很容易被忽略。
就写到这。如果哪天你也被《AI 应用下的 JVM 内存特征与调优》里同一个坑绊住,回来翻这篇,能省半小时。