Spring Bean作用域与并发安全

一、Spring的bean作用域

作用域 描述
单例(singleton) (默认)每一个Spring IoC容器都拥有唯一的一个实例对象。
原型(prototype) 一个Bean定义,每次创建一个新的实例对象。
请求(request) 一个HTTP请求会产生一个Bean对象,
也就是说,每一个HTTP请求都有自己的Bean实例。
会话(session) 限定一个Bean的作用域为HTTPSession的生命周期
(同一个会话共享一个实例,不同会话使用不同的实例)。
全局会话(global-session) 限定一个Bean的作用域为全局HTTPSession的生命周期(所有会话共享一个实例)。
通常用于门户网站场景。

其中requestsessionglobal-session三个只在基于webSpring ApplicationContext中可用。

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是用来配置当前类的代理模式的。主要用于scopesingleton的情况。因为非singletonbeanspring并不会立刻创建对象,如果需要注入时就产生一个代理对象,这时代理模式就起作用了。

public enum ScopedProxyMode {
    DEFAULT,
    NO,
    INTERFACES,
    TARGET_CLASS;

    private ScopedProxyMode() {
    }
}
作用域代理 描述
DEFAULT 不使用代理,如果需要就立刻创建
NO DEFAULTNO的作用是一样
INTERFACES 使用JDK的动态代理来创建代理对象
TARGET_CLASS 使用CGLIB来创建代理对象

对于bean的作用域,有一个典型的电子商务应用:需要有一个bean代表用户的购物车。

  • 如果购物车是单例,那么将会导致所有的用户都往一个购物车中添加商品。
  • 如果购物车是原型作用域的,那么在应用中某个地方往购物车中添加商品,然后到应用中的另外一个地方可能就没法使用了,因为在这里被注入了另外一个原型作用域的的购物车。

就购物车bean而言,会话作用域是最合适的,因为他与给定用户的关联性最大。

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, 
    	proxyMode =ScopedProxyMode.INTERFACES)
public class ShippingCart {
    //todo: doSomething
}

这里我们将value设置成了WebApplicationContext.SCOPE_SESSION常量。这会告诉SpringWeb应用的每个会话创建一个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元素。它会告诉Springbean创建一个作用域代理。默认情况下,它会使用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是否线程安全,容器本身并没有提供Bean的线程安全策略,因此可以说Spring容器中的Bean本身不具备线程安全的特性,但是具体还是要结合具体scopeBean去研究。

线程安全这个问题,要从单例与原型Bean分别进行说明。

  • 原型Bean:对于原型Bean,每次创建一个新对象,也就是线程之间并不存在Bean共享,自然是不会有线程安全的问题。
  • 单例Bean:对于单例Bean,所有线程都共享一个单例实例Bean,因此是存在资源的竞争。

如果单例Bean,是一个无状态Bean,也就是线程中的操作不会对Bean的成员执行查询以外的操作,那么这个单例Bean是线程安全的。比如SpringMvcControllerServiceDao等,这些Bean大多是无状态的,只关注于方法本身。所以结论是Spring中的Bean不是线程安全的

Spring单例,为什么controllerservicedao确能保证线程安全?

Spring中的Bean默认是单例模式的,框架并没有对bean进行多线程的封装处理。实际上大部分时间Bean是无状态的(比如Dao)所以说在某种程度上来说Bean其实是安全的。但是如果Bean是有状态的那就需要开发人员自己来进行线程安全的保证,最简单的办法就是改变bean的作用域把"singleton"改为"protopyte"这样每次请求Bean就相当于是new Bean()这样就可以保证线程的安全了。

  • 有状态就是有数据存储功能
  • 无状态就是不会保存数据

controllerservicedao层本身并不是线程安全的,如果只是调用里面的方法,而且多线程调用一个实例的方法,会在内存中复制变量,这是自己的线程的工作内存,是安全的。

想理解原理可以看看《深入理解JVM虚拟机》,2.2.2节:

Java虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

《Java并发编程实战》第3.2.2节:

局部变量的固有属性之一就是封闭在执行线程中。
它们位于执行线程的栈中,其他线程无法访问这个栈。

所以其实任何无状态单例都是线程安全的Spring的根本就是通过大量这种单例构建起系统,以事务脚本的方式提供服务。

2.1 举例说明

@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.2 小结

1、在@Controller/@Service等容器中,默认情况下,scope值是单例(singleton)的,也是线程不安全的。
2、尽量不要在@Controller/@Service等容器中定义静态变量,不论是单例(singleton)还是多实例(prototype)都是线程不安全的。
3、默认注入的Bean对象,在不设置scope的时候他也是线程不安全的。
4、一定要定义变量的话,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,而ClassBscopeprototype,因此不是单例的,这时候跑个测试就看出这样写的问题:

@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的值不变是可以理解的,因为它是单例的,但是ClassBscopeprototype却也保持hashCode不变,似乎也成了单例?

产生这种的情况的原因是,ClassAscope是默认的singleton,因此Context只会创建ClassAbean一次,所以也就只有一次注入依赖的机会,容器也就无法每次给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

可以看到ClassAhashCode在三次输出中保持不变,而ClassB的却每次都不同,说明问题得到了解决,每次调用时用到的都是新的实例。

但是这样的写法就和Spring强耦合在一起了,Spring提供了另外一种方法来降低侵入性。

3.2 方案二:@Lookup

Spring提供了一个名为@Lookup的注解,这是一个作用在方法上的注解,被其标注的方法会被重写,然后根据其返回值的类型,容器调用BeanFactorygetBean()方法来返回一个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

使用@Scope

@Component
public class ClassA {

    @Resource
    private ClassB classB;

    public void printClass() {
        System.out.println("This is Class A:" + this);
        classB.printClass();
    }
}
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE,
        proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ClassB {
    public void printClass() {
        System.out.println("This is Class B:" + this);
    }
}

参考文章

posted @ 2022-04-26 09:43  夏尔_717  阅读(191)  评论(0编辑  收藏  举报