
1. IoC与DI的核心思想到底解决了什么问题先聊一个最基础也最容易被忽略的问题Spring 的 IoCInversion of Control控制反转和 DIDependency Injection依赖注入到底是干什么的很多刚接触 Spring 的人都会陷入一个误区觉得 IoC 就是个“对象工厂”DI 就是“自动赋值”背完概念就觉得自己懂了。但实际上这两个词的背后藏着一个非常深刻的设计思想转变不理解这个思想后面看源码、写扩展、排查问题都会觉得隔了一层。先看传统写法。假设你有一个订单服务要调用用户服务查用户信息在没有 Spring 之前代码大概是这样的public class OrderService { private UserService userService new UserService(); public void doSomething() { User user userService.getUserById(1L); // ... } }这段代码有什么问题看起来没问题但实际维护起来很痛苦。OrderService不仅要写业务逻辑还得负责创建UserService对象这意味着订单服务和用户服务的具体实现类耦合死了。如果哪天UserService的构造函数变了加了个参数你所有 new 过它的地方全要改。更别提如果UserService依赖了数据库连接池、Redis 客户端那OrderService还得负责把这些依赖一层层搭好这简直是一场灾难。IoC 的核心思想就是把这个“创建对象、管理对象生命周期”的活儿从业务代码里剥离出来交给一个容器统一处理。你的业务类不再自己new依赖对象而是“声明”自己需要什么容器在合适的时机把你需要的东西“注入”给你。用一句话概括对象不再主动去找它的依赖而是被动地接收容器给它的依赖。这就是“控制反转”中“反转”二字的含义——控制权从业务代码手里反转到了容器手里。DI 是 IoC 的一种实现方式。IoC 是设计思想DI 是具体落地手段。Spring 通过依赖注入来达到控制反转的目的。在面试中如果你只背了“IoC 反转了对象的创建权”这句话而没有说出 DI 这个实现手段很容易被追问到卡壳。2. 三种依赖注入方式构造器、Setter、字段各自的门道Spring 官方提供的依赖注入方式主要有三种构造器注入、Setter 注入、字段注入。虽然都能把依赖塞进来但适用场景和风险差别很大这也是面试里很爱问的点。2.1 构造器注入Spring 官方推荐的方式构造器注入是把所需的依赖作为构造函数的参数传入Component public class OrderService { private final UserService userService; // Autowired 在只有一个构造函数时其实可以省略 public OrderService(UserService userService) { this.userService userService; } }这种方式是 Spring 官方文档里明确推荐的原因有三个。第一依赖不可变。final关键字修饰的字段在对象创建后无法被修改这保证了依赖的稳定性避免了某些代码在别处偷偷改掉你的依赖。第二依赖绝对不能为空。如果容器里没有UserService这个 BeanSpring 在启动阶段就直接报错而不是等你运行到某行代码调用时才空指针崩溃。这种“启动即失败”的特性能把问题提前暴露在开发期。第三便于写单测。你不需要启动 Spring 容器直接new OrderService(mockUserService)就能完成测试非常简单干净。2.2 Setter 注入可选依赖的较好选择Setter 注入通过Autowired标注在 setter 方法上实现Component public class OrderService { private UserService userService; Autowired public void setUserService(UserService userService) { this.userService userService; } }这种方式的优势在于允许对象创建后重新配置依赖像是给对象留了一个“后门”。但是它的缺点也很明显依赖不是final的对象可能处于未完全注入的中间状态。Spring 官方支持它但建议只在“依赖是可选场景”下使用比如某些自动配置类里依赖可能不存在时用Autowired(required false)配合 Setter 比较合适。2.3 字段注入最方便但最不推荐的方式字段注入就是最常见的写法Component public class OrderService { Autowired private UserService userService; }说实话这种写法真的省事写业务代码时我也经常图快这么用。但心里要清楚它的问题字段注入绕过了构造器和 Setter在 Spring 之外的环境里你没有办法给这个对象赋值写单测时要么启动容器要么用反射强行塞值。而且 IDE 和 Sonar 等静态检查工具也会对它亮黄牌警告。真正反思一下项目中如果依赖数量特别多往往不是依赖注入方式的问题而是这个类本身职责过重该做拆分重构了。字段注入的便利性背后隐藏着依赖关系不直观的问题——读者无法快速看清这个类到底依赖哪些东西。3. 装配 Bean 的三种配置方式XML、注解、Java ConfigSpring 容器怎么知道要创建哪些 Bean、怎么注入依赖这就要说到配置方式了。从 Spring 诞生到现在配置方式大致经历了三个阶段当下流行的写法你可能天天在用但未必清楚每种方式的来历和适用边界。3.1 XML 配置老项目的标配早年间的 Spring 项目全是 XML 配置长这样?xml version1.0 encodingUTF-8? beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd bean iduserService classcom.example.UserService/ bean idorderService classcom.example.OrderService constructor-arg refuserService/ /bean /beansXML 配置的优势在于无需重新编译即可调整装配关系在早年间 Java 还没有注解功能时是唯一的选择。但它的问题也很致命配置冗长、查错困难Bean 多起来以后 XML 文件动辄上千行而且编译器无法帮你检查引用是否正确只能运行时报错。现在除了维护老项目已经很少有人从零开始写 XML 配置了。在我的实操经验里XML 配置还残留在一些特殊场景中比如某些第三方框架的集成配置、某些需要动态调整 Bean 定义的场景但总的来说它已经退出了主舞台。3.2 注解配置现代项目的默认选择注解配置通过Component、Service、Repository、Controller等注解标注类让 Spring 自动扫描注册 BeanService public class UserService { Autowired private UserMapper userMapper; }使用时要先开启组件扫描Spring Boot 里默认扫描启动类所在包及其子包所以主启动类的包位置要注意否则扫描不到你的 Bean。这也是新手最常见的坑之一启动类放在了错误的包层级或者业务类放在了启动类所在包的兄弟包里结果 Spring 一个 Bean 都没扫到启动后一调用就报 NoSuchBeanDefinitionException。注解配置的好处是零 XML、装配关系就在代码里IDE 能帮你检查引用重构时改名也不容易遗漏。缺点是装配关系分散在各个类中想总览全局配置比较困难新人入职时看项目结构不如 XML 时代一目了然。但在实际开发中配合 IDEA 的 Spring 插件各个依赖关系都能可视化展示这个缺点其实被工具弥补了不少。3.3 Java Config类型安全的配置方式Java Config 用ConfigurationBean注解来定义 Bean这是一种类型安全的配置方式Configuration public class AppConfig { Bean public UserService userService() { return new UserService(); } Bean public OrderService orderService() { return new OrderService(userService()); } }Java Config 的核心优势是类型安全如果引用类型搞错了编译期就能发现。而且配置逻辑是代码可以写判断、循环等逻辑能灵活实现动态装配。Spring Boot 大量使用这种方式做自动配置你在依赖里引入一个spring-boot-starter-web容器里就自动有了DispatcherServlet、ViewResolver等一系列 Bean背后就是功劳。3.4 混合装配实际项目中的常态实例里一个中型项目往往是三种方式混用的Spring Boot 的自动配置负责底层基础设施Java Config 负责显式配置某些自定义的、有特定初始化逻辑的 Bean而业务 Bean 基本靠注解扫描搞定。理解每种方式的边界才能写出既高效又易维护的代码。4. Bean 的生命周期从定义到销毁容器帮你做了什么面试高频问题是“Spring Bean 的生命周期”源码级考察也不少。这篇文章把这块讲透你对照着自己 Debug 一遍源码基本就能应付大部分场景了。一个 Bean 在容器中大致经历以下几个阶段解析 BeanDefinition容器读取配置生成 Bean 的定义信息类名、作用域、是否懒加载、依赖关系等。实例化 Bean通过构造器或工厂方法创建对象实例这一步仅仅是new了一个对象属性还没赋值。属性填充依赖注入容器根据 Bean 定义把依赖的其他 Bean 和各配置项填充进去。初始化前的各种回调依次执行 Aware 接口回调BeanNameAware、BeanFactoryAware、ApplicationContextAware、BeanPostProcessor的postProcessBeforeInitialization方法、PostConstruct标注的方法、InitializingBean的afterPropertiesSet方法、自定义的init-method。初始化完成BeanPostProcessor的postProcessAfterInitialization方法执行完毕Bean 可以投入使用了。使用阶段容器里所有的单例 Bean 被业务代码调用。销毁阶段容器关闭时依次执行PreDestroy标注的方法、DisposableBean的destroy方法、自定义的destroy-method。很多人觉得这个生命周期很抽象我换个生活化的类比Bean 就像一件定制的西装。先量体解析定义然后裁剪实例化接着缝扣子修边属性填充再熨烫整理初始化回调最后包装好交到你手里初始化完成。你穿它出席各种场合使用最终旧了淘汰销毁。这个过程中你只负责下单声明需求所有制作细节都是裁缝铺容器帮你完成的。5. 三级缓存与循环依赖Spring 解决棘手问题的精妙设计Spring 最著名的设计之一就是“三级缓存解决单例 Bean 的循环依赖”。面试十有八九会问但能真正讲清楚的候选人不多。先明确一个前提Spring 只能解决单例作用域 构造器注入之外的循环依赖原因往下看就会明白。先说什么叫循环依赖。假设ServiceA依赖ServiceB而ServiceB又依赖ServiceA这就形成了循环Component public class ServiceA { Autowired private ServiceB serviceB; } Component public class ServiceB { Autowired private ServiceA serviceA; }Spring 在创建ServiceA时发现需要ServiceB于是去创建ServiceB创建ServiceB时又发现需要ServiceA于是又去创建ServiceA……如果没有任何机制这就变成死循环了。Spring 的解决方案是三个缓存容器源码在DefaultSingletonBeanRegistry中一级缓存singletonObjects存已经完整创建好的单例 Bean。二级缓存earlySingletonObjects存已经实例化但还未完成属性填充的早期 Bean。三级缓存singletonFactories存单例工厂用于在需要时生成早期 Bean 的代理对象。处理流程简化如下创建ServiceA时先实例化对象得到一个原始对象instanceA。把这个原始对象包装进singletonFactories三级缓存存一个ObjectFactory这个工厂能生成instanceA的早期引用需要时还会生成 AOP 代理。开始填充ServiceA的属性发现需要ServiceB于是去创建ServiceB。创建ServiceB同样先实例化对象放进三级缓存然后填充属性发现需要ServiceA。此时去三级缓存查找ServiceA找到ObjectFactory调用工厂拿到早期引用instanceAAOP 场景下这里拿到的是代理对象注入给ServiceB。ServiceB创建完成放进一级缓存singletonObjects。回到步骤 3把ServiceB注入给ServiceA的serviceB属性。ServiceA创建完成放进一级缓存。这里的关键点在于第 5 步Spring 拿到的是早期裸对象或者代理对象这个对象的依赖还没有填充完成但已经可以注入给别的对象了。这就像把决定西装样式的设计图先发给别人看等成衣做完再送过去替换——而这在 Java 里是完全可行的因为引用传递的本质是“指针”只要指针指向正确早晚能访问到最终对象。为什么要三级缓存两级不够吗这是个非常经典的追问。关键在于 AOP 代理。如果 Spring 在实例化后立即进行 AOP 代理那问题就简单了早期对象直接就是代理对象二级缓存就够了。但现实是 Spring 要在 Bean 初始化完成后才创建 AOP 代理而其他 Bean 在依赖注入时需要的又必须是有能力生成代理的早期对象。三级缓存就是把“是否生成代理”这个决定延迟到了真正需要早期引用的那一刻由ObjectFactory来动态决定。正是这个延迟让“Bean 还没完全初始化但已经可以被引用”这件事成为了可能。如果你是小白这样理解三级缓存一级缓存是“成品货架”二级缓存是“半成品货架”三级缓存是“制作工单”。别人要买东西依赖这个 Bean时先看成品货架有没有没有就看半成品货架还没有就去制作工单里查这个订单是否已经在做了——如果正在做就先临时拿一个半成品或它的代理顶上避免重新再做一个最终成品完成后再补货上架。6. Autowired 的查找策略先类型、再限定、后名称Autowired是我们最常用的注入注解但它内部查找 Bean 的规则很多人并没有完全搞懂。我遇见过一个真实的生产事故一个接口有两个实现类Spring 启动时直接爆了NoUniqueBeanDefinitionException同事一脸懵“我明明把想要的实现类作为字段名了怎么还报错”这就需要了解Autowired的查找顺序了。Autowired默认的查找策略是byType按类型。具体流程是先按类型UserService在容器中查找。如果找到且只有一个直接注入。如果有多个尝试按Qualifier指定的名称过滤。如果没有Qualifier尝试按字段名/参数名过滤byName兜底。刚才那个事故的根源是接口有两个实现类按类型查找已经找到了两个候选此时 Spring 会尝试用字段名去匹配字段名是userService但两个实现类的 Bean 名称分别是userServiceImplA和userServiceImplB都没有匹配上于是报错。解决方案有两个方案一给某个实现类标注Primary表示它是主要候选人多选一时优先选它Service Primary public class UserServiceImplA implements UserService { // ... }方案二在注入点明确指定QualifierAutowired Qualifier(userServiceImplB) private UserService userService;这里要注意Qualifier的值默认是类名首字母小写也可以显式指定 Bean 名称。还有个隐性细节如果用了构造器注入且构造函数有多个参数Qualifier要放在参数上public OrderService(Qualifier(userServiceImplB) UserService userService) { this.userService userService; }7. Bean 的作用域和延迟加载单例之外的几种玩法Spring 中的 Bean 默认是**单例Singleton**作用域这意味着整个容器共享一个实例。但有些场景下你需要不同的作用域。常见的几种作用域singleton默认每个容器一个实例整个应用共享。prototype每次获取都创建一个新的实例。request每个 HTTP 请求一个实例仅 Web 应用。session每个 HTTP Session 一个实例仅 Web 应用。application每个 ServletContext 一个实例仅 Web 应用。request和session作用域在 Spring MVC 里比较常用。比如用户的购物车往往需要存在 session 里你会这么写Component Scope(value session, proxyMode ScopedProxyMode.TARGET_CLASS) public class ShoppingCart { // ... }注意那个proxyMode ScopedProxyMode.TARGET_CLASS它的作用是生成一个代理对象注入到依赖方。为什么需要代理因为单例 BeanOrderController在创建时需要注入ShoppingCart但ShoppingCart是 session 作用域——在容器启动时 session 还不存在呢代理对象作为替身注入真正调用方法时才去 session 里取真实实例。这个设计非常巧妙类似懒加载的思想。再说延迟加载。Spring 默认在启动时立即创建所有非懒加载的单例 Bean通过 preInstantiateSingletons 阶段所以一个大型 Spring Boot 项目启动慢很大一部分时间花在创建各种 Bean 上。如果你希望某些 Bean 在使用时才初始化可以加LazyComponent Lazy public class HeavyComponent { // 这个 Bean 的创建会延迟到第一次被使用时 }Lazy也可以加到注入点上表示注入一个代理对象真正调用时才去解析真实 BeanAutowired Lazy private HeavyComponent heavyComponent;但我要给个实操建议除非确实有性能痛点否则不要滥用Lazy。它会让你在启动时发现不了潜在问题比如 Bean 依赖错误、初始化失败等都是使用时才报排查成本更高。8. 手写一个微型 IoC 容器把核心原理落成代码很多时候觉得自己懂了一个概念但要把它写出来才发现一知半解。建议你尝试手写一个微型 IoC 容器不用复杂几十行代码即可。我在这里提供一份核心代码你照着试一遍对原理的理解会比盯一天源码更深刻。先说思路需要一个容器Map 存 Bean 名称到实例、一个扫描机制找到带特定注解的类、一个创建机制实例化并完成依赖注入、一个依赖解析机制根据类型或名称找到已存在的 Bean。首先是待注入的注解Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MyInject { }容器核心public class MyIocContainer { private MapString, Object beans new HashMap(); // 注册 Bean public void register(Class? clazz) throws Exception { String beanName toLowerFirst(clazz.getSimpleName()); Object instance clazz.getDeclaredConstructor().newInstance(); beans.put(beanName, instance); // 完成属性注入 injectFields(instance); } private void injectFields(Object instance) throws Exception { for (Field field : instance.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(MyInject.class)) { String fieldName field.getName(); if (beans.containsKey(fieldName)) { field.setAccessible(true); field.set(instance, beans.get(fieldName)); } } } } public T T getBean(String name) { return (T) beans.get(name); } private String toLowerFirst(String name) { return Character.toLowerCase(name.charAt(0)) name.substring(1); } }创建容器时先注册没有依赖的 Bean再注册有依赖的 Bean注入逻辑就能正常工作public class Main { public static void main(String[] args) throws Exception { MyIocContainer container new MyIocContainer(); container.register(UserService.class); container.register(OrderService.class); OrderService orderService container.getBean(orderService); orderService.doWork(); } }你这个微缩版容器虽然简陋但已经把 IoC 的精髓表达出来了对象不再自己 new 依赖而是容器创建好后按字段注入。把它和 Spring 源码对比你会发现 Spring 不过是把 Map 换成了更复杂的缓存体系、把反射创建换成了支持构造器选择的过程、把注解扫描换成了类路径扫描骨架是相通的。9. 常见问题与排查技巧NoSuchBeanDefinitionException 等高频报错排查这里汇总一些实际项目中高频出现的 Spring IoC/DI 相关报错和排查思路做成速查表方便你遇到问题时快速定位。报错信息出现原因排查思路NoSuchBeanDefinitionException容器里没有找到对应的 Bean检查类是否加了Component/Service等注解检查启动类包路径是否覆盖到了该类所在包检查是否被ConditionalOnXxx条件排除NoUniqueBeanDefinitionException按类型找到多个候选 Bean给目标类加Primary或在注入点加Qualifier指定具体名称BeanCurrentlyInCreationException构造器注入导致循环依赖调整依赖结构或用 Setter/字段注入替代构造器注入但这治标不治本应从代码设计层面消除循环依赖UnsatisfiedDependencyException依赖无法满足查看 Cause 定位具体是哪个依赖注入失败通常伴随其他异常信息BeanDefinitionOverrideException同名 Bean 被重复定义Spring Boot 2.1 默认禁止同名 Bean 覆盖检查是否有多个配置类定义了同一个同名 BeanAutowired field xxx is null对象不是由 Spring 管理的或者字段注入失败了检查对象是否通过new创建出来的如果是那样 Spring 管不到它检查类是否被代理后注入了别的字段排查这类问题的通用套路是三步走看启动日志里有没有异常上下文Spring 的报错机制很友好通常会指出“在上面的错误信息中由xxx的yyy属性导致”。打开断言配置在application.yml中设置spring.main.lazy-initializationfalse默认就已关闭懒加载确保启动时就能暴露问题。借助 IDE 的可视化工具IDEA 的 Spring 窗口能看到所有 Bean 的依赖关系图用它对定位复杂问题了如指掌。10. 与 Spring Boot 自动配置的衔接为什么你引入依赖就有 Bean在用 Spring 的时候你只需要在 pom 里加一个依赖项目启动后容器里就自动有了很多 Bean比如引入spring-boot-starter-web就有DispatcherServlet、RestControllerAdvice等。这些 Bean 是谁创建的答案就是 Spring Boot 的自动配置机制它的底层依然是我们上面讲的那些概念——ConfigurationBean 条件装配。Spring Boot 的SpringBootApplication注解由三个注解组合而成SpringBootConfiguration本质是Configuration表明这是配置类。EnableAutoConfiguration开启自动配置这是核心。ComponentScan扫描当前包及子包下的所有 Bean。EnableAutoConfiguration背后通过Import(AutoConfigurationImportSelector.class)加载一个全局配置文件里面列了所有自动配置类的全限定名。Spring Boot 启动时挨个尝试加载这些配置类但每个配置类里都有一堆ConditionalOnXxx条件注解比如ConditionalOnClass当前 classpath 下有某个类才生效、ConditionalOnMissingBean容器里还没有某个 Bean 才生效、ConditionalOnProperty配置了某个属性才生效。所以你在 pom 里加spring-boot-starter-data-redis后classpath 下出现了RedisTemplate相关的类RedisAutoConfiguration里的ConditionalOnClass条件就满足了RedisTemplate这个 Bean 就被自动创建。这就是为什么“引入依赖就在容器里有了 Bean”的根本原因。这个机制和手写 IoC 原理一脉相承容器负责 Bean 的创建和注入而“创建哪些 Bean”的决策由条件和配置共同决定。如果你能理解这一点就不会对 Spring Boot 的“魔法”感到神秘反而能看到它背后是建立在 IoC/DI 这套基础能力之上的。11. 实战经验分享用 Spring 这些年踩过不少 IoC/DI 相关的坑分享几条最实在的经验。第一能不用Autowired字段注入就不用。不是说它会出错而是它让类缺少“可见的依赖入口”。我接手过一个老项目一个 Service 里十几个Autowired字段还互相耦合想测试根本没门后来花了大力气重构才把依赖关系理清。新写的代码一律用构造器注入配合 Lombok 的RequiredArgsConstructor代码又少又清晰。第二不要为了“消除循环依赖”而消除循环依赖。循环依赖通常是代码设计出了问题——模块边界没切好、职责划分不清楚。与其用Lazy或者改成 Setter 注入硬解不如花时间调整结构。就我个人经验而言90% 的循环依赖都能通过提取公共模块、下沉数据处理层来从根源解决。第三有效利用ApplicationContext获取 Bean 时要克制。毕竟是绕过依赖注入直接向容器伸手要对象偶尔在极少数工具类中可用但一旦在业务代码里大量出现context.getBean()就要警惕了——说明你没有把依赖关系表达出来而是在运行时动态拼装。依赖注入的本质追求是让依赖关系显式化而不是隐藏起来。第四理解“容器启动就是一次完整的体检”。设置spring.main.allow-circular-referencesfalseSpring Boot 2.6 默认禁止循环依赖让容器在启动时把依赖校验做彻底。别为了临时跑起来而放开限制那样只是把问题藏在了运行期。把 IoC 和 DI 吃透不只是为了写代码顺畅更是一种设计思维的转变。真正的高手写代码时眼里看到的不是一个一个的类而是类与类之间的依赖关系网。这张网如何构建、如何解耦、如何测试才是 Spring 这套框架想教给你的核心心法。