前五章走完了儲存引擎與 JVM,接下來三章換到框架這一層。
Spring 的 IoC 容器大概是 Java 生態裡最常被使用、卻最少被真正理解的東西——
每天都在用 @Autowired,但只要一出現這行錯誤就束手無策:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| orderService defined in file [OrderService.class]
↑ ↓
| userService defined in file [UserService.class]
└─────┘
網路上的答案通常是「加個 @Lazy 就好了」。這確實能讓程式跑起來,但如果不知道它為什麼有效,下次換個情境(例如換成建構子注入、或加了 @Transactional)就又卡住了。
這一章要回答的核心問題只有一個:Spring 的三級快取,為什麼需要三級?
兩級快取就能解決循環依賴了——這是可以自己推導出來的。所以第三級一定是為了解決別的問題。把那個問題找出來,整套機制就通了。
路徑是這樣:
@Lazy 到底做了什麼。這章會用到 ch05 的一個結論:
@Service是單例,成員變數放可變狀態就是並發 bug。IoC 的作用域設計正是為了處理這件事。
「控制反轉」這個名字很抽象,多數人的理解停在「Spring 幫你 new 物件」——那只是結果,不是重點。重點是「控制權」從哪裡轉到哪裡。
public class OrderService {
private final PaymentClient payment = new AlipayClient(); // ← 我自己決定用誰
}OrderService 自己決定了依賴的具體實作。這造成三個問題:
PaymentClient 進去public class OrderService {
private final PaymentClient payment;
public OrderService(PaymentClient payment) { // ← 我只說「我需要一個」
this.payment = payment;
}
}還有另一種實作方式是服務定位器(Service Locator)——物件主動跟容器要(applicationContext.getBean(...))。兩者都是 IoC,但 DI 好得多,因為依賴關係是明確可見的,而服務定位器把依賴藏在方法內部。
Spring 容器不只是一個 Map。它管的是:
這是整章最重要的一張卡。把「建立一個 Bean」拆成幾個階段之後,循環依賴、AOP 代理、三級快取全部都能推導出來。
① 掃描與註冊
@ComponentScan 掃到類別 → 產生 BeanDefinition(設計圖,還沒有物件)
② 實例化(Instantiation)
呼叫建構子 → 記憶體裡有物件了,但欄位全是 null
★ 此時物件「存在」,已經有記憶體位址可以被別人參照
③ 屬性填充(Populate)
把 @Autowired 的依賴注入進去
★ 循環依賴就卡在這一步
④ Aware 回呼
BeanNameAware、ApplicationContextAware… 把容器資訊塞給 Bean
⑤ BeanPostProcessor.postProcessBeforeInitialization()
⑥ 初始化(Initialization)
@PostConstruct → InitializingBean.afterPropertiesSet() → 自訂 init-method
⑦ BeanPostProcessor.postProcessAfterInitialization()
★ AOP 代理在這裡生成!原始物件被包成代理物件
⑧ 放進一級快取,可以使用了
⑨ 銷毀
@PreDestroy → DisposableBean.destroy()代理的目的是攔截方法呼叫。如果在初始化之前就包成代理,那麼 @PostConstruct 這些初始化方法也會被攔截——而那時候 Bean 都還沒準備好,切面邏輯(例如交易、快取)在那個階段執行是沒有意義甚至危險的。
先看死結怎麼形成的。
@Service class A { @Autowired B b; }
@Service class B { @Autowired A a; }建立 A → 實例化 A → 填充屬性,需要 B
→ 建立 B → 實例化 B → 填充屬性,需要 A
→ 建立 A → 實例化 A → 填充屬性,需要 B
→ 建立 B → …… 無限遞迴,StackOverflowError回到上一張卡的時間點一:A 執行完建構子的那一刻,記憶體裡就有一個 A 物件了,只是它的 b 欄位還是 null。
關鍵洞察是:B 拿到的只是一個「參照」(記憶體位址)。B 不需要 A 現在就是完整的,只要在 B 真正呼叫 a.someMethod() 之前,A 被填滿就行了。
① 實例化 A(建構子跑完,b 欄位還是 null)
② ★ 把「半成品 A」的參照先曝光出去
③ 填充 A 的屬性,需要 B → 去建立 B
④ 實例化 B
⑤ 填充 B 的屬性,需要 A → 從曝光的地方拿到「半成品 A」的參照 ✅
⑥ B 初始化完成,放進一級快取(B 完整了)
⑦ 回到 A:拿到完整的 B,A 的屬性填充完成
⑧ A 初始化完成,放進一級快取
★ 此時 B 手上那個 A 的參照,指向的正是已經填滿的 A —— 同一個物件照這個邏輯,只需要兩個容器:
接著上一張卡的問題:兩級就夠了,為什麼要三級?
假設 A 身上有 @Transactional(所以最終要被包成 AOP 代理),而 A 和 B 循環依賴:
① 實例化 A(原始物件,稱為 A-raw)
② 把 A-raw 放進二級快取曝光
③ 填充 A 的屬性 → 建立 B → B 從二級快取拿到 A-raw ← ⚠️
④ B 完成,A 繼續
⑤ A 初始化完成
⑥ BeanPostProcessor 生成 AOP 代理 → A-proxy
⑦ 容器一級快取放的是 A-proxy
結果:
容器裡的 A = A-proxy(有交易功能)
B 手上的 A = A-raw (沒有交易功能)★ 同一個 Bean 有兩個版本!不行,有兩個問題:
三級 singletonFactories:Map<String, ObjectFactory>
實例化 A 之後,放進去的不是 A 物件,而是一個 lambda:
() -> getEarlyBeanReference(beanName, A-raw)
這個 lambda「還沒有被執行」。
只有當「真的有人來拿 A」(也就是真的發生循環依賴)時才會執行它,
此時才決定:需要代理就回傳 A-proxy,不需要就回傳 A-raw。當 lambda 被執行、產生出 A-proxy 之後,結果會被存進二級快取,同時把三級的 lambda 移除。
為什麼要存起來?因為可能有多個 Bean 都依賴 A。如果每次都重新執行 lambda,每次都會生成一個新的代理物件——那就從「兩個版本」變成「N 個版本」了。二級快取保證:不管多少個 Bean 來拿,拿到的都是同一個提前生成的代理。
把上面的推導再往前推一步,就會得到一個必然的結論。
整套機制的第一步是「實例化之後,把半成品曝光出去」。
所以它成立的前提是:物件必須先能被建立出來(有記憶體位址),才有東西可以曝光。
@Service
class A {
private final B b;
A(B b) { this.b = b; } // ← 沒有 B 就沒辦法呼叫建構子
}建立 A → 要呼叫建構子 → 需要 B
→ 建立 B → 要呼叫建構子 → 需要 A
→ A 還沒實例化,沒有半成品可以曝光
→ 死結,無解BeanCurrentlyInCreationException: Requested bean is currently in creation: Is there an unresolvable circular reference?# 舊專案升級後可能需要這行才能啟動(但不建議長期依賴它)
spring.main.allow-circular-references=true官方的態度很明確:循環依賴能被解決不代表它是好設計。預設禁止是為了逼你在設計階段就發現問題。
@Service
class A {
private final B b;
A(@Lazy B b) { this.b = b; } // ← 注入的不是真的 B
}b.someMethod(),代理才去容器裡拿真正的 B@Lazy 是止血不是治療。它讓程式跑得起來,但循環依賴本身通常是設計問題的訊號——最後一張卡會講怎麼真正解掉。final——保證依賴不可變new OrderService(mockPayment)@Autowired private X x;)則全部相反:不能 final、依賴被藏起來、測試要動用反射,而且可以無限制地加依賴而不覺得痛——一個有 15 個依賴的類別是設計有問題,但欄位注入讓你感覺不到。Spring 的啟動錯誤訊息其實資訊量很大,但格式冗長,多數人只看第一行就去搜尋引擎了。
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| orderService
↑ ↓
| userService
└─────┘Spring 直接把依賴環畫出來了,順著箭頭看就是循環路徑。注意環可能超過兩個 Bean(A→B→C→A),這時候圖會更長,要找的是「哪一段依賴是不合理的」而不是「哪個 Bean 有問題」。
UnsatisfiedDependencyException: Error creating bean with name 'orderService':
Unsatisfied dependency expressed through constructor parameter 0
Caused by: NoSuchBeanDefinitionException: No qualifying bean of type 'PaymentClient'常見原因:類別不在 @ComponentScan 的範圍內(Spring Boot 預設只掃主類別所在套件及其子套件)、忘記加 @Component/@Service、或該 Bean 被 @ConditionalOn... 條件擋掉了。
NoUniqueBeanDefinitionException: expected single matching bean but found 2:
alipayClient, wechatPayClient解法:@Primary(指定預設)、@Qualifier("alipayClient")(明確指名),或把參數名稱改成 Bean 名稱(Spring 會 fallback 用名稱匹配)。
mvn spring-boot:run 完全正常,CI 或另一台機器啟動就失敗。@DependsOn 掩蓋它,那只是把炸彈往後推。# 看實際載入了哪些 Bean、誰依賴誰
--debug # 啟動時印出自動組態報告
Actuator 的 /actuator/beans # 列出所有 Bean 與其依賴
# 啟動很慢時,找出是誰慢
ApplicationStartup / spring-boot-starter-actuator 的 startup endpoint這是 Spring 作用域最經典的誤解。
@Component
@Scope("prototype") // 每次取得都要是新的實例
class ReportBuilder { ... }
@Service // 單例
class ReportService {
@Autowired
private ReportBuilder builder; // ⚠️ 只會被注入「一次」
public void generate() {
builder.build(); // 永遠是同一個實例
}
}// ① ObjectProvider(推薦,Spring 4.3+)
@Service
class ReportService {
private final ObjectProvider<ReportBuilder> builderProvider;
ReportService(ObjectProvider<ReportBuilder> p) { this.builderProvider = p; }
public void generate() {
ReportBuilder builder = builderProvider.getObject(); // 每次都是新的
builder.build();
}
}
// ② @Lookup:Spring 產生子類別覆寫這個方法
@Lookup
protected abstract ReportBuilder createBuilder();
// ③ 注入 ApplicationContext 自己 getBean(不推薦,服務定位器反模式)@Service
class OrderService {
private List<String> currentItems = new ArrayList<>(); // ❌ 災難
public void process(Order order) {
currentItems.clear();
currentItems.addAll(order.getItems());
// 多執行緒同時進來 → 互相踩踏
}
}@Service 預設單例這件事太理所當然,反而容易忘記「這個物件會被所有請求共用」。singleton(預設)——整個容器一個prototype——每次取得都新建;注意容器不管它的銷毀,@PreDestroy 不會被呼叫request / session——Web 環境專用;注入到單例時需要代理(proxyMode),原理跟 @Lazy 類似整章講了 Spring 怎麼「解決」循環依賴。這張卡要講的是——大多數時候你不該讓它需要被解決。
A 需要 B、B 又需要 A,通常代表下列三件事之一:
// ❌ 循環
class OrderService { @Autowired UserService userService; }
class UserService { @Autowired OrderService orderService; }
// ✅ 解法一:抽出共用邏輯成第三個 Bean
class OrderService { @Autowired MemberQueryService query; }
class UserService { @Autowired MemberQueryService query; }
// ✅ 解法二:用事件解耦(單向依賴 + 非同步通知)
class OrderService {
@Autowired ApplicationEventPublisher publisher;
void create(Order o) { publisher.publishEvent(new OrderCreatedEvent(o)); }
}
class UserService {
@EventListener void onOrderCreated(OrderCreatedEvent e) { ... }
}
// ✅ 解法三:合併——如果兩者確實密不可分,本來就該是同一個類別1. 不要用 ApplicationContextAware 到處拿 Bean
// ❌ 服務定位器反模式
ApplicationContext ctx;
PaymentClient client = ctx.getBean(PaymentClient.class);依賴被藏在方法內部,從類別的簽章完全看不出來——等於放棄了 DI 最大的好處。只有在真正動態的場景(例如依執行期參數選擇實作)才用,而且優先考慮 Map<String, PaymentClient> 注入所有實作再自己挑。
2. 不要用 @DependsOn 掩蓋順序問題
如果需要靠 @DependsOn 才能啟動,代表 Bean 之間有隱性的初始化順序依賴——那是應該被明確化的設計問題。
3. 不要無限擴大 @ComponentScan 範圍
啟動慢的頭號原因就是掃描範圍過大(例如直接掃 com)。Spring Boot 預設從主類別所在套件開始,維持這個預設,並讓套件結構反映模組邊界。
| 層級 | 存什麼 | 解決什麼問題 | 沒有它會怎樣 |
|---|---|---|---|
| 一級 singletonObjects | 完整初始化完畢的 Bean | 正常使用 | — |
| 二級 earlySingletonObjects | 提前曝光的半成品(或提前生成的代理) | ① 解決循環依賴 ② 快取代理生成的結果,保證只生成一次 | 多個 Bean 依賴同一個 A 時,每次都重新執行工廠 → 產生多個不同的代理 |
| 三級 singletonFactories | ObjectFactory(尚未執行的 lambda) | 延遲決策:只有真的發生循環依賴時,才提前生成 AOP 代理 | B 拿到原始物件、容器裡是代理物件 → 同一個 Bean 兩個版本,@Transactional 靜默失效 |
| 方式 | 能否 final | 測試 | 循環依賴 | 建議 |
|---|---|---|---|---|
| 建構子注入 | ✅ 可以 | 直接 new,不需要 Spring | 無法解決 → 啟動時就會報錯 | ✅ 預設選擇;報錯是優點,逼你正視設計問題 |
| Setter 注入 | ❌ 不行 | 呼叫 setter 即可 | 可以解決(三級快取) | 只用於「可選依賴」 |
| 欄位注入 @Autowired | ❌ 不行 | 要用反射或 Spring 容器 | 可以解決 | ❌ 不建議:依賴被隱藏,可以無痛加到 15 個都不覺得有問題 |
| 錯誤 | 訊息關鍵字 | 常見原因 | 修法 |
|---|---|---|---|
| 循環依賴 | form a cycle(Spring 會畫出依賴環) | 職責劃分錯、缺共用抽象、分層被打破 | 抽第三個 Bean/事件解耦/合併;@Lazy 只是止血 |
| 找不到 Bean | NoSuchBeanDefinitionException | 不在 @ComponentScan 範圍、忘記加 @Service、被 @ConditionalOn 擋掉 | 檢查套件位置;用 --debug 看自動組態報告 |
| 多個候選 | NoUniqueBeanDefinitionException | 同一介面有多個實作 | @Primary 指定預設,或 @Qualifier 明確指名 |
| 建立中被要求 | BeanCurrentlyInCreationException | 建構子注入的循環依賴——邏輯上無解 | 改設計;暫時可用 @Lazy 或改成 setter 注入 |
| 本機正常、CI 掛掉 | 同上任一種,但只在某些機器出現 | Bean 建立順序受檔案系統掃描順序影響,換順序就解不開 | 代表本來就有循環依賴;不要用 @DependsOn 掩蓋 |
點擊卡片翻面查看答案,共 16 張。