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:

Hai chuỗi tạo bean đặt cạnh nhau cho cùng một vòng A và B: cột constructor đi tới BeanCurrentlyInCreationException vì chưa bao giờ có object A để cho mượn, cột field injection có object A ngay sau khi constructor chạy xong nên gỡ được vòng

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:

Bốn bước tạo bean bên trái gắn với ba tầng cache bên phải: bước đăng ký chạm singletonFactories, lời gọi factory đưa tham chiếu sớm của A xuống earlySingletonObjects, hai bước cuối đẩy B rồi A lên singletonObjects

Tại sao cần ba cấp thay vì một?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) chính là bốn bước ở sơ đồ trên. 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.

Chuỗi chết này là đúng cột trái của sơ đồ ở section 1. 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
0/5 câu đã trả lời
  1. 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?
  2. 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.
  3. Q3
    Vì sao singletonFactories giữ ObjectFactory (lambda) thay vì giữ thẳng early reference của bean?
  4. 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ì?
  5. Q5
    @Lazy trên constructor parameter hoạt động ra sao để phá circular? Bean thực được resolve khi nào?

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

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

Bài tiếp theo

Nhập môn & IoC/DI — tổng kết