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:从文件系统加载XMLAnnotationConfigApplicationContext:基于注解/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排查链路从这三步里找答案:
- 检查
getBean里的字符串是否和XML中bean标签的id完全一致。大小写往往是最容易翻车的地方,userService和userService看着像,程序可不认。 - 检查配置文件有没有用对。如果
applicationContext.xml没有放在resources下,或者ClassPathXmlApplicationContext("applicationContext.xml")路径写错,容器加载的就是另一个"空"配置文件。 - 看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,先做以下三件事:
- 用IDE打开XML,看有没有明显语法错误,比如标签没闭合。
- 看
xsi:schemaLocation写对了没有,空格和换行不要乱来。 - 确认文件确实能被类路径加载——最稳妥的做法是把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写错),用报错回答自己的好奇——这样踩出来的经验,比任何教程里的"注意"都管用。