上线那天,一个 ConcurrentHashMap 差点送走我的 AI 客服——3 天排查 JVM 血泪史

这不是一篇八股文。这是我真实踩坑、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

整条链路清清楚楚:

  1. SessionManager 持有 sessions 这个 ConcurrentHashMap
  2. 每个 sessionId 对应一个 ConversationContext
  3. 每个 ConversationContext 里面有个 embeddingCache(HashMap)
  4. 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,我还得自己写定时任务清理过期会话------代码里一堆 removeIfscheduleAtFixedRate,而且总怕清不干净。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 调优不是背参数,是数据驱动的闭环过程。

我的四步法

  1. 基线测试:默认配置启动 → 压测 → 记录 GC 频率、P99、堆使用率
  2. 分析瓶颈:GC 日志(GCEasy 在线分析)→ 内存分布(jmap + MAT)→ 线程状态(jstack / Arthas)
  3. 针对性调参
    • Minor GC 频繁 → 加大新生代
    • Full GC 频繁 → 加堆 + 查内存泄漏
    • GC 偶顿太长 → 换收集器(G1 → ZGC)
    • 内存泄漏 → MAT 分析 HeapDump,修代码
  4. 验证效果:再压测 → 对比基线 → 观察 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 负担、不支持分布式、重启丢数据。这些代价在低并发时不明显,高并发时就变成致命伤。

设计时偷的懒,上线后都是债。


如果这篇文章让你少踩一个坑,点个赞我就很开心了。有问题评论区见,踩坑路上咱们一起走。

相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十三):权限规则管理器
后端·go·gin
秦任之2 小时前
Gliding Horse 整体架构拼图:当 AI Agent 有了自己的操作系统
人工智能·架构
MindUp2 小时前
企业网盘权限模型解析:多层级访问控制与审计能力选型指南
java·linux·运维
Kim_wang2 小时前
agent项目部署流程
人工智能·后端
NG4772 小时前
【软件设计与体系结构】-策略设计模式
java·设计模式·软件工程
微三云 - 廖会灵 (私域系统开发)2 小时前
电商推荐系统从0到1:协同过滤+深度学习双塔模型的架构演进与实时排序实践
人工智能·深度学习·架构
程序员天天困2 小时前
优雅记录操作日志:从注解到 SpEL 的全链路实践与开源方案对比
java·后端
战血石LoveYY3 小时前
Integer类型超限,导致数据异常
java·算法
CaffeinePro3 小时前
SOLID五大设计原则:重构优雅代码与架构的底层规范
设计模式·架构