☰
Spring IoC入门:控制反转与依赖注入原理与容器配置详解
2026/10/1 17:21:07 网站建设 项目流程

1. 从"new"说起:为什么Spring IoC值得花一章来学

我相信每个刚接触Java后端的人都有过类似的体验:读官方文档,看到"控制反转(Inversion of Control)""依赖注入(Dependency Injection)"这两个概念,字面意思都认识,但完全不知道代码上要怎么写、为什么要这么写。跟着教程敲了一遍又一遍,容器倒是跑起来了,可心里总觉得不踏实——"这玩意到底好玩在哪?"

我把话放在前面:Spring IoC的意义,不在于"换一种方式创建对象",而在于把对象之间的"组装关系"从代码里抽离出去,放到一个统一的地方去管理。这一章讲的是入门案例,但我会把背后的设计逻辑、常见误区和排查思路一起讲透。学完以后你再去看AOP、事务管理这些内容,会发现它们全都建立在这套"装配合约"之上。

这一章适合三类人来看:第一次接触Spring、想通过一个最小案例建立体感的学习者;写过一些Spring但只会照抄配置、遇到bean相关报错就懵的初学者;以及准备面试、想把IoC概念讲清楚而不是背定义的求职者。

先说最朴素的问题:假如没有IoC,一段业务代码长什么样?

public class UserService { private UserDao userDao = new UserDao(); public User getUserById(Long id) { return userDao.findById(id); } }

这段代码看起来干干净净,问题出在哪里?问题在于UserService这个类自己决定了要创建哪个UserDao。假如明天UserDao的实现类变了,或者要在测试时换成mock对象,你就得打开UserService源码去改这一行——而如果项目里有一百个地方都用new UserDao(),那就是一百处改动。

我见过最夸张的一次,一个老项目里DateFormat这类工具对象被new了几百次,后来升级接口签名,全局翻代码翻到崩溃。这个痛点,就是IoC要解决的第一个核心问题。

IoC做的事情,用一句话说清楚:对象自己不再负责创建依赖,而是声明"我需要什么",由容器在合适的时机把依赖交到它手里。

  • 创建对象的控制权,从"对象自己"转交到"容器"——这是"控制反转"。
  • 容器把依赖对象"注入"到当前对象的过程——这是"依赖注入"。

这样一拆,你就能明白为什么很多高手会把IoC和DI放在一起讲:IoC是设计思想,"谁说了算";DI是具体实现技术,"怎么给到"。

给你打个比方。以前做饭,从买菜、洗菜、切菜到炒菜全是自己干,这叫"主动依赖";现在点外卖,你只管在App上告诉平台想吃什么,饭送到你手上——至于哪个厨师做的、在哪做的、怎么做熟的,你一概不需要也没资格干涉。这就是控制反转:你自己不造依赖了,转而"声明"依赖。

那容器又是从哪里冒出来的?下面我们进入工具层面。

2. 两个核心接口:容器从哪里来,bean又是什么

Spring容器不是玄学,它是一个实实在在的对象池。所有被容器管理的对象,都叫"bean"。这章的案例里,你会看到容器、bean、配置文件三者之间的关系,可以用一句话先记住:配置文件是"说明书",容器是"照说明书干活的生产线",bean是"生产线上造出来的零部件"。

2.1 BeanFactory与ApplicationContext,先认识两个大管家

Spring的容器有两个核心接口,面试也常考,捋清楚它们的关系很重要:

接口定位特点
BeanFactory最底层的容器接口懒加载,调用getBean时才会实例化对象
ApplicationContext增强版容器继承BeanFactory,启动时预加载单例bean,附加事件发布、国际化、资源加载等能力

我在入门案例里直接推荐ApplicationContext,不在乎那点性能差异。原因很简单:工程中几乎没有直接拿BeanFactory用的场景,而且它的懒加载行为会给新手造成"怎么没报错?到底创建了没有"的虚假安全感。

