真正开始用Spring框架之前,我一直有个疑问:Java生态里框架那么多,为什么偏偏是Spring活成了“事实标准”?后来自己写业务、带团队、帮忙做技术评审,踩过的坑多了才慢慢想明白:Spring真正厉害的地方,不是哪个注解多好用,而是它用一整套容器思想,把后端开发里最麻烦的对象管理、依赖关系、事务控制这些事,变成了可以统一处理的工程问题。这篇文章不打算从大而全的技术手册角度去罗列概念,而是沿着我自己的学习路径,把Spring框架的IoC容器、依赖注入、Bean生命周期、三级缓存、Spring Boot工程实践、Spring Cloud微服务选型,以及现在话题度很高的Spring AI一次性梳理清楚。不管你是刚入门的新手,还是已经写了一段时间但总觉得对Spring“隔层纱”的同学,这篇都适合你。
1. Spring框架到底在解决什么问题
很多初学者上来就学Spring Boot,结果写了几个RestController就以为自己会Spring了。其实这个认知很危险。Spring Boot只是Spring生态的一个入口,Spring框架最核心的东西,是一个叫IoC容器的设计。要理解Spring,首先得理解它出生的背景。
1.1 从手动New对象到控制反转
在Spring出现之前,Java后端主流的做法是EJB那套重量级方案,开发一个业务模块要写大量的接口实现、部署描述符,连一个简单的查询都可能要配置一堆东西。更让人头疼的是对象之间的依赖关系:你写的业务类要调用DAO,DAO要调用数据源,每加一层依赖,都要手动new一个对象,然后一层层传进去。这套做法的问题很明显:代码耦合度高、替换实现类很麻烦、单元测试根本没法写。
Spring做的事情,本质上就是把“对象的创建”和“对象的使用”拆开。你不需要自己new对象,而是告诉容器:我需要一个什么东西,容器负责把它创建出来,并在合适的时机自动塞给你。这就是控制反转,英文叫IoC,Inversion of Control。控制权从“程序自己”反转到了“容器手上”。而具体实现方式,就是依赖注入DI,Dependency Injection。
我经常用开餐厅来打比方。传统做法是你既要开餐厅,又要自己去种菜、养鱼、做调料;Spring的做法是你只管写菜单,需要什么食材时喊一声,中央厨房会按时把东西送过来,而且保证是你要的那个品质。这就是控制反转和依赖注入最朴素的逻辑:你只声明需求,别人负责供货。
1.2 用最小代码理解IoC与依赖注入
看一个最直观的例子。假设有一个OrderService,它需要调用UserService来查询用户信息。传统写法是这样的:
public class OrderService { private UserService userService; public OrderService() { // 自己创建依赖,耦合死了 this.userService = new UserService(); } }问题在于,如果以后UserService构造函数变了,比如需要传入数据库连接池,OrderService也要跟着改。更麻烦的是测试的时候,你想用一个模拟的假UserService,根本替换不进去。用Spring的依赖注入写法就变成了:
@Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { // 自己不new,让容器注入 this.userService = userService; } }加上@Service注解后,Spring容器会在启动时发现这个类,自动创建一个Bean,并找到UserService的实例注入进来。OrderService不再关心UserService是怎么创建的,它只关心“给我一个能用的UserService”。这就是依赖注入最直观的理解,也是Spring框架所有高级特性往下延伸的基石。
1.3 手写一个极简Spring容器
如果不亲手写一个几十行的迷你容器,你对IoC的理解永远停留在背诵层面。我当年搞明白Spring,就是从手写一个超简易版本开始的。思路只有三步:
- 扫描指定包下带有@Service之类注解的类;
- 用反射创建实例;
- 遍历字段,发现需要注入的依赖就递归创建并填入。
核心代码简化下来大概长这样:
public class SimpleContainer { private Map<String, Object> beans = new HashMap<>(); public void scan(String packageName) throws Exception { // 1. 扫描包下所有Class Set<Class<?>> classes = findAllClasses(packageName); // 2. 先实例化所有带@Service注解的类 for (Class<?> clazz : classes) { if (clazz.isAnnotationPresent(Service.class)) { String beanName = lowerFirst(clazz.getSimpleName()); beans.put(beanName, clazz.getDeclaredConstructor().newInstance()); } } // 3. 完成依赖注入 for (Object bean : beans.values()) { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { field.setAccessible(true); field.set(bean, beans.get(field.getName())); } } } } }这段代码虽然粗糙,但已经把Spring容器最核心的“扫描、创建、注入”三件事说清楚了。理解了这一步,再回头去看Spring的源码,你会发现它只是在同样的思路上加了Bean定义解析、生命周期管理、代理增强、条件装配等一堆工程化能力而已。
2. 从Spring到Spring Boot:框架落地的最佳姿势
Spring本身是个好框架,但早期用起来并不舒服。光配置一个XML就动不动几十行,还容易写错。Spring Boot出现以后,整个局面完全变了。它不是一个新框架,而是一套让Spring更好用的工程化方案。现在招聘市场上,几乎不会有人单独问“Spring怎么配置XML”了,大家聊的都是Spring Boot怎么用。
2.1 Spring Boot的两大核心机制
Spring Boot解决了Spring落地的两个痛点:依赖管理和自动配置。依赖管理上,它提供了一系列“起步依赖”,比如spring-boot-starter-web,你只要引入这一个,它就把Spring MVC、内置Tomcat、Jackson等Web开发需要的常用库按照兼容版本一起带进来。以前你需要自己操心Jar包版本冲突,现在Spring Boot帮你做了一次集中治理。
自动配置就更关键了。你引入spring-boot-starter-web之后,Spring Boot会自动判断当前项目里有没有相关的类,有就自动配置一个DispatcherServlet、自动注册消息转换器、自动配置默认的错误页面。你在application.yml里写一个端口号,它就知道该把服务器启动在哪个端口。这种“约定大于配置”的思路,极大降低了项目的上手成本。
但自动配置也带来一个常见问题:很多新人不知道某些行为是怎么来的。我建议你在项目里打开spring-boot-autoconfigure这个依赖,去看META-INF目录下的spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,里面列的就是所有自动配置类的清单。比如DataSourceAutoConfiguration、WebMvcAutoConfiguration都在里面。花半小时扫一眼,你对Spring Boot的认知会立刻从“黑盒”变成“灰盒”。
2.2 官方推荐的目录规范与分层思想
Spring Boot官方文档里给了一套推荐的项目结构,很多人不重视,等团队人多起来之后就开始痛苦。我强烈建议新项目一开始就规范好。典型的目录结构是:
com.example.project ├── Application.java // 启动类 ├── config/ // 配置类 ├── controller/ // 接口层 ├── service/ // 业务逻辑层 ├── repository/ // 数据访问层 ├── entity/ // 数据库实体 ├── dto/ // 数据传输对象 └── common/ // 通用工具、异常、返回结果这套分层的核心思想是依赖单向流动:controller依赖service,service依赖repository,实体只在同层和下一层之间传输。很多新人图省事,直接在controller里写SQL、在service里返回entity给前端,短期看着效率高,长期就是维护灾难。我在实际评审代码时,最常吐槽的就是entity被当dto用,结果数据库字段一改,接口文档全得跟着改。
目录规范没有绝对标准,小项目甚至可以简化成controller、service、dao三层。但有一点是底线:避免循环依赖。如果你发现A模块调用B模块,B模块又反过来调用A模块,那你的模块划分一定有问题,该重构就重构,别指望Spring的三级缓存帮你解决所有循环依赖(后面会细说这是怎么回事)。
2.3 从0到1搭建一个Web接口
为了让你对Spring Boot的“爽”有体感,我拿一个最简单的用户查询接口举例。首先创建一个项目,引入spring-boot-starter-web,启动类长这样:
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后写一个Controller:
@RestController @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public Result<UserVO> getUser(@PathVariable Long id) { return Result.success(userService.getUserById(id)); } }服务层加上@Service,数据访问层用MyBatis或Spring Data JPA,配置好数据源,一个接口就跑起来了。整个过程没有XML配置文件,没有额外装配Servlet,所有东西都是“按需自动搞定”。如果想把新接口暴露出去,只需要增加Controller方法即可,不需要重启基础设施。
但注意,Spring Boot本身不是银弹。它帮你省掉配置成本的同时,也容易让你忽略底层原理。比如Spring MVC的请求处理链路、过滤器与拦截器的执行顺序、Bean的作用域等,这些底层问题,Spring Boot并帮不了你。
3. 把Spring的命根子吃透:Bean生命周期与三级缓存
如果要挑一个Spring面试的高频深水区,“Bean生命周期”和“三级缓存”绝对排在前列。我刚工作那会儿,能背出Bean的生命周期,但完全不知道它和三级缓存的关系。后来手写Spring、调试循环依赖,才把这些概念真正串成一条线。
3.1 一个Bean从出生到销毁要经历什么
Spring里的对象不叫对象,叫Bean。每个Bean要经过一套完整的生命周期流程:
- 扫描Bean定义,Spring会读取类上的@Component、@Service、@Bean等信息,生成BeanDefinition;
- 实例化,通过反射调用构造函数,在内存中创建出对象;
- 属性填充,也就是依赖注入,Spring会去找当前Bean需要的依赖属性并填入;
- 初始化前,处理各种BeanPostProcessor的postProcessBeforeInitialization方法;
- 初始化,执行InitializingBean接口或者@PostConstruct、init-method;
- 初始化后,执行BeanPostProcessor的postProcessAfterInitialization,这一步常用来做AOP代理;
- 使用阶段;
- 销毁,执行@PreDestroy或DisposableBean接口。
为什么生命周期这么重要?因为Spring的很多高级功能都嵌在里面。比如AOP就是利用“初始化后”这一步,把原本的对象替换成一个代理对象;比如@Autowired依赖注入是在属性填充阶段完成的;比如事务管理,本质上也是通过后置处理器生成代理来增强方法。你如果不理解生命周期,光背注解,遇到“为什么我加的AOP没生效”这类问题就会抓瞎。
3.2 三级缓存到底是怎么回事
三级缓存是Spring为解决单例Bean循环依赖而设计的一套机制。所谓循环依赖,就是A依赖B,B又依赖A。在实例化A时,发现需要B,于是去创建B,而B又需要A。如果不做处理,这个创建过程会无限递归,最终堆栈溢出。
先看三个缓存的数据结构定义:
// 一级缓存:存放完全创建好的单例Bean Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); // 二级缓存:存放提前暴露的原始对象,但还没完成属性填充 Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); // 三级缓存:存放ObjectFactory,也就是可以生成早期引用的工厂 Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);执行流程大致是这样的:
- 创建A,实例化后把A的ObjectFactory放入三级缓存;
- 填充A的属性时发现需要B,于是去创建B;
- B实例化后,同样把自己的ObjectFactory放入三级缓存;
- B填充属性时发现需要A,这次在一级缓存没找到A,但发现三级缓存里有A的工厂,于是调用工厂获取A的早期引用,放入二级缓存;
- B顺利完成注入和初始化,成为完整Bean;
- A从二级缓存里拿到B并注入,随后也完成初始化;
- 两个Bean都进入一级缓存,循环依赖解决。
很多人在这一步会问:为什么要三级缓存而不是二级缓存?关键是第三级存的是ObjectFactory,而不是直接存对象。因为Spring需要处理“A被AOP增强”的情况。如果B在注入A时,A已经被增强成了代理对象,那B拿到的应该是代理;而真正的一级缓存里存的最终也可能是代理。三级缓存的意义就是让对象在实例化之后、初始化完成之前,能够按照工厂逻辑判断是否提前生成代理。用二级缓存直接存原始对象,就少了这一层动态决策的能力。
不过我必须提醒一句:Spring能解决循环依赖,不代表你可以随便写循环依赖。我见过太多项目里A服务依赖B服务,B服务又依赖A服务,最后通过@Lazy勉强跑起来。这种代码可读性极差,也不利于后续维护。Spring设计三级缓存更多是一种兜底策略,而不是鼓励大家制造循环依赖。
3.3 手写Spring时最容易踩的坑
我自己手写Spring框架的时候,栽得最深的一个坑就是循环依赖处理。刚开始我按“扫描-创建-注入”三步走,到了“注入”这一步发现无法处理A依赖B、B依赖A的情况。后来我去读了DefaultSingletonBeanRegistry的源码,才理解了“提前暴露”的精髓。
具体点是:在实例化完成但属性填充之前,先把当前对象的工厂放进缓存。对象虽然还没有完全准备好,但已经能对外提供引用了。这就像做蛋糕,蛋糕坯子已经烤好,虽然还没抹奶油,但你可以先把蛋糕坯子给要的人看,让对方确认就是这个东西。
还有一个坑是代理对象和原始对象混淆。如果A被AOP代理了,你在早期暴露时直接暴露的是原始对象,那B拿到的就是原始对象而不是代理,这会导致切面逻辑失效。所以Spring才需要工厂,工厂内部会判断当前对象是否需要代理,需要则返回代理,不需要则返回原始对象。理解了这一点,你才算真正理解了三级缓存的精髓。建议有兴趣的同学不要只看文章,去找个开源的手写Spring项目读一遍,或者自己动手写一个简单的容器,几天时间就能把这一块彻底吃透。
4. Spring生态进阶:Cloud、Security、AI一次讲个明白
Spring最强大的地方在于生态。围绕Spring框架,衍生出了Spring Boot、Spring Cloud、Spring Security、Spring Authorization Server、Spring AI等等一系列子项目。对于开发者来说,难点不在于单个框架怎么用,而在于怎么根据项目场景选择合适的组合。
4.1 Spring Cloud与微服务选型
微服务框架的争论已经持续了好多年,其中最常见的对比就是Dubbo和Spring Cloud。简单来说,Dubbo更偏重于高性能的RPC调用和服务治理,核心场景是服务与服务之间的远程调用;Spring Cloud则是一整套微服务解决方案,包含服务发现、配置中心、网关、熔断、链路追踪等一堆组件。
我个人的选型经验是:如果团队Java技术栈非常统一,项目又需要完整的微服务治理能力,优先考虑Spring Cloud Alibaba生态,因为它的Nacos既能做注册中心又能做配置中心,比单独维护Eureka加Config Server要省心得多。Dubbo则更适合那种明确以高性能RPC为主、团队对服务治理有较强掌控力的场景。
Spring Cloud最典型的核心组件有:
- Nacos或Eureka:服务注册与发现;
- Spring Cloud Gateway:统一网关,负责路由转发和过滤;
- OpenFeign:声明式HTTP客户端,让服务间调用像调用本地方法一样;
- Sentinel或Resilience4j:限流和熔断降级;
- Spring Cloud Config或Nacos Config:配置集中管理。
新手容易犯的错是一上来就追求微服务,一个几千行的项目也非要拆成十个服务。微服务本质上是解决复杂组织和流量问题的,不是为了拆而拆。项目早期,一个Spring Boot单体应用加一个合理的模块划分,绝对比十个微服务舒服得多。等流量和团队规模上来,再按照业务边界把模块拆成独立服务,这才是更务实的路线。
4.2 Spring Security与授权服务器该怎么选
安全框架几乎是企业项目的刚需。Spring Security是Spring生态里做认证授权的标准方案,从传统的Session登录到现代的JWT,再到OAuth2都能覆盖。它核心是一套过滤器链机制,只需要把自定义的过滤器插入到链中,就能实现各种登录校验逻辑。网上看到很多人用JWT做无状态认证,方案的核心就是写一个OncePerRequestFilter,解析请求头里的Token,校验通过后把用户信息放入SecurityContext。
如果你需要在多个应用之间做统一认证,或者要对接第三方授权,那就要考虑Spring Authorization Server。这是官方提供的OAuth2授权服务器实现,可以帮你搭建起授权码模式、客户端凭证模式等标准的认证流程。官方还提供了标准的建表SQL,直接初始化好client、scope、authorization相关的表结构,省了很多自己设计的功夫。
用Spring Authorization Server做自定义过滤器时需要注意执行顺序。比如想让自定义过滤器在用户名密码认证过滤器之后执行,就需要在SecurityFilterChain里用addFilterAfter这个方法,并指定对应过滤器的Class。JDK 21并不影响这套机制的逻辑,新的Java版本在语法和内存管理上更舒服,但Spring Security过滤器机制的设计依然稳定。
安全这块我特别想提醒的是:千万别觉得“接口里面手动判断一下登录状态”就等于安全方案。真正成熟的做法是依托Spring Security的过滤器链,把认证、鉴权、会话管理统一收敛,再通过注解或权限表达式控制接口访问。这样当安全策略变化时,你只用改一处配置,而不是满项目找逻辑。
4.3 Spring AI与LLM应用的新物种
最近一两年,AI应用开发火得不行,Spring生态也没有缺席。Spring AI是Spring社区推出的AI应用开发框架,它把OpenAI、通义千问、Ollama等各种模型接入抽象成了统一的接口,让开发者可以用一套代码切换不同的模型供应商。
Spring AI Alibaba是Spring AI在阿里云上的落地版本,主打和阿里云的通义千问模型、百炼平台深度集成。它有几个核心概念:ChatClient用来发起对话请求,PromptTemplate用来管理提示词,Tool Calling用来让模型调用外部工具。更关键的是,Spring AI Alibaba支持MCP协议,也就是模型上下文协议。MCP可以理解成AI世界的“USB接口”,只要模型和服务端都支持MCP,AI应用就能动态发现并调用外部提供的服务能力。
举个例子,你可以在AI应用里注册一个“查询天气”的MCP服务,然后让大模型在用户问天气时自动调用这个服务,再基于返回结果组织自然语言答案。Spring AI Alibaba会帮你去管理MCP服务的连接和工具注册逻辑,开发者只需要专注业务本身。
我看很多传统Java团队在犹豫要不要学Spring AI。我的建议是:先别急着推翻现有系统,而是把它当成一个增强组件。你可以用Spring Boot写一个简单的聊天助手,把内部的知识库文档切分后做向量化,再配合RAG模式让大模型回答问题时基于你们的资料。这个过程并不复杂,但它能让团队快速建立起对AI应用的体感,之后再考虑复杂的Agent编排。
5. 常见问题与排查技巧实录
这一节是我最想分享的,因为所有框架学习到最后,拼的都是“出问题时能不能快速定位”。我把自己和团队在实际开发中经常遇到的一些Spring相关问题整理了出来,希望对你有实际帮助。
5.1 启动失败与依赖注入报错
最典型的一个报错是No qualifying bean of type。意思是Spring容器里找不到需要的Bean。原因可能有几种:类没有加@Component等注解,或者扫描包路径不对,或者依赖是接口而容器里没有对应的实现类。
排查思路很固定:先去看启动类上的@SpringBootApplication生效的扫描范围,默认是启动类所在包及其子包。如果你的类放在包外,就必须用@ComponentScan显式指定。还有一个常见场景是同一个接口有两个实现类,Spring不知道注入哪一个。这时候需要用@Primary标记主实现,或者用@Qualifier指定名称,把选择说清楚。
另一个让我印象深刻的报错是BeanDefinitionOverrideException,意思是Bean定义被覆盖了。通常出现在两个配置类里定义了同名的@Bean方法。Spring Boot 2.1之后默认禁止Bean覆盖,目的是让配置更明确。遇到这个问题,去看是不是有组件重复扫描了,也可以检查是不是同一个配置类被import了多次。
5.2 AOP和事务为什么偶尔失效
说实话这些问题跟报错一个性质。我排查AOP不生效,第一步就是看调用的到底是不是代理对象。如果在同一个类内部,一个方法调另一个方法,就算调用的是有@Transactional注解的方法,事务也不会生效。因为被调用的this是原始对象,不是代理对象,切面逻辑和事务增强都绕过去了。
解决办法要么把内部调用拆到另一个Bean里,要么通过AopContext.currentProxy()获取当前代理。但最推荐还是第一种,保持类的职责清晰,让事务边界跟着独立的方法走。
还有一个容易被忽略的点是自调用导致的事务失效,在异步方法@Async上同样存在。我见过有人把同步方法和异步方法写在同一个类里,发现异步一直不生效,折腾半天才想起来spring是早期暴露对象,同类的自调用拿不到代理。这类问题,只要理解Spring容器里“方法增强靠代理”这个底层逻辑,排查速度会快很多。
5.3 性能排查与安全维护的常规动作
Spring Boot应用运行一段时间后,会遇到接口变慢、内存上涨等问题。这时候我习惯先打开spring-boot-starter-actuator的端点,用/actuator/health检查存活,用/actuator/metrics看JVM和HTTP指标,再用/actuator/loggers动态调整日志级别。Micrometer是Spring Boot里默认的指标门面,它把各种监控系统的数据格式统一掉,配合Prometheus和Grafana就能做出比较完整的监控看板。
对于日志级别的排查,我有一次是在线上环境临时把某个包的日志级别从info改成debug,然后用/actuator/loggers端点直接生效,不用重启服务。这种做法在定位“线上偶发问题”时非常救命。
安全维护方面,除了要关注Spring Security的配置,还要养成查看官方安全公告的习惯。历史上Spring Framework曾公布过需要关注的目录遍历相关漏洞,这类漏洞主要影响静态资源处理的场景。应对方式并不复杂,升级到官方修复版本,同时对外部输入的路径做校验,不要盲目信任用户传来的文件路径。生产环境里除了常规代码漏洞,第三方依赖的版本管理也不能松懈,建议定期扫描依赖清单,及时升级有漏洞的组件。
5.4 面试高频题与底层原理复盘
Spring的面试题虽然多,但底层往往指向几个核心点。IoC和DI的理解考的是设计思想;Bean生命周期考的是框架流程;三级缓存考的是循环依赖;自动配置考的是Spring Boot机制;AOP代理考的是动态代理和字节码增强;Spring事务传播行为考的是对数据库事务边界的理解。
这里我建议一个学习法:先把“手写Spring”这种项目做一遍,再去学Spring Cloud和Spring AI。道理很简单,手写一遍之后,你会真正理解Bean是从哪里来的、依赖是怎么进去的、代理是谁生成的,后续看任何Spring生态的框架,都会觉得地基是稳的。否则你学了一堆组件,遇到问题还是只能靠搜,搜到答案也不知道为什么。
另一个建议是动手做一个自己的技术文档库。把每一次报错、排查过程、解决方法记下来。这个习惯我坚持了好几年,现在很多问题我都不用重新翻StackOverflow,直接翻自己的笔记就能找到当时踩坑的上下文。这套档案,比任何面试题集都值钱。
结尾
Spring框架这个主题太大,一篇博文不可能把所有内容都覆盖到。我写这篇文章的初衷,是希望你能跳出“只会用Spring Boot”的舒适区,真正回到Spring的核心去理解IoC容器、Bean生命周期和三级缓存这些底层概念。我自己的体会是,越往上走,越会发现底层原理才是能复用的东西。框架可能会换代,前面几年大家都在聊XML,后来是注解,现在又多了Spring AI这种和LLM相关的方向,但Spring一直扎实站在“容器管理对象”这个地基上,只要地基稳,上面怎么盖楼都有底气。如果你看完这篇文章,也去动手写一个自己的迷你Spring容器,再回头读一遍源码,你一定会回来感谢当时愿意动手的自己。