【Spring6笔记】 - 3 - Bean的作用域

1. Singleton Scope(单例作用域)

singleton 是 Spring 容器默认的作用域。在每一个 Spring IoC 容器(如 ClassPathXmlApplicationContext 实例)中,同名的 Bean 定义只会对应一个唯一的实例对象。

1.1 核心特征

  • 唯一性:在整个容器生命周期内,该 Bean 只有一个对象。
  • 共享性:所有对该 Bean 的 getBean() 调用或依赖注入,指向的都是同一个内存地址。
  • 默认性:如果 <bean> 标签中不写 scope 属性,默认就是 singleton

1.2 实例化时机与策略

根据测试代码和 Spring 源码机制,singleton 的生命周期有以下特点:

  1. 预实例化(Pre-instantiation):默认情况下,当你执行 new ClassPathXmlApplicationContext(...) 初始化容器时,Spring 就会立即创建所有的单例 Bean。
    • 验证方法:可以在 SpringBean 的无参构造方法里打印一句话。你会发现,还没调用 getBean(),控制台就已经打印了。
  2. 延迟加载(Lazy Init):如果你希望在第一次调用 getBean() 时才创建,可以在 XML 中配置 lazy-init="true"

1.3 代码实战分析

结合你提供的测试代码 testScope(),我们可以总结出以下结论:

XML 配置:

<bean id="springBean" class="com.zzz.bean.SpringBean" scope="singleton"/>

测试结论:

SpringBean springBean1 = applicationContext.getBean("springBean", SpringBean.class);
SpringBean springBean2 = applicationContext.getBean("springBean", SpringBean.class);

// 结果为 true,证明两次获取的是同一个对象(内存地址一致)
System.out.println(springBean1 == springBean2); 

1.4 深度原理:单例池(Singleton Objects)

Spring 内部通过一个所谓的“一级缓存”来保证单例。其本质是一个 ConcurrentHashMap

  • Key:Bean 的名字(如 "springBean")。
  • Value:Bean 的实例对象。
  • 流程:当调用 getBean 时,Spring 先去 Map 里看有没有。有则直接返回;没有则创建、注入依赖、放入 Map、最后返回。

1.5 注意事项:线程安全问题

这是单例模式下最需要关注的开发痛点!

  • 无状态 Bean(Thread-Safe):绝大多数的 ServiceDAO 属于此类。它们只负责逻辑处理,不存储成员变量数据。在多线程环境下是安全的,推荐使用单例
  • 有状态 Bean(Not Thread-Safe):如果 SpringBean 中定义了一个 private int count; 变量,多个线程同时去修改这个 count,就会发生数据冲突。
    • 解决方案
      1. 将 Scope 改为 prototype
      2. 使用 ThreadLocal(如你代码中展示的 SimpleThreadScope 思路)。

1.6 与 Java 设计模式中单例的区别

维度 Java 原生单例 (Singleton Pattern) Spring 单例 Bean
控制权 程序员手动实现(私有构造、静态方法) 由 Spring 容器负责生命周期管理
范围 整个 ClassLoader(类加载器)唯一 每一个 Spring IoC 容器 内唯一
灵活性 较差,硬编码 极强,通过 XML 或注解轻松切换

2. Prototype Scope(原型/多例作用域)

prototype(原型模式)与 singleton 完全相反。每当向 IoC 容器请求该 Bean 时,Spring 都会新建一个实例

2.1 核心特征
  • 多例性:每次调用 getBean(),Spring 都会执行一次 new 操作。
  • 独立性:每个通过容器获取的对象都有独立的内存地址,互不影响。
  • 显式配置:必须在 XML 中明确设置 scope="prototype"
2.2 实例化时机(关键区别)

不同于单例的“预实例化”,原型 Bean 采用的是 “延迟初始化” 策略:

  1. 启动阶段:当 new ClassPathXmlApplicationContext(...) 执行时,Spring 不会创建 scope="prototype" 的 Bean。
  2. 运行阶段:只有当你显式调用 applicationContext.getBean("springBean") 时,容器才会实时创建该对象。
