主题
Spring Bean 作用域:singleton/prototype 与代理注入
问题
两个面试必问题:Bean 的六种作用域各有什么特性?为什么单例 Bean 里注入一个 prototype Bean 每次拿到的却是同一个对象?
六种作用域怎么选的
Spring 从 2.0 开始只有 singleton 和 prototype,3.0 引入 web 作用域(request/session/application),5.0 加入 websocket。六种一到,面试考到"哪个作用域常用"时,别只答前两个。
| 作用域 | 实例数 | 生命周期 | 适用范围 |
|---|---|---|---|
| singleton | 整个容器一个 | 容器启动→容器关闭 | 绝大多数 Bean(默认),无状态组件 |
| prototype | 每次 getBean 创建一个 | 容器创建→交给你后不管 | 有状态对象、模板实例 |
| request | 每个 HTTP 请求一个 | 请求开始→请求结束 | Web 上下文中的请求级状态 |
| session | 每个 HTTP Session 一个 | 会话开始→会话结束 | 用户会话级状态 |
| application | 每个 ServletContext 一个 | 应用启动→应用关闭 | 和 singleton 同一级别但绑定 ServletContext |
| websocket | 每个 WebSocket 会话一个 | 连接建立→连接关闭 | WebSocket 会话级状态 |
singleton 占 95% 以上,因为大部分 Bean 无状态,共享一个实例省内存也省创建开销。prototype 只在明确需要每次新建的场景用:比如一个模板类持有每次调用不同的参数,或者一个消息处理器每次处理前需要重置状态。web 作用域多数场景被 scope proxy 替代,后面会讲到。
千万别踩的坑:单例注入原型
开发中经常会遇到这类代码:
java
@Component
@Scope("prototype")
public class PriceCalculator {
private final UUID instanceId = UUID.randomUUID();
public void calculate() {
System.out.println("Calculator instance: " + instanceId);
}
}
@Component
public class OrderService {
@Autowired
private PriceCalculator calculator;
public void processOrder() {
calculator.calculate();
}
}猜猜每次调用 processOrder() 输出的 instanceId 是什么?永远一样。
原因很简单:OrderService 是单例,在容器初始化阶段只执行一次属性注入。Spring 调用 getBean("priceCalculator") 创建一个 prototype 实例注入进去,然后这个实例就被固定了。此后每次 processOrder() 拿到的都是同一个 PriceCalculator 对象,prototype 根本没生效。
两种解法:ObjectFactory 与代理
解法一:ObjectFactory / Provider
把延迟获取交给容器,每次调用时主动拿一个新实例:
java
@Component
public class OrderService {
@Autowired
private ObjectFactory<PriceCalculator> calculatorFactory;
public void processOrder() {
PriceCalculator calculator = calculatorFactory.getObject();
calculator.calculate();
}
}每次调用 getObject() 都会走 BeanFactory.getBean(),触发 prototype 的创建流程。javax.inject.Provider 效果一样,是 JSR-330 的标准化接口。显式、可控,没有代理开销。
解法二:作用域代理(Scoped Proxy)
@Scope 的 proxyMode 参数让 Spring 在注入时不是放真实 Bean,而是放一个代理对象:
java
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PriceCalculator {
// ...
}OrderService 里照常 @Autowired,注入的是一个 CGLIB 动态生成的子类代理。每次调用 calculator.calculate(),代理都会走到 CglibAopProxy 拦截器,从容器里重新获取一个实例来执行方法。这样调用方不需要改代码,业务逻辑和获取逻辑解耦了。
java
// 代理最终生成的类伪代码
public class PriceCalculator$$EnhancerBySpringCGLIB extends PriceCalculator {
@Override
public void calculate() {
PriceCalculator real = getBeanFromApplicationContext(); // 每次新实例
real.calculate();
}
}TARGET_CLASS 用 CGLIB 生成子类代理,INTERFACES 用 JDK 动态代理。原型作用域用 TARGET_CLASS 最保险,因为目标类不一定实现了接口。
选型:ObjectFactory 零开销、显式,适合调用方知道自己要新实例的场景;proxyMode 对调用方透明,但每次方法调用都有拦截开销。个人倾向于 ObjectFactory,显式依赖比隐式代理容易排查。
prototype 的销毁回调不执行
题目:@Scope("prototype") 上加了 @PreDestroy,容器关闭时执行了吗?
不执行。容器创建后就不持有 prototype 实例的引用,无法在关闭时遍历销毁。销毁逻辑只对 singletonObjects 里的单例生效。原型 Bean 的资源释放自己管,比如在调用方显式调用 close(),或通过 @Bean(destroyMethod = "close")。
两个 web 作用域的真相
request/session/application 作用域必须在 WebApplicationContext 中才能使用。在非 web 环境(比如单元测试)下直接声明 @Scope("request") 启动会报 IllegalStateException,因为对应的 Scope 实现没有注册。Spring Boot 测试通过 @WebAppConfiguration 解决。
实际开发中,request 作用域最常见的场景是把当前请求的用户信息、TraceId 通过 Filter 放到 RequestContextHolder 里,而不是真把 Bean 设成 request 作用域。RequestContextHolder 背后是 ThreadLocal,比 Scope 代理更轻量,也不依赖 Spring 容器的作用域解析。
几种作用域注入方案对比
把上面涉及的技术放到一起看:
| 方案 | 每次新实例 | 对调用方透明 | 开销 | 适合场景 |
|---|---|---|---|---|
| 原型注入(@Autowired prototype) | ❌ 不生效 | 透明 | 无 | 错误用法,不要写 |
| ObjectFactory | ✅ | 需显式调用 | 每次 getBean 创建 | 调用方明确知道需要新实例 |
| Scope Proxy | ✅ | 透明 | 每次方法调用都走拦截 | 遗留代码/团队一致性要求高 |
| ThreadLocal | 线程级 | 需手动 get/set | 无拦截,线程级存储 | 请求级上下文、TraceId 传递 |
总结
singleton 是默认,prototype 是例外,但例外往往藏着面试题。单例注入原型不生效,解法是 ObjectFactory 或 proxyMode,选 ObjectFactory 更可控。prototype 销毁回调不执行,资源自己管。web 作用域多数场景用 RequestContextHolder 替代。代理方便,但隐式拦截路径在排查性能问题时容易被忽略——优先显式方案。
参考
- Spring 官方文档:Bean Scopes — The
@Scopeannotation AbstractBeanFactory#getBean与Scope接口实现源码SimpleThreadScope与AbstractRequestAttributesScope实现对比ScopedProxyUtils#createScopedProxy代理生成逻辑