Java工程师复健Spring IoC:所有Java开发的第一个面试题
一、Spring中new 去哪了日常敲代码的时候我们习惯了在一个类里打上Autowired或者Resource然后就理所当然地调用这个对象的方法。不知道你有没有停下来想过一个问题在原生的 Java 世界里想要一个对象唯一的合法途径就是new出来。那 Spring 里的new去哪了为什么它消灭了我们在业务代码里手动new对象的权利1.1 原生开发的问题假设你要写一个“Agent 调度引擎”核心是AgentService。它要能正常工作必须依赖两个组件负责调大模型的LlmClient以及负责提供上下文记忆的MemoryService。而这个MemoryService底层又必须连着一个向量数据库VectorDatabase来做持久化。如果不按 Spring 的套路我们回归最原始的 Java 写法在代码里手动new会发生什么令人绝望的初始化顺序你想在入口处创建一个AgentService代码写出来大概是这样的// 必须从最底层的依赖开始逆向 new 上去 VectorDatabase vectorDb new MilvusDatabase(localhost, 19530); MemoryService memoryService new MemoryService(vectorDb); LlmClient llmClient new OpenAiClient(sk-xxxx...); // 底层零件全备齐了最后才能拼装出最终的服务 AgentService agentService new AgentService(llmClient, memoryService);看起来似乎还能接受但这仅仅是 4 个类。真实的系统里一个核心 Service 背后可能牵扯几十上百个类的依赖网。只要最底层的VectorDatabase的构造函数加了一个鉴权参数整条调用链上所有手动new它的上层代码全部报错你必须顺藤摸瓜挨个修改而且完全是无意义的活儿同时你还要考虑到是当前IDE发展的很好在Spring还没出来的那个时候你这样改个代码怎么找全都是个问题。替换实现的崩溃老板今天看新闻说 DeepSeek 便宜又好用我们要支持国产让你把 OpenAI 换掉。这时候有兄弟可能会拍说了“这算什么灾难Java开发的基本功是面向接口编程。我在AgentService里写LlmClient llmClient new OpenAiClient();左边声明的是接口这不就解耦了吗”错这只是一种虚假的解耦。接口确实解决了“多态调用”的问题但它根本没有解决“对象创建权”的问题。只要你的代码里出现了new OpenAiClient()这个确切的类名你的AgentService就被这个具体实现死死绑架了。当你要换成DeepSeekClient时你依然得捏着鼻子打开AgentService.java的源码去修改那行new的代码。如果系统里有 50 个地方用到了大模型你就得改 50 个地方。真正优雅的解耦是AgentService压根不需要知道具体的实现类是谁只要别人给我一个符合标准的接口就行。运行时状态分裂这是很多新手最容易忽视也是实际工作中最致命的一点。假设MemoryService里缓存了一些当前会话的运行时状态。你在AgentService里手动new了一个MemoryService跑着跑着系统里的另一个模块ChatHistoryController也需要查阅记忆它也顺手new了一个MemoryService。想想看会发生什么这两个模块在内存里拿到的根本不是同一个对象。AgentService往自己那个实例里存的数据ChatHistoryController在它自己的实例里根本查不到。在 A 处修改了状态B 处浑然不知这种BUG 排查起来能让人怀疑人生。1.2 解决方案看完这一地鸡毛你会发现让业务类自己去管理对象的创建、依赖顺序和生命周期是一个极其繁琐且不符合效率的事情。开发者的精力应该集中到业务的实现而不是被这些琐碎的事情绊住我们需要一个 容器来帮我们管理这些东西。我们和这个容器达成一个协议以后代码里再也不许随便new了。所有的对象实例在系统启动的时候由容器按照配置统一创建好并且默认只创建一份单例解决上面的状态分裂问题存放在容器里面。谁需要用只要声明一下比如加上Autowired容器就会自动把组装好的成品按需塞给你。在这个机制下那些不再是散落在代码各处、被随意new出来的“野孩子”而是被统一收编、具有标准生命周期、被严格管控的对象我们就给它起个名字——Bean。现在我们搞清楚我们为什么要交出new的权利。明白了 Bean 存在的原因接下来我们就来看看Spring的容器是怎么一步步发展成今天这个样子的。二、Spring 的演进既然决定了要把new对象的权力交出去那接下来的问题就是IoC 容器怎么知道我想要什么对象又要怎么把它们组装起来为了回答这个问题Spring 走过了三个极其折腾但也极其顺理成章的阶段。这三个阶段的主线实际只有一个不断将那些重复性的工作交给框架解决把开发者的精力彻底释放到业务逻辑上。2.1 XML 时代权力的交接与伪需求的破灭最早的时候Spring 的思路很直白既然不想在 Java 代码里写死依赖那我们就在外面建一个统一的文件。早期的开发者需要写一份 XML 文件在里面用bean标签一行行地告诉容器给我造一个AgentService它的llmClient属性对应的是下面那个叫openAiClient的bean。当时这套方法有一个极具迷惑性的卖点“修改依赖不需要改 Java 代码改改 XML 就不需要重新编译了”技术上这句话是成立的改外部配置确实不需要重新编译 class。但在真实的工程实践中这其实是个伪需求。试想一下如果你要把底层数据库从 Oracle 换成 MySQL这种核心变更必然要经过完整的代码提交、CI/CD 构建和全量回归测试。既然无论如何都要重新打包走发布流程改 XML 和改 Java 代码有什么本质区别随着系统变大XML 动辄几千行找个依赖关系能在文件里翻瞎眼。为了消灭代码里的强耦合当年那批程序员掉进了另一个名为“配置地狱”的坑。于是Spring进入了下一个阶段。2.2 注解时代元数据与反射的天作之和痛定思痛Spring 发现既然是我自己写的类干嘛非得到 XML 里再抄一遍名字并且这个东西鬼一样的长比如一个简单的类的全限定类名长这样bean classcom.yourcompany.ai.agent.core.service.impl.AgentServiceImpl这东西不仅写起来恶心更致命的是一旦你在 IDE 里重构代码、挪动了包的位置如果不小心漏改了 XML编译期完全不报错系统一启动直接崩溃。很巧啊很巧Java 5 引入了一个划时代的语言级新特性注解Annotation。这里要澄清一个误区注解并不是 Spring 发明的魔法其他语言也有类似概念比如 C# 的 Attribute 或 Python 的装饰器。Java 官方最初加这个特性可不是专门为了给 Spring 用的。在那之前如果你想给一段代码附加一些说明信息也就是元数据 Metadata比如告诉框架这个方法是个测试用例或者这个方法需要开启事务你只能靠写外部 XML或者极其别扭的命名约定比如 JUnit 3 规定测试方法必须以test开头。Java 引入注解本质上是为了让代码具备自我描述的能力。它允许你直接把标签贴在源码上让数据和代码物理绑定在一起。Spring 敏锐地抓住了这个特性。它是如何应用的你在AgentServiceImpl类头上贴个Service在属性上贴个Autowired。注意注解本身没有任何执行能力它真的只是一张静静贴在那里的便利贴。但这张便利贴完美解决了 XML 的包名痛点 Spring 容器启动时会去扫描你指定的包目录。当它看到类头上贴着Service时它不仅标记了这个类需要被管理更重要的是因为这个注解是直接贴在类文件上的Spring 可以直接通过底层 API 获取到这个类的完整包名和结构 拿到包名后底层的反射机制Reflection就可以直接在内存里把这个类动态实例化出来并放入容器的缓存里。你再也不用手写那串长长的com.yourcompany...了。包名随便改类随便挪只要注解还在容器就能利用反射就能把它造出来。代码与配置真正合二为一开发效率直接起飞。2.3 Spring Boot 时代约定优于配置发展到这程序员老哥们还觉得不够简单每次搭新项目你依然要手动配一大堆基础设施扫哪些包、配事务管理器、写各种样板式的配置类。这时候Spring Boot 横空出世祭出了约定优于配置的杀手锏。拿数据库连接来说在以前你引入了 MySQL 驱动还得在代码里一步一步地new一个DataSource配置连接池再配置JdbcTemplate。而在 Spring Boot 中你只要在pom.xml里引入了数据库相关的 starter 和驱动包。容器在启动时一看“哎你引入了 MySQL 驱动那我就默认你肯定需要连接池我直接在底层默默帮你把HikariDataSource和JdbcTemplate这些 Bean 全造好扔进容器里。”你只需要在配置文件里填个账号密码就能直接用。从 XML 的笨重到注解的灵活再到 Boot 的全自动装配Spring 所有的演进都只为了一件事让那些和核心业务无关的技术底座对象的创建、依赖、环境装配彻底隐形。只有把开发者的心智负担降到最低我们才能把全部精力倾注到真正的业务逻辑上。三、IoC 源码梳理在正式开始梳理源码之前三水儿想先讲两句看源码的心法。很多人学 Spring 源码一上来就去死记硬背createBeanInstance、populateBean这些长串的英文名过两个月忘得一干二净。为什么因为你是从结果倒推。我觉得看源码应该是“带着痛点找解法”假设现在框架由你来写你会怎么做遇到了什么死局你是怎么打补丁的今天我们就假装自己是 Spring 的早期开发者试着从零开始一行行把刚才那个AgentService依赖MemoryService给装配出来。3.1 V1.0 时代不就是一个大 Map 吗要接管所有的对象通过一个key获取到它不就是建一个全局的缓存吗系统启动时我扫一遍代码只要看到Service我就用反射把它new出来然后丢进一个 Map 里。以后谁要用直接从 Map 里拿。// V1.0 极简版容器 private MapString, Object singletonObjects new ConcurrentHashMap(); public Object getBean(String beanName) { if (!singletonObjects.containsKey(beanName)) { // 利用反射 new 一个对象 Object bean Class.forName(className).newInstance(); singletonObjects.put(beanName, bean); } return singletonObjects.get(beanName); }这就是 Spring 源码里一级缓存singletonObjects单例池的雏形。看起来是不超简单别急继续往下看。3.2 V2.0 时代递归组装与populateBean的诞生V1.0 存在的最大的 BUG 在于它造出来的是个没用的对象。对象有属性、有依赖不注入就全是 null一调用就 NPE。例如当容器去new AgentService()时它发现里面有个标注了Autowired的MemoryService字段。如果你只是简单地new这个字段永远是null一调程序就报空指针。于是我们又给getBean方法加了一道核心工序属性填充populateBean。// V2.0 增加了依赖注入的代码 public Object getBean(String beanName) { // 1. 实例化造空壳 Object bean instantiateBean(beanName); // 2. 属性填充塞依赖 populateBean(beanName, bean); // 3. 放入单例池 singletonObjects.put(beanName, bean); return bean; } private void populateBean(String beanName, Object bean) { // 发现 AgentService 需要 MemoryService // 转身再去调 getBean 去容器里要 MemoryService Object dependency getBean(memoryService); // 拿到了反射塞进去 field.set(bean, dependency); }仔细看populateBean里的那句getBean(memoryService)这其实是一个完美的递归调用。A 缺 B就暂停造 A 去造 BB 造好了塞给 AA 继续造。到这里Spring 已经能处理绝大多数的普通业务逻辑了。3.3 V3.0 时代循环依赖随着业务越来越复杂又一个bug诞生了。假设现在MemoryService在更新完记忆后需要调用AgentService的状态上报方法。也就是说MemoryService里也Autowired了一个AgentService。我们拿着 V2.0 的代码走一遍容器调用getBean(agentService)造出 A 的空壳。A 开始populateBean发现缺 B调用getBean(memoryService)。容器造出 B 的空壳。B 开始populateBean发现缺 A调用getBean(agentService)。A 还没造完还没放进单例池 Map 里于是容器又去造 A 的空壳…乓击嘎巴碎StackOverflowError。我们发现了大名鼎鼎的循环依赖。3.4 V4.0 时代引入半成品缓存怎么打破这个死循环Spring 的开发者展现出了极高的工程智慧既然死锁是因为大家都在等对方变成“完全体”那我就先把“半成品”交出去这就好比我们在造两台互相依赖的机器。机器 A 需要连接机器 B 的信号机器 B 也需要接入机器 A 的底盘。 如果按死理A 必须等 B 完全造好才能动工B 也必须等 A 完全造好那就永远卡死了。Spring 的解法是别死等我先把机器 A 的“空铁壳子”仅仅是在 JVM 堆里分配了内存地址但里面的属性全是 null推过去你先把这个空壳子用螺丝固定在机器 B 上B 完成了对 A 早期引用的注入。等你机器 B 全部组装好、变成完全体之后我再回过头来把机器 A 内部需要的零件其他属性一个个塞进刚才那个空铁壳子里。注意这里有一个 Java 原生机制引用传递。因为 Java 传递的是对象的内存地址所以 B 当初拿走的那个空壳子和我后来填满零件的壳子物理上是同一个东西只要我这边把 A 的属性一填完B 肚子里那个原本空荡荡的 A瞬间就变成了拥有全部属性的完全体。为了实现这个操作Spring 在一级缓存之外又加了一个 Map二级缓存earlySingletonObjects专门存早期半成品的引用。我们来看一下一个简单的示例代码// 一级缓存存放完全体 Bean日常 getBean 都是从这里拿 private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 二级缓存存放早期暴露的半成品 Bean空铁壳子专门用来破死锁 private final MapString, Object earlySingletonObjects new HashMap(16);接着我们来到 Bean 诞生的核心流水线doCreateBean方法protected Object doCreateBean(String beanName) { // 1. 实例化反射造出一个空铁壳子 Object bean createBeanInstance(beanName); // 【破局的灵魂一步】提前暴露 // 刚 new 出来啥属性都没填直接强行塞进二级缓存 // 相当于向全系统大喊我的内存地址已经在这了谁急用谁先拿去占个位 earlySingletonObjects.put(beanName, bean); // 2. 属性填充开始依赖注入这里会触发去拿 MemoryService从而引发递归 populateBean(beanName, bean); // 3. 初始化精加工执行 PostConstruct 等 bean initializeBean(beanName, bean); // 4. 收尾成为完全体后从二级缓存移除正式放入一级缓存 earlySingletonObjects.remove(beanName); singletonObjects.put(beanName, bean); return bean; }最后配合上寻找 Bean 的getSingleton方法循环依赖被彻底打通public Object getSingleton(String beanName) { // 先去一级缓存找完全体 Object singletonObject this.singletonObjects.get(beanName); // 如果一级没有且发现这个 Bean 正在别的地方创建中说明发生循环依赖了 if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 【救命稻草】去二级缓存拿半成品空铁壳子 singletonObject this.earlySingletonObjects.get(beanName); } return singletonObject; }现在整个Bean的初始化流程已经基本确立了实例化Instantiation造出AgentService的空铁壳子后立刻把它扔进二级缓存属性填充populateBeanA 发现缺 B去造 B。B 发现缺 A去缓存里找。一级缓存没有。二级缓存找到了 A 的空铁壳子B 毫不犹豫地拿着 A 的空壳引用塞进自己的肚子里。B 填充完毕成为完全体扔进一级缓存。回到 A 的流程A 从一级缓存里拿到了完全体的 B塞进自己肚子里。A 也成为完全体从二级缓存移出正式放入一级缓存。重要边界面试必问这套提前暴露机制默认只解决单例 setter/字段注入构造器注入循环依赖对象都没实例化出来没法提前暴露prototype 循环依赖默认也不支持通常直接报错3.5 三级缓存为 AOP 留的后门与下一场的伏笔到这里依赖注入其实已经彻底跑通了。但肯定有兄弟要掀桌子了“不对啊我背的八股文里明明说 Spring 是三级缓存啊怎么被你吃了一级”放心没少。这第三级缓存是为了解决另一个问题才被迫引入的AOP 代理导致的“早期引用一致性”。回到刚才的流水线Spring 在把对象正式扔进一级缓存之前其实还偷偷加了最后一道工序initializeBean()初始化。为什么要加这步因为我们需要对 Bean 进行再加工比如执行带有PostConstruct注解的方法。 但更致命的是Spring 在这里埋下了一个极其深远的伏笔如果你的AgentService头上加了Transactional事务或者其他切面注解Spring 就会在这个最后关头把你原本老老实实造出来的原始 Bean 给扣下利用 CGLIB 凭空捏造一个代理对象最终扔进一级缓存的其实是这个替身问题来了如果最终给出去的是替身那前面二级缓存里提前暴露出去的“空铁壳子”是不是就给错了别人肚子里装的是原始对象单例池里却是代理对象——单例语义被破坏。所以需要三级缓存它不是再放一份对象而是放“生成早期引用的能力”通常表现为 ObjectFactory 之类。当发生循环依赖又可能需要代理时容器可以通过这层能力拿到“正确的早期引用”可能已被增强避免出现“早期注入原始对象最终对外是代理对象”的不一致。这个细节留到下一篇 AOP 专场再细盘——但你现在至少知道三级缓存不是为了凑数是被 AOP 逼出来的。回过头来看看这条演进之路你再去看源码里那些又臭又长的方法名是不是不再觉得像天书了反而像是在看一部系统架构的进化史 所有的复杂底层设计从来不是哪位大神拍脑袋想出来炫技的全都是被真实的工程痛点一步步改进出来的。同样那些曾经让我们痛苦不堪、只能死记硬背的面试题其实也是从这些痛点里演化出来的。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2427508.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!