标了 @Scope("prototype"),为什么拿到的还是同一个对象
3 月上旬,同事遇到个怪事。他写了个导出 Excel 的任务类,因为里面有状态(当前行号、样式缓存),标成了多例:
@Component
@Scope("prototype")
public class ExcelExportTask {
private int currentRow = 0;
private final Map<String, CellStyle> styleCache = new HashMap<>();
public void export(List<Order> orders) {
// 用 currentRow 和 styleCache
}
}
@Service
public class ReportService {
@Autowired
private ExcelExportTask task;
public void handle(List<Order> orders) {
task.export(orders);
}
}
结果两个用户同时点导出,报出来的错很离谱:
java.util.ConcurrentModificationException: null
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1445)
at com.xxx.ExcelExportTask.export(ExcelExportTask.java:47)
他加了个日志打 hashCode,发现两次请求的 task 是同一个对象。@Scope("prototype") 白标了。
原因:注入只发生一次
这个不怪 prototype,怪的是注入的方向。
Spring 容器启动的时候,创建 ReportService(单例),发现它依赖 ExcelExportTask,于是在这一刻去容器里要一个 ExcelExportTask 实例,然后塞进字段里。之后 ReportService 一直是同一个对象,它里面的 task 字段自然也永远是启动时那一个。
换句话说:prototype 说的是"每次从容器 getBean() 都给你一个新的",但字段注入只 getBean() 了一次。
验证很简单:
@Autowired
private ApplicationContext ctx;
@GetMapping("/test")
public String test() {
ExcelExportTask a = ctx.getBean(ExcelExportTask.class);
ExcelExportTask b = ctx.getBean(ExcelExportTask.class);
ExcelExportTask c = ctx.getBean(ExcelExportTask.class); // 字段注入进来的那个
return String.format("getBean两次: %s / %s 字段注入: %s",
a.hashCode(), b.hashCode(), c.hashCode());
}
getBean两次: 1732947821 / 902143657 字段注入: 1563891204
getBean() 每次都不同,说明 prototype 本身是生效的,问题确实在注入时机。
三种解法
一、作用域代理,改动最小
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ExcelExportTask { ... }
加了 proxyMode 之后,注入进 ReportService 的不再是真实对象,而是一个 CGLIB 生成的代理。每次调用代理的方法时,代理才会去容器里取一个真实实例,然后转发调用。
日志验证:
注入进来的对象 class: com.xxx.ExcelExportTask$$EnhancerBySpringCGLIB$$8a2f1b03
类名后面那串 EnhancerBySpringCGLIB 就是代理。
两个限制要记住:
- 用的是 CGLIB,所以类不能是
final,方法也不能是final。如果类有接口,可以用ScopedProxyMode.INTERFACES走 JDK 动态代理。 - 代理只在方法调用时才解析真实实例。如果你直接读被代理对象的字段,拿到的是代理对象自己的字段(永远是默认值)。
第二点很隐蔽,我们有人这么写过:
@Service
public class ReportService {
@Autowired
private ExcelExportTask task;
public void handle() {
System.out.println(task.currentRow); // 永远是 0!代理对象的字段没被初始化
}
}
必须通过方法调用才能触发真实实例的解析。
二、ObjectProvider,我最常用的
@Service
public class ReportService {
@Autowired
private ObjectProvider<ExcelExportTask> taskProvider;
public void handle(List<Order> orders) {
ExcelExportTask task = taskProvider.getObject(); // 每次调都拿新的
try {
task.export(orders);
} finally {
// 自己负责清理,Spring 不管 prototype 的销毁
}
}
}
ObjectProvider 是 Spring 4.3 引入的(用来替代啰嗦的 ObjectFactory),getObject() 每次都会触发一次真实的 getBean()。这个写法的优点是意图非常明确——看代码就知道这里要一个新实例,不用像代理那样靠"猜"。
它还有 getIfAvailable() 和 getIfUnique(),处理 Bean 可能不存在的情况,比 @Autowired(required = false) 优雅:
ExcelExportTask task = taskProvider.getIfAvailable(ExcelExportTask::new);
三、@Lookup 方法注入
@Service
public abstract class ReportService {
public void handle(List<Order> orders) {
ExcelExportTask task = createTask(); // 每次调这个方法都返回新实例
task.export(orders);
}
@Lookup
protected abstract ExcelExportTask createTask();
}
Spring 会用 CGLIB 重写这个抽象方法,让它返回容器里的新实例。缺点是把类变成了 abstract,而且方法不能是 private。用得不多,我只在老项目里见过。
不推荐:ApplicationContext.getBean()
@Autowired
private ApplicationContext ctx;
public void handle() {
ExcelExportTask task = ctx.getBean(ExcelExportTask.class);
}
能跑,但这是服务定位器模式,把 IoC 容器的依赖散落到业务代码里,单元测试要自己 mock 一个 ApplicationContext。除非实在没办法,别用。
三种方式的对比
| 方式 | 改动量 | 可读性 | 坑 |
|---|---|---|---|
proxyMode 代理 | 只改一行注解 | 差,看不出字段是代理 | 直接读字段拿到默认值;不能 final |
ObjectProvider | 改字段类型加一行 getObject | 好,意图明确 | 需要自己管生命周期 |
@Lookup | 类变成 abstract | 一般 | 侵入性较强 |
我现在的习惯:新代码统一用 ObjectProvider,只有在改不动的老代码里才用 proxyMode。
顺带几个关于 prototype 的冷知识
这几个是我在 AbstractBeanFactory.doGetBean() 源码里翻出来的:
- prototype 的销毁回调不会被调用。Spring 只负责创建 prototype Bean,创建完就撒手了。
@PreDestroy、DisposableBean.destroy()都不会执行,因为它可能在被很多地方引用,容器不敢销毁。所以 prototype Bean 里如果持有了文件句柄、连接这类资源,必须自己释放。 - prototype 的初始化回调是会执行的,
@PostConstruct每次创建都跑。这意味着如果你的 prototype Bean 初始化很重,每次getObject()都要付这个代价。 - singleton 依赖 prototype 时,Spring 不会报错也不会警告。这点挺坑的,完全靠人工 review 发现。
- 反过来 prototype 依赖 singleton 是完全正常的,singleton 会被复用。
还有个 request 作用域的类比:在 Web 应用里,@Scope("request") 的 Bean 注入到单例 Controller 里,会遇到一模一样的问题,而且更常见。解决方式也是这三种,proxyMode = ScopedProxyMode.TARGET_CLASS 在那里几乎是标配写法。
留个问题
关于《Spring Bean 作用域与 prototype 注入失效问题》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。