这不是一篇八股文。这是我真实踩坑、3 天没睡好觉、差点被领导叫去谈话的血泪经历。
一、那天下午,我以为要完了
项目上线前最后一轮压测,我坐在工位上盯着 Grafana 面板,心里还算稳------测试环境跑了一个月没啥大问题,P99 一直在 2 秒左右,虽然不算快但 LLM 本身就要吃 2 秒,我觉得还能接受。
然后 1000 并发一上去------
P99 直接飙到 5 秒。
不是缓慢上升,是那种看着曲线"嗖"一下就上去的飙。我还以为监控出了 bug,刷新了两遍页面,数字还是 5 秒。
更恐怖的是,曲线不是平滑的------它像心电图一样,每隔一段时间就"停一下",然后又恢复,再停一下。用户端反馈说"聊天卡住了,等了好久才回复,然后突然又好了"。
我的第一反应:完了,LLM 接口出问题了。
赶紧去查大模型服务的监控------人家稳稳的 2 秒响应,一点波动都没有。
那问题就在我们这边。但我代码里也没啥复杂逻辑啊,就是调 LLM API、拼消息、存上下文......哪来的 5 秒卡顿?
那一刻我脑子里只有一个想法:这锅我背定了。
二、开始追凶------GC 日志里的秘密
我硬着头皮翻 GC 日志,本来以为就是普通的 Minor GC,结果看到这一行:
csharp
[Full GC (Allocation Failure) 3.2G->2.8G(4G), 2.5 secs]
2.5 秒的 Full GC,回收前 3.2G,回收后 2.8G------就回收了 400MB?
一小时来 35 次,每次停 23 秒,这他妈的就是那个"心电图"的来源啊!应用暂停 2~3 秒,用户就觉得卡了,GC 完了恢复,然后过了一会又 GC,又卡......
而且老年代占用回收后还有 85%------说明有大量对象死活回收不掉,这不是正常的 GC 行为,这是内存泄漏的前兆。
我当时心里一凉:完了,不是接口问题,是我自己代码写出来的问题。
三、jmap -histo:凶手浮出水面
光看 GC 日志只能猜方向,得看具体是谁占内存。
bash
jmap -histo:live <pid> | head -20
输出结果一出来,我差点吐血:
yaml
num #instances #bytes class name
1: 50000 209715200 [F ← float 数组!200MB!
2: 10000 104857600 ConversationContext ← 会话对象 100MB
3: 10000 52428800 ConcurrentHashMap$Node ← HashMap 节点 50MB
float 数组占了 200MB? 我项目里哪来的这么多 float?
然后我猛然想起------我的 Embedding 缓存。
每个会话存了多轮对话的 Embedding 向量,1024 维 float,一个就是 4KB。我为了省调 Embedding API 的钱(一次调用几毛钱呢),在本地缓存了所有已计算的向量。
我算了一下:
| 数据项 | 单会话占用 | 1000 用户 |
|---|---|---|
| 对话历史(20 条消息) | ~20 KB | ~20 MB |
| 工具缓存(5 个结果) | ~5 KB | ~5 MB |
| Embedding 缓存 | ~100 KB | ~100 MB |
100KB 乘以 1000 用户就是 100MB,加上对话历史和其他数据,一个会话 130KB,1000 个并发就是 130MB 直奔老年代。
省了几毛钱的 API 费用,代价是 Full GC 搞垮了整个系统的响应时间。 这笔账怎么算都亏。
四、MAT 深挖:引用链全曝光
jmap -histo 只是看个大概,要确认到底是谁引用了这些对象、为什么回收不掉,还得导 HeapDump 用 MAT 分析。
bash
jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>
MAT 打开 hprof 文件,一眼就看到了 GC Roots 引用链:
scss
SessionManager.sessions (ConcurrentHashMap)
→ ConversationContext
→ embeddingCache (HashMap)
→ float[1024] × N
整条链路清清楚楚:
SessionManager持有sessions这个 ConcurrentHashMap- 每个
sessionId对应一个ConversationContext - 每个
ConversationContext里面有个embeddingCache(HashMap) embeddingCache里存了多个 1024 维的 float 数组
问题出在哪?ConcurrentHashMap 里的对象是 GC Root 可达的,只要会话还活着,这些 Embedding 缓存就永远回收不掉。
会话持续 10~30 分钟,这些对象就一直在老年代里赖着不走,越积越多,直到老年代塞满 → Full GC → 回收率低 → 又塞满 → 又 Full GC......
恶性循环。
那一刻我明白了:我把不该放在 JVM 堆里的东西,硬塞了进去。
五、紧急止血:先让系统活下去
领导已经在催了:"什么时候能上线?" 不能说"我在排查 JVM 问题",得先止血。
第一招:限制缓存数量
java
// ConversationContext.java
private static final int MAX_EMBEDDING_CACHE = 500;
public void putCachedEmbedding(String textHash, List<Float> embedding) {
if (embeddingCache.size() >= MAX_EMBEDDING_CACHE) {
// 超过上限,清理一半
int removeCount = MAX_EMBEDDING_CACHE / 2;
Iterator<String> it = embeddingCache.keySet().iterator();
while (removeCount-- > 0 && it.hasNext()) {
it.next();
it.remove();
}
}
embeddingCache.put(textHash, embedding);
}
这是最粗暴的 LRU 简化版------满了就砍一半。不优雅,但有效。
第二招:加大堆
bash
# 从 4G 拉到 8G
-Xmx8g -Xms8g
堆大了,老年代空间就大了,Full GC 的触发频率自然就低了。
这两招下来,Full GC 从每小时 5 次降到每天 1~2 次,P99 从 5 秒回到 3 秒左右。
系统勉强能用了,但我知道这不是根本解法------堆大只是延缓问题,缓存限制只是治标。根本问题还在:不该把会话数据放 JVM 堆里。
六、根本解决方案:迁移 Redis
止血之后,我花了一个周末做了真正的重构------把 ConcurrentHashMap 替换为 Redis。
说实话一开始我挺犹豫的:Redis 多一层网络调用,序列化反序列化也有开销,会不会反而更慢?
但想了想,我的瓶颈根本不是"读会话慢",而是"GC 停顿导致所有请求卡住"。Redis 哪怕每次读多花 1 毫秒,也比 GC 停顿 2.5 秒好一万倍。
java
@Component
public class SessionManager {
@Autowired
private StringRedisTemplate redisTemplate;
private final Gson gson = new Gson();
private static final String SESSION_KEY = "ai:session:";
public ConversationContext getOrCreate(String sessionId, ...) {
String key = SESSION_KEY + sessionId;
// 1. 从 Redis 查
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return gson.fromJson(json, ConversationContext.class);
}
// 2. 创建新的,写入 Redis(30 分钟自动过期)
ConversationContext ctx = new ConversationContext(sessionId);
ctx.setUserId(userId);
redisTemplate.opsForValue().set(key, gson.toJson(ctx),
30, TimeUnit.MINUTES);
return ctx;
}
}
Redis 方案最爽的一点是 TTL 自动过期。
之前用 ConcurrentHashMap,我还得自己写定时任务清理过期会话------代码里一堆 removeIf、scheduleAtFixedRate,而且总怕清不干净。Redis 直接 expire 30min,到期自动消失,连会话续期都是 expire 一行搞定。
| 维度 | ConcurrentHashMap | Redis |
|---|---|---|
| 容量 | 受 JVM 堆限制(4~8G) | 独立进程,随便扩 |
| 分布式 | 不支持 | 天然支持 |
| 重启 | 数据全丢 | 持久化可选 |
| 过期清理 | 手写定时任务 | TTL 自动过期 |
| 会话续期 | 手写逻辑 | 一行 expire |
七、最终效果------终于能睡好觉了
迁移 Redis 后再跑压测:
sql
Full GC:0 次/天 ✅
P99 延迟:从 5 秒降到 2.2 秒 ✅(LLM 本身约 2 秒,基本上就是模型耗时了)
老年代占用:稳定在 30% 以下 ✅
支持水平扩展:多实例共享 Redis ✅
看着 Grafana 上那条平平的 P99 曲线,我终于安心了。
3 天没睡好觉,值了。
八、复盘:JVM 调优不是背参数
这次踩坑让我真正理解了一件事:JVM 调优不是背参数,是数据驱动的闭环过程。
我的四步法
- 基线测试:默认配置启动 → 压测 → 记录 GC 频率、P99、堆使用率
- 分析瓶颈:GC 日志(GCEasy 在线分析)→ 内存分布(jmap + MAT)→ 线程状态(jstack / Arthas)
- 针对性调参 :
- Minor GC 频繁 → 加大新生代
- Full GC 频繁 → 加堆 + 查内存泄漏
- GC 偶顿太长 → 换收集器(G1 → ZGC)
- 内存泄漏 → MAT 分析 HeapDump,修代码
- 验证效果:再压测 → 对比基线 → 观察 1~2 天确认稳定
没改善就回滚,换方向重来。 别死磕一个方向。
几个我踩过的坑,分享给你
坑 1:Xms 不等于 Xmx
很多人开发环境设 -Xms512m -Xmx4g,觉得"先省着用,需要再扩"。但生产环境千万别这样------JVM 运行中动态扩容堆,会申请内存 + 拷贝数据,触发 STW 停顿。生产环境要的是可预测性 ,所以 -Xms 和 -Xmx 一定设一样的值。
坑 2:新生代不是越大越好
我一开始觉得新生代大 = 更多对象在新生代被回收 = 更好。但我的项目里 ConversationContext 是长存活对象(用户聊天持续 10~30 分钟),它们每次 Minor GC 都存活,年龄快速增长,很快晋升老年代。
大新生代适合短存活对象(创建即死的那种),长存活对象最终都去老年代。 所以给老年代留更多空间更重要------新生代取 1/3(偏小),老年代占 2/3(偏大),这在长存活对象多的场景下更合理。
坑 3:GC 日志不是摆设
很多人上线连 GC 日志都不开,出问题了才发现没数据可看。生产环境必须开:
bash
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10m
还有 HeapDump 也要配:
bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
OOM 时自动导出堆dump,比手动 jmap 香多了。
坑 4:Arthas 是线上排查神器
线上出问题最怕的就是"看不了"。Arthas 可以在线看 JVM 信息、内存分布、方法耗时、火焰图,不用重启不用额外部署:
bash
# 看某个类的实例数量
sc -d com.xxx.ConversationContext
# → instances: 1234 ← 直击要害
# 看方法实时耗时
trace com.xxx.AiCustomerServiceEngine process
# → 一步步展示每个环节耗时
# 火焰图找 CPU 热点
profiler start
# 等 30 秒
profiler stop --format html --file /tmp/flamegraph.html
九、决策速查表
| 你要做什么 | 看什么 | 怎么调 |
|---|---|---|
| 选堆大小 | 机器总内存 | 总内存 × 70% |
| 选新生代 | 对象存活时间 | 短存活→大新生代;长存活→小新生代 |
| 选收集器 | 延迟要求+堆大小 | G1(通用)/ ZGC(超低延迟) |
| 发现 GC 问题 | GC 日志 | GCEasy 在线分析 |
| 发现内存问题 | HeapDump | MAT 分析 |
| 发现 CPU 问题 | jstack | 找热点线程/死锁 |
| 线上排查 | Arthas | dashboard / trace / profiler |
十、最后说几句掏心窝的话
这次踩坑最大的教训不是 JVM 参数怎么配------那些网上到处都有。真正的教训是:别把不该放 JVM 堆的东西放进去。
会话数据、缓存数据、状态数据------这些东西天生就该在外部存储(Redis、数据库)里。JVM 堆是用来跑业务逻辑的,不是当数据库用的。
我当初用 ConcurrentHashMap 存会话,代码写起来确实爽------线程安全、零网络开销、零序列化成本。但代价是:堆内存压力、GC 负担、不支持分布式、重启丢数据。这些代价在低并发时不明显,高并发时就变成致命伤。
设计时偷的懒,上线后都是债。
如果这篇文章让你少踩一个坑,点个赞我就很开心了。有问题评论区见,踩坑路上咱们一起走。