Spring 单例 Bean 注入 Prototype Bean 的内存泄露问题分析

201 阅读8分钟

Spring 单例 Bean 注入 Prototype Bean 的内存泄露问题分析

问题背景

在 Spring 开发中,Bean 默认是单例(Singleton)作用域,但当单例 Bean 依赖于原型(Prototype)作用域的 Bean 时,可能会引发问题。本文通过一个案例,分析由单例 Bean 导致的内存泄露问题,解答以下疑惑:

  1. 为什么抽象基类的 List 中元素会不断增加?
  2. 这种增加与单例 Bean 注入 Prototype Bean 失败的关系是什么?即使注入失败,测试方法只是调用了两次 say() 方法,为什么会导致数据无限增加?

最终,我们将复盘问题场景,提出解决策略,并总结预防措施。

问题场景复盘

1. 初始设计与问题根源

架构师设计了一个有状态的抽象类 SayService,包含一个 ArrayList 字段 data,用于存储每次调用 say() 方法生成的中间数据。每次调用会向 data 添加一个包含 100 万个字符的字符串,外加一个 UUID。由于 SayService 是有状态的,如果它是单例 Bean,data 会持续累积,导致内存溢出(OOM)。

@Slf4j
public abstract class SayService {
    List<String> data = new ArrayList<>();

    public void say() {
        data.add(IntStream.rangeClosed(1, 1000000)
                .mapToObj(__ -> "a")
                .collect(Collectors.joining("")) + UUID.randomUUID().toString());
        log.info("I'm {} size:{}", this, data.size());
    }
}

开发人员未考虑父类的有状态特性,直接为子类 SayHelloSayBye 添加了 @Service 注解,使其成为 Spring Bean,并通过 @Autowired 注入到一个 List<SayService> 中。

@Service
@Slf4j
public class SayHello extends SayService {
    @Override
    public void say() {
        super.say();
        log.info("hello");
    }
}

@Service
@Slf4j
public class SayBye extends SayService {
    @Override
    public void say() {
        super.say();
        log.info("bye");
    }
}

@Autowired
List<SayService> sayServiceList;

@GetMapping("test")
public void test() {
    log.info("====================");
    sayServiceList.forEach(SayService::say);
}

2. 问题暴露

由于 Spring Bean 默认是单例的,SayHelloSayBye 各自的实例只创建一次。每次调用 test() 方法,sayServiceList 中的两个单例实例(SayHelloSayBye)分别调用一次 say() 方法,导致父类 SayServicedata 字段持续累积数据,最终引发 OOM。

3. 初步修复尝试

架构师发现问题后,开发人员为 SayHelloSayBye 添加了 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 注解,期望每次调用 test() 方法时创建新的实例,避免 data 累积。

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Slf4j
public class SayHello extends SayService {
    // ...
}

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Slf4j
public class SayBye extends SayService {
    // ...
}

然而,上线后内存泄露问题依然存在。日志显示,SayHelloSayBye 的实例在多次调用中保持不变(对象哈希值相同),data 的大小持续增长:

[15:01:09.349] [INFO] - ====================
[15:01:09.401] [INFO] - I'm SayBye@4c0bfe9e size:1
[15:01:09.402] [INFO] - bye
[15:01:09.469] [INFO] - I'm SayHello@490fbeaa size:1
[15:01:09.469] [INFO] - hello
[15:01:15.167] [INFO] - ====================
[15:01:15.197] [INFO] - I'm SayBye@4c0bfe9e size:2
[15:01:15.198] [INFO] - bye
[15:01:15.224] [INFO] - I'm SayHello@490fbeaa size:2
[15:01:15.224] [INFO] - hello

4. 问题原因分析

疑惑 1:为什么抽象基类的 List 中元素会不断增加?