2.3 代码实战分析

结合测试类 SpringScopeTest 中的 testScope() 方法,我们可以得出以下实战结论:

XML 配置:

<bean id="springBean" class="com.zzz.bean.SpringBean" scope="prototype"/>

测试验证逻辑:

// 第一次获取,Spring new 了一个对象 A
SpringBean springBean1 = applicationContext.getBean("springBean", SpringBean.class);
// 第二次获取,Spring 又 new 了一个对象 B
SpringBean springBean2 = applicationContext.getBean("springBean", SpringBean.class);

// 结果为 false,证明这是两个完全不同的对象
System.out.println(springBean1 == springBean2); 
2.4 深度原理:只管生,不管养

这是 prototype 最特殊的一点:Spring 不负责原型 Bean 的完整生命周期。

  • 创建阶段:容器会帮其实例化、配置、装饰并初始化。
  • 销毁阶段:一旦 Bean 交给了客户端(调用者),Spring 容器就不再持有这个对象的引用,也不会调用它的 destroy 方法。
  • 后果:原型 Bean 的资源释放(如关闭流、清理内存)必须由程序员手动处理,否则频繁调用可能导致内存泄漏。
2.5 线程安全
  • 天然线程安全:因为每个线程请求时都可以获取一个新的实例,不存在多个线程共享同一个对象成员变量的问题。
2.6 适用场景
  • 有状态的 Bean:比如一个 User 实体类,或者包含用户特定信息的 Action/Controller(在某些老框架中)。
  • 非共享资源:每次使用都需要干净状态的对象。

3. Singleton vs Prototype

特性 Singleton(单例) Prototype(原型)
实例数量 容器内仅一个 每次请求创建一个新实例
创建时机 容器启动时(默认) 调用 getBean()
Bean 存储 存在 Spring 一级缓存(单例池)中 容器不存储,直接交给调用者
生命周期 容器负责全过程(初始化到销毁) 容器只负责创建和初始化
性能 高(对象复用) 相对较低(频繁创建对象)

4. 作用域失效问题:当 Singleton 依赖 Prototype

如果你有一个单Singleton 的 Service 注入了一个Prototype 的 User,会发生什么?

  • 结论:由于 Service 只初始化一次,它持有的 User 也只会被注入一次。这意味着,虽然 User 配置成了 prototype,但在该 Service 中它表现得像单例。
  • 解决方法:使用 Lookup Method Injection(查找方法注入)。
1. 现象还原(为什么会失效?)

假设我们有一个单例的 OrderService,每处理一个订单都需要一个新的 Cart(原型)对象。

Java 代码:

// 原型 Bean
public class Cart {
    public Cart() { System.out.println("Cart 实例已创建!"); }
}

// 单例 Bean
public class OrderService {
    private Cart cart; // 期待每次使用时都是新的

    public void setCart(Cart cart) { this.cart = cart; }

    public void processOrder() {
        System.out.println("使用购物车实例: " + cart);
    }
}

XML 配置:

<bean id="cart" class="com.zzz.bean.Cart" scope="prototype"/>
<bean id="orderService" class="com.zzz.bean.OrderService" scope="singleton">
    <property name="cart" ref="cart"/>
</bean>

测试结论: 当你多次调用 orderService.processOrder() 时,你会发现控制台打印的 cart 地址始终是一样的。这是因为 orderService 只实例化了一次,cart 也只被注入了一次。


2. 解决方法:Lookup Method Injection(查找方法注入)

Spring 提供的 lookup-method 可以通过 CGLIB 动态代理技术,在运行时重写单例 Bean 的某个方法,让该方法每次去容器中重新请求目标 Bean。

第一步:修改 Java 类

将单例 Bean 定义为一个抽象类,并定义一个抽象方法来获取原型 Bean。

public abstract class OrderService {
    
    public void processOrder() {
        // 注意:这里不再使用成员变量,而是调用抽象方法
        Cart cart = getCart(); 
        System.out.println("获取到新的购物车实例: " + cart);
    }

