Java 後端底層 ch06 Spring IoC:三級快取為什麼是三級
下一章→
CH 06 Spring

Spring IoC:三級快取為什麼是三級

實例化與初始化是兩件事循環依賴的突破口第三級快取存在的唯一理由建構子注入為什麼解不了啟動失敗診斷單例注入 prototype 的陷阱

前五章走完了儲存引擎與 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 的三級快取,為什麼需要三級?

兩級快取就能解決循環依賴了——這是可以自己推導出來的。所以第三級一定是為了解決別的問題。把那個問題找出來,整套機制就通了。

路徑是這樣:

  • IoC 到底反轉了什麼——不是「Spring 幫你 new 物件」這麼淺。
  • Bean 的生命週期,特別是「實例化」和「初始化」是兩件事——這是理解一切的前提。
  • 循環依賴的突破口:為什麼半成品物件是可以被別人拿去用的。
  • 第三級快取存在的唯一理由:跟 AOP 代理有關,而不是跟循環依賴有關。
  • 為什麼建構子注入的循環依賴永遠解不了,以及 @Lazy 到底做了什麼。
  • 啟動失敗診斷,包含「本機跑得起來、CI 卻掛掉」這種最惱人的狀況。
  • 單例注入 prototype 的經典陷阱。

這章會用到 ch05 的一個結論:@Service 是單例,成員變數放可變狀態就是並發 bug。IoC 的作用域設計正是為了處理這件事。

「控制反轉」這個名字很抽象,多數人的理解停在「Spring 幫你 new 物件」——那只是結果,不是重點。重點是「控制權」從哪裡轉到哪裡。

沒有 IoC 的時候

public class OrderService {
    private final PaymentClient payment = new AlipayClient();   // ← 我自己決定用誰
}

OrderService 自己決定了依賴的具體實作。這造成三個問題:

  • 換不掉——要改成微信支付就得改原始碼
  • 測不了——單元測試沒辦法塞一個假的 PaymentClient 進去
  • 生命週期沒人管——誰負責關閉它?多個地方 new 出好幾個怎麼辦?

有 IoC 之後

public class OrderService {
    private final PaymentClient payment;

    public OrderService(PaymentClient payment) {   // ← 我只說「我需要一個」
        this.payment = payment;
    }
}
反轉的是「決定用哪個實作」與「何時建立、何時銷毀」這兩項控制權——從物件自己手上,交給了容器。
物件只負責宣告「我需要什麼」,不再負責「去哪裡拿」。

IoC 與 DI 的關係

  • IoC(控制反轉)是思想:把控制權交出去
  • DI(依賴注入)是實作方式:容器透過建構子/setter/欄位把依賴「送進來」

還有另一種實作方式是服務定位器(Service Locator)——物件主動跟容器要(applicationContext.getBean(...))。兩者都是 IoC,但 DI 好得多,因為依賴關係是明確可見的,而服務定位器把依賴藏在方法內部。

容器實際在管什麼

Spring 容器不只是一個 Map。它管的是:

  • BeanDefinition——每個 Bean 的「設計圖」:類別、作用域、依賴、初始化方法
  • 實例的生命週期——建立、屬性填充、初始化、AOP 包裝、銷毀
  • 依賴的解析順序——誰要先建立
💡 下一張卡的生命週期是整章的地基。特別要記住的一件事是:「實例化」和「初始化」在 Spring 裡是兩個不同的階段——這正是循環依賴能被解決的關鍵。

這是整章最重要的一張卡。把「建立一個 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()

兩個必須記住的時間點

時間點一(第 ② 步後):物件已經存在,但還是個「半成品」——欄位都還沒填。
重點是它已經有記憶體位址了,所以別人可以先拿著這個參照,等它之後被填滿。這是循環依賴的突破口。

時間點二(第 ⑦ 步):AOP 代理在初始化「之後」才生成。
也就是說,正常情況下容器最終存的是代理物件,而第 ②③ 步流通的是原始物件——這兩個是不同的物件。這是第三級快取存在的原因。