SayServicedata 字段是一个实例变量,每个 SayService 子类实例(如 SayHelloSayBye)都有自己的 data 列表。由于 SayHelloSayBye 是单例 Bean,它们的实例在应用生命周期内只创建一次。每次调用 say() 方法,data.add() 会向同一个 data 列表添加新元素,导致元素不断累积。

例如:

  • 第一次调用 test()SayByedata 添加 1 个元素,SayHellodata 添加 1 个元素。
  • 第二次调用 test()SayByedata 再添加 1 个元素(总数为 2),SayHellodata 再添加 1 个元素(总数为 2)。

由于单例 Bean 的 data 列表不会被重置,多次调用会导致 data 持续增长,最终耗尽内存。

疑惑 2:List 元素增加与单例注入 Prototype 失败的关系是什么?注入失败后不是相当于单例吗?为什么会导致数据无限增加?

SayHelloSayBye 被标记为 Prototype 作用域时,期望是每次注入或调用时创建新实例。然而,由于它们被注入到一个单例 Controller 的 sayServiceList 中,Spring 在初始化 Controller 时只创建了一次 SayHelloSayBye 的实例,并缓存到 sayServiceList 中。这导致 Prototype 作用域失效,SayHelloSayBye 实际上仍以单例方式运行。

注入失败(Prototype 失效)与 List 元素增加的直接关系在于:

  • 单例行为导致状态保留:由于 SayHelloSayBye 是单例,它们的 data 字段在整个应用生命周期内只有一个实例。每次调用 say() 方法都会向同一个 data 列表添加数据。
  • 测试方法的多次调用test() 方法通过 HTTP 请求触发(例如 /test 端点),每次请求都会调用 sayServiceList.forEach(SayService::say),导致 SayHelloSayBye 各自的 data 列表添加新元素。多次请求会反复触发此过程,造成数据无限增加。

关于“理论上不会无限增加”的疑惑,关键在于 测试场景的重复调用

  • 案例中的日志表明,test() 方法被 HTTP 请求触发了多次(例如两次请求,时间戳分别为 15:01:09 和 15:01:15)。
  • 每次请求都会调用 sayServiceList.forEach(SayService::say),即 SayBye.say()SayHello.say() 各执行一次。
  • 因为 SayByeSayHello 是单例,它们的 data 列表在每次请求中持续累积,而不是每次请求都从空列表开始。

因此,即使单次请求只调用两次 say() 方法,多次 HTTP 请求会导致 data 无限增长,最终引发 OOM。

5. 解决策略

为了让 Prototype Bean 在单例 Bean 中每次调用时都创建新实例,需解决 Prototype 作用域失效的问题。以下是两种解决方案:

方法 1:使用代理模式

通过为 Prototype Bean 配置代理模式(proxyMode = ScopedProxyMode.TARGET_CLASS),Spring 会在注入时创建一个代理对象。每次调用时,代理会动态获取新的 Prototype 实例。

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Slf4j
public class SayHello extends SayService {
    @Override
    public void say() {
        super.say();
        log.info("hello");
    }
}

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Slf4j
public class SayBye extends SayService {
    @Override
    public void say() {
        super.say();
        log.info("bye");
    }
}

修复后,日志显示每次调用都创建新的 SayHelloSayBye 实例,data 不再累积:

[15:08:42.649] [INFO] - ====================
[15:08:42.747] [INFO] - I'm SayBye@3fa64743 size:1
[15:08:42.747] [INFO] - bye
[15:08:42.871] [INFO] - I'm SayHello@2f0b779 size:1
[15:08:42.872] [INFO] - hello
[15:08:42.932] [INFO] - ====================
[15:08:42.991] [INFO] - I'm SayBye@7319b18e size:1
[15:08:42.992] [INFO] - bye
[15:08:43.046] [INFO] - I'm SayHello@77262b35 size:1
[15:08:43.046] [INFO] - hello
方法 2:通过 ApplicationContext 动态获取