    // 定义一个抽象方法,Spring 会负责实现它
    public abstract Cart getCart();
}
第二步:修改 XML 配置

使用 <lookup-method> 标签告知 Spring:当你调用 getCart 方法时,请去容器里找名为 "cart" 的 Bean。

<bean id="cart" class="com.zzz.bean.Cart" scope="prototype"/>

<bean id="orderService" class="com.zzz.bean.OrderService" scope="singleton">
    <lookup-method name="getCart" bean="cart"/>
</bean>
第三步:运行结果

此时,每当你调用 orderService.processOrder(),Spring 代理对象都会拦截 getCart() 方法,并执行类似 context.getBean("cart") 的操作。你会发现:

  1. 控制台会再次打印 “Cart 实例已创建!”。
  2. 每次拿到的 cart 对象内存地址都不一样了。

3. 其他替代方案

除了 lookup-method,在现代开发(注解驱动)中,还有以下两种常用方法:

  1. 注入 ApplicationContext: 让 OrderService 实现 ApplicationContextAware 接口,手动在方法内部调用 context.getBean("cart")

    • 缺点:代码与 Spring API 强耦合,违背了 IoC 思想。
  2. 使用 @Lookup 注解(推荐的注解方式):

    @Component
    public class OrderService {
        @Lookup
        public Cart getCart() {
            return null; // Spring 会重写此方法,返回原型 Bean
        }
    }
    

5. 其他 Scope(Web & 自定义)

除了 singletonprototype,Spring 还提供了 5 种专门用于 Web 环境的作用域,以及支持开发者根据需求扩展的自定义作用域

5.1 Web 相关作用域(仅限 Web 应用)

这些作用域在标准的 Java 应用程序(如简单的 Swing 或控制台程序)中不可用。只有在使用了 Spring MVC 或类似 Web 框架的环境下,且配置了 RequestContextListener 后才能生效。

作用域名称 生命周期描述 适用场景
request 每一个 HTTP 请求都会创建一个新的 Bean 实例。请求结束后,Bean 随之销毁。 存储单个请求的临时数据(如表单校验错误)。
session 在同一个 HTTP Session(会话)中,该 Bean 是单例的。 存储用户信息、购物车等需要跨请求保持的数据。
application 在整个 ServletContext 生命周期内是一个单例。 类似于 singleton,但它是 Servlet 容器级别的全局共享。
websocket 在一个 WebSocket 的生命周期内,该 Bean 是单例的。 实时通信中的用户连接状态管理。
global session (已过时)原用于 Portlet 环境,目前在普通 Web 开发中等同于 session。 早期 Portlet 应用规范。

5.2 自定义 Scope:ThreadScope(线程作用域)

ThreadScope 是一个非常经典的非标准作用域。它保证了同一个线程内获取的是同一个对象,不同线程获取的是不同对象

1. XML 配置实现

要使用非内置作用域,必须先向 Spring 注册。配置如下:

<bean id="springBean" class="com.zzz.bean.SpringBean" scope="threadScope"/>

<bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
    <property name="scopes">
        <map>
            <entry key="threadScope">
                <bean class="org.springframework.context.support.SimpleThreadScope"/>
            </entry>
        </map>
    </property>
</bean>
2. 测试代码逻辑分析

通过testThreadScope() 方法,我们可以验证其核心逻辑:

public void testThreadScope(){
    // 主线程(Thread A)获取
    SpringBean b1 = applicationContext.getBean("springBean", SpringBean.class);
    SpringBean b2 = applicationContext.getBean("springBean", SpringBean.class);
    System.out.println(b1 == b2); // 结果为 true:同一线程内是单例

    // 启动一个新线程(Thread B)
    new Thread(() -> {
        SpringBean b3 = applicationContext.getBean("springBean", SpringBean.class);
        System.out.println(b3); 
        // 结果:b3 的内存地址与 b1/b2 不同,因为跨线程了
    }).start();
}
3. 底层原理:ThreadLocal

SimpleThreadScope 的底层原理是利用了 Java 的 ThreadLocal。它为每个线程准备了一个独立的“盒子”,线程 A 只能从自己的盒子里拿对象,线程 B 亦然。


