Spring IoC 详解

Spring IoC 详解

本文基于某企业级薪资系统的真实代码整理(已脱敏,包名、类名、应用名均做泛化处理),所有示例保留了原始设计意图,可直接映射到日常开发中的同类场景。


目录

  1. 从没有 Spring 的世界说起
  2. Bean 与容器的本质
  3. 真实案例逐行解读:SequenceConfig
  4. 关联文件:一条完整的 IOC 链路
  5. @Configuration 是 AOP 切面吗?
  6. 单例住 Map,多例不进仓
  7. @Autowired 与单例是两个正交的概念
  8. 多环境下的 Bean
  9. 总结

一、从没有 Spring 的世界说起

假设你要用发号器生成一个 ID,最朴素的写法是:

public class OrderService {
    public long createOrder() {
        // 1. 自己 new 依赖
        DbSequenceDao dao = new DbSequenceDao();
        dao.setAppName("MY_APP");
        dao.setDbGroupKeys(Arrays.asList("MY_DB_GROUP"));
        dao.setRetryTimes(3);
        dao.init();                          // 2. 自己记得初始化

        SequenceGenerator generator = new SequenceGenerator();
        generator.setSequenceDao(dao);       // 3. 自己组装依赖关系

        return generator.getIdNext(...);     // 4. 才能干活
    }
}

问题:

  • 每个用到发号器的地方都要重复这段"创建 + 配置 + 组装"代码;
  • dao.init() 会连数据库,每个调用方都 new 一次,数据库连接就爆了;
  • 想换个实现(比如测试换 H2),所有 new 过的地方全得改。

"控制反转"反转的就是这件事:对象的创建权、配置权、组装权,从业务代码手里移交给了容器。


二、Bean 与容器的本质

2.1 Bean 就是一个普通 Java 对象

new DbSequenceDao() 得到的那个对象,本身没有任何神奇之处。它和普通对象的唯一区别是:这个对象不是你 new 的,是别人帮你 new 好、配置好、放在那里,你用的时候去领

"Bean"这个词没有任何深意,就是 Spring 给"由我管理的对象"起的名字。就像仓库里的货物贴上标签叫"库存",货物本身没变,变的只是它归仓库管了。

2.2 容器 = 对象仓库 + 管理员

容器在物理上就是应用启动时创建的一个对象,内部最核心的东西是一张 Map:

Map<String, Object> 仓库 = new ConcurrentHashMap<>();
// "sequenceDao"      → 那个配好的 DbSequenceDao 实例
// "sequenceGenerator" → 那个已注入 dao 的 SequenceGenerator 实例
// "orderService"      → ...
// 应用里几百个对象,都在这张表里

管理员(容器的逻辑)在应用启动时干四件事:

  1. 盘点清单:扫描所有 @Component/@Service/@Repository/@Configuration,读所有 @Bean 方法,得到一张"需要生产哪些对象"的清单(BeanDefinition);
  2. 按单生产:逐个 new 出来,填好配置;
  3. 组装关系:看到谁有 @Autowired,就从仓库里找出对应对象塞给它;
  4. 入库:全部放进那张 Map,然后对外营业。

之后业务代码要用对象,再也不 new,而是从仓库领

@Repository
public class SequenceGenerator {
    @Autowired
    private SequenceDao sequenceDao;   // 启动时容器自动把仓库里的货塞进这个字段
}

2.3 Bean 的两层结构

一个 Bean 在容器里其实是两份东西

  1. BeanDefinition(配方):记录"这个 Bean 叫什么、什么类、scope 是什么、initMethod 是什么、依赖谁",全部存在 beanDefinitionMap 里;
  2. Bean 实例(成品):按配方生产出来的对象。

2.4 餐厅类比

类比 对应概念
餐厅的中央厨房 + 冷库 容器
冷库里一份份做好的预制菜 Bean
菜谱(用什么料、怎么做、几份) BeanDefinition
服务员要菜时不自己做,直接去冷库拿 @Autowired 注入
"今天不做辣菜"这类当日规则 @Profile / @Conditional(决定哪些菜进冷库)

没有 Spring = 每个服务员接待客人前自己买菜、洗菜、炒菜;
有 Spring = 开业前厨房按菜谱统一做好入库,服务员随取随用。


