Administrator
发布于 2020-03-13 / 4250 阅读
36

Spring Bean 作用域与 prototype 注入失效问题

标了 @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,创建完就撒手了。@PreDestroyDisposableBean.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 注入失效问题》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考