专业的JAVA编程教程与资源

网站首页 > java教程 正文

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

temp10 2025-10-14 05:13:25 java教程 1 ℃ 0 评论

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

一个 “稳定” 接口引发的服务雪崩

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

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

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

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

3 类典型内存泄漏场景:你写的代码可能也在踩坑

1. 静态集合类:被遗忘的 “永久存储箱”

小周在接口里定义了一个静态 HashMap,用来缓存订单状态的中文描述,代码大概长这样:

public class OrderUtil {
    // 静态集合缓存订单状态
    private static final Map<Integer, String> ORDER_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 会无限膨胀,占用的内存永远无法被回收。

2. 监听器未移除:隐形的 “内存钩子”

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

@Component
public 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 回收,形成了一条长长的内存泄漏链。

3. 线程局部变量 ThreadLocal:线程池里的 “内存陷阱”

为了在多线程环境下传递用户上下文,小周使用了 ThreadLocal:

public class UserContext {
    private static final ThreadLocal<UserDTO> USER_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 条避坑建议,从根源杜绝泄漏

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

1. 静态集合必须加 “边界控制”

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

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

2. 监听器、回调必须 “成对操作”

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

@Component
public 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);
    }
}

3. ThreadLocal 必须在 finally 里 “清空”

“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 ThreadLocal<UserDTO> USER_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?有没有遇到过查了几天才解决的 “奇葩” 泄漏问题?欢迎在评论区分享你的经历和技巧。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表