三、真实案例逐行解读:SequenceConfig

业务背景:分库分表环境下不能用数据库自增主键,需要全局序列生成器。SequenceDao 是"序列号发号器的数据源"(来自分库分表中间件),这个配置类负责把它构造成 Spring Bean。

@Configuration                                                 // ★IOC 核心①:标记为"Java 配置类"

作用:告诉 Spring 容器这是一个 Bean 定义的来源。容器启动时会用 CGLIB 为该类生成子类代理(full 模式),保证类内多个 @Bean 方法互相调用时拿到的仍是同一个单例。它本身也会被注册为一个 Bean。

public class SequenceConfig {
    @Bean(initMethod = "init")                                 // ★IOC 核心②:声明一个 Bean + 生命周期回调
  • @Bean:方法返回值对象将被注册进容器,Bean 名默认 = 方法名 sequenceDao。这就是"控制反转"的第一层:对象的创建权从业务代码转移到了容器配置。
  • initMethod = "init":容器完成该 Bean 的实例化和属性填充后,自动回调 DbSequenceDao.init()(中间件用它连接数据库、加载序列表)。这是 InitializingBean 生命周期接口的注解式替代写法。
    @ConditionalOnMissingBean                                  // ★IOC 核心③:条件装配

作用:仅当容器中不存在 SequenceDao 类型的 Bean 时才注册本 Bean。这是"默认实现 + 允许覆盖"的设计:生产环境用这份配置,测试环境可以塞一个自己的 SequenceDao 让它自动让位。

    public SequenceDao sequenceDao() {                         // ★IOC 核心④:面向接口声明

返回类型是接口 SequenceDao 而非实现类。容器里 Bean 的类型登记为接口类型,消费方按接口注入——将来换实现不需要改任何消费方代码。

        DbSequenceDao sequenceDao = new DbSequenceDao();            // 手工 new 实现类并做参数化配置
        sequenceDao.setAppName("MY_APP");                           // 中间件应用名,用于定位序列元数据
        sequenceDao.setAdjust(true);                                // 序列值异常时自动校正
        sequenceDao.setDbGroupKeys(Arrays.asList("MY_DB_GROUP"));   // 指定序列存储的数据库分组
        sequenceDao.setRetryTimes(3);                               // 取号失败重试 3 次
        if (EnvironmentUtils.isAcceptance()) {                      // 判断是否 UAT 验收环境(读 CURR_ENV 环境变量)
            sequenceDao.setAppName("UAT_MY_APP");                   // UAT 环境切换到独立应用名
            sequenceDao.setDbGroupKeys(Arrays.asList("UAT_MY_DB_GROUP")); // 及独立库分组
        }
        return sequenceDao;                                         // 返回对象,容器接管其后续生命周期
    }
}

IOC 在这个例子中的四个维度

IOC 维度 在本例中的体现
控制反转(创建权) DbSequenceDao 不由使用方 new,而是配置类统一构建、配参,容器持有单例
依赖注入(DI) 消费方 SequenceGenerator 通过 @Autowired 按类型拿到它,双方零耦合
生命周期托管 initMethod="init" 由容器在合适时机回调,业务代码不关心初始化时序
可替换性 声明为接口 + @ConditionalOnMissingBean,测试环境可整体替换实现

四、关联文件:一条完整的 IOC 链路

这个例子实际涉及 4 个文件,构成:声明 Bean(SequenceConfig)→ 扫描注册(Application)→ 注入消费(SequenceGenerator)→ 条件替换(TestDataSourceConfig)

4.1 消费方:SequenceGenerator(同包)

@Repository                          // 立体注解:被组件扫描注册为 Bean(IOC 的另一种入口,对比 @Bean 的"手工声明")
public class SequenceGenerator {
    @Autowired
    private SequenceDao sequenceDao; // ★字段注入:容器按【类型】找到 SequenceConfig 产出的 Bean 注入
    ...
}
  • 它是全应用发 ID 的组件:getIdNext(...) 生成 16~18 位业务主键(日期前缀 + 序列值 + 分表后缀);
  • 内部把注入进来的 DAO 交给中间件的序列对象使用——它完全不知道也不关心底层是 DbSequenceDao 还是别的实现,这就是面向接口注入的价值;
  • 学习点:@Repository(扫描式注册)与 @Bean(配置类式注册)是 IOC 容器的两条 Bean 来源路径,本例恰好各占一条。

