Spring Bean作用域与并发安全
一、bean作用域
Spring提供5种核心作用域,其中request、session和global-session三个只在基于web的Spring ApplicationContext中可用。
| 作用域 | 标识常量(推荐使用) | 描述 |
|---|---|---|
单例(singleton) |
ConfigurableBeanFactory.SCOPE_SINGLETON |
(默认)IoC容器中仅存在一个Bean实例,全局共享 |
原型(prototype) |
ConfigurableBeanFactory.SCOPE_PROTOTYPE |
一个Bean定义,每次创建一个新的实例对象 |
请求(request) |
WebApplicationContext.SCOPE_REQUEST |
一个HTTP请求会产生一个Bean对象,每一个 HTTP请求都有自己的Bean实例。 |
会话(session) |
WebApplicationContext.SCOPE_SESSION |
限定一个Bean的作用域为HTTPSession的生命周期(同一个会话共享一个实例, 不同会话使用不同的实例)。 |
全局会话(global-session) |
- | 限定一个Bean作用域为全局HTTPSession的生命周期(所有会话共享一个实例) |
1.1 @Scope注解
单例是默认的作用域,但有些时候并不符合我们的实际运用场景,因此我们可以使用@Scope注解来选择其他的作用域。该注解可以配合@Component和@Bean一起使用。
例如,如果你使用组件扫描来发现和声明bean,那么你可以在bean的类上使用@Scope配合@Component,将其声明为原型bean:
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class Notepad {
//todo: doSomething
}
这里使用的是ConfigurableBeanFactory类的SCOPE_PROTOTYPE常量设置了原型作用域。当然你也可以使用@Scope(value = "prototype"),相对而言更推荐使用SCOPE_PROTOTYPE常量,因为这样使用不易出现拼写错误以及便于代码的维护。
如果你想在Java配置中将Notepad声明为原型bean,那么可以组合使用@Scope和@Bean来指定所需的作用域:
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Notepad notepad() {
return new Notepad();
}
如果你使用xml来配置bean的话,可以使用<bean>元素的scope属性来设置作用域:
<bean id="notepad" class="com.test.Notepad" scope="prototype"/>
1.2 作用域代理(proxyMode)
proxyMode是用来配置当前类的代理模式的。主要用于scope非singleton的情况。因为非singleton的bean,spring并不会立刻创建对象,如果需要注入时就产生一个代理对象,这时代理模式就起作用了。
public enum ScopedProxyMode {
DEFAULT,
NO,
INTERFACES,
TARGET_CLASS;
private ScopedProxyMode() {
}
}
ScopedProxyMode提供4种代理策略:
| 作用域代理 | 描述 |
|---|---|
DEFAULT |
不使用代理,如果需要就立刻创建 |
NO |
DEFAULT和NO的作用是一样 |
INTERFACES |
使用JDK的动态代理来创建代理对象 |
TARGET_CLASS |
使用CGLIB来创建代理对象 |
1.3 代理模式实战场景
对于bean的作用域,有一个典型的电子商务应用:需要有一个bean代表用户的购物车。
- 如果购物车是单例,那么将会导致所有的用户都往一个购物车中添加商品。
- 如果购物车是原型作用域的,那么在应用中某个地方往购物车中添加商品,然后到应用中的另外一个地方可能就没法使用了,因为在这里被注入了另外一个原型作用域的的购物车。
就购物车bean而言,会话作用域是最合适的,因为他与给定用户的关联性最大。
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
proxyMode =ScopedProxyMode.INTERFACES)
public class ShippingCart {
//todo: doSomething
}
这里我们将value设置成了WebApplicationContext.SCOPE_SESSION常量。这会告诉Spring为Web应用的每个会话创建一个ShippingCart。这会创建多个ShippingCart bean的实例。但是对于给定的会话只会创建一个实例,在当前会话各种操作中,这个bean实际上相当于单例的。
要注意的是,@Scope中使用了proxyMode属性,被设置成了ScopedProxyMode.INTERFACES。这个属性是用于解决将会话或请求作用域的bean注入到单例bean中所遇到的问题。
假设我们将ShippingCart bean注入到单例StoreService bean的setter方法中:
@Component
public class StoreService {
private ShippingCart shippingCart;
public void setShoppingCart(ShippingCart shoppingCart) {
this.shippingCart = shoppingCart;
}
//todo: doSomething
}
因为StoreService是个单例bean,会在Spring应用上下文加载的时候创建。当它创建的时候,Spring会试图将ShippingCart bean注入到setShoppingCart()方法中。但是ShippingCart bean是会话作用域,此时并不存在。直到用户进入系统创建会话后才会出现ShippingCart实例。
另外,系统中会有多个ShippingCart实例,每个用户一个。我们并不希望注入固定的ShippingCart实例,而是希望当StoreService处理购物车时,它所使用的是当前会话的ShippingCart实例。
Spring并不会将实际的ShippingCart bean注入到StoreService,Spring会注入一个ShippingCart bean的代理。这个代理会暴露与ShippingCart相同的方法,所以StoreService会认为它就是一个购物车。但是,当StoreService调用ShippingCart的方法时,代理会对其进行懒解析并将调用委任给会话作用域内真正的ShippingCart bean。
在上面的配置中,proxyMode属性,被设置成了ScopedProxyMode.INTERFACES,这表明这个代理要实现ShippingCart接口,并将调用委托给实现bean。
但如果ShippingCart是一个具体的类而不是接口的话,Spring就没法创建基于接口的代理了。此时,它必须使用CGLib来生成基于类的代理。所以,如果bean类型是具体类的话我们必须要将proxyMode属性,设置成ScopedProxyMode.TARGET_CLASS,以此来表明要以生成目标类扩展的方式创建代理。
请求作用域的bean应该也以作用域代理的方式进行注入。
如果你需要使用xml来声明会话或请求作用域的bean,那么就需要使用<aop:scoped-proxy />元素来指定代理模式。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-3.2.xsd
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
<bean id="cart" class="com.test.bean.ShoppingCart" scope="session"/>
<aop:scoped-proxy />
</beans>
<aop:scoped-proxy />是与@Scope注解的proxyMode属性相同的xml元素。它会告诉Spring为bean创建一个作用域代理。默认情况下,它会使用CGLib创建目标类的代理,如果要生成基于接口的代理可以将proxy-target-class属性设置成false,如下:
<bean id="cart" class="com.test.bean.ShoppingCart" scope="session"/>
<aop:scoped-proxy proxy-target-class="false"/>
二、Spring Bean线程安全
Spring容器本身不提供线程安全保障,Bean的线程安全性完全由作用域类型和是否有状态决定。
2.1 作用域与线程安全的关系
线程安全这个问题,要从单例与原型Bean分别进行说明。
- 原型
Bean:对于原型Bean,每次创建一个新对象,也就是线程之间并不存在Bean共享,自然是不会有线程安全的问题。 - 单例
Bean:对于单例Bean,所有线程都共享一个单例实例Bean,因此是存在资源的竞争。
如果单例Bean,是一个无状态Bean,也就是线程中的操作不会对Bean的成员执行查询以外的操作,那么这个单例Bean是线程安全的。比如SpringMvc的Controller、Service、Dao等,这些Bean大多是无状态的,只关注于方法本身。所以结论是Spring中的Bean不是线程安全的
2.2 为什么Bean是单例却安全?
Spring单例,为什么controller、service和dao确能保证线程安全?
Spring中的Bean默认是单例模式的,框架并没有对bean进行多线程的封装处理。实际上大部分时间Bean是无状态的(比如Dao)所以说在某种程度上来说Bean其实是安全的。但是如果Bean是有状态的那就需要开发人员自己来进行线程安全的保证,最简单的办法就是改变bean的作用域把"singleton"改为"protopyte"这样每次请求Bean就相当于是new Bean()这样就可以保证线程的安全了。
- 有状态就是有数据存储功能
- 无状态就是不会保存数据
controller、service和dao层本身并不是线程安全的,如果只是调用里面的方法,而且多线程调用一个实例的方法,会在内存中复制变量,这是自己的线程的工作内存,是安全的。
想理解原理可以看看《深入理解JVM虚拟机》,2.2.2节:
Java虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
《Java并发编程实战》第3.2.2节:
局部变量的固有属性之一就是封闭在执行线程中。
它们位于执行线程的栈中,其他线程无法访问这个栈。
所以其实任何无状态单例都是线程安全的。Spring的根本就是通过大量这种单例构建起系统,以事务脚本的方式提供服务。
2.3 举例说明
@RestController
@Scope(value = "singleton") // prototype singleton
public class TestController {
// 定义一个普通变量
private int var = 0;
// 定义一个静态变量
private static int staticVar = 0;
// 从配置文件中读取变量
@Value("${test-int}")
private int testInt;
// 用ThreadLocal来封装变量
ThreadLocal<Integer> tl = new ThreadLocal<>();
// 注入一个对象来封装变量
@Autowired
private User user;
@GetMapping(value = "/test_var")
public String test() {
tl.set(1);
System.out.println("先取一下user对象中的值:"+user.getAge()
+ ", 再取一下hashCode:"+user.hashCode());
user.setAge(1);
System.out.println("普通变量var:" + (++var)
+ ", 静态变量staticVar:" + (++staticVar)
+ ", 配置变量testInt:" + (++testInt)
+ ", ThreadLocal变量tl:" + tl.get()
+ ", 注入变量user:" + user.getAge());
return "普通变量var:" + var
+ ", 静态变量staticVar:" + staticVar
+ ", 配置读取变量testInt:" + testInt
+ ", ThreadLocal变量tl:" + tl.get()
+ ", 注入变量user:" + user.getAge();
}
}
补充Controller以外的代码:config里面自己定义的Bean:User
@Configuration
public class MyConfig {
@Bean
public User user() {
return new User();
}
}
我暂时能想到的定义变量的方法就这么多了,三次http请求结果如下:
先取一下user对象中的值:0,再取一下hashCode:241165852
普通变量var:1, 静态变量staticVar:1, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:1,再取一下hashCode:241165852
普通变量var:2, 静态变量staticVar:2, 配置变量testInt:2, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:1,再取一下hashCode:241165852
普通变量var:3, 静态变量staticVar:3, 配置变量testInt:3, ThreadLocal变量tl:1, 注入变量user:1
可以看到,在单例模式下Controller中只有用ThreadLocal封装的变量是线程安全的。为什么这样说呢?我们可以看到3次请求结果里面只有ThreadLocal变量值每次都是从0+1=1的,其他的几个都是累加的,而user对象呢,默认值是0,第二交取值的时候就已经是1了,关键他的hashCode是一样的,说明每次请求调用的都是同一个user对象。
下面将TestController上的@Scope注解的属性改一下改成多实例的:@Scope(value = "prototype"),其他都不变,再次请求,结果如下:
先取一下user对象中的值:0,再取一下hashCode:853315860
普通变量var:1, 静态变量staticVar:1, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:1,再取一下hashCode:853315860
普通变量var:1, 静态变量staticVar:2, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:1,再取一下hashCode:853315860
普通变量var:1, 静态变量staticVar:3, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
分析这个结果发现,多实例模式下普通变量,取配置的变量还有ThreadLocal变量都是线程安全的,而静态变量和user(看他的hashCode都是一样的)对象中的变量都是非线程安全的。也就是说尽管TestController是每次请求的时候都初始化了一个对象,但是静态变量始终是只有一份的,而且这个注入的user对象也是只有一份的。静态变量只有一份这是当然的咯,那么有没有办法让user对象可以每次都new一个新的呢?当然可以:
public class MyConfig {
@Bean
@Scope(value = "prototype")
public User user() {
return new User();
}
}
在config里面给这个注入的Bean加上一个相同的注解@Scope(value = "prototype")就可以了,再来请求一下看看:
先取一下user对象中的值:0,再取一下hashCode:1612967699
普通变量var:1, 静态变量staticVar:1, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:0,再取一下hashCode:985418837
普通变量var:1, 静态变量staticVar:2, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
先取一下user对象中的值:0,再取一下hashCode:1958952789
普通变量var:1, 静态变量staticVar:3, 配置变量testInt:1, ThreadLocal变量tl:1, 注入变量user:1
可以看到每次请求的user对象的hashCode都不是一样的,每次赋值前取user中的变量值也都是默认值0。
2.4 小结
- 在
@Controller/@Service等容器中,默认情况下,scope值是单例(singleton)的,也是线程不安全的。 - 尽量不要在
@Controller/@Service等容器中定义静态变量,不论是单例(singleton)还是多实例(prototype)都是线程不安全的。 - 默认注入的
Bean对象,在不设置scope的时候他也是线程不安全的。 - 一定要定义变量的话,用
ThreadLocal来封装,这个是线程安全的。
三、单例Bean依赖非单例Bean
在使用Spring时,可能会遇到这种情况:一个单例的Bean依赖另一个非单例的Bean。如果简单的使用自动装配来注入依赖,就可能会出现一些问题,如下所示:
单例的ClassA
@Component
public class ClassA {
@Autowired
private ClassB classB;
public void printClass() {
System.out.println("This is Class A:" + this);
classB.printClass();
}
}
非单例的ClassB
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ClassB {
public void printClass() {
System.out.println("This is Class B:" + this);
}
}
这里ClassA采用了默认的单例scope,并依赖于ClassB,而ClassB的scope是prototype,因此不是单例的,这时候跑个测试就看出这样写的问题:
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {ClassA.class, ClassB.class})
public class MyTest {
@Autowired
private ClassA classA;
@Test
public void simpleTest() {
for (int i = 0; i < 3; i++) {
classA.printClass();
}
}
}
输出的结果是:
This is Class A:ClassA@282003e1
This is Class B:ClassB@7fad8c79
This is Class A:ClassA@282003e1
This is Class B:ClassB@7fad8c79
This is Class A:ClassA@282003e1
This is Class B:ClassB@7fad8c79
可以看到,两个类的hashCode在三次输出中都是一样。ClassA的值不变是可以理解的,因为它是单例的,但是ClassB的scope是prototype却也保持hashCode不变,似乎也成了单例?
产生这种的情况的原因是,ClassA的scope是默认的singleton,因此Context只会创建ClassA的bean一次,所以也就只有一次注入依赖的机会,容器也就无法每次给ClassA提供一个新的ClassB。
3.1 方案一:ApplicationContextAware
要解决上述问题,可以对ClassA做一些修改,让它实现ApplicationContextAware。
@Component
public class ClassA implements ApplicationContextAware {
private ApplicationContext applicationContext;
public void printClass() {
System.out.println("This is Class A:" + this);
getClassB().printClass();
}
public ClassB getClassB() {
return applicationContext.getBean(ClassB.class);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
这样就能够在每次需要到ClassB的时候手动去Context里找到新的bean。再跑一次测试后得到了以下输出:
This is Class A:com.test.ClassA@4df828d7
This is Class B:com.test.ClassB@31206beb
This is Class A:com.test.ClassA@4df828d7
This is Class B:com.test.ClassB@3e77a1ed
This is Class A:com.test.ClassA@4df828d7
This is Class B:com.test.ClassB@3ffcd140
可以看到ClassA的hashCode在三次输出中保持不变,而ClassB的却每次都不同,说明问题得到了解决,每次调用时用到的都是新的实例。
但是这样的写法就和Spring强耦合在一起了,Spring提供了另外一种方法来降低侵入性。
3.2 方案二:@Lookup
Spring提供了一个名为@Lookup的注解,这是一个作用在方法上的注解,被其标注的方法会被重写,然后根据其返回值的类型,容器调用BeanFactory的getBean()方法来返回一个bean。
@Component
public class ClassA {
public void printClass() {
System.out.println("This is Class A:" + this);
getClassB().printClass();
}
@Lookup
public ClassB getClassB() {
return null;
}
}
可以发现简洁了很多,而且不再和Spring强耦合,再次运行测试依然可以得到正确的输出。
被标注的方法的返回值不再重要,因为容器会动态生成一个子类然后将这个被注解的方法重写/实现,最终调用的是子类的方法。
使用的@Lookup的方法需要符合如下的签名:
<public|protected> [abstract] <return-type> theMethodName(no-arguments);
3.3 方案三:@Scope
给非单例Bean配置代理,单例Bean注入代理对象,每次调用代理方法时都会获取新的非单例实例。
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ClassB {
public void printClass() {
System.out.println("This is Class B:" + this);
}
}
@Component
public class ClassA {
@Resource
private ClassB classB;
public void printClass() {
System.out.println("This is Class A:" + this);
classB.printClass();
}
}
四、总结
- 作用域选择:无状态组件用单例(默认),有状态组件用原型/会话/请求作用域;
Web环境下需注意proxyMode配置解决依赖冲突。 - 线程安全:单例
Bean无状态则安全,有状态需用ThreadLocal或同步机制;原型Bean天然安全,但需注意性能开销。 - 依赖注入:单例依赖非单例优先用
@Lookup或作用域代理,避免直接依赖导致的实例共享问题。
通过合理运用Bean作用域和无状态设计,可以在保证Spring应用性能的同时,规避线程安全风险。

浙公网安备 33010602011771号