為什麼 AOP 要放在初始化之後

代理的目的是攔截方法呼叫。如果在初始化之前就包成代理,那麼 @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 執行完建構子的那一刻,記憶體裡就有一個 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 —— 同一個物件
核心觀念:Java 傳的是參照。B 早一步拿到 A 的位址,不代表它拿到的是永遠殘缺的 A——那塊記憶體之後會被填滿,B 手上的位址指向的就是完整的 A。

推論:兩級快取就夠了

照這個邏輯,只需要兩個容器:

  • 一級:放完整的 Bean
  • 二級:放實例化了但還沒填充完的半成品
✅ 兩級快取確實就能解決循環依賴。所以下一個問題就很明確了——Spring 為什麼還要第三級?答案不在循環依賴本身,而在 AOP。

接著上一張卡的問題:兩級就夠了,為什麼要三級?

先看沒有第三級會出什麼事

假設 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 有兩個版本!
這是災難級的 bug:透過容器拿到的 A 有交易,但 B 內部呼叫的 A 沒有交易。而且完全不會報錯——只會在某天發現「明明加了 @Transactional 卻沒有回滾」。

那直接在第 ② 步就生成代理不行嗎?

不行,有兩個問題:

  1. 違反生命週期——代理應該在初始化之後生成(上上張卡講過的原因)。
  2. 絕大多數 Bean 根本沒有循環依賴。為了少數循環依賴的情況,讓每一個 Bean 都提前生成代理,是不必要的成本,而且會改變所有 Bean 的行為。

第三級快取的設計:存「工廠」而不是「物件」

三級 singletonFactories:Map<String, ObjectFactory>

實例化 A 之後,放進去的不是 A 物件,而是一個 lambda:
   () -> getEarlyBeanReference(beanName, A-raw)

這個 lambda「還沒有被執行」。
只有當「真的有人來拿 A」(也就是真的發生循環依賴)時才會執行它,
此時才決定:需要代理就回傳 A-proxy,不需要就回傳 A-raw。
✅ 第三級快取的本質是「延遲決策」:把「要不要提前生成代理」這個決定,推遲到真的發生循環依賴的那一刻。沒有循環依賴的 Bean(絕大多數)完全不受影響,走的還是正常的生命週期。

那二級快取還有什麼用

當 lambda 被執行、產生出 A-proxy 之後,結果會被存進二級快取,同時把三級的 lambda 移除。

為什麼要存起來?因為可能有多個 Bean 都依賴 A。如果每次都重新執行 lambda,每次都會生成一個新的代理物件——那就從「兩個版本」變成「N 個版本」了。二級快取保證:不管多少個 Bean 來拿,拿到的都是同一個提前生成的代理。

三級快取的完整分工:
• 三級 singletonFactories:延遲決策——需要時才生成代理
• 二級 earlySingletonObjects:快取決策結果——保證只生成一次
• 一級 singletonObjects:完整可用的 Bean

查詢順序是一級 → 二級 → 三級。

把上面的推導再往前推一步,就會得到一個必然的結論。

三級快取的前提

整套機制的第一步是「實例化之後,把半成品曝光出去」。

所以它成立的前提是:物件必須先能被建立出來(有記憶體位址),才有東西可以曝光。

建構子注入破壞了這個前提

@Service
class A {
    private final B b;
    A(B b) { this.b = b; }    // ← 沒有 B 就沒辦法呼叫建構子
}
建立 A → 要呼叫建構子 → 需要 B
   → 建立 B → 要呼叫建構子 → 需要 A
      → A 還沒實例化,沒有半成品可以曝光
      → 死結,無解
這不是 Spring 的缺陷,是邏輯上的必然:物件連生出來都需要對方,就不可能有「先出生、後填充」的空間。
錯誤訊息會是 BeanCurrentlyInCreationException: Requested bean is currently in creation: Is there an unresolvable circular reference?