ApplicationContext常用实现类有这些,认识一下即可:

  • ClassPathXmlApplicationContext:从类路径加载XML配置文件
  • FileSystemXmlApplicationContext:从文件系统加载XML
  • AnnotationConfigApplicationContext:基于注解/JavaConfig的容器

入门阶段用ClassPathXmlApplicationContext最直观,配置文件放resources目录下,一行代码就能把容器启动起来。

2.2 三种配置方式,为什么先从XML学起

配置bean有三大流派:XML配置、注解配置、JavaConfig配置。

我建议案例阶段一定要从XML入手。别急着用@Component,XML的好处是"所见即所得"——容器里有哪些bean、每个bean的依赖是什么,全在一份文件里摊开,一目了然。注解在IDE里是散落的,你必须自己脑补"哪些类被扫描到了",对概念尚未建立的初学者来说很容易产生理解偏差。

打个比方:XML让你先看"仓库库存清单",注解则是"你需要在货架上一个一个找"。等这套装配逻辑在脑子里扎根了,再切换到注解,你就知道@Autowired背后其实还是在走"容器找bean、按类型匹配、注入"那套流程。

下面的入门案例,就是用XML方式来演示的。

3. 手写第一个IoC案例:一份能跑通的最小工程

案例题目很常见:"通过Spring IoC容器获取用户信息"。我不绕弯子,直接用一个最贴近真实开发形态的例子把CURD的架子搭起来,重点看容器如何把UserDao装配进UserService。

3.1 项目结构与Maven依赖

Maven工程结构如下:

src └── main ├── java │ └── com.demo │ ├── dao │ │ ├── UserDao.java │ │ └── impl │ │ └── UserDaoImpl.java │ ├── service │ │ ├── UserService.java │ │ └── impl │ │ └── UserServiceImpl.java │ └── Main.java └── resources └── applicationContext.xml

新建一个普通的Maven项目(不需要Spring Boot),pom.xml里加上:

<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.31</version> </dependency> </dependencies>

为什么只需要spring-context一个坐标?因为它是Spring容器的核心包,会把spring-core、spring-beans、spring-aop等基础依赖自动带进来。这个版本的选取我用了5.3.x,稳定且兼容性广;如果你跟的是较新的Spring 6.x或者直接上的Spring Boot 3.x,依赖坐标不变,但要注意JDK版本需要17以上。这里先用5.3.x,是因为它兼容JDK 8,对新手环境最友好。

提示:下载依赖慢是网络问题,不是配置问题,调整Maven镜像源解决。不要纠结于"为什么我加了依赖还报ClassNotFoundException"——先确认spring-core是否真的进入了本地仓库。

3.2 接口与实现类:解耦从"面向接口"开始

按照真实项目的习惯,DAO和Service都拆成接口+实现类。先定义DAO层:

