排查 3 天的 Java 内存泄漏,竟藏在这 3 段 “没问题” 的代码里!

B站影视 欧美电影 2025-09-25 10:27 1

摘要:你是不是也遇到过这种情况:本地调试 Java 程序明明顺风顺水,上线跑了两三天,服务就开始频繁报 OOM,日志里翻来翻去找不到报错堆栈,重启后又能撑一阵,但问题总在重复出现?其实这大概率是内存泄漏在悄悄 “啃食” 你的服务器资源 —— 今天就结合我们团队刚解决

你是不是也遇到过这种情况:本地调试 Java 程序明明顺风顺水,上线跑了两三天,服务就开始频繁报 OOM,日志里翻来翻去找不到报错堆栈,重启后又能撑一阵,但问题总在重复出现?其实这大概率是内存泄漏在悄悄 “啃食” 你的服务器资源 —— 今天就结合我们团队刚解决的真实案例,扒一扒 Java 开发里最容易踩的泄漏坑,再附上阿里 P8 架构师的避坑建议。

上周三凌晨,运维同事突然在工作群 @所有人:“用户中心服务内存使用率突破 95%,部分接口超时!” 我们紧急登录监控平台一看,好家伙,JVM 老年代内存从上线时的 2G,三天内一路飙升到 8G 满负荷,GC 次数从每分钟 3 次涨到每分钟 27 次,但每次 GC 后内存回收连 10% 都不到。

负责接口开发的小周当场懵了:“这接口就是查询用户订单列表啊,本地压测 10 万次都没毛病,SQL 也加了索引,怎么会内存泄漏?” 我们先临时扩容了内存撑住服务,然后拉了堆转储文件(heap dump)开始排查 —— 这一查就是整整 3 天。

最后定位到的问题,居然藏在三段看起来 “毫无问题” 的代码里。

public class OrderUtil { // 静态集合缓存订单状态 private static final MapORDER_STATUS_MAP = new HashMap; public static String getStatusDesc(Integer status) { // 从数据库查询状态描述 String desc = orderMapper.getStatusDesc(status); // 放入静态集合 ORDER_STATUS_MAP.put(status, desc); return desc; }}

问题分析:静态集合 ORDER_STATUS_MAP 的生命周期和 JVM 一致,只要程序在运行,它就会一直持有所有放入的对象引用。虽然订单状态通常是固定的,但小周的代码里没有判断是否已存在,后续如果有新状态不断传入(比如业务迭代新增的 “待退款”“已取消”),这个 HashMap 会无限膨胀,占用的内存永远无法被回收。

用户中心服务集成了消息队列,小周为了监听订单支付成功的消息,写了这样一段代码:

@Componentpublic class PayMessageListener { @Autowired private UserService userService; public PayMessageListener { // 注册消息监听器 MQClient.registerListener("pay_success_topic", new MessageListener { @Override public void onMessage(Message message) { // 处理支付成功逻辑 userService.updateUserVip(message.getUserId); } }); }}

问题分析:MQClient 是一个全局单例对象,当 PayMessageListener 被 Spring 容器初始化时,会向 MQClient 注册一个匿名内部类的监听器。匿名内部类会隐式持有外部类 PayMessageListener 的引用,而 PayMessageListener 又持有 UserService 的引用,UserService 再关联一系列 DAO 和数据库连接池对象。当后续服务迭代,这个监听器不再需要使用时,小周只停掉了业务逻辑,却忘了从 MQClient 中移除监听器 —— 这就导致 MQClient 一直持有监听器的引用,进而导致 PayMessageListener、UserService 等一系列对象都无法被 GC 回收,形成了一条长长的内存泄漏链。

public class UserContext { private static final ThreadLocalUSER_CONTEXT = new ThreadLocal; public static void setUser(UserDTO user) { USER_CONTEXT.set(user); } public static UserDTO getUser { return USER_CONTEXT.get; }}// 接口调用处@RequestMapping("/getUserInfo")public Result getUserInfo { UserDTO user = userService.getCurrentUser; UserContext.setUser(user); // 处理业务逻辑 return Result.success(xxx);}

问题分析:服务使用的是线程池处理请求,线程池里的线程是复用的,不会随着请求结束而销毁。小周在接口里调用了 UserContext.setUser ,但没有在请求结束后调用 remove 方法 —— 这意味着线程会一直持有 UserDTO 对象的引用。每次请求进来都会给 ThreadLocal 设置新的对象,旧的对象既不会被线程使用,也无法被 GC 回收,随着请求量增加,内存里堆积的 UserDTO 对象会越来越多,最终引发 OOM。

针对这些场景,我们特意请教了合作的阿里 P8 架构师老杨,他给出了 3 条非常实用的建议,尤其适合团队里的初、中级开发者:

“静态集合不是垃圾桶,一定要明确它的存储边界。” 老杨强调,使用静态 Map、List 时,要么像常量池一样只存固定数据,初始化后不再新增;要么加容量限制和过期清理机制,比如用 Guava 的 LoadingCache 替代 HashMap,设置最大容量和过期时间:

// 推荐写法:带容量和过期时间的缓存private static final LoadingCacheORDER_STATUS_CACHE = CacheBuilder.newBuilder .maximumSize(100) // 最大容量 .expireAfterWrite(1, TimeUnit.DAYS) // 写入后过期 .build(new CacheLoader{ @Override public String load(Integer status) { return orderMapper.getStatusDesc(status); } });

“注册和移除要像开关一样配套,这是开发规范里必须写清楚的。” 老杨建议,所有监听器、定时器、资源连接等,都要在对应的销毁方法里做移除 / 关闭操作。比如 Spring Bean 可以用 @PreDestroy 注解:

@Componentpublic class PayMessageListener { @Autowired private UserService userService; // 保存监听器引用 private MessageListener messageListener; @PostConstruct public void init { messageListener = new MessageListener { @Override public void onMessage(Message message) { userService.updateUserVip(message.getUserId); } }; MQClient.registerListener("pay_success_topic", messageListener); } // 销毁时移除监听器 @PreDestroy public void destroy { MQClient.removeListener("pay_success_topic", messageListener); }}

“ThreadLocal 的 remove 方法,一定要写在 finally 块里,这是铁律。” 老杨提醒,无论业务逻辑是否抛出异常,都要确保 ThreadLocal 被清空,避免线程复用导致的泄漏:

@RequestMapping("/getUserInfo")public Result getUserInfo { try { UserDTO user = userService.getCurrentUser; UserContext.setUser(user); // 处理业务逻辑 return Result.success(xxx); } finally { // 必须清空ThreadLocal UserContext.remove; }}// 同时优化UserContext,增加remove方法public class UserContext { private static final ThreadLocalUSER_CONTEXT = new ThreadLocal; public static void setUser(UserDTO user) { USER_CONTEXT.set(user); } public static UserDTO getUser { return USER_CONTEXT.get; } public static void remove { USER_CONTEXT.remove; }}

其实除了这 3 种场景,Java 里的内存泄漏还有很多 “变种”,比如未关闭的流资源、单例持有过多对象、集合里的对象未重写 equals 导致的内存残留等等。

想问问屏幕前的你:排查内存泄漏时,你最常用的工具是 VisualVM 还是 MAT?有没有遇到过查了几天才解决的 “奇葩” 泄漏问题?欢迎在评论区分享你的经历和技巧。

来源:从程序员到架构师

相关推荐