5.3 如何自定义一个 Scope?

如果你想完全自己实现一个 Scope(例如:按“地理位置”划分的作用域),你需要:

  1. 实现 org.springframework.beans.factory.config.Scope 接口
    • 实现 get(String name, ObjectFactory<?> objectFactory):定义如何获取/创建对象。
    • 实现 remove(String name):定义销毁逻辑。
  2. 注册到容器:使用 CustomScopeConfigurer 进行配置。
1. 第一步:编写自定义 Scope 类

我们需要实现 org.springframework.beans.factory.config.Scope 接口。核心在于使用一个 ThreadLocal 来保存每个线程自己的 Bean 副本。

package com.zzz.scope;

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.config.Scope;
import java.util.HashMap;
import java.util.Map;

/**
 * 自定义线程作用域实现
 */
public class MySimpleThreadScope implements Scope {

    // 使用 ThreadLocal 存储当前线程的所有 Bean 实例
    private final ThreadLocal<Map<String, Object>> threadLocal = ThreadLocal.withInitial(HashMap::new);

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        // 1. 获取当前线程的 Bean 容器(Map)
        Map<String, Object> scope = threadLocal.get();
        
        // 2. 检查 Map 中是否已经存在该 Bean
        Object bean = scope.get(name);
        
        if (bean == null) {
            // 3. 如果不存在,调用 ObjectFactory 创建 Bean 实例
            bean = objectFactory.getObject();
            // 4. 存入当前线程的 Map 中
            scope.put(name, bean);
        }
        return bean;
    }

    @Override
    public Object remove(String name) {
        // 从当前线程的 Map 中移除 Bean
        return threadLocal.get().remove(name);
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {
        // 这里可以实现销毁回调逻辑,简单实现可以先忽略
        System.out.println("注册销毁回调: " + name);
    }

    @Override
    public Object resolveContextualObject(String key) {
        return null;
    }

    @Override
    public String getConversationId() {
        // 返回当前线程的名字作为会话 ID
        return Thread.currentThread().getName();
    }
}

2. 第二步:在 XML 中注册并使用

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
        <property name="scopes">
            <map>
                <entry key="myThread">
                    <bean class="com.zzz.scope.MySimpleThreadScope"/>
                </entry>
            </map>
        </property>
    </bean>

    <bean id="myBean" class="com.zzz.bean.SpringBean" scope="myThread"/>

</beans>

3. 第三步:编写 JUnit 测试用例

我们要通过多线程环境来验证:同一个线程拿到的是同一个对象,不同线程拿到的是不同对象。

import com.zzz.bean.SpringBean;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.ClassPathXmlApplicationContext;

public class MyCustomScopeTest {

    @Test
    public void testMyCustomScope() throws InterruptedException {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring-custom-scope.xml");

        // --- 线程 1 (主线程) ---
        SpringBean b1 = context.getBean("myBean", SpringBean.class);
        SpringBean b2 = context.getBean("myBean", SpringBean.class);
        System.out.println("主线程 Bean1: " + b1);
        System.out.println("主线程 Bean2: " + b2);
        System.out.println("主线程两次获取是否相同: " + (b1 == b2)); // true

        // --- 线程 2 (新线程) ---
        Thread thread2 = new Thread(() -> {
            SpringBean b3 = context.getBean("myBean", SpringBean.class);
            SpringBean b4 = context.getBean("myBean", SpringBean.class);
            System.out.println("新线程 Bean3: " + b3);
            System.out.println("新线程 Bean4: " + b4);
            System.out.println("新线程内部获取是否相同: " + (b3 == b4)); // true
            System.out.println("跨线程获取是否相同 (b1 vs b3): " + (b1 == b3)); // false
        });

        thread2.start();
        thread2.join(); // 等待子线程运行结束
    }
}

测试结果:
在这里插入图片描述

