Spring Core & Boot/Circular dependency — ba dạng vòng lặp và cơ chế three-level cache
5/41
Bài 5 / 41~12 phútNhập môn & IoC/DIMiễn phí lượt xem

Circular dependency — ba dạng vòng lặp và cơ chế three-level cache

Constructor-constructor circular không giải được; field/setter circular Spring giải bằng three-level cache (singletonObjects, earlySingletonObjects, singletonFactories). Bài này mổ từng cấp cache, giải thích tại sao constructor injection không có early reference, và hướng fix đúng.

TL;DR: Circular dependency xảy ra khi A cần B và B cần A. Spring giải được vòng lặp field/setter bằng three-level cache — ba map trong DefaultSingletonBeanRegistry: singletonObjects (instance hoàn chỉnh), earlySingletonObjects (instance chưa inject xong), singletonFactories (factory tạo early reference). Constructor circular không giải được vì bean chưa tồn tại để expose early reference trước khi constructor trả về. Spring Boot 2.6+ tắt tính năng giải vòng lặp mặc định — fix đúng là refactor hoặc dùng @Lazy.

Trong bài IoC & DI ta đã thấy ba dạng circular và cách fix bề mặt. Bài này đào sâu vào cơ chế bên dưới: Spring làm gì khi gặp A→B→A? Ba cái map nằm ở đâu trong heap? Tại sao constructor injection không hưởng lợi từ cơ chế đó? Hiểu cơ chế này là hiểu tại sao Spring Boot đổi default ở 2.6, và khi nào @Lazy thực sự an toàn.

1. Ba dạng circular dependency

Cùng một vòng A → B → A, nhưng cách inject quyết định Spring có giải được không:

flowchart LR
  subgraph case1["Constructor-constructor: FAIL startup"]
    AC["A(B b)"] -->|"can B"| BC["B(A a)"]
    BC -->|"can A"| AC
  end
  subgraph case2["Field/Setter: Spring tu giai"]
    AF["A @Autowired B b"] -->|"can B"| BF["B @Autowired A a"]
    BF -->|"can A"| AF
  end
DạngSpring xử lýHậu quả
Constructor A cần B, constructor B cần AThrow BeanCurrentlyInCreationException tại startupTốt — lỗi lộ sớm
Field/setter A cần B, field/setter B cần AGiải bằng three-level cache — app start đượcNguy hiểm nếu không hiểu cơ chế
Hỗn hợp (A constructor cần B, B field cần A)Phụ thuộc ai được tạo trướcKhó dự đoán, tránh

2. Three-level cache — ba map trong DefaultSingletonBeanRegistry

Spring quản lý singleton qua ba map nằm trong DefaultSingletonBeanRegistry (superclass của DefaultListableBeanFactory, đã đề cập trong BeanFactory vs ApplicationContext):

DefaultSingletonBeanRegistry
  singletonObjects      : ConcurrentHashMap<String, Object>
      -- instance HOAN CHINH: da inject xong, lifecycle callback xong, san sang dung
  earlySingletonObjects : ConcurrentHashMap<String, Object>
      -- instance CHUA HOAN CHINH: constructor da chay, nhung chua inject dependency
  singletonFactories    : HashMap<String, ObjectFactory<?>>
      -- factory tao ra "early reference" (co the la AOP proxy) khi can

Đây là ba "tầng" của cache — mỗi tầng phục vụ một giai đoạn khác nhau trong vòng đời tạo bean:

flowchart TB
  subgraph cache["Three-level cache trong DefaultSingletonBeanRegistry"]
    direction TB
    L3["Level 3: singletonFactories<br/>ObjectFactory tao early ref"]
    L2["Level 2: earlySingletonObjects<br/>early reference (chua inject xong)"]
    L1["Level 1: singletonObjects<br/>instance hoan chinh"]
  end
  L3 -->|"goi factory.getObject()<br/>khi co yeu cau circular"| L2
  L2 -->|"inject xong, lifecycle xong"| L1

Tại sao cần ba cấp thay vì một? Vì AOP proxy. Nếu A được wrap bởi AOP proxy (@Transactional, @Async), early reference phải là chính proxy đó, không phải raw object. singletonFactories giữ logic tạo proxy lười — chỉ gọi khi thực sự có circular request. Nếu không có circular, factory không bao giờ được gọi và earlySingletonObjects không bao giờ populated.