避免直接注入 Prototype Bean,通过 ApplicationContext 在运行时动态获取 Bean 实例。

@Autowired
private ApplicationContext applicationContext;

@GetMapping("test2")
public void test2() {
    applicationContext.getBeansOfType(SayService.class).values().forEach(SayService::say);
}

此方法无需代理,但需要在每次调用时手动获取 Bean,代码稍显繁琐。

6. 额外发现:Bean 注入顺序问题

sayServiceList 的注入顺序是 SayBye 在前,SayHello 在后,但业务可能希望先执行 SayHello 再执行 SayBye。可以通过 @Order 注解控制 Bean 的优先级:

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Order(1)
@Slf4j
public class SayHello extends SayService {
    // ...
}

@Service
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Order(2)
@Slf4log
public class SayBye extends SayService {
    // ...
}

@Order(1) 表示更高的优先级,Spring 会将 SayHello 放在 sayServiceList 的前面。

思考与总结

问题逻辑脉络

  1. 有状态基类设计SayServicedata 字段导致单例模式下数据累积,易引发 OOM。
  2. 子类未考虑作用域:开发人员未评估父类的有状态特性,直接标记 @Service,导致单例 Bean 状态保留。
  3. 单例 Controller 的限制:单例 Controller 注入 Prototype Bean 时,Spring 只初始化一次,Prototype 作用域失效。
  4. 数据无限增加的原因:多次 HTTP 请求触发 test() 方法,单例的 SayHelloSayBye 持续向 data 添加元素。
  5. 初步修复失败:仅添加 @Scope(PROTOTYPE) 无法解决问题,因为单例 Bean 缓存了依赖。
  6. 最终解决方案:通过代理模式或动态获取 Bean,确保每次调用创建新的 Prototype 实例。
  7. 额外优化:通过 @Order 控制 Bean 注入顺序,满足业务需求。

解决策略总结

  1. 评估 Bean 状态:标记 @Service 前,检查类及其父类是否包含状态。

  2. 选择合适的作用域

    • 无状态 Bean:使用 Singleton 作用域。
    • 有状态 Bean:使用 Prototype 作用域,结合代理模式或动态获取。
  3. 单例注入 Prototype 的解决方案

    • 使用 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) 创建代理。
    • 通过 ApplicationContext.getBean() 动态获取 Prototype 实例。
  4. 控制 Bean 顺序:使用 @OrderOrdered 接口调整注入顺序。

  5. 验证与调试:通过日志验证 Bean 实例,调试时确认注入对象是否为代理类。

预防措施

  • 架构设计:基类避免有状态设计,或明确说明使用约束。
  • 代码审查:审查 @Service@Component 的作用域和状态。
  • 测试覆盖:通过单元测试验证 Bean 生命周期和内存使用。
  • 监控与报警:上线后监控内存使用,及时发现 OOM 问题。

解答疑惑总结

  1. 为什么 List 元素不断增加?

    • SayServicedata 是实例变量,单例 Bean 的实例在应用生命周期内只有一个。每次调用 say() 方法向 data 添加数据,导致元素累积。
  2. 增加与注入 Prototype 失败的关系?

    • Prototype 失效导致 SayHelloSayBye 以单例方式运行,data 列表持续累积。多次 HTTP 请求触发 test() 方法,每次请求都向同一个 data 添加数据,造成无限增长。
  3. 为什么不是只调用两次?

    • 测试方法通过 HTTP 端点反复调用(非单次执行),每次请求都会触发 say() 方法,导致单例 Bean 的 data 持续增长。

结论

单例 Bean 注入 Prototype Bean 的问题源于 Spring 的依赖注入机制和作用域冲突。通过代理模式或动态获取 Bean,可以确保 Prototype 作用域生效。同时,需关注类的状态、作用域和注入顺序,避免内存泄露或逻辑错误。这一案例提醒我们,在使用 Spring 的便利性时,需谨慎设计和验证。