凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!
引子:那个让运维集体加班的夜晚
"凡哥!线上服务响应时间飙到10秒了!"凌晨1点,实习生小李的语音带着哭腔。
监控大屏上,JVM堆内存曲线像坐了火箭——刚扩容的16G内存,30分钟就被吃干抹净。
我咬着牙拍桌子:"把最近一周上线的代码给我翻个底朝天!"
第一坑:Static集合成永动机
▌ 翻车代码(真实项目片段)
// 缓存用户AI对话历史 → 翻车写法! public class ChatHistoryCache { private static Map<Long, List<String>> cache = new HashMap<>(); public static void addMessage(Long userId, String msg) { cache.computeIfAbsent(userId, k -> new ArrayList<>()).add(msg); } }
▌ 翻车现场
- 用户量暴增时,缓存数据只进不出,48小时撑爆内存
- 用Arthas抓现行:
vmtool --action getInstances -c 4614556e
看到Map尺寸破千万 - MAT分析:
HashMap$Node
对象占堆内存82%
▌ 正确姿势
// 改用Guava带过期时间的缓存 private static Cache<Long, List<String>> cache = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .maximumSize(10000) .build();
第二坑:Lambda忘记关文件流
▌ 致命代码(处理AI模型文件)
// 加载本地模型文件 → 翻车写法! public void loadModels(List<File> files) { files.forEach(file -> { try { InputStream is = new FileInputStream(file); // 漏了关闭! parseModel(is); } catch (IOException e) { /*...*/ } }); }
▌ 诡异现象
- 服务运行三天后突然报 "Too many open files"
- Linux排查:
lsof -p 进程ID | grep 'deleted'
发现大量未释放文件句柄 - JVM监控:
jcmd PID VM.native_memory
显示文件描述符数量突破1万
▌ 抢救方案
// 正确写法:try-with-resources自动关闭 files.forEach(file -> { try (InputStream is = new FileInputStream(file)) { // 自动关流 parseModel(is); } catch (IOException e) { /*...*/ } });
第三坑:Spring事件监听成钉子户
▌ 坑爹代码(消息通知模块)
// 监听AI处理完成事件 → 翻车写法! @Component public class NotifyService { @EventListener public void handleAiEvent(AICompleteEvent event) { // 错误持有外部服务引用 externalService.registerCallback(this::sendNotification); } }
▌ 内存曲线
- 每次事件触发,监听器对象就被外部服务强引用,永远不释放
- MAT分析:
NotifyService
实例数随时间线性增长 - GC日志:老年代占用率每周增长5%
▌ 避坑绝招
// 使用弱引用解除绑定 public void handleAiEvent(AICompleteEvent event) { WeakReference<NotifyService> weakRef = new WeakReference<>(this); externalService.registerCallback(() -> { NotifyService service = weakRef.get(); if (service != null) service.sendNotification(); }); }
第四坑:线程池里的僵尸任务
▌ 问题代码(异步处理AI请求)
// 异步线程池配置 → 翻车写法! @Bean public Executor asyncExecutor() { return new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); // 无界队列! }
▌ 灾难现场
- 请求突增时队列堆积50万任务,每个任务持有一个AI响应对象
- 堆dump显示:
byte[]
占内存90%,全是待处理的响应数据 - 监控指标:
queue_size
指标持续高位不降
▌ 正确配置
// 设置队列上限+拒绝策略 new ThreadPoolExecutor(10, 50, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
第五坑:MyBatis连接池里的幽灵
▌ 致命代码(查询用户对话记录)
public List<ChatRecord> getHistory(Long userId) { SqlSession session = sqlSessionFactory.openSession(); try { return session.selectList("queryHistory", userId); } finally { // 忘记session.close() → 连接池逐渐枯竭 } }
▌ 泄露证据
- Druid监控面板显示活跃连接数达到最大值
- 日志报错:
Cannot get connection from pool, timeout 30000ms
- 堆分析:
SqlSession
实例数异常增长
▌ 正确姿势
// 使用try-with-resources自动关闭 try (SqlSession session = sqlSessionFactory.openSession()) { return session.selectList("queryHistory", userId); }
第六坑:第三方库的温柔陷阱
▌ 问题代码(缓存用户偏好设置)
// 使用Ehcache时的错误配置 CacheConfiguration<Long, UserPreference> config = new CacheConfiguration<>() .setName("user_prefs") .setMaxEntriesLocalHeap(10000); // 只设置了数量,没设过期时间!
▌ 内存症状
- GC日志显示老年代每周增长3%
- Arthas监控:
watch com.example.CacheService getCachedUser
返回对象存活时间超7天 - 压测时触发OOM,堆中发现大量
UserPreference
对象
▌ 正确配置
config.setTimeToLiveSeconds(3600) // 1小时过期 .setDiskExpiryThreadIntervalSeconds(60); // 过期检查间隔
第七坑:ThreadLocal用完不打扫
▌ 致命代码(用户上下文传递)
public class UserContextHolder { private static final ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void set(User user) { currentUser.set(user); } // 缺少remove方法! }
▌ 内存异常
- 线程池复用后,ThreadLocal中旧用户数据堆积
- MAT分析:
User
对象被ThreadLocalMap
强引用无法释放 - 监控发现:每个线程持有平均50个过期用户对象
▌ 修复方案
// 使用后必须清理! public static void remove() { currentUser.remove(); } // 在拦截器中强制清理 @Around("execution(* com.example..*.*(..))") public Object clearContext(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } finally { UserContextHolder.remove(); // 关键! } }
终极排查工具箱
1. Arthas实战三连击
# 实时监控GC情况 dashboard -n 5 -i 2000 # 追踪可疑方法调用频次 trace com.example.CacheService addCacheEntry -n 10 # 动态修改日志级别(无需重启) logger --name ROOT --level debug
2. MAT分析三板斧
- Dominator Tree:揪出内存吞噬者
- Path to GC Roots:顺藤摸瓜找凶手
- OQL黑科技:
SELECT * FROM java.util.HashMap WHERE size > 10000 SELECT toString(msg) FROM java.lang.String WHERE msg.value LIKE "%OOM%"
3. 线上救火命令包
# 快速查看堆内存分布 jhsdb jmap --heap --pid <PID> # 统计对象数量排行榜 jmap -histo:live <PID> | head -n 20 # 强制触发Full GC(慎用!) jcmd <PID> GC.run
防泄漏军规十二条
- 所有缓存必须设置双保险:过期时间 + 容量上限
- IO操作三重防护:
try (InputStream is = ...) { // 第一重 useStream(is); } catch (IOException e) { // 第二重 log.error("IO异常", e); } finally { // 第三重 cleanupTempFiles(); } - 线程池四不原则:
- 不用无界队列
- 不设不合理核心数
- 不忽略拒绝策略
- 不存放大对象
- Spring组件三查:
- 查事件监听器引用链
- 查单例对象中的集合类
- 查@Async注解的线程池配置
- 第三方库两验:
- 验连接池归还机制
- 验缓存默认配置
- 代码审查重点关注:
- 所有static修饰的集合
- 所有close()/release()调用点
- 所有内部类持有外部引用的地方
运维老凡的避坑日记
2024-03-20 凌晨2点
"小王啊,知道为什么我头发这么少吗?
当年有人把用户会话存到ThreadLocal里不清理,
结果线上十万用户同时在线时——
那内存泄漏的速度比理发店推子还快!"
自测题:你能看出这段代码哪里会泄漏吗?
// 危险代码!请找出三个泄漏点 public class ModelLoader { private static List<Model> loadedModels = new ArrayList<>(); public void load(String path) { Model model = new Model(Files.readAllBytes(Paths.get(path))); loadedModels.add(model); Executors.newSingleThreadScheduledExecutor() .scheduleAtFixedRate(() -> model.refresh(), 1, 1, HOURS); } }
答案揭晓:
- static集合无清理机制
- 定时任务线程池未关闭
- 匿名内部类持有Model强引用
本文来自博客园,作者:程序员晓凡,转载请注明原文链接:https://www.cnblogs.com/xiezhr/p/18737457
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)