4.2 条件替换的实证:TestDataSourceConfig(test 目录)

@TestConfiguration
public class TestDataSourceConfig {
    @Bean
    public SequenceDao sequenceDao(DataSource dataSource) {   // 测试环境提供另一个 SequenceDao
        DefaultSequenceDao sequenceDao = new DefaultSequenceDao(); // 实现类换成了基于本地 H2 的简单实现
        sequenceDao.setDataSource(dataSource);                     // 绑定内嵌 H2 数据源
        sequenceDao.setStep(1000);
        return sequenceDao;
    }
  • 这正是 @ConditionalOnMissingBean 存在的意义:单测加载此配置后,容器里先有了测试版 SequenceDao生产版 SequenceConfig.sequenceDao() 自动放弃注册SequenceGenerator 无感知地切换到 H2 实现;
  • 附带学习点:sequenceDao(DataSource dataSource) 演示了 @Bean 方法参数注入——容器先把 DataSource Bean 找来作为参数传入,是构造期依赖注入的写法。

4.3 环境工具:EnvironmentUtils(公共工具包)

public static boolean isAcceptance() {                       // 被 SequenceConfig 调用
    String env = Optional.ofNullable(System.getenv("CURR_ENV"))
            .orElse(System.getProperty("CURR_ENV"));         // 读部署时注入的环境变量/系统属性
    return env != null && "acceptance".equalsIgnoreCase(env);
}
  • 它是纯静态工具类,不是 Spring Bean——这是一个值得注意的对比:并非所有工具都要进容器,无状态、无依赖的环境探测用静态方法即可;
  • 它来自团队内部的公共工具 starter,体现了业务工程对自定义 starter 的复用。

4.4 完整运行链路

  1. 启动类 ApplicationscanBasePackages = {"com.example.payroll", ...} 触发组件扫描;
  2. SequenceConfigcom.example.payroll.infrastructure... 包下被扫到,容器解析其 @Bean 方法,先检查 @ConditionalOnMissingBean
  3. 条件通过 → 执行 sequenceDao() 方法 → 实例入容器 → 回调 init()
  4. 扫描同时发现 @RepositorySequenceGenerator,实例化时解析其 @Autowired SequenceDao 字段,按类型匹配到第 3 步的 Bean 并注入。

五、@Configuration 是 AOP 切面吗?

不是。 它和 AOP 只是"碰巧用了同一种底层技术(CGLIB 生成子类)",但机制、时机、目的完全不同:

对比维度 AOP 切面 @Configuration 增强
实现机制 AbstractAutoProxyCreator(Bean 后处理器)按切点表达式匹配,把目标 Bean 包一层代理 ConfigurationClassPostProcessor(BeanFactory 后处理器)在注册阶段直接把这个类的 class 替换成 CGLIB 子类
发生时机 Bean 实例化之后,返回给容器前包装 Bean 实例化之前,改的是类本身(容器 new 出来的已经是子类对象)
拦截规则 动态的 pointcut,匹配任意方法 固定的:只拦截 @Bean 方法,逻辑写死在 BeanMethodInterceptor
拦截后做什么 执行你写的横切逻辑(加锁、记日志…) 检查"这个 Bean 容器是不是已经在创建了",是则直接从容器取单例返回,而不是真的执行方法体 new 一个新对象
目的 横切关注点 保证 @Bean 方法语义正确

它要解决的问题很具体:

@Configuration
public class AppConfig {
    @Bean public A a() { return new A(); }
    @Bean public B b() { return new B(a()); }   // ← 这里调用 a()
}

如果没有 CGLIB 增强,b() 里的 a() 就是一次普通 Java 方法调用,会 new第二个 A 实例,和容器里那个单例不是同一个对象。增强后的子类拦截这次调用,改为"去容器里取已注册的 A 单例",从而保证单例一致性。

补充:@Configuration(proxyBeanMethods = false) 可以关闭这个增强(所谓 lite 模式),此时 @Bean 方法互调就是普通方法调用——Spring Boot 大量自动配置类用这种写法来省掉 CGLIB 开销。


六、单例住 Map,多例不进仓

所有初始化完成的单例 Bean,最终都住进同一个 Map:

// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry
Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);  // 一级缓存

