Spring高手之路4——深度解析Spring内置作用域及其在实践中的应用

134 阅读10分钟

推荐课程

1. Spring的内置作用域

我们来看看 Spring内置的作用域类型。在 5.x版本中, Spring内置了六种作用域:

  • singleton:在 IOC容器中,对应的 Bean只有一个实例,所有对它的引用都指向同一个对象。这种作用域非常适合对于无状态的 Bean,比如工具类或服务类。
  • prototype:每次请求都会创建一个新的 Bean实例,适合对于需要维护状态的 Bean
  • request:在 Web应用中,为每个 HTTP请求创建一个 Bean实例。适合在一个请求中需要维护状态的场景,如跟踪用户行为信息。
  • session:在 Web应用中,为每个 HTTP会话创建一个 Bean实例。适合需要在多个请求之间维护状态的场景,如用户会话。
  • application:在整个 Web应用期间,创建一个 Bean实例。适合存储全局的配置数据等。
  • websocket:在每个 WebSocket会话中创建一个 Bean实例。适合 WebSocket通信场景。

我们需要重点学习两种作用域: singletonprototype。在大多数情况下 singletonprototype这两种作用域已经足够满足需求。

2. singleton作用域

2.1 singleton作用域的定义和用途

SingletonSpring的默认作用域。在这个作用域中, Spring容器只会创建一个实例,所有对该 bean的请求都将返回这个唯一的实例。

例如,我们定义一个名为 Plaything的类,并将其作为一个 bean

@Component
public class Plaything {

    public Plaything() {
        System.out.println("Plaything constructor run ...");
    }
}

在这个例子中, Plaything是一个 singleton作用域的 bean。无论我们在应用中的哪个地方请求这个 beanSpring都会返回同一个 Plaything实例。

下面的例子展示了如何创建一个单实例的 Bean

package com.example.demo.bean;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class Kid {

    private Plaything plaything;

    @Autowired
    public void setPlaything(Plaything plaything) {
        this.plaything = plaything;
    }

    public Plaything getPlaything() {
        return plaything;
    }
}
package com.example.demo.bean;

import org.springframework.stereotype.Component;

@Component
public class Plaything {

    public Plaything() {
        System.out.println("Plaything constructor run ...");
    }
}

这里可以在 Plaything类加上 @Scope(BeanDefinition.SCOPE_SINGLETON),但是因为是默认作用域是 Singleton,所以没必要加。

package com.example.demo.configuration;

import com.example.demo.bean.Kid;
import com.example.demo.bean.Plaything;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BeanScopeConfiguration {

    @Bean
    public Kid kid1(Plaything plaything1) {
        Kid kid = new Kid();
        kid.setPlaything(plaything1);
        return kid;
    }

    @Bean
    public Kid kid2(Plaything plaything2) {
        Kid kid = new Kid();
        kid.setPlaything(plaything2);
        return kid;
    }
}

package com.example.demo.application;

import com.example.demo.bean.Kid;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan("com.example")
public class DemoApplication {

    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(DemoApplication.class);
        context.getBeansOfType(Kid.class).forEach((name, kid) -> {
            System.out.println(name + " : " + kid.getPlaything());
        });
    }

}

Spring IoC容器的工作中,扫描过程只会创建 bean的定义,真正的 bean实例是在需要注入或者通过 getBean方法获取时才会创建。这个过程被称为 bean的初始化。

这里运行 ctx.getBeansOfType(Kid.class).forEach((name, kid) -> System.out.println(name + " : " + kid.getPlaything())); 时, Spring IoC容器会查找所有的 Kid类型的 bean定义,然后为每一个找到的 bean定义创建实例(如果这个 bean定义还没有对应的实例),并注入相应的依赖。

运行结果:

三个 KidPlaything bean是相同的,说明默认情况下 Plaything 是一个单例 bean,整个 Spring应用中只有一个 Plaything bean被创建。

为什么会有 3kid

  1. Kid: 这个是通过在 Kid类上标注的 @Component注解自动创建的。 Spring在扫描时发现这个注解,就会自动在 IOC容器中注册这个 bean。这个 Bean的名字默认是将类名的首字母小写 kid
  2. kid1: 在 BeanScopeConfiguration 中定义,通过 kid1(Plaything plaything1)方法创建,并且注入了 plaything1
  3. kid2: 在 BeanScopeConfiguration 中定义,通过 kid2(Plaything plaything2)方法创建,并且注入了 plaything2

2.2 singleton作用域线程安全问题

需要注意的是,虽然 singleton Bean只会有一个实例,但 Spring并不会解决其线程安全问题,开发者需要根据实际场景自行处理。

我们通过一个代码示例来说明在多线程环境中出现 singleton Bean的线程安全问题。

首先,我们创建一个名为 Countersingleton Bean,这个 Bean有一个 count变量,提供 increment方法来增加 count的值:

package com.example.demo.bean;

import org.springframework.stereotype.Component;