package com.demo.dao; public interface UserDao { String getUserNameById(Long id); }
package com.demo.dao.impl; import com.demo.dao.UserDao; public class UserDaoImpl implements UserDao { @Override public String getUserNameById(Long id) { // 模拟从数据库查询 return "用户-" + id; } }

接下来是Service层。这是本案例的"灵魂":实现类里不出现new UserDaoImpl(),而是声明一个UserDao字段,并提供setter方法——这个setter专门用于接收容器注入:

package com.demo.service; public interface UserService { String getUserName(Long id); }
package com.demo.service.impl; import com.demo.dao.UserDao; public class UserServiceImpl implements UserService { private UserDao userDao; // setter注入,容器会通过这个方法把UserDaoImpl传进来 public void setUserDao(UserDao userDao) { this.userDao = userDao; } @Override public String getUserName(Long id) { return userDao.getUserNameById(id); } }

你可能会问:为什么搞个setter,直接给字段不行吗?用setter是历史遗留也是最容易理解的注入方式,它明确暴露了"容器将要调用这个方法来设置依赖"。后续切到注解开发,@Autowired会在字段上直接注入,但底层缺省还是要走Setter/构造器。现在先不贪多,把setter注入吃透就够。

3.3 配置文件:bean的"户口簿"

在src/main/resources下新建applicationContext.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 id="userDao" class="com.demo.dao.impl.UserDaoImpl"/> <bean id="userService" class="com.demo.service.impl.UserServiceImpl"> <property name="userDao" ref="userDao"/> </bean> </beans>

解读一下这份"户口簿":

  • 第一个bean标签表示"容器帮我创建UserDaoImpl对象",id叫userDao。
  • 第二个bean标签表示"容器帮我创建UserServiceImpl对象",id叫userService。
  • <property name="userDao" ref="userDao"/>表示:创建userService这个bean时,调用setUserDao方法,传入id为userDao的那个bean。

name="userDao"对应的是setter方法名的定位——setUserDao去掉set、首字母小写就是userDao。这个规则在很多框架里都通用,理解了以后看别人的配置就不会发懵。

3.4 启动容器,见证装配结果

写一个最简单的入口来运行:

package com.demo; import com.demo.service.UserService; import org.springframework.context.support.ClassPathXmlApplicationContext; public class Main { public static void main(String[] args) { // 1. 加载配置文件,启动容器 ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml"); // 2. 从容器中获取bean UserService userService = (UserService) context.getBean("userService"); // 3. 调用业务方法 String name = userService.getUserName(1L); System.out.println("查询结果:" + name); // 4. 关闭容器 context.close(); } }

运行结果:

查询结果:用户-1

但你现在应该想到一个问题:UserService的实现类里明明没有new过UserDaoImpl,为什么userService.getUserName(1L)能用userDao?答案就在配置文件那一行property——容器在创建userService时,已经提前把userDaobean塞进去了。这就是所谓的"依赖注入"第一次在你眼前显形。

4. getBean的四种姿势:用容器取bean信息的实用指南

第一遍跑通之后,大部分人会卡在同一个地方:getBean这个API到底怎么用才对?标题里那句"使用spring ioc 容器获取bean信息",就落在这一节。我结合踩过的坑,把常见写法从头到尾过一遍。

4.1 按id获取:最原始,但最不推荐

UserService userService = (UserService) context.getBean("userService");

这种方法我前面用了,但从工程视角来说不可取。原因有三:

  • 字符串写错不报编译错,要等运行时才炸;
  • 类型转换出错是隐蔽的,编译器完全不管;
  • 没有任何代码提示,全靠人肉记忆。

所以它能用来做演示,但别养成习惯。

4.2 按类型获取:类型安全,但怕"一型多bean"

UserService userService = context.getBean(UserService.class);

这是类型安全的,不需要强制转换,IDE还能跳转查看定义。但它有个前提:**在容器中,这个类型只能对应唯一一个bean。**一旦你定义了两个UserService类型的bean,比如:

<bean id="userServiceA" class="com.demo.service.impl.UserServiceImpl"/> <bean id="userServiceB" class="com.demo.service.impl.UserServiceImpl"/>

再执行上面的代码,Spring会直接抛出NoUniqueBeanDefinitionException:

expected single matching bean but found 2: [userServiceA, userServiceB]

遇到这个问题,要么改用getBean("userServiceA", UserService.class)精确指定,要么用@Primary标记"首选bean"——这是后话,但你要知道"按类型"的核心矛盾在这里。

4.3 按名称+类型联合获取:最稳妥的姿势

UserService userService = context.getBean("userService", UserService.class);

这个方法同时校验id和类型,既避免了字符串风格的类型转换隐患,又解决了"多bean"的歧义问题。我的建议是:**凡是bean的id能够确定,就用这种方式。**它是现实开发中最常见的写法之一,也最容易读代码的人理解你拿的是哪一个bean。

4.4 容器还提供了一组"体检"方法:查有没有、查是什么、查所有

getBean是拿对象,但实际排查问题的时候,你更需要"看容器里有什么"。ApplicationContext(准确说是ListableBeanFactory接口)有一系列方法,入门时容易被忽略,但排错时特别有用:

boolean hasUserDao = context.containsBean("userDao"); System.out.println("是否包含userDao:" + hasUserDao); boolean isSingleton = context.isSingleton("userService"); System.out.println("userService是否单例:" + isSingleton); String[] beanNames = context.getBeanDefinitionNames(); System.out.println("容器中的bean名称:"); for (String name : beanNames) { System.out.println(name + " -> " + context.getType(name)); }

这里面getBeanDefinitionNames是排查"我的bean怎么没创建?"的常用工具——它会把当前容器里定义的所有bean名打印出来。如果你发现自己配了却没打印出来,问题大概率出在配置文件没被加载,或者id写错了。另外context.getType("bean的名称")可以快速确认某个bean的真实类型跟你预期是否一致,处理类型转换问题的时候特别香。

我还想提醒你一个细节:context.getBean("userService")第一次调用时,如果是ApplicationContext而不是原生的BeanFactory,那么在容器启动阶段bean已经被实例化好了,getBean只是"取货"。所以入门阶段开局加载慢一点,别焦虑。

5. 入门必踩的五个坑:从报错堆栈读到问题根因

IoC入门案例的代码量很小,但这不意味着不会出错。我把过去见到的初学者错误做了一个汇总,用一个"现象—原因—解决"的框架来呈现。你以后不看这篇也能按着这套思路排查。

5.1 NoSuchBeanDefinitionException:容器里根本没有这个bean

现象:

No bean named 'userService' is defined

排查链路从这三步里找答案:

  1. 检查getBean里的字符串是否和XML中bean标签的id完全一致。大小写往往是最容易翻车的地方,userService和userService看着像,程序可不认。
  2. 检查配置文件有没有用对。如果applicationContext.xml没有放在resources下,或者ClassPathXmlApplicationContext("applicationContext.xml")路径写错,容器加载的就是另一个"空"配置文件。
  3. 看target/classes目录里是否存在这个XML。Maven项目里如果配置文件不在resources下而是落在src/main/java里,有时不会被打进classpath——这属于Maven打包的经典坑,复制过去也只会让你"看起来有文件,实际加载不了"。

我建议排查时先写一行Arrays.toString(context.getBeanDefinitionNames())看输出,比起盲猜快得多。

5.2 NullPointerException:注入没产生,service拿到空指针

这是最让人摸不着头脑的报错之一:

Exception in thread "main" java.lang.NullPointerException at com.demo.service.impl.UserServiceImpl.getUserName(UserServiceImpl.java:18)

NPE的位置在userDao.getUserNameById(id)这行,说明userDao字段是null。通常原因只有一个:XML的property没配对。

<bean id="userService" class="com.demo.service.impl.UserServiceImpl"> <property name="userDao" ref="userDao"/> <!-- 如果name写错,注入被忽略 --> </bean>

property的name必须对应setter方法名去掉"set"后首字母小写的形式。写错以后Spring不报错,只是不执行注入,让你在运行时才开始痛苦。这也是我建议"入门阶段所有依赖都走setter"的原因——一旦出错,你能立刻在XML上看出来。

5.3 ClassNotFoundException或ClassCastException:class配置错了

如果是ClassNotFoundException,说明bean标签的class属性写错了全限定名,比如漏写了包名,或者包结构调整后XML没有同步更新。

如果是ClassCastException,更多是"按类型转换"时用错了类型,比如:

UserService userService = (UserService) context.getBean("userDao");

id指向的是UserDao的bean,你却把它当成UserService来转换——这种低级错误,前面说过的getBean("id", 类型)联合方式能帮你提前拦住一半,另一半靠"看id,别靠猜"的习惯。

5.4 BeanDefinitionStoreException:XML格式或文件路径有问题

这个异常多半在容器启动阶段就抛出来。看到BeanDefinitionStoreException,先做以下三件事:

  1. 用IDE打开XML,看有没有明显语法错误,比如标签没闭合。
  2. 看xsi:schemaLocation写对了没有,空格和换行不要乱来。
  3. 确认文件确实能被类路径加载——最稳妥的做法是把XML放到resources根目录,而不是子目录里绕一圈再把路径写错。

5.5 NoUniqueBeanDefinitionException:按类型取bean遇到一型多bean

前面已经提到过,这个异常的场景很典型:同一种类型有多个bean,你却用了getBean(UserService.class)。这时候有两个解决思路:

  • 改getBean("具体id", UserService.class)精确锁定;
  • 在多个bean中,用primary="true"标记其中一个为"主bean":
<bean id="userServiceA" class="com.demo.service.impl.UserServiceImpl" primary="true"/> <bean id="userServiceB" class="com.demo.service.impl.UserServiceImpl"/>

这样getBean(UserService.class)就能成功,Spring会默认给你primary的那个。

注意:多个bean同时标记primary="true"是没有意义的,Spring会继续抛异常,因为你给了它两个"首选"。这种问题在注解配置里同样存在,记住一个原则——首选只能有一个。

6. 案例之外:IoC之后你还需要掌握的三件事

案例跑通了,报错会排查了,接下来如果想让IoC这块基础更扎实,还有三件事值得在上手第一周内做掉。

6.1 bean的作用域:单例还是原型

默认情况下,所有bean都是单例的——容器只创建一次,后续getBean拿到的都是同一个对象。这一点可以在代码里快速验证:

UserService s1 = context.getBean("userService", UserService.class); UserService s2 = context.getBean("userService", UserService.class); System.out.println(s1 == s2); // true,说明是同一个对象

若需要每次获取都得到新实例,就在bean标签上加scope="prototype"。但现实中,业务Service基本都用单例,因为Service本身无状态、依赖的DAO也是线程安全的,单例省内存且效率高。你只需要知道prototype的存在,具体什么时候用——比如多例状态对象、原型模式的场景——那是后面设计层面的事。

6.2 构造器注入与setter注入,选谁

我示例里用了setter注入,因为理解门槛最低。但在实际项目中,我更推荐构造器注入:

public class UserServiceImpl implements UserService { private final UserDao userDao; public UserServiceImpl(UserDao userDao) { this.userDao = userDao; } // ... }

配合XML:

<bean id="userService" class="com.demo.service.impl.UserServiceImpl"> <constructor-arg ref="userDao"/> </bean>

理由也很务实:字段用final修饰,保证依赖一旦注入不可变、也不会出现"忘了setter导致NPE"的尴尬;同时在编译期就能发现谁缺构造参数。代价是代码行数稍微多点,需要你多写一个构造方法。如果你参考的开源代码用的是构造器注入,不用觉得奇怪——这是成熟的工程惯例。

6.3 注解驱动,到底是接着学还是马上学

我的建议是把XML案例吃透后再切注解,但不要拖太久。切的时候,你只需要关注三个注解:

  • @Component:标记类为bean
  • @Autowired:按类型自动注入依赖
  • @ComponentScan:告诉容器去哪里扫描带注解的类

用起来是这样的:

@Component public class UserDaoImpl implements UserDao { // ... } @Component public class UserServiceImpl implements UserService { @Autowired private UserDao userDao; // ... }

然后XML就可以缩成一行扫描配置:

<context:component-scan base-package="com.demo"/>

或者干脆不要XML,换成AnnotationConfigApplicationContext加一个配置类。你会发现,注解背后做的事情跟XML一模一样,只不过"户口簿"从文件变成了一大堆注解。这个转变在概念上并没有新增负担,因为容器那套逻辑你已经通透了。

以我自己的学习路径来看,最早写的那个Hello World式的IoC案例,在之后理解Spring Boot的自动装配、MyBatis的mapper扫描乃至微服务里的各种starter原理时,都发挥了重要支撑作用。说句掏心窝的话:Spring这个框架,最值得花心思的就是容器这套机制。你现在跑通的不只是一个案例,而是后续所有框架特性的地基。建议你把文中的代码自己完整敲一遍,再故意改错几个地方(比如把id写错、把name写错),用报错回答自己的好奇——这样踩出来的经验,比任何教程里的"注意"都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询