Spring 全栈硬核教程 · 01:Spring 核心(IoC / AOP / 事务 / 事件)
这一篇是整套教程的"物理定律"。后面所有篇章——Boot 的自动配置、Web 的请求处理、Security 的过滤器链、Cloud 的服务治理——底层全都跑在这一层的机制上。跳过这篇,后面所有内容对你来说都是黑魔法。
1. IoC:把"谁创建对象"这件事从你手里抢走
1.1 为什么需要 IoC(不要背定义,看这个例子)
没有 IoC 的世界长这样:
public class OrderService {
// 每个依赖都自己 new,依赖的依赖也要自己 new
private UserService userService = new UserService(new UserDao(new DataSource(...)));
private PaymentService paymentService = new PaymentService(new AlipayClient("appId", "key"));
}问题不在"麻烦",在于三件事:① 换实现要改源码(想把 AlipayClient 换成 WechatPayClient?改代码、重新编译);② 没法单元测试(new 死的依赖没法 mock);③ 对象生命周期无人管理(谁负责关闭连接池?谁保证单例?)。
IoC(控制反转)的解法是:你只声明"我需要什么",由容器负责"怎么给你"。
@Service
public class OrderService {
private final UserService userService;
private final PaymentService paymentService;
// 构造器注入:我只声明需求,不关心怎么来的
public OrderService(UserService userService, PaymentService paymentService) {
this.userService = userService;
this.paymentService = paymentService;
}
}给 Go 开发者的类比:这本质上是把 Go 里"手写
NewOrderService(userSvc, paySvc)并在 main 里层层组装"这件事,交给了一个自动化的容器去做。代价是"魔法感"增强、排查链路变长,收益是大型项目里的依赖组装不再是手工活。这个权衡是 Java 和 Go 生态哲学差异的核心体现之一。
1.2 Bean 的完整生命周期(⭐ 必须能徒手画出来)
① 实例化 Instantiation
反射调用构造器,得到一个"属性全是 null 的半成品"
↓
② 属性填充 Populate
依赖注入发生在这里,@Autowired 字段/setter 被赋值
(循环依赖的问题就出在这一步,见 1.3)
↓
③ Aware 接口回调
BeanNameAware / BeanFactoryAware / ApplicationContextAware
作用:让 Bean 能感知到容器本身(框架代码常用,业务代码少用)
↓
④ BeanPostProcessor.postProcessBeforeInitialization
↓
⑤ 初始化 Initialization
@PostConstruct → InitializingBean.afterPropertiesSet() → 自定义 init-method
(三者按此顺序执行,用来做"依赖都就绪后的初始化工作",比如预热缓存)
↓
⑥ BeanPostProcessor.postProcessAfterInitialization
⭐⭐⭐ AOP 代理在这里生成!这是整个 Spring 最重要的一个时间点
(AbstractAutoProxyCreator 就是一个 BeanPostProcessor)
↓
⑦ Bean 就绪,放入单例池,可被使用
↓
⑧ 销毁
@PreDestroy → DisposableBean.destroy() → 自定义 destroy-method为什么第⑥步是整个 Spring 最重要的时间点? 因为它解释了 Spring 里一大半的"诡异现象":
容器里真正存着的、注入给别人的,不是你写的那个类的实例,而是一个包着它的代理对象。
@Transactional、@Async、@Cacheable、@Retryable这些注解全都是靠这层代理外壳实现的。
由此直接推出那个经典问题的答案:
❓ 为什么 @Transactional 在同类方法内部调用时会失效?
@Service
public class OrderService {
public void createOrder(Order order) {
this.saveOrder(order); // ❌ 走的是 this(原始对象),绕过了代理外壳,事务不生效!
}
@Transactional
public void saveOrder(Order order) { ... }
}调用链是 代理对象.createOrder() → 进入代理 → 转发给原始对象的 createOrder() → 内部 this.saveOrder() 直接在原始对象上执行,代理外壳完全没参与,@Transactional 自然形同虚设。
三种解法(按推荐度排序):
// 方案1(最推荐):拆到不同的类,让调用真正跨越代理边界
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderTransactionalService txService; // 注入的是代理对象 ✅
public void createOrder(Order order) { txService.saveOrder(order); }
}
// 方案2:自我注入(Spring 4.3+ 支持,但可读性一般)
@Service
public class OrderService {
@Lazy @Autowired private OrderService self;
public void createOrder(Order order) { self.saveOrder(order); }
}
// 方案3:AopContext(需要开启 @EnableAspectJAutoProxy(exposeProxy = true),侵入性最强)
public void createOrder(Order order) {
((OrderService) AopContext.currentProxy()).saveOrder(order);
}1.3 循环依赖与三级缓存(源码级,面试终极题)
问题:A 依赖 B,B 又依赖 A,容器创建 A 时要先创建 B,创建 B 时又要先创建 A……死循环怎么破?
Spring 的解法:三级缓存 + "提前暴露半成品"。
| 缓存 | 字段名 | 存什么 |
|---|---|---|
| 一级 | singletonObjects | 完全创建好的成品 Bean |
| 二级 | earlySingletonObjects | 提前暴露的半成品(已实例化,未完成属性填充) |
| 三级 | singletonFactories | ObjectFactory 工厂,按需生成早期引用 |
完整流程:
创建 A:
1. 实例化 A(半成品,属性全 null)
2. 【关键】立刻把 A 的 ObjectFactory 放进三级缓存
3. 开始填充 A 的属性 → 发现需要 B
↓
创建 B:
4. 实例化 B
5. B 的 ObjectFactory 放进三级缓存
6. 填充 B 的属性 → 发现需要 A
↓
7. 去缓存里找 A:一级没有 → 二级没有 → 三级命中!
8. 调用 ObjectFactory.getObject() 拿到 A 的早期引用
⭐ 如果 A 需要 AOP 代理,代理就在这一刻提前生成
9. 把结果放进二级缓存,并从三级缓存删除
(因为"到底给原始对象还是代理对象"已经定了,不用再算第二次)
10. B 拿到 A 的早期引用,B 创建完成,进入一级缓存
↓
11. A 继续填充完属性,初始化,进入一级缓存❓ 为什么非要三级?两级不够吗?
这是区分"背过答案"和"真懂"的分水岭。假设只有两级(直接在实例化后把半成品放二级缓存):
- 如果 A 不需要 AOP:两级完全够用。
- 如果 A 需要 AOP:B 拿到的是原始对象,而 A 最终初始化完成后放进一级缓存的是代理对象——于是 B 里持有的 A 和容器里的 A 是两个不同的对象,B 调用 A 的方法时事务/缓存注解全部失效,而且这个 bug 极其隐蔽。
三级缓存的 ObjectFactory 通过"延迟到真正需要时才决定给原始对象还是代理对象"解决了这个一致性问题。
❓ 构造器注入的循环依赖为什么解决不了?
因为三级缓存的前提是"先实例化,再填充属性"——半成品必须先存在才能被提前暴露。而构造器注入要求"依赖必须在实例化那一刻就准备好",根本没有"半成品"这个中间状态可以暴露,所以 Spring 直接抛 BeanCurrentlyInCreationException。
这其实是好事:构造器注入让循环依赖在启动期就暴露,而不是埋在运行时。循环依赖通常意味着职责划分有问题,倒逼你重构才是正解,而不是庆幸 Spring 帮你兜住了。
1.4 依赖注入方式对比:一张表定胜负
| 方式 | 能否 final | 能否脱离容器测试 | 循环依赖表现 | 官方态度 |
|---|---|---|---|---|
字段注入 @Autowired private X x; | ❌ | ❌ 必须起容器或用反射 | 悄悄允许,掩盖设计问题 | ❌ 不推荐 |
| Setter 注入 | ❌ | ✅ | 允许 | 用于可选依赖 |
| 构造器注入 | ✅ | ✅ 直接 new 即可 | 启动期报错,暴露问题 | ✅ 推荐 |
实战写法(Lombok 一行搞定):
@Service
@RequiredArgsConstructor // 为所有 final 字段生成构造器
public class OrderService {
private final UserService userService;
private final PaymentService paymentService;
// 不用手写构造器,同时享受构造器注入的全部好处
}老司机盲区提醒:很多人知道"该用构造器注入",但不知道为什么。真正的理由不是"官方说的",而是——构造器参数列表是一个类依赖数量的可视化仪表盘。当你发现某个类的构造器有 12 个参数时,那个类已经烂了,构造器注入让这件事藏不住;字段注入则让你可以无限制地往类里塞
@Autowired,直到它变成一个三千行的上帝类还没人察觉。
1.5 Bean 作用域与常见陷阱
| Scope | 说明 | 典型陷阱 |
|---|---|---|
singleton(默认) | 容器内唯一实例 | 单例 Bean 里放可变成员变量 = 线程安全事故,这是新人最常见的生产事故来源 |
prototype | 每次获取都新建 | 容器不管理销毁,@PreDestroy 不会被调用,资源要自己释放 |
request / session | Web 环境,每请求/每会话一个 | 注入到单例 Bean 时需要代理(proxyMode),否则拿到的永远是第一次那个 |
application / websocket | ServletContext / WebSocket 会话级 | 使用场景较少 |
// ❌ 生产事故写法
@Service
public class CounterService {
private int count = 0; // 单例 + 可变状态 = 并发下数据错乱
public void increment() { count++; }
}
// ✅ 正确做法:无状态,或用线程安全容器
@Service
public class CounterService {
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
}2. AOP:把横切关注点从业务代码里剥离
2.1 核心概念速通
| 术语 | 人话解释 |
|---|---|
| 切面 Aspect | 一个"横切逻辑"的封装单元(比如"日志切面") |
| 连接点 JoinPoint | 可以被织入的点(Spring AOP 里只支持方法执行) |
| 切点 Pointcut | 用表达式圈定"哪些连接点要被织入" |
| 通知 Advice | 织入的具体逻辑(前置/后置/环绕/异常/最终) |
| 织入 Weaving | 把切面应用到目标对象的过程(Spring 在运行时通过动态代理完成) |
2.2 实战:一个接口耗时统计切面
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
// 切点:所有 controller 包下的 public 方法
@Pointcut("execution(public * com.example..controller..*.*(..))")
public void controllerMethods() {}
@Around("controllerMethods()")
public Object measure(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
String method = pjp.getSignature().toShortString();
try {
return pjp.proceed();
} finally {
long costMs = (System.nanoTime() - start) / 1_000_000;
if (costMs > 500) {
log.warn("慢接口告警 {} 耗时 {}ms", method, costMs);
}
}
}
}2.3 JDK 动态代理 vs CGLIB:硬核对比
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 实现原理 | 运行时生成实现了目标接口的代理类 | 运行时生成目标类的子类(字节码增强) |
| 前提条件 | 目标类必须实现接口 | 目标类不能是 final,方法不能是 final/private |
| 性能 | 创建快,调用略慢(反射) | 创建慢,调用快(Spring 4+ 后差距已很小) |
| Spring Boot 默认 | —— | ✅ 默认强制用 CGLIB(proxyTargetClass=true) |
为什么 Spring Boot 默认选 CGLIB? 因为 JDK 代理有个反直觉的坑:如果 OrderServiceImpl implements OrderService,JDK 代理生成的对象只能转型成 OrderService 接口,不能转成 OrderServiceImpl。当有人写 @Autowired private OrderServiceImpl impl; 时就会启动失败报"类型不匹配"。CGLIB 生成的是子类,没这个问题,所以 Boot 干脆全局默认 CGLIB 省心。
老司机盲区:CGLIB 代理的构造器会被调用两次(一次是父类即目标类的、一次是代理子类的),而且代理子类的字段是空的(它只转发方法调用,不复制字段值)。所以如果你在切面里通过反射直接读代理对象的字段,读到的是 null——必须通过
getter方法读。这个坑每年都有人踩。
2.4 Spring AOP vs AspectJ
| 维度 | Spring AOP | AspectJ |
|---|---|---|
| 织入时机 | 运行时(动态代理) | 编译期 / 类加载期(字节码织入) |
| 能切什么 | 只能切 Spring 管理的 Bean 的 public 方法 | 任意类、任意方法,甚至字段访问、构造器 |
| 性能 | 有代理调用开销 | 几乎零运行时开销 |
| 复杂度 | 开箱即用 | 需要编译插件/Agent,配置复杂 |
| 何时用 | 99% 的业务场景 | 需要切非 Spring 对象、切 private 方法、或对性能极度敏感 |
3. 事务:最容易出事故的一块
3.1 事务传播行为七种模式(⭐ 配决策表)
| 传播行为 | 有事务时 | 无事务时 | 典型场景 |
|---|---|---|---|
REQUIRED(默认) | 加入当前事务 | 新建事务 | 绝大多数业务方法 |
REQUIRES_NEW | 挂起当前,新建独立事务 | 新建事务 | 记录操作日志(主业务失败也要留下日志) |
SUPPORTS | 加入 | 非事务执行 | 只读查询 |
NOT_SUPPORTED | 挂起当前,非事务执行 | 非事务执行 | 耗时的报表查询,不想占着事务连接 |
MANDATORY | 加入 | 抛异常 | 强制要求调用方必须开事务的内部方法 |
NEVER | 抛异常 | 非事务执行 | 明确禁止在事务中调用的方法 |
NESTED | 创建保存点(嵌套事务) | 等同 REQUIRED | 部分回滚:子操作失败只回滚子操作 |
REQUIRES_NEW 的生产陷阱(很多人不知道):它会挂起外层事务、从连接池再拿一个连接。在高并发下,如果一个请求链路上有多层 REQUIRES_NEW,单个请求可能同时占用 3-4 个数据库连接,连接池很容易被打满并引发自我死锁(外层事务持有连接等待内层,内层在等待池里没有的连接)。
实战建议:
REQUIRES_NEW只在真正需要"独立提交"的场景用(日志、审计),且务必评估连接池容量。
NESTED vs REQUIRES_NEW 的关键区别:NESTED 是同一个物理事务里的保存点,外层回滚会连带内层一起回滚;REQUIRES_NEW 是两个完全独立的物理事务,互不影响。
3.2 @Transactional 失效的 7 种场景(打印贴墙上)
| # | 场景 | 根因 |
|---|---|---|
| 1 | 同类内部方法调用 | 走 this,绕过代理(见 1.2) |
| 2 | 方法非 public | Spring AOP 只代理 public 方法 |
| 3 | 异常被 catch 吞掉 | 没异常抛到代理层,代理不知道要回滚 |
| 4 | 抛受检异常但没配 rollbackFor | 默认只对 RuntimeException/Error 回滚 |
| 5 | 类没被 Spring 管理(手动 new) | 压根不在容器里,没有代理 |
| 6 | 数据库引擎不支持事务 | MySQL 的 MyISAM 引擎 |
| 7 | 多线程调用 | 事务靠 ThreadLocal 绑定连接,跨线程后不是同一个连接 |
第 3 条的正确写法:
@Transactional(rollbackFor = Exception.class)
public void transfer(Long from, Long to, BigDecimal amount) {
try {
accountService.debit(from, amount);
accountService.credit(to, amount);
} catch (Exception e) {
log.error("转账失败", e);
// ❌ 只 catch 不抛 → 事务正常提交,钱丢了
// ✅ 必须重新抛出,或手动标记回滚:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw new BusinessException("转账失败", e);
}
}3.3 事务隔离级别与并发问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED | ✅会 | ✅会 | ✅会 | 几乎不用 |
| READ_COMMITTED | ❌ | ✅会 | ✅会 | Oracle/PostgreSQL 默认 |
| REPEATABLE_READ | ❌ | ❌ | ⚠️ MySQL InnoDB 通过 MVCC+间隙锁基本解决 | MySQL 默认 |
| SERIALIZABLE | ❌ | ❌ | ❌ | 串行化,性能代价极大 |
实用建议:99% 的业务用数据库默认隔离级别即可(
Isolation.DEFAULT)。真正需要强一致的场景(库存扣减、余额变更),靠乐观锁(版本号)或悲观锁(SELECT ... FOR UPDATE) 解决,而不是调高隔离级别——调高隔离级别的性能代价往往超出预期。
4. 常被忽略但很好用的核心能力
4.1 事件机制:优雅的应用内解耦
// 1. 定义事件
public record OrderCreatedEvent(Long orderId, Long userId, BigDecimal amount) {}
// 2. 发布
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
@Transactional
public void createOrder(CreateOrderCmd cmd) {
Order order = orderRepository.save(...);
publisher.publishEvent(new OrderCreatedEvent(order.getId(), cmd.userId(), cmd.amount()));
}
}
// 3. 监听(可以有任意多个监听器,互不感知)
@Component
public class OrderEventListener {
// ⭐ @TransactionalEventListener:等事务真正提交后才执行
// 避免"发短信说下单成功了,结果事务回滚了"的经典事故
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void onOrderCreated(OrderCreatedEvent event) {
smsService.send(event.userId(), "下单成功");
}
}为什么这个特性被低估了:它让你在不引入消息队列的前提下实现应用内的发布订阅解耦。很多中小项目为了"解耦"直接上 Kafka/RabbitMQ,运维复杂度陡增,其实
ApplicationEvent就够了。当然它是进程内的、不持久化的——跨服务或需要可靠投递时才该上 MQ。
4.2 Spring Framework 7 新增的弹性注解
Spring Framework 7 把过去要引 spring-retry 才能做的事收进了核心:
@Service
public class ExternalApiService {
// 自动重试:3次,指数退避,带抖动
@Retryable(maxAttempts = 3, delay = 1000, multiplier = 2, jitter = 200)
public String callThirdParty() { ... }
// 并发限流:同时最多 10 个线程能进这个方法,防止打垮下游
@ConcurrencyLimit(10)
public void heavyOperation() { ... }
}这两个注解对做"防御性编程"很实用——过去要么自己写切面,要么引第三方库,现在是框架原生能力。
4.3 SpEL:配置文件里的表达式语言
@Value("#{systemProperties['user.region'] ?: 'cn-north'}")
private String region;
@Cacheable(value = "users", key = "#userId + ':' + #type")
public User getUser(Long userId, String type) { ... }
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public void updateUser(Long userId, UserDTO dto) { ... }SpEL 在 @Cacheable 的 key 生成、@PreAuthorize 的权限表达式、@ConditionalOnExpression 的条件判断里无处不在,值得专门花半小时熟悉语法。
5. 本篇自测:能答出来才算过关
- 画出 Bean 的完整生命周期,并指出 AOP 代理在哪一步生成。
- 解释三级缓存,并说明"为什么两级不够"。
- 列举
@Transactional的 7 种失效场景及各自根因。 REQUIRES_NEW和NESTED的本质区别是什么?前者在高并发下有什么陷阱?- JDK 动态代理和 CGLIB 各自的前提条件是什么?Spring Boot 为什么默认选 CGLIB?
- 为什么单例 Bean 里不能随便放可变成员变量?
@TransactionalEventListener解决了@EventListener的什么问题?
下一篇(02 · Spring Boot):自动配置的魔法到底怎么实现的?
.imports文件替代spring.factories背后藏着什么战略意图?如何手写一个能给全公司用的 Starter?以及——配置优先级那十几层,到底谁覆盖谁?