@Component
public class Counter {

    private int count = 0;

    public int increment() {
        return ++count;
    }
}

然后,我们创建一个名为 CounterServicesingleton Bean,这个 Bean依赖于 Counter,在 increaseCount方法中,我们调用 counter.increment方法:

package com.example.demo.service;

import com.example.demo.bean.Counter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CounterService {
	@Autowired
    private final Counter counter;

    public void increaseCount() {
        counter.increment();
    }
}

我们在多线程环境中调用 counterService.increaseCount方法时,就可能出现线程安全问题。因为 counter.increment方法并非线程安全,多个线程同时调用此方法可能会导致 count值出现预期外的结果。

要解决这个问题,我们需要使 counter.increment方法线程安全。

这里可以使用原子变量,在 Counter类中,我们可以使用 AtomicInteger来代替 int类型的 count,因为 AtomicInteger类中的方法是线程安全的,且其性能通常优于 synchronized关键字。

package com.example.demo.bean;

import org.springframework.stereotype.Component;

import java.util.concurrent.atomic.AtomicInteger;

@Component
public class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public int increment() {
        return count.incrementAndGet();
    }
}

尽管优化后已经使 Counter类线程安全,但在设计 Bean时,我们应该尽可能地减少可变状态。这是因为可变状态使得并发编程变得复杂,而无状态的 Bean通常更容易理解和测试。

什么是无状态的Bean呢? 如果一个 Bean不持有任何状态信息,也就是说,同样的输入总是会得到同样的输出,那么这个 Bean就是无状态的。反之,则是有状态的 Bean

3. prototype作用域

3.1 prototype作用域的定义和用途

prototype作用域中, Spring容器会为每个请求创建一个新的 bean实例。

例如,我们定义一个名为 Plaything的类,并将其作用域设置为 prototype

package com.example.demo.bean;

import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
public class Plaything {

    public Plaything() {
        System.out.println("Plaything constructor run ...");
    }
}

在这个例子中, Plaything是一个 prototype作用域的 bean。每次我们请求这个 beanSpring都会创建一个新的 Plaything实例。

我们只需要修改上面的 Plaything类,其他的类不用动。

打印结果:

这个 @Scope(BeanDefinition.SCOPE_PROTOTYPE)可以写成 @Scope("prototype"),按照规范,还是利用已有的常量比较好。

3.2 prototype作用域在开发中的例子

以我个人来说,我在 excel多线程上传的时候用到过这个,当时是 EasyExcel框架,我给一部分关键代码展示一下如何在 Spring中使用 prototype作用域来处理多线程环境下的任务(实际业务会更复杂),大家可以对比,如果用 prototype作用域和使用 new对象的形式在实际开发中有什么区别。

使用 prototype 作用域的例子

@Resource
private ApplicationContext context;

@PostMapping("/user/upload")
public ResultModel upload(@RequestParam("multipartFile") MultipartFile multipartFile) {
	......
	ExecutorService es = new ThreadPoolExceutor(10, 16, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(2000), new ThreadPoolExecutor.CallerRunPolicy());
	......
	EasyExcel.read(multipartFile.getInputStream(), UserDataUploadVO.class,
		new PageReadListener<UserDataUploadVO>(dataList ->{
		......

			Future<?> future = es.submit(context.getBean(AsyncUploadHandler.class, user, dataList, errorCount));
		......
		})).sheet().doRead();
	......

}

有人可能会问这里为什么使用 context.getBean,而不是 @Resource@Autowired注解, @Resource@Autowired注解只会在注入时创建一个新的实例,这里并不会反复注入。 ApplicationContext.getBean()方法是在每次调用时解析的,所以它会在每次调用时创建一个新的 AsyncUploadHandler实例。

AsyncUploadHandler.java

@Component
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
public class AsyncUploadHandler implements Runnable {

	private User user;

	private List<UserDataUploadVO> dataList;

	private AtomicInteger errorCount;

	@Resource
	private RedisService redisService;

	......

	@Resource
	private CompanyManagementMapper companyManagementMapper;

	public AsyncUploadHandler(user, List<UserDataUploadVO> dataList, AtomicInteger errorCount) {
		this.user = user;
		this.dataList = dataList;
		this.errorCount = errorCount;
	}

	@Override
	public void run() {
		......
	}

	......

}

AsyncUploadHandler类是一个 prototype作用域的 bean,它被用来处理上传的 Excel数据。由于并发上传的每个任务可能需要处理不同的数据,并且可能需要在不同的用户上下文中执行,因此每个任务都需要有自己的 AsyncUploadHandler bean。这就是为什么需要将 AsyncUploadHandler定义为 prototype作用域的原因。

由于 AsyncUploadHandler是由 Spring管理的,我们可以直接使用 @Resource注解来注入其他的 bean,例如 RedisServiceCompanyManagementMapper

AsyncUploadHandler交给 Spring容器管理,里面依赖的容器对象可以直接用 @Resource注解注入。如果采用 new出来的对象,那么这些对象只能从外面注入好了再传入进去。