Spring Boot 2.6 起預設禁止循環依賴

# 舊專案升級後可能需要這行才能啟動(但不建議長期依賴它)
spring.main.allow-circular-references=true

官方的態度很明確:循環依賴能被解決不代表它是好設計。預設禁止是為了逼你在設計階段就發現問題。

@Lazy 做了什麼

@Service
class A {
    private final B b;
    A(@Lazy B b) { this.b = b; }   // ← 注入的不是真的 B
}
  1. Spring 注入一個 B 的代理物件(不需要真的建立 B)
  2. A 順利完成建構
  3. 直到第一次真的呼叫 b.someMethod(),代理才去容器裡拿真正的 B
  4. 那時候 B 早就建立好了,死結自然消失
💡 但 @Lazy 是止血不是治療。它讓程式跑得起來,但循環依賴本身通常是設計問題的訊號——最後一張卡會講怎麼真正解掉。

順帶:三種注入方式為什麼推薦建構子

  • 可以宣告 final——保證依賴不可變
  • 依賴是明確的——看建構子參數就知道這個類別需要什麼
  • 單元測試不用 Spring——直接 new OrderService(mockPayment)
  • 會在啟動時就暴露循環依賴——這是優點不是缺點
  • 欄位注入(@Autowired private X x;)則全部相反:不能 final、依賴被藏起來、測試要動用反射,而且可以無限制地加依賴而不覺得痛——一個有 15 個依賴的類別是設計有問題,但欄位注入讓你感覺不到。

Spring 的啟動錯誤訊息其實資訊量很大,但格式冗長,多數人只看第一行就去搜尋引擎了。

1. 循環依賴

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  orderService
↑     ↓
|  userService
└─────┘

Spring 直接把依賴環畫出來了,順著箭頭看就是循環路徑。注意環可能超過兩個 Bean(A→B→C→A),這時候圖會更長,要找的是「哪一段依賴是不合理的」而不是「哪個 Bean 有問題」。

2. 找不到依賴

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... 條件擋掉了。

3. 有多個候選

NoUniqueBeanDefinitionException: expected single matching bean but found 2:
  alipayClient, wechatPayClient

解法:@Primary(指定預設)、@Qualifier("alipayClient")(明確指名),或把參數名稱改成 Bean 名稱(Spring 會 fallback 用名稱匹配)。

4. 最惱人的:本機正常、CI 掛掉

徵狀:同一份程式碼,本機 mvn spring-boot:run 完全正常,CI 或另一台機器啟動就失敗。

原因:Bean 的建立順序在某些情況下依賴檔案系統的掃描順序,而不同作業系統/檔案系統回傳的順序不同。循環依賴是否能被解開,有時候正好取決於「誰先被建立」——A 先建立時能解開,B 先建立時就死結。

這代表你的專案本來就有循環依賴,只是本機剛好走到能解開的那條路徑。不要去調掃描順序或加 @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();     // 永遠是同一個實例
    }
}

為什麼

注入這個動作只發生在「ReportService 被建立的時候」,也就是一輩子只發生一次。
Spring 確實照 prototype 的規則建了一個新的 ReportBuilder 給它——但之後就再也不會換了。

作用域描述的是「向容器要一次會發生什麼」,不是「每次使用會發生什麼」。

三種解法

// ① 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());
        // 多執行緒同時進來 → 互相踩踏
    }
}
這是 ch05 提過的頭號並發錯誤,而 IoC 讓它更容易發生——因為 @Service 預設單例這件事太理所當然,反而容易忘記「這個物件會被所有請求共用」。

規則:Spring Bean 的成員變數只能放「不會變的東西」(其他 Bean、設定值)。請求層級的狀態一律用方法區域變數或參數傳遞。

其他作用域

  • singleton(預設)——整個容器一個
  • prototype——每次取得都新建;注意容器不管它的銷毀,@PreDestroy 不會被呼叫
  • request / session——Web 環境專用;注入到單例時需要代理(proxyMode),原理跟 @Lazy 類似

整章講了 Spring 怎麼「解決」循環依賴。這張卡要講的是——大多數時候你不該讓它需要被解決。

循環依賴在告訴你什麼

A 需要 B、B 又需要 A,通常代表下列三件事之一:

  1. 職責劃分錯了——兩個類別其實在處理同一件事,被硬拆成兩個
  2. 有一段共用邏輯沒有被抽出來——A 和 B 都需要它,於是互相呼叫對方的方法
  3. 分層被打破了——上層呼叫下層是正常的,下層又回頭呼叫上層就是設計錯誤

三種真正的解法

// ❌ 循環
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) { ... }
}

// ✅ 解法三:合併——如果兩者確實密不可分,本來就該是同一個類別
✅ 判斷順序:先問「這兩個類別的職責是什麼」,而不是先問「怎麼讓 Spring 不報錯」。能抽出第三者是最好的結果,因為那個第三者通常正是你原本缺少的那個概念。

另外三個不該做的事

1. 不要用 ApplicationContextAware 到處拿 Bean

// ❌ 服務定位器反模式
ApplicationContext ctx;
PaymentClient client = ctx.getBean(PaymentClient.class);

依賴被藏在方法內部,從類別的簽章完全看不出來——等於放棄了 DI 最大的好處。只有在真正動態的場景(例如依執行期參數選擇實作)才用,而且優先考慮 Map<String, PaymentClient> 注入所有實作再自己挑。

2. 不要用 @DependsOn 掩蓋順序問題

如果需要靠 @DependsOn 才能啟動,代表 Bean 之間有隱性的初始化順序依賴——那是應該被明確化的設計問題。

3. 不要無限擴大 @ComponentScan 範圍

啟動慢的頭號原因就是掃描範圍過大(例如直接掃 com)。Spring Boot 預設從主類別所在套件開始,維持這個預設,並讓套件結構反映模組邊界。

一個實用的自我檢查:如果一個類別的建構子參數超過 5 個,它大概承擔了太多責任。欄位注入會讓你完全感覺不到這件事——這也是推薦建構子注入的隱藏理由:它讓設計問題會痛。
三級快取:各自解決什麼問題
層級存什麼解決什麼問題沒有它會怎樣
一級 singletonObjects完整初始化完畢的 Bean正常使用—
二級 earlySingletonObjects提前曝光的半成品(或提前生成的代理)① 解決循環依賴 ② 快取代理生成的結果,保證只生成一次多個 Bean 依賴同一個 A 時,每次都重新執行工廠 → 產生多個不同的代理
三級 singletonFactoriesObjectFactory(尚未執行的 lambda)延遲決策:只有真的發生循環依賴時,才提前生成 AOP 代理B 拿到原始物件、容器裡是代理物件 → 同一個 Bean 兩個版本,@Transactional 靜默失效
三種注入方式的取捨
方式能否 final測試循環依賴建議
建構子注入✅ 可以直接 new,不需要 Spring無法解決 → 啟動時就會報錯✅ 預設選擇;報錯是優點,逼你正視設計問題
Setter 注入❌ 不行呼叫 setter 即可可以解決(三級快取)只用於「可選依賴」
欄位注入 @Autowired❌ 不行要用反射或 Spring 容器可以解決❌ 不建議:依賴被隱藏,可以無痛加到 15 個都不覺得有問題
啟動失敗診斷表
錯誤訊息關鍵字常見原因修法
循環依賴form a cycle(Spring 會畫出依賴環)職責劃分錯、缺共用抽象、分層被打破抽第三個 Bean/事件解耦/合併;@Lazy 只是止血
找不到 BeanNoSuchBeanDefinitionException不在 @ComponentScan 範圍、忘記加 @Service、被 @ConditionalOn 擋掉檢查套件位置;用 --debug 看自動組態報告
多個候選NoUniqueBeanDefinitionException同一介面有多個實作@Primary 指定預設,或 @Qualifier 明確指名
建立中被要求BeanCurrentlyInCreationException建構子注入的循環依賴——邏輯上無解改設計;暫時可用 @Lazy 或改成 setter 注入
本機正常、CI 掛掉同上任一種,但只在某些機器出現Bean 建立順序受檔案系統掃描順序影響,換順序就解不開代表本來就有循環依賴;不要用 @DependsOn 掩蓋