getBean("sequenceDao") 本质上就是 singletonObjects.get("sequenceDao")一个 JVM 进程里,一个容器,一张表,按名字取对象——这就是 IOC 容器的物理形态。

两点补充:

  • 三级缓存:为解决循环依赖,还有 earlySingletonObjects(二级,存提前暴露的半成品)和 singletonFactories(三级,存对象工厂)。Bean 完成后统一搬进一级缓存;
  • 不是所有 Bean 都进这张表:prototype 作用域的 Bean 每次 getBean 现做现给,容器不缓存也不管它的销毁;request/session 作用域存在各自的 Scope 里。
单例 Bean(singleton) 多例 Bean(prototype)
容器里存什么 配方 + 成品都存 只存配方,不存成品
生产时机 启动时生产一次,放进 singletonObjects 每次有人来要(getBean)才现场 new 一个
交付后 容器一直持有,应用关闭时统一销毁 交出去就不管了,不缓存、不调用销毁回调
多次获取 拿到同一个对象 每次拿到对象

真实项目里就有 prototype Bean 的典型场景——导入数据校验器(每次导入任务需要一个独立实例来承载本次任务的状态):

@Component
@Scope("prototype")      // ← 作用域写在【类】上,声明"我每次要新的"
public class ImportValidator extends AbstractImportValidator { ... }

它的使用方每次都是现场去容器要

SpringContextUtils.getBeanOfType(ImportValidator.class)   // 每调一次 = 新 new 一个

七、@Autowired 与单例是两个正交的概念

一个常见误解:"类里有 @Autowired,它就是单例 Bean"。错。

首先,@Autowired 根本不标注在类上——它只能标在字段、构造器、方法上。标在上的是另一组注解:

@Repository                    // ← 标在类上:决定"这个类是不是 Bean"
public class SequenceGenerator {
    @Autowired
    private SequenceDao sequenceDao;   // ← 标在字段上:决定"这个字段要不要注入东西"
}

一个类是不是单例 Bean,由两个独立的问题决定:

问题 由什么决定 默认值
① 它是不是 Bean? 类上有没有 @Component/@Service/@Repository/@Configuration,或被某个 @Bean 方法声明 没有就不是 Bean,只是普通类
② 它是单例吗? 类上的 @Scope 不写 = singleton

@Autowired 两个问题都不参与。对照两个真实类:

例 ASequenceGenerator —— @Repository + 无 @Scope → 单例。它是单例的原因是"@Repository 注册 + 没写 @Scope",跟里面那个 @Autowired 无关

例 BImportValidator —— @Component @Scope("prototype"),内部却有多个 @Autowired 字段:

@Component
@Scope("prototype")                       // 自己是多例
public class ImportValidator ... {
    @Autowired private EmployeeRepository employeeRepository;   // 照样注入
    @Autowired private ThreadPoolTaskExecutor generalThreadPool; // 照样注入
}

含义是:容器每 new 一个新的校验器,都会把它的这些字段重新注入一遍(注入进来的 repository、线程池本身是单例,所以每次注的都是同一个)。@Autowired 只负责"接线",完全不决定单例与否。

经典陷阱:prototype 注入单例会退化

@Service                                   // 我是单例,启动时创建一次
public class ImportService {
    @Autowired
    private ImportValidator validator;     // ← prototype 被注入进单例
}

注入动作只在启动时发生一次,之后 ImportService 一直活着,字段里就永远是同一个 validator 实例——prototype 的"每次要新的"语义完全失效。

这就是为什么成熟项目里不用 @Autowired 注入这类校验器,而是在每次需要时调 SpringContextUtils.getBeanOfType(...) 现取——这是使用 prototype Bean 的正确姿势。若确实想注入式使用,还有 ObjectProvider<T>(每次 .getObject() 取新的)或 @Lookup 方法两种标准解法。


八、多环境下的 Bean

先明确前提:容器是进程级的。日常/预发/线上是不同机器上的不同 JVM,各自有独立的容器和独立的 singletonObjects,机器之间不共享任何 Bean。