不使用 prototype 作用域改用 new 对象的例子

@PostMapping("/user/upload")
public ResultModel upload(@RequestParam("multipartFile") MultipartFile multipartFile) {
	......
	ExecutorService es = new ThreadPoolExceutor(10, 16, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(2000), new ThreadPoolExecutor.CallerRunPolicy());
	......
	EasyExcel.read(multipartFile.getInputStream(), UserDataUploadVO.class,
		new PageReadListener<UserDataUploadVO>(dataList ->{
		......

			Future<?> future = es.submit(new AsyncUploadHandler(user, dataList, errorCount, redisService, companyManagementMapper));
		......
		})).sheet().doRead();
	......

}

AsyncUploadHandler.java

public class AsyncUploadHandler implements Runnable {

	private User user;

	private List<UserDataUploadVO> dataList;

	private AtomicInteger errorCount;

	private RedisService redisService;

	private CompanyManagementMapper companyManagementMapper;

	......

	public AsyncUploadHandler(user, List<UserDataUploadVO> dataList, AtomicInteger errorCount,
						RedisService redisService, CompanyManagementMapper companyManagementMapper) {
		this.user = user;
		this.dataList = dataList;
		this.errorCount = errorCount;
		this.redisService = redisService;
		this.companyManagementMapper = companyManagementMapper;
	}

	@Override
	public void run() {
		......
	}

	......

}

如果直接新建 AsyncUploadHandler对象,则需要手动传入所有的依赖,这会使代码变得更复杂更难以管理,而且还需要手动管理 AsyncUploadHandler的生命周期。

4. request作用域(了解)

request作用域: Bean在一个 HTTP请求内有效。当请求开始时, Spring容器会为每个新的 HTTP请求创建一个新的 Bean实例,这个 Bean在当前 HTTP请求内是有效的,请求结束后, Bean就会被销毁。如果在同一个请求中多次获取该 Bean,就会得到同一个实例,但是在不同的请求中获取的实例将会不同。

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestScopedBean {

    private String requestData;

    public void setRequestData(String requestData) {
        this.requestData = requestData;
    }

    public String getRequestData() {
        return this.requestData;
    }
}

上述 Bean在一个 HTTP请求的生命周期内是一个单例,每个新的 HTTP请求都会创建一个新的 Bean实例。

5. session作用域(了解)

session作用域: Bean是在同一个 HTTP会话( Session)中是单例的。也就是说,从用户登录开始,到用户退出登录(或者 Session超时)结束,这个过程中,不管用户进行了多少次 HTTP请求,只要是在同一个会话中,都会使用同一个 Bean实例。

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionScopedBean {

    private String sessionData;

    public void setSessionData(String sessionData) {
        this.sessionData = sessionData;
    }

    public String getSessionData() {
        return this.sessionData;
    }
}

这样的设计对于存储和管理会话级别的数据非常有用,例如用户的登录信息、购物车信息等。因为它们是在同一个会话中保持一致的,所以使用 session作用域的 Bean可以很好地解决这个问题。

但是实际开发中没人这么干,会话 id都会存在数据库,根据会话 id就能在各种表中获取数据,避免频繁查库也是把关键信息序列化后存在 Redis

6. application作用域(了解)

application作用域:在整个 Web应用的生命周期内, Spring容器只会创建一个 Bean实例。这个 BeanWeb应用的生命周期内都是有效的,当 Web应用停止后, Bean就会被销毁。

@Component
@Scope(value = WebApplicationContext.SCOPE_APPLICATION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ApplicationScopedBean {

    private String applicationData;

    public void setApplicationData(String applicationData) {
        this.applicationData = applicationData;
    }

    public String getApplicationData() {
        return this.applicationData;
    }
}

如果在一个 application作用域的 Bean上调用 setter方法,那么这个变更将对所有用户和会话可见。后续对这个 Bean的所有调用(包括 gettersetter)都将影响到同一个 Bean实例,后面的调用会覆盖前面的状态。

7. websocket作用域(了解)

websocket作用域: Bean 在每一个新的 WebSocket 会话中都会被创建一次,就像 session 作用域的 Bean 在每一个 HTTP 会话中都会被创建一次一样。这个 Bean在整个 WebSocket会话内都是有效的,当 WebSocket会话结束后, Bean就会被销毁。

@Component
@Scope(value = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class WebSocketScopedBean {

    private String socketData;

    public void setSocketData(String socketData) {
        this.socketData = socketData;
    }

    public String getSocketData() {
        return this.socketData;
    }
}

上述 Bean在一个 WebSocket会话的生命周期内是一个单例,每个新的 WebSocket会话都会创建一个新的 Bean实例。

这个作用域需要 Spring Websocket模块支持,并且应用需要配置为使用 websocket

欢迎一键三连~

有问题请留言,大家一起探讨学习

----------------------Talk is cheap, show me the code-----------------------