第1695篇:AI服务的内存泄漏排查实战——Heap Dump分析与根因定位
第1695篇:AI服务的内存泄漏排查实战——Heap Dump分析与根因定位
内存泄漏是最让人头疼的生产问题之一,没有之一。
它不像空指针那样立刻报错,而是像温水煮青蛙,内存缓缓上涨,等你意识到的时候,可能已经是凌晨两点接到OOM告警电话了。
AI服务的内存泄漏比普通服务更难排查,因为它的内存使用模式本身就不平稳——一个大的对话上下文、一批Embedding向量,可能临时让内存使用率涨30%,看起来很像泄漏,但等处理完了又降下来了。这种正常的内存波动很容易干扰判断。
今天这篇,我把自己做过几次AI服务内存泄漏排查的经历整理出来,包括具体的工具和代码。
如何判断"是泄漏"还是"内存高"
这是第一个问题。很多同事看到内存使用率70%就开始担心,其实大可不必——JVM会主动使用内存,只要不超过堆上限且GC能正常回收,70%的使用率是健康的。
真正需要关注的是内存使用的趋势,而不是某一时刻的绝对值。
内存泄漏的特征:
- 内存使用率持续单调递增(即使在低流量时段也不下降)
- Full GC后内存不能回到之前的水平
- 随着时间推移,GC越来越频繁,每次回收效果越来越差用最简单的Prometheus查询来判断:
# 看堆内存使用趋势(Old Gen)
jvm_memory_used_bytes{area="heap", id="G1 Old Gen"}
# GC后的内存使用情况(更准确,排除Eden中的临时对象)
# 如果这条线持续上升,基本可以确认有泄漏
jvm_memory_after_gc_used_bytes{area="heap"}用图表看7天的趋势,如果Old Gen在每次Full GC后的最低点持续升高,这是内存泄漏的典型特征。
触发Heap Dump的几种方式
确认有内存泄漏之后,第一步是抓Heap Dump。Heap Dump是JVM堆内存在某个时刻的完整快照,是分析内存泄漏最有效的手段。
方式一:OOM时自动触发
# JVM启动参数
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump-$(hostname)-$(date +%Y%m%d%H%M%S).hprof这是最基本的配置,但有个问题:OOM发生时往往系统已经很严重了,这时候dump可能不完整,或者dump本身导致系统更糟糕。
方式二:手动触发(推荐)
内存使用率到了90%但还没OOM时,主动触发dump:
# 找到Java进程PID
jps -lv | grep app.jar
# 使用jmap触发dump
jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>
# 或者只dump存活对象(文件更小,但准确性略差)
jmap -dump:live,format=b,file=/tmp/heapdump-live.hprof <pid>方式三:通过Actuator端点触发(最方便)
# application.yml
management:
endpoint:
heapdump:
enabled: true
endpoints:
web:
exposure:
include: heapdump# 调用Actuator接口下载heap dump
curl -X GET http://localhost:8080/actuator/heapdump --output heapdump.hprof注意:Heap Dump文件会很大,一个16GB堆的dump可能有10GB以上。确保目标磁盘有足够空间,dump过程中JVM会暂停(Stop-the-World),生产环境操作要谨慎。
用MAT分析Heap Dump
Eclipse Memory Analyzer Tool(MAT)是分析Heap Dump的最强工具,免费且功能强大。
下载地址:https://eclipse.dev/mat/downloads.php
第一步:识别内存占用大户
打开dump文件后,先看Dominator Tree:
Window → Heap Dump Overview → Dominator Tree
这个视图显示了哪些对象持有最多的内存(包括它们引用的所有对象)。对于AI服务的内存泄漏,我通常会看到这几类可疑对象:
HashMap或ConcurrentHashMap(可能是某个缓存没有设置上限)String[]或byte[](可能是大量Prompt/Response堆积)- 线程对象(线程池泄漏)
第二步:运行泄漏分析报告
MAT有一个自动化的泄漏分析功能,非常好用:
File → Run Leak Suspects Report
它会自动识别最可能的泄漏点,并给出详细的分析。
第三步:用OQL查询特定类型的对象
MAT支持Object Query Language(OQL),可以用SQL风格查询对象:
-- 查询所有ChatSession对象
SELECT * FROM com.yourapp.model.ChatSession
-- 查找持有大量内存的HashMap
SELECT toString(k), v FROM java.util.HashMap$Entry k, v
WHERE (v.@retainedHeapSize > 1024*1024)
-- 查找长字符串(可能是没有被释放的Prompt)
SELECT s FROM java.lang.String s
WHERE (s.@retainedHeapSize > 10240 AND s.count > 5000)我排查过的几个真实AI服务内存泄漏案例
案例一:会话对话历史无限增长
这是最典型的AI服务内存泄漏。
代码大概是这样的:
// 问题代码
@Service
public class ConversationService {
// 用HashMap存储所有活跃会话的对话历史
// 问题:没有上限,没有过期清理!
private final Map<String, List<Message>> sessionHistory = new ConcurrentHashMap<>();
public void addMessage(String sessionId, Message message) {
sessionHistory.computeIfAbsent(sessionId, k -> new ArrayList<>())
.add(message);
}
public List<Message> getHistory(String sessionId) {
return sessionHistory.getOrDefault(sessionId, Collections.emptyList());
}
}这个问题在Heap Dump里的表现:ConcurrentHashMap 在Dominator Tree里占了很大比例,里面有大量的 List<Message> 对象,而且 Message 对象里有大量的String(对话内容)。
修复方案:
@Service
public class FixedConversationService {
// 用Caffeine替代HashMap,强制设置上限和过期时间
private final Cache<String, List<Message>> sessionHistory = Caffeine.newBuilder()
.maximumSize(10_000) // 最多10000个会话
.expireAfterAccess(2, TimeUnit.HOURS) // 2小时不访问就过期
.removalListener((key, value, cause) -> {
// 过期时记录日志或者持久化
if (cause.wasEvicted()) {
log.debug("会话历史被驱逐: sessionId={}", key);
}
})
.build();
// 同时,限制单个会话的最大对话轮数
private static final int MAX_HISTORY_TURNS = 50;
public void addMessage(String sessionId, Message message) {
sessionHistory.asMap().compute(sessionId, (k, history) -> {
if (history == null) history = new ArrayList<>();
history.add(message);
// 保持最近N轮,删除最老的
if (history.size() > MAX_HISTORY_TURNS * 2) {
return new ArrayList<>(history.subList(
history.size() - MAX_HISTORY_TURNS * 2, history.size()));
}
return history;
});
}
}案例二:Embedding缓存无界增长
我们用Redis做Embedding向量缓存,但本地有一层内存缓存(用来减少Redis访问)。
// 问题代码:本地缓存没有大小限制
@Component
public class EmbeddingService {
// 这个Map会无限增长!
private final Map<String, float[]> localEmbeddingCache = new HashMap<>();
public float[] getEmbedding(String text) {
return localEmbeddingCache.computeIfAbsent(text, t -> {
// 调用Embedding API
return embeddingApi.embed(t);
});
}
}在Heap Dump里,这表现为大量的 float[] 对象,每个1536个float(约6KB),加起来很快就是几个GB。
修复:
@Component
public class FixedEmbeddingService {
// 明确设置上限,LRU淘汰
private final Cache<String, float[]> localEmbeddingCache = Caffeine.newBuilder()
.maximumWeight(500 * 1024 * 1024L) // 最多500MB本地缓存
.weigher((String key, float[] value) -> key.length() + value.length * 4)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
public float[] getEmbedding(String text) {
return localEmbeddingCache.get(text, t -> embeddingApi.embed(t));
}
}案例三:ThreadLocal没有清理
这是一个比较隐蔽的泄漏。我们用ThreadLocal传递请求上下文(用户信息、traceId等),但在某些代码路径上忘记了 remove()。
当使用线程池时,线程是复用的,ThreadLocal里的数据不会随请求结束而自动清理。随着时间推移,每个线程的ThreadLocal都积累了大量不再需要的对象。
// 问题代码
public class UserContext {
private static final ThreadLocal<UserInfo> CURRENT_USER = new ThreadLocal<>();
public static void set(UserInfo user) {
CURRENT_USER.set(user);
}
public static UserInfo get() {
return CURRENT_USER.get();
}
// 忘记了remove()方法!
}
// 过滤器里设置但没清理
@Component
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
UserInfo user = authenticate(req);
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
// 这里忘记了!
// UserContext.remove(); ← 应该加这行
}
}
}在Heap Dump里,这表现为大量的 UserInfo 对象,被 ThreadLocal$ThreadLocalMap$Entry 引用,而这些Entry又被线程对象引用,GC无法回收。
修复很简单,加上 finally 块里的 remove():
@Component
public class FixedAuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
UserInfo user = authenticate(req);
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
UserContext.remove(); // 必须清理!
}
}
}更安全的方式是把ThreadLocal封装成一个会自动清理的工具:
public class SafeThreadLocal<T> {
private final ThreadLocal<T> delegate = new ThreadLocal<>();
// 返回一个AutoCloseable,用try-with-resources确保清理
public AutoCloseable set(T value) {
delegate.set(value);
return delegate::remove;
}
public T get() {
return delegate.get();
}
}
// 使用方式
try (var ignored = userContext.set(user)) {
chain.doFilter(req, res);
} // 自动调用remove()用JVM工具实时监控内存泄漏
除了Heap Dump分析,还有一些轻量级工具可以实时监控内存情况,不需要触发完整dump。
Java Flight Recorder (JFR)
JFR是JDK内置的低开销性能分析工具,对GC和内存分配都有很好的支持。
# 启动JFR记录(低开销,约1-2% CPU)
jcmd <pid> JFR.start name=ai-memory-check duration=300s
settings=profile filename=/tmp/memory-check.jfr
# 结束后用JMC(Java Mission Control)分析在JFR里重点看:
- Allocation in New TLAB / Outside TLAB:找出分配最多对象的代码
- GC Events:每次GC后的内存变化
jconsole的内存趋势图
最简单的工具,远程连上去看Old Gen的使用趋势:
# 启动时开启JMX(生产环境要加认证)
-Dcom.sun.management.jmxremote=true
-Dcom.sun.management.jmxremote.port=9090
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false用代码定期检测内存泄漏迹象
@Component
public class MemoryLeakDetector {
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final List<Long> oldGenSamples = new LinkedList<>();
private static final int SAMPLE_SIZE = 20;
@Scheduled(fixedDelay = 60_000) // 每分钟采样
public void sampleMemory() {
MemoryUsage heapUsage = memoryMXBean.getHeapMemoryUsage();
// 获取Old Gen的使用量
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
if (pool.getName().contains("Old") || pool.getName().contains("Tenured")) {
long used = pool.getUsage().getUsed();
oldGenSamples.add(used);
if (oldGenSamples.size() > SAMPLE_SIZE) {
oldGenSamples.remove(0);
// 检测是否持续增长
if (isMonotonicallyIncreasing(oldGenSamples)) {
log.warn("疑似内存泄漏:Old Gen持续增长,当前={}MB, 20分钟前={}MB",
used / 1024 / 1024,
oldGenSamples.get(0) / 1024 / 1024);
// 发告警
}
}
}
}
}
private boolean isMonotonicallyIncreasing(List<Long> samples) {
if (samples.size() < 5) return false;
int increasingCount = 0;
for (int i = 1; i < samples.size(); i++) {
if (samples.get(i) > samples.get(i-1) * 1.01) { // 增长超过1%
increasingCount++;
}
}
return increasingCount > samples.size() * 0.8; // 80%以上的样本都在增长
}
}内存泄漏排查的完整流程图
防患于未然:代码层面的预防措施
与其被动排查,不如在代码里加入主动预防。
// 1. 所有缓存必须有上限和过期
// 不允许直接使用 new HashMap<>() 作为缓存
// 必须用 Caffeine.newBuilder().maximumSize(n).expireAfterWrite(...).build()
// 2. 资源必须在finally块关闭
try (InputStream is = openStream()) {
// 使用is
} // 自动关闭,不会泄漏
// 3. 使用资源泄漏检测工具
// 在测试环境开启资源泄漏检测
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
// 4. 定期代码审查,重点看这几类
// - 单例对象中的集合字段
// - 静态Map/List
// - ThreadLocal的使用
// - 内部类持有外部类引用(可能导致外部类无法被GC)总结
AI服务的内存泄漏排查,相比普通服务多了几个特殊点:
- AI特有的大对象:对话历史、Embedding向量、大Prompt,这些对象如果没有合理管理,是最常见的泄漏来源
- 正常波动干扰判断:看趋势(特别是Full GC后的内存基线),而不是看当前绝对值
- Heap Dump是关键:MAT分析加上Dominator Tree,通常能很快定位问题
一句我觉得很实用的话:如果你在代码里看到一个没有大小限制的Map或List,它要么现在有问题,要么将来一定有问题。
