Administrator
发布于 2025-10-02 / 8329 阅读
222

AI 应用下的 JVM 内存特征与调优

把 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 单次耗时25ms41ms
GC 总停顿占比0.3%19.3%
老年代增长速率3 MB/min340 MB/min
Full GC几乎为零每 42 分钟一次
请求 P99210ms1,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 / formatted31.4%prompt 拼接、文档切分
byte[](Jackson 反序列化)24.8%JSON 解析模型响应
float[] / double[]18.2%embedding 向量
HashMap$Node / ArrayList11.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

另外把 ObjectMapperJsonFactory 复用起来(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/s620 MB/s
Young GC 频率4.7 次/秒0.9 次/秒
Young GC 单次41ms23ms
GC 停顿占比19.3%2.1%
老年代增长340 MB/min21 MB/min
Full GC每 42 分钟0(一周观察)
P991,340ms186ms
P9994.2s520ms
CPU55%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 内存特征与调优》里同一个坑绊住,回来翻这篇,能省半小时。

参考