所谓"多环境 Bean",指的是同一套代码制品,在不同环境启动时,容器决定哪些 BeanDefinition 保留、哪些丢弃。真实项目里有 4 种典型玩法:

@Profile —— 按环境名整体启停(用得最多)

@Profile("!utest")            // 远程服务/缓存的配置类:单测环境不注册真实远程调用 Bean
@Profile("beta1")             // 流量回放、影子数据源等测试专属 Bean:只在 beta1 环境存在
@Profile({"!beta1","!beta2"}) // 正式实现:与上面互斥,实现"同一接口的环境专属实现"

激活方式:spring.profiles.active。常见做法是本地默认 spring.profiles.active=local,部署时用占位符(如 spring.profiles.active=${deploy.env})由发布平台在打包/启动时替换成真实环境——同一份代码,daily 机器上长出一套 Bean,线上机器上长出另一套

@ConditionalOnProperty —— 按配置项开关

@ConditionalOnProperty(name = "deploy.unit", havingValue = "center"):中心机房才注册这批远程服务消费者 Bean,单元机房直接不注册。

@ConditionalOnMissingBean —— 环境间"覆盖替换"

就是本文主例:测试环境 TestDataSourceConfig 提供 H2 版 SequenceDao,生产版自动让位。同一个 Bean 名/类型,不同环境装不同的豆。

④ 同一个 Bean 内部运行时分支

SequenceConfigif (EnvironmentUtils.isAcceptance()) 改 appName——Bean 定义只有一个,但构造参数随环境变量(CURR_ENV)变化。

全景图

代码制品(所有环境的 @Component/@Bean 定义全集)
        │ 启动
        ▼
环境信号注入:spring.profiles.active / 自定义配置项 / CURR_ENV 环境变量
        ▼
容器过滤:@Profile、@Conditional* 逐条判定 → 生成本环境专属的 BeanDefinition 集合
        ▼
实例化 + 依赖注入 + 生命周期回调
        ▼
singletonObjects(一级缓存,Map<String, Object>)← 这就是"豌豆荚"

多环境不是"一个容器里放多套 Bean 来回切",而是启动那一刻就按环境裁剪出唯一一套,之后容器里只有一套


九、总结

  1. Bean 就是被容器托管的普通 Java 对象;容器物理上就是一张 Map<String, Object> 加一套管理逻辑。SequenceConfig 是写给容器看的生产说明书SequenceGenerator领货单,两者互不认识,全靠容器在启动时对接——这就是 IOC 的全部含义。
  2. @Configuration 不是 AOP 切面:它靠 ConfigurationClassPostProcessor 在注册期做 CGLIB 类替换,拦截 @Bean 方法保证单例语义;AOP 是在实例化后按切点包代理。同用 CGLIB,机制、时机、目的全不同。
  3. 单例 Bean 全住一级缓存 Map,prototype 只存配方不进仓;三级缓存为循环依赖服务。
  4. @Autowired 只管"接线",不管"几个实例":是不是 Bean 看类上的立体注解,单例与否看 @Scope(缺省即单例)。把 prototype 注入单例会退化成"伪多例",正确姿势是每次 getBean 或用 ObjectProvider
  5. 多环境 Bean = 启动时按环境信号裁剪 BeanDefinition@Profile@ConditionalOnProperty@ConditionalOnMissingBean、运行时分支四种手段,容器里永远只有当前环境的唯一一套 Bean。

本文示例角色索引(脱敏)

角色 示例类 说明
Bean 声明方 SequenceConfig @Configuration + @Bean + @ConditionalOnMissingBean
Bean 消费方 SequenceGenerator @Repository + @Autowired 按类型注入
测试覆盖方 TestDataSourceConfig @TestConfiguration 提供 H2 实现,触发条件让位
非 Bean 工具 EnvironmentUtils 静态环境探测工具,不进容器
扫描入口 Application @SpringBootApplication(scanBasePackages=...)
多例 Bean ImportValidator @Component + @Scope("prototype")
多例正确取用 校验器链构建器 每次 getBeanOfType(...) 现取,而非字段注入
posted @ 2026-09-09 11:38  cwp0  阅读(5)  评论(0)    收藏  举报