Spring 单例 Bean 注入 Prototype Bean 的内存泄露问题分析
问题背景
在 Spring 开发中,Bean 默认是单例(Singleton)作用域,但当单例 Bean 依赖于原型(Prototype)作用域的 Bean 时,可能会引发问题。本文通过一个案例,分析由单例 Bean 导致的内存泄露问题,解答以下疑惑:
- 为什么抽象基类的
List中元素会不断增加? - 这种增加与单例 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());
}
}
开发人员未考虑父类的有状态特性,直接为子类 SayHello 和 SayBye 添加了 @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 默认是单例的,SayHello 和 SayBye 各自的实例只创建一次。每次调用 test() 方法,sayServiceList 中的两个单例实例(SayHello 和 SayBye)分别调用一次 say() 方法,导致父类 SayService 的 data 字段持续累积数据,最终引发 OOM。
3. 初步修复尝试
架构师发现问题后,开发人员为 SayHello 和 SayBye 添加了 @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 {
// ...
}
然而,上线后内存泄露问题依然存在。日志显示,SayHello 和 SayBye 的实例在多次调用中保持不变(对象哈希值相同),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 中元素会不断增加?
SayService 的 data 字段是一个实例变量,每个 SayService 子类实例(如 SayHello 或 SayBye)都有自己的 data 列表。由于 SayHello 和 SayBye 是单例 Bean,它们的实例在应用生命周期内只创建一次。每次调用 say() 方法,data.add() 会向同一个 data 列表添加新元素,导致元素不断累积。
例如:
- 第一次调用
test():SayBye的data添加 1 个元素,SayHello的data添加 1 个元素。 - 第二次调用
test():SayBye的data再添加 1 个元素(总数为 2),SayHello的data再添加 1 个元素(总数为 2)。
由于单例 Bean 的 data 列表不会被重置,多次调用会导致 data 持续增长,最终耗尽内存。
疑惑 2:List 元素增加与单例注入 Prototype 失败的关系是什么?注入失败后不是相当于单例吗?为什么会导致数据无限增加?
当 SayHello 和 SayBye 被标记为 Prototype 作用域时,期望是每次注入或调用时创建新实例。然而,由于它们被注入到一个单例 Controller 的 sayServiceList 中,Spring 在初始化 Controller 时只创建了一次 SayHello 和 SayBye 的实例,并缓存到 sayServiceList 中。这导致 Prototype 作用域失效,SayHello 和 SayBye 实际上仍以单例方式运行。
注入失败(Prototype 失效)与 List 元素增加的直接关系在于:
- 单例行为导致状态保留:由于
SayHello和SayBye是单例,它们的data字段在整个应用生命周期内只有一个实例。每次调用say()方法都会向同一个data列表添加数据。 - 测试方法的多次调用:
test()方法通过 HTTP 请求触发(例如/test端点),每次请求都会调用sayServiceList.forEach(SayService::say),导致SayHello和SayBye各自的data列表添加新元素。多次请求会反复触发此过程,造成数据无限增加。
关于“理论上不会无限增加”的疑惑,关键在于 测试场景的重复调用:
- 案例中的日志表明,
test()方法被 HTTP 请求触发了多次(例如两次请求,时间戳分别为 15:01:09 和 15:01:15)。 - 每次请求都会调用
sayServiceList.forEach(SayService::say),即SayBye.say()和SayHello.say()各执行一次。 - 因为
SayBye和SayHello是单例,它们的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");
}
}
修复后,日志显示每次调用都创建新的 SayHello 和 SayBye 实例,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 的前面。
思考与总结
问题逻辑脉络
- 有状态基类设计:
SayService的data字段导致单例模式下数据累积,易引发 OOM。 - 子类未考虑作用域:开发人员未评估父类的有状态特性,直接标记
@Service,导致单例 Bean 状态保留。 - 单例 Controller 的限制:单例 Controller 注入 Prototype Bean 时,Spring 只初始化一次,Prototype 作用域失效。
- 数据无限增加的原因:多次 HTTP 请求触发
test()方法,单例的SayHello和SayBye持续向data添加元素。 - 初步修复失败:仅添加
@Scope(PROTOTYPE)无法解决问题,因为单例 Bean 缓存了依赖。 - 最终解决方案:通过代理模式或动态获取 Bean,确保每次调用创建新的 Prototype 实例。
- 额外优化:通过
@Order控制 Bean 注入顺序,满足业务需求。
解决策略总结
-
评估 Bean 状态:标记
@Service前,检查类及其父类是否包含状态。 -
选择合适的作用域:
- 无状态 Bean:使用 Singleton 作用域。
- 有状态 Bean:使用 Prototype 作用域,结合代理模式或动态获取。
-
单例注入 Prototype 的解决方案:
- 使用
@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)创建代理。 - 通过
ApplicationContext.getBean()动态获取 Prototype 实例。
- 使用
-
控制 Bean 顺序:使用
@Order或Ordered接口调整注入顺序。 -
验证与调试:通过日志验证 Bean 实例,调试时确认注入对象是否为代理类。
预防措施
- 架构设计:基类避免有状态设计,或明确说明使用约束。
- 代码审查:审查
@Service或@Component的作用域和状态。 - 测试覆盖:通过单元测试验证 Bean 生命周期和内存使用。
- 监控与报警:上线后监控内存使用,及时发现 OOM 问题。
解答疑惑总结
-
为什么
List元素不断增加?SayService的data是实例变量,单例 Bean 的实例在应用生命周期内只有一个。每次调用say()方法向data添加数据,导致元素累积。
-
增加与注入 Prototype 失败的关系?
- Prototype 失效导致
SayHello和SayBye以单例方式运行,data列表持续累积。多次 HTTP 请求触发test()方法,每次请求都向同一个data添加数据,造成无限增长。
- Prototype 失效导致
-
为什么不是只调用两次?
- 测试方法通过 HTTP 端点反复调用(非单次执行),每次请求都会触发
say()方法,导致单例 Bean 的data持续增长。
- 测试方法通过 HTTP 端点反复调用(非单次执行),每次请求都会触发
结论
单例 Bean 注入 Prototype Bean 的问题源于 Spring 的依赖注入机制和作用域冲突。通过代理模式或动态获取 Bean,可以确保 Prototype 作用域生效。同时,需关注类的状态、作用域和注入顺序,避免内存泄露或逻辑错误。这一案例提醒我们,在使用 Spring 的便利性时,需谨慎设计和验证。