调用链条如下:调用链条如下:

  1. 测试代码:执行 context.getBean("myBean")
  2. Spring 容器(BeanFactory)
    • 查找 myBean 的定义,发现它的 scope="myThread"
    • 容器去它的“作用域注册表”里找:"myThread" 对应哪个实现类?
    • 找到了你注册的 MySimpleThreadScope 实例。
  3. 关键转折点:Spring 容器不再自己去创建对象,而是把创建权转交给自定义 Scope。

核心方法被调用的时机

(1) get(String name, ObjectFactory<?> objectFactory)

  • 调用时机:当你调用 context.getBean() 且 Bean 的作用域是自定义的时候。
  • Spring 的逻辑
    • Spring 会把创建 Bean 的逻辑封装成一个 ObjectFactory(工厂对象)。
    • Spring 调用 scope.get(...),并把这个工厂传给你。
    • 由你决定:是直接从 ThreadLocal 拿旧的(如果你已经存过了),还是调用 objectFactory.getObject() 创建一个新的。

(2) remove(String name)

  • 调用时机:通常在作用域显式结束时。
  • 注意:对于 ThreadScope 或自定义作用域,Spring 不会自动知道线程什么时候结束。
  • 手动调用:通常需要你在代码中(例如 Web 拦截器或线程池任务结束时)手动调用 configurableBeanFactory.getRegisteredScope("myThread").remove("myBean") 来清理内存。

(3) registerDestructionCallback(...)

  • 调用时机:在 Bean 实例创建完成并初始化后,Spring 会自动调用此方法。
  • 作用:Spring 会传给你一个 callback(其实就是 Bean 的销毁方法,如 @PreDestroy)。
  • 责任:因为是自定义作用域,Spring 无法预知这个 Bean 什么时候该死。我们需要把这个 callback 存起来,等到我们觉得该销毁这个作用域的时候(比如线程销毁),由我们手动运行这个 callback

(4) getConversationId()

  • 调用时机:Spring 内部记录日志或进行调试统计时调用。
  • 作用:用来标识当前的“会话”到底是谁。返回线程名,这样 Spring 打印日志时就知道这个 Bean 属于哪个线程。

用一个生动的例子来理解

想象你在一家**定制化餐厅(Spring)**吃饭:

  1. 你(测试代码):对服务员说“我要一份‘myBean’”。
  2. 服务员(Spring 容器):看了一下菜单,发现这个菜标注了“由‘myThread’大厨负责”。
  3. 服务员:把点菜单和原材料交给了 大厨( MySimpleThreadScope)
  4. 大厨(执行 get 方法)
    • 大厨先看了一下自己灶台上的备菜盘(ThreadLocal)。
    • 发现盘子里已经有一份做好的了,直接拿给服务员(返回缓存对象)。
    • 如果盘子是空的,大厨才现场开火把菜做出来(执行 objectFactory.getObject()),然后放进盘子备份。

流程总结:

  1. 配置注册:通过 CustomScopeConfigurer 将自定义 Scope 注入 Spring 的 Scope 映射表。
  2. 拦截请求AbstractBeanFactory.doGetBean 方法会判断 Bean 的 Scope 类型。
  3. 委托执行:如果是非单例、非原型的自定义 Scope,Spring 调用 scope.get(name, objectFactory)
  4. 策略实现:开发者在 get 方法中利用 ThreadLocalRedis 或其他容器实现特定的存储逻辑。

深度原理解析

  1. ObjectFactory 的作用:在 get 方法中,objectFactory.getObject() 是关键。Spring 容器并不会直接把 Bean 交给 Scope,而是把“生产 Bean 的工厂”交给 Scope。由 Scope 决定什么时候调用工厂去生产(如果是第一次访问就生产,如果已经有了就直接从 Map 拿缓存)。
  2. ThreadLocal 隔离:每个线程在调用 threadLocal.get() 时,拿到的都是属于该线程独立的 HashMap。这就是为什么不同线程之间的 Bean 互不干扰。
  3. 注册机制CustomScopeConfigurer 实际上是告诉 Spring 的 BeanFactory:“以后如果你看到 scope="myThread",就去调用我提供的这个 MySimpleThreadScope 实例来处理。”
Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