3. Cơ chế bên dưới — luồng giải vòng lặp field injection

Xét hai bean dùng field injection:

@Service
public class A {
    @Autowired private B b;
}

@Service
public class B {
    @Autowired private A a;
}

Luồng thực tế khi Spring khởi tạo (giả sử A được tạo trước):

sequenceDiagram
  participant Ctx as Container
  participant A as Bean A
  participant B as Bean B
  participant L3 as singletonFactories
  participant L2 as earlySingletonObjects
  participant L1 as singletonObjects

  Ctx->>A: new A() -- constructor chay xong
  Ctx->>L3: dang ky ObjectFactory cho A<br/>(factory tra ve early ref cua A)
  Ctx->>B: new B() -- constructor chay xong
  Note over Ctx,B: B can inject field A
  Ctx->>L3: getSingleton("a") -- tim trong L1, L2, L3
  L3-->>Ctx: goi factory.getObject() -> early ref A
  Ctx->>L2: dat early ref A vao earlySingletonObjects
  Ctx->>B: inject field a = early ref A
  Note over B: B hoan thanh, chuyen len L1
  Ctx->>L1: B hoan chinh -> singletonObjects["b"]
  Ctx->>A: inject field b = B hoan chinh
  Note over A: A hoan thanh, xoa khoi L2/L3
  Ctx->>L1: A hoan chinh -> singletonObjects["a"]

Bước then chốt là bước 3: khi container đang tạo B và cần inject field a, nó gọi getSingleton("a"). Method này tra lần lượt L1 → L2 → L3. L1 chưa có (A chưa hoàn chỉnh), L2 chưa có (chưa ai hỏi A trước), L3 ObjectFactory của A. Factory được gọi, trả về early reference của A (có thể là AOP proxy nếu A được proxy), đặt vào L2. B nhận được early reference này, inject xong, chuyển lên L1.

Sau đó container quay lại tiếp tục tạo A: inject field b = B đã hoàn chỉnh lấy từ L1. A hoàn chỉnh, xóa khỏi L2/L3, đặt vào L1.

4. Tại sao constructor injection không giải được circular

Với constructor injection:

@Service public class A { public A(B b) { /* ... */ } }
@Service public class B { public B(A a) { /* ... */ } }

Container cần tạo A nên gọi constructor A; constructor này cần B, container quay sang tạo B; constructor B lại cần A — nhưng A đang trong quá trình tạochưa có gì để trả lại.

Vấn đề nằm ở thứ tự: new A(b) đòi hỏi b phải tồn tại trước khi constructor A chạy. Constructor Java là nguyên tử — không có điểm nào giữa chừng để expose this ra ngoài (đó cũng là lý do this leak trong constructor là anti-pattern). Không có early reference thì không có gì để đặt vào singletonFactories — L3 trống, getSingleton("a") trả về null, và Spring không còn cách nào thoát vòng.

flowchart TD
  Start["Container: tao bean A"]
  CA["Goi new A(b) -- can b truoc"]
  CB["Tao B truoc: goi new B(a) -- can a truoc"]
  CA2["Tao A truoc -- DANG TAO ROI"]
  Error["BeanCurrentlyInCreationException"]

  Start --> CA --> CB --> CA2 --> Error
  style Error fill:#fee2e2,stroke:#ef4444

Spring phát hiện vòng bằng cách kiểm tra singletonsCurrentlyInCreation (một Set<String> track những bean đang được tạo). Khi container thử tạo A lần thứ hai trong khi A đang trong set này, nó throw ngay.

Tại sao constructor circular tốt hơn field circular

Constructor circular fail rõ ràng tại startup. Field/setter circular âm thầm "chạy được" trong nhiều năm rồi gây bug tinh tế — this.b null trong constructor, race condition khi A spawn thread sớm, AOP proxy không wrap đúng. Thà fail startup còn hơn fail production.

5. Spring Boot 2.6 thay đổi default

Trước Spring Boot 2.6, tính năng giải vòng lặp field/setter circular luôn bật. Từ Boot 2.6+, default là:

spring.main.allow-circular-references=false

Ý nghĩa: DefaultSingletonBeanRegistry sẽ không đặt ObjectFactory vào singletonFactories trong quá trình tạo bean. Nếu có circular — bất kể field hay setter — container throw ngay.