練習題 點選選項查看解析

0 / 10
01 / 10
Spring 的「實例化」和「初始化」差別是什麼?為什麼這個區分很重要?
A 沒有差別,是同一件事的兩種說法
B 實例化=呼叫建構子(物件存在但欄位為 null),初始化=@PostConstruct 等;區分它們才能理解半成品可以被提前曝光
C 實例化是 Spring 做的,初始化是開發者做的
D 初始化在前,實例化在後
解析
實例化之後物件已經有記憶體位址,只是欄位還沒填。這個「已存在但不完整」的狀態正是循環依賴的突破口——B 只需要 A 的參照,不需要 A 現在就完整。
02 / 10
為什麼兩級快取就足以解決循環依賴?
A 因為兩級快取可以互相備份
B 因為 Java 傳的是參照——B 早一步拿到 A 的記憶體位址,那塊記憶體之後會被填滿,B 手上的位址指向的就是完整的 A
C 因為 Spring 會複製一份 A 給 B
D 兩級不夠,一定要三級
解析
只要「實例化後、填充前」把半成品的參照曝光出去,B 就能拿去完成自己,之後 A 再繼續填充。B 手上的參照最終指向的就是完整的 A。所以第三級快取一定是為了解決別的問題——那就是 AOP。
03 / 10
第三級快取(singletonFactories)存在的真正理由是什麼?
A 為了解決循環依賴,沒有它循環依賴無法解決
B 為了提升效能,減少物件建立次數
C 為了延遲決策:只有真的發生循環依賴時才提前生成 AOP 代理,避免所有 Bean 都被迫提前代理
D 為了支援 prototype 作用域
解析
三級存的是還沒執行的 lambda。絕大多數 Bean 沒有循環依賴,不該為了少數情況讓每個 Bean 都提前生成代理、違反「代理在初始化後生成」的生命週期。只有真的有人來拿時才執行 lambda 決定回傳原始物件還是代理。
04 / 10
如果沒有第三級快取,A 有 @Transactional 且與 B 循環依賴,會發生什麼?
A 啟動直接失敗,拋出例外
B 容器裡是 A 的代理物件,但 B 手上是原始物件——同一個 Bean 兩個版本,B 呼叫 A 時交易靜默失效
C AOP 代理會被自動關閉
D Spring 會自動重試直到成功
解析
這是災難級的 bug,因為完全不會報錯——只會在某天發現「明明加了 @Transactional 卻沒有回滾」。AOP 代理在 BeanPostProcessor.postProcessAfterInitialization 才生成,而循環依賴時 B 拿到的是更早的原始物件。
05 / 10
二級快取除了存半成品,還有什麼作用?
A 備份一級快取的內容
B 快取「工廠執行的結果」,保證多個 Bean 依賴同一個 A 時拿到的是同一個提前生成的代理
C 存放 prototype 作用域的 Bean
D 存放 BeanDefinition
解析
如果每次都重新執行三級的 lambda,每次都會生成一個新的代理物件——就從「兩個版本」變成「N 個版本」。二級快取存下結果並移除三級的 lambda,保證只生成一次。
06 / 10
為什麼建構子注入的循環依賴永遠無法用三級快取解決?
A 因為 Spring 沒有實作這個功能
B 因為三級快取的前提是「物件已經實例化」,而建構子注入時物件連建立都需要對方,沒有半成品可以曝光
C 因為 final 欄位不能被修改
D 因為建構子注入不支援 AOP
解析
這是邏輯上的必然而非 Spring 的缺陷:物件連生出來都需要對方,就不可能有「先出生、後填充」的空間。錯誤是 BeanCurrentlyInCreationException。Spring Boot 2.6 起預設禁止循環依賴,正是為了逼開發者在設計階段發現問題。
07 / 10
@Lazy 是怎麼化解循環依賴的?
A 延後整個應用的啟動時間
B 注入一個代理物件,直到第一次真正呼叫方法時才去容器拿真身,此時對方早已建立完成
C 把 Bean 改成 prototype 作用域
D 跳過該依賴的注入,讓欄位保持 null
解析
注入代理讓 A 能順利完成建構,打破了「建立時就必須有對方」的死結。但這是止血不是治療——循環依賴通常是職責劃分錯誤、缺少共用抽象,或分層被打破的訊號。
08 / 10
prototype 作用域的 Bean 注入到單例 Bean,會發生什麼?
A 每次使用都會得到新實例
B 注入只發生在單例建立的那一次,之後永遠是同一個實例
C 啟動時會報錯
D Spring 會自動改用 ObjectProvider
解析
作用域描述的是「向容器要一次會發生什麼」,不是「每次使用會發生什麼」。要每次都新的請用 ObjectProvider.getObject() 或 @Lookup。另外 prototype 的銷毀不受容器管理,@PreDestroy 不會被呼叫。
09 / 10
同一份程式碼本機啟動正常、CI 卻失敗,最可能的原因是什麼?
A CI 的記憶體不足
B Bean 建立順序受檔案系統掃描順序影響,而循環依賴能否解開取決於誰先被建立——代表專案本來就有循環依賴
C CI 的 JDK 版本不同
D CI 沒有網路連線
解析
A 先建立時能解開、B 先建立時就死結。本機只是剛好走到能解開的那條路徑。不要用 @DependsOn 或調整掃描順序掩蓋——那只是把炸彈往後推,該做的是消除循環依賴本身。
10 / 10
為什麼推薦建構子注入而不是欄位注入?(最關鍵的理由)
A 建構子注入的效能比較好
B 因為它讓設計問題會痛:依賴明確可見、可以 final、測試不需要 Spring,而且循環依賴會在啟動時就報錯
C 因為欄位注入已經被 Spring 廢棄
D 因為建構子注入支援更多作用域
解析
欄位注入讓你可以無痛加到 15 個依賴而毫無感覺——但一個有 15 個依賴的類別是設計有問題。建構子注入把這件事變得可見(參數列變長就是警訊),也讓循環依賴在啟動時就暴露而不是被三級快取默默化解。

點擊卡片翻面查看答案,共 16 張。

QUESTION
IoC 反轉的是什麼?
點擊翻面
ANSWER
「決定用哪個實作」與「何時建立、何時銷毀」這兩項控制權,從物件自己手上交給容器。物件只宣告「我需要什麼」,不再負責「去哪裡拿」。
點擊翻回
QUESTION
IoC 和 DI 的關係?
點擊翻面
ANSWER
IoC 是思想(把控制權交出去),DI 是實作方式(容器把依賴送進來)。另一種實作是服務定位器(自己 getBean),兩者都是 IoC,但 DI 好得多——依賴關係明確可見。
點擊翻回
QUESTION
★ 實例化和初始化的差別?為什麼重要?
點擊翻面
ANSWER
實例化=呼叫建構子完成,物件存在但欄位為 null;初始化=@PostConstruct/afterPropertiesSet。重要是因為「已存在但不完整」這個狀態,正是循環依賴的突破口。
點擊翻回
QUESTION
AOP 代理在生命週期的哪一步生成?為什麼是那裡?
點擊翻面
ANSWER
BeanPostProcessor.postProcessAfterInitialization(),也就是初始化「之後」。因為若更早包成代理,@PostConstruct 這些初始化方法也會被切面攔截——那時 Bean 還沒準備好。
點擊翻回
QUESTION
循環依賴的突破口是什麼?
點擊翻面
ANSWER
Java 傳的是參照。A 實例化後就有記憶體位址,把這個「半成品」先曝光出去,B 拿去完成自己;之後 A 被填滿,B 手上的位址指向的就是完整的 A。
點擊翻回
QUESTION
兩級快取夠不夠解決循環依賴?
點擊翻面
ANSWER
夠。所以第三級一定是為了別的問題——那就是 AOP 代理。這是推導出三級快取設計的關鍵提問。
點擊翻回
QUESTION
★ 第三級快取存在的唯一理由?
點擊翻面
ANSWER
延遲決策。存的是還沒執行的 lambda,只有真的發生循環依賴時才執行、才決定要不要提前生成 AOP 代理。避免讓絕大多數沒有循環依賴的 Bean 都被迫提前代理。
點擊翻回
QUESTION
沒有第三級快取,有 @Transactional 的 Bean 循環依賴會怎樣?
點擊翻面
ANSWER
容器裡是代理物件、B 手上是原始物件——同一個 Bean 兩個版本。B 呼叫 A 時交易靜默失效,完全不報錯,某天才發現「加了 @Transactional 卻沒回滾」。
點擊翻回
QUESTION
二級快取的第二個作用?
點擊翻面
ANSWER
快取三級 lambda 執行後的結果並移除 lambda。因為可能有多個 Bean 依賴同一個 A,每次重新執行會生成多個不同的代理——二級保證只生成一次。
點擊翻回
QUESTION
為什麼建構子注入的循環依賴無解?
點擊翻面
ANSWER
三級快取的前提是「物件已實例化、有位址可曝光」。建構子注入時物件連建立都需要對方,沒有半成品存在。這是邏輯必然,不是 Spring 缺陷。錯誤:BeanCurrentlyInCreationException。
點擊翻回
QUESTION
@Lazy 的原理?
點擊翻面
ANSWER
注入一個代理物件(不需要真的建立對方),讓建構順利完成;直到第一次真正呼叫方法時,代理才去容器拿真身,那時對方早已建立好。是止血不是治療。
點擊翻回
QUESTION
prototype 注入單例為什麼失效?怎麼解?
點擊翻面
ANSWER
注入只發生在單例建立的那一次。作用域描述的是「要一次會發生什麼」,不是「每次使用會發生什麼」。解法:ObjectProvider.getObject()、@Lookup。
點擊翻回
QUESTION
本機啟動正常、CI 掛掉,為什麼?
點擊翻面
ANSWER
Bean 建立順序受檔案系統掃描順序影響,而循環依賴能否解開取決於誰先被建立。代表專案本來就有循環依賴,本機只是剛好走到能解開的路徑。不要用 @DependsOn 掩蓋。
點擊翻回
QUESTION
循環依賴通常在告訴你什麼?
點擊翻面
ANSWER
三件事之一:① 職責劃分錯(兩個類別其實在做同一件事)② 有共用邏輯沒抽出來 ③ 分層被打破(下層回頭呼叫上層)。解法是抽第三個 Bean、事件解耦,或直接合併。
點擊翻回
QUESTION
為什麼建構子注入優於欄位注入?(關鍵理由)
點擊翻面
ANSWER
它讓設計問題會痛。欄位注入可以無痛加到 15 個依賴而毫無感覺,建構子注入的參數列變長就是警訊。另外能 final、測試不用 Spring、循環依賴啟動時就報錯。
點擊翻回
QUESTION
Spring Bean 的成員變數可以放什麼?
點擊翻面
ANSWER
只能放不會變的東西(其他 Bean、設定值)。請求層級的狀態一律用方法區域變數或參數傳遞——因為 @Service 預設單例,所有請求共用同一個物件(ch05 的頭號並發錯誤)。
點擊翻回