Lý do thay đổi: circular dependency là design smell, không phải feature nên tự động hỗ trợ. Cơ chế giải âm thầm che giấu vấn đề thiết kế, khiến developer không biết có vòng lặp cho đến khi gặp bug tinh tế. Tắt default buộc developer xử lý tường minh.

Bật lại bằng spring.main.allow-circular-references=true chỉ là band-aid — không nên làm trong code production mới.

6. Hai cách fix đúng

6.1 Refactor — tách logic chung sang class C

90% trường hợp circular dependency xảy ra vì hai class cùng cần một logic mà mỗi class giữ riêng. Giải pháp: extract logic đó ra class C.

// TRUOC: A can B, B can A -- circular
@Service
public class A {
    @Autowired private B b;
    public void processA() { b.sharedLogic(); }
}

@Service
public class B {
    @Autowired private A a;
    public void processB() { a.sharedLogic(); }
    public void sharedLogic() { /* ... */ }
}
// SAU: A va B cung can C -- khong circular
@Service
public class C {
    public void sharedLogic() { /* ... */ }   // extracted
}

@Service
public class A {
    private final C c;
    public A(C c) { this.c = c; }
    public void processA() { c.sharedLogic(); }
}

@Service
public class B {
    private final C c;
    public B(C c) { this.c = c; }
    public void processB() { c.sharedLogic(); }
}

Refactor không chỉ fix vòng lặp — nó cải thiện design: C bây giờ là module rõ ràng với trách nhiệm cụ thể.

6.2 @Lazy — phá vòng tại construction time

Khi refactor không khả thi ngay (code legacy, thời gian hạn chế), @Lazy là giải pháp hợp lý:

@Service
public class A {
    private final B b;

    public A(@Lazy B b) {
        this.b = b;   // b la CGLIB proxy, chua resolve B thuc su
    }

    public void doWork() {
        b.process();  // TAI DAY B thuc su moi duoc resolve lan dau
    }
}

@Service
public class B {
    private final A a;

    public B(A a) {
        this.a = a;
    }
}

@Lazy trên parameter constructor khiến Spring inject một CGLIB proxy thay vì bean B thực. Proxy không resolve B thực cho đến khi method đầu tiên được gọi trên nó. Điều này phá vòng tại construction time: A được tạo (với proxy), B được tạo (inject A thực), proxy của A trong B khi gọi method sẽ resolve B thực.

Trade-off của @Lazy:

  • Method call đầu tiên trên proxy chậm hơn (resolve lazy).
  • Overhead proxy nhỏ trên mọi lời gọi.
  • Lỗi "bean không tồn tại" bị defer từ startup sang runtime.
  • Code ít rõ ràng hơn — người đọc phải biết @Lazy có nghĩa gì.

Dùng @Lazy như bước trung gian trong khi refactor, không phải giải pháp cuối cùng.

ObjectProvider — defer tối đa

Một lựa chọn khác là ObjectProvider<B> — inject provider thay vì bean, resolve khi cần. Khác @Lazy ở chỗ có thể check bean có tồn tại không (provider.getIfAvailable()), và phù hợp hơn khi B là optional. Xem bài IoC & DI phần 9.3 để biết ví dụ đầy đủ.

7. Pitfall thường gặp

Nhầm 1 — bật allow-circular-references=true để app start:

spring:
  main:
    allow-circular-references: true   # band-aid, che giau van de thiet ke

App start được nhưng thiết kế vẫn có vòng. Lần sau thêm @Transactional hoặc @Async vào một trong hai class, AOP proxy có thể gây bug tinh tế với early reference.

✅ Fix root: refactor tách class hoặc dùng @Lazy tường minh.

Nhầm 2 — inject ApplicationContext để tránh circular (service locator):

@Service
public class A {
    @Autowired private ApplicationContext ctx;

    public void doWork() {
        B b = ctx.getBean(B.class);   // goi getBean runtime de tranh circular
        b.process();
    }
}

Đây là service locator anti-pattern, che giấu dependency, khó test. Circular vẫn tồn tại về mặt logic, chỉ bị defer sang runtime.

✅ Refactor hoặc @Lazy.

Nhầm 3 — tưởng rằng circular field injection luôn an toàn vì app start được:

Trong constructor hoặc @PostConstruct của A, nếu bạn gọi method trên this.bb có thể chưa được inject (chỉ là early reference chưa complete):

@Service
public class A {
    @Autowired private B b;

    @PostConstruct
    public void init() {
        b.process();   // AN TOAN: PostConstruct chay sau khi inject xong
    }

    public A() {
        // b.process();  // NGUY HIEM: b con null o day
    }
}

@PostConstruct chạy sau khi inject, nên b đã được set. Constructor thì b còn null.

8. Deep Dive — source Spring

Đọc source để hiểu cơ chế thật

Ba map và logic giải vòng nằm trong một class:

  • DefaultSingletonBeanRegistry — tìm field singletonObjects, earlySingletonObjects, singletonFactories, singletonsCurrentlyInCreation. Method getSingleton(String, boolean) là logic tra ba cấp cache.
  • AbstractAutowireCapableBeanFactory#doCreateBean — tìm đoạn addSingletonFactory(beanName, () -> getEarlyBeanReference(...)) — đây là lúc ObjectFactory được đăng ký vào L3.
  • AbstractBeanFactory#doGetBean — đoạn đầu gọi getSingleton(beanName) trước khi createBean — luồng tra cache.

Chỉ cần đọc tên field và signature method, không cần hiểu hết body. Sau 15 phút đọc, ba map trên trở thành concrete object trong đầu thay vì khái niệm trừu tượng.

Liên hệ các bài khác

  • BeanFactory vs ApplicationContext: ba map singletonObjects, earlySingletonObjects, singletonFactories đều nằm trong DefaultListableBeanFactory (kế thừa từ DefaultSingletonBeanRegistry) — bài đó giải thích tại sao ApplicationContext compose DefaultListableBeanFactory thay vì kế thừa thẳng.
  • Dependency Injection: ba dạng circular và ba cách fix ở cấp độ API — bài này là phần cơ chế bên dưới giải thích tại sao mỗi fix hoạt động.
  • Bean lifecycle: @PostConstruct chạy sau inject hoàn chỉnh — liên quan trực tiếp đến pitfall "b vẫn null trong constructor" đã nêu ở mục 7; bài lifecycle mổ đầy đủ thứ tự các callback.

Tóm tắt

  • Constructor circular: Spring không thể giải — throw BeanCurrentlyInCreationException tại startup. Đây là behavior tốt (fail fast).
  • Field/setter circular: Spring giải bằng three-level cache trong DefaultSingletonBeanRegistry. Khi B đang tạo và cần A (đang được tạo), Spring lấy early reference của A từ singletonFactories (L3), cache vào earlySingletonObjects (L2), inject cho B. A hoàn chỉnh rồi mới vào singletonObjects (L1).
  • Tại sao constructor không hưởng lợi: constructor Java không expose this trước khi trả về — không có điểm nào để đặt ObjectFactory vào L3.
  • Spring Boot 2.6+ tắt tính năng giải vòng lặp mặc định — buộc developer fix tường minh.
  • Fix đúng: refactor tách logic chung sang class C (90% case). @Lazy là band-aid hợp lý khi chưa refactor được.
  • Bật allow-circular-references=true là che giấu design smell, không phải fix.

Tự kiểm tra

Tự kiểm tra
Q1
Spring dùng ba map nào để giải circular dependency field injection? Map nào giữ instance hoàn chỉnh, map nào giữ early reference, map nào giữ factory?

Ba map trong DefaultSingletonBeanRegistry:

  • singletonObjects (ConcurrentHashMap): instance hoàn chỉnh — đã inject xong toàn bộ dependency, đã chạy @PostConstruct, sẵn sàng dùng. Đây là cache chính, mọi getBean() tra ở đây trước tiên.
  • earlySingletonObjects (ConcurrentHashMap): early reference — constructor đã chạy, object tồn tại trong heap, nhưng chưa inject dependency. Populated khi có circular request gọi factory ở L3.
  • singletonFactories (HashMap): ObjectFactory — lambda tạo early reference (có thể là AOP proxy) khi có circular yêu cầu. Factory chỉ được gọi khi cần, không gọi nếu không có circular.

Tra theo thứ tự: L1 (hoàn chỉnh) → L2 (early ref) → L3 (gọi factory). Nếu L3 trả về, kết quả được cache vào L2 cho lần hỏi tiếp theo.

Q2
Tại sao constructor circular dependency không thể giải bằng three-level cache, trong khi field injection có thể? Trả lời theo cơ chế tạo bean.

Vấn đề nằm ở thứ tự: ObjectFactory được đăng ký vào singletonFactories (L3) sau khi constructor chạy xong — constructor phải trả về trước thì mới có this để đăng ký.

Với field injection: constructor A chạy xong (không cần tham số B), A được đặt vào L3. Container bắt đầu tạo B; B cần A nên tra L3, thấy factory và gọi nó để nhận early ref của A. B inject xong, hoàn chỉnh, rồi đến lượt A inject B và cũng hoàn chỉnh.

Với constructor injection: constructor A đòi hỏi B trước khi constructor trả về, nên container chuyển sang tạo B; constructor B lại đòi hỏi A trong khi A chưa chạy xong constructor — L3 không có gì, tra L1, L2, L3 đều trống. Spring phát hiện vòng lặp qua singletonsCurrentlyInCreation và throw BeanCurrentlyInCreationException.

Ngắn gọn: field injection cho Spring cơ hội "tạo object trước, inject sau" — early reference có thể tồn tại. Constructor injection đòi "tất cả dependency phải có trước khi object tồn tại" — không có early reference.

Q3
Vì sao singletonFactories giữ ObjectFactory (lambda) thay vì giữ thẳng early reference của bean?

Vì AOP proxy. Nếu bean A được wrap bởi @Transactional hoặc @Async, early reference không được phép là raw A — phải là CGLIB/JDK proxy của A. Proxy này được tạo bởi AbstractAutoProxyCreator là một BeanPostProcessor.

ObjectFactory (lambda) giữ logic: "khi cần early reference của A, gọi getEarlyBeanReference(a)". Method này để các BeanPostProcessor can thiệp và trả proxy nếu cần.

Nếu giữ thẳng raw reference: bean B inject raw A, nhưng container sau đó tạo proxy wrap A và đặt proxy vào L1. Kết quả: B giữ raw A, nhưng phần còn lại của app dùng proxy A — hai tham chiếu khác nhau đến cùng nghiệp vụ, và @Transactional không chạy khi gọi qua B.

Dùng factory lười giải quyết vấn đề này: proxy chỉ được tạo khi thực sự có circular request, và được cache vào L2. Cả B và L1 đều giữ cùng proxy reference.

Q4
Spring Boot 2.6 thay đổi default allow-circular-references=false. Điều này ảnh hưởng cụ thể đến cơ chế three-level cache như thế nào — Spring dừng làm gì?

Khi allow-circular-references=false, trong AbstractAutowireCapableBeanFactory#doCreateBean, đoạn code đăng ký ObjectFactory vào singletonFactories bị bỏ qua:

Cụ thể: earlySingletonExposure flag (kiểm tra có nên expose early reference không) sẽ là false. Dòng addSingletonFactory(beanName, () -> getEarlyBeanReference(...)) không được gọi.

Hậu quả: L3 (singletonFactories) trống với mọi bean. Khi circular request xảy ra, getSingleton tra L1 → L2 → L3 đều không thấy; AbstractBeanFactory phát hiện bean đang nằm trong singletonsCurrentlyInCreation nên throw BeanCurrentlyInCreationException. Behavior giống constructor injection: fail startup bất kể injection mode.

Mục đích: buộc developer nhìn thấy circular dependency và fix tường minh, không để cơ chế âm thầm giải rồi gây bug tinh tế về sau.

Q5
@Lazy trên constructor parameter hoạt động ra sao để phá circular? Bean thực được resolve khi nào?

Khi Spring thấy @Lazy trên constructor parameter, thay vì inject bean B thực, nó inject một CGLIB proxy của interface/class B. Proxy này được tạo ngay (không cần B thực tồn tại), chỉ chứa metadata "khi method được gọi, resolve B từ container rồi delegate".

Nhờ đó:

  • A được tạo với proxy của B trong constructor (không cần B thực tồn tại lúc này).
  • Container không bị vào vòng circular tại construction time.
  • B được tạo bình thường (inject A thực từ L1 hoặc singletonObjects).
  • Lần đầu A gọi method trên proxy B, proxy mới gọi ctx.getBean(B.class) nội bộ và cache reference thực.

Trade-off: lỗi "B không tồn tại" bị defer từ startup sang lần gọi method đầu tiên. Overhead proxy nhỏ trên mỗi lời gọi. Code ít rõ ràng hơn refactor. Dùng như giải pháp trung gian, không phải cuối cùng.

Bài tiếp theo: Tổng kết module

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên