Circular Dependency: Cơ Chế Three-Level Cache Trong Spring
Cơ chế Three-Level Cache của Spring gỡ phụ thuộc vòng ở field/setter, lý do constructor thất bại và các giải pháp tái cấu trúc chuẩn mực.
TL;DR: Circular Dependency (phụ thuộc vòng) phát sinh khi Bean A cần Bean B và Bean B lại phụ thuộc ngược lại Bean A. Spring giải quyết được vòng lặp với Field/Setter Injection nhờ kiến trúc Three-Level Cache (ba tầng cache trong DefaultSingletonBeanRegistry). Ngược lại, vòng lặp Constructor Injection là bất khả kháng vì bean chưa thể khởi tạo để lộ tham chiếu sớm (early reference). Từ Spring Boot 2.6+, cơ chế tự động gỡ vòng bị tắt mặc định để buộc lập trình viên tái cấu trúc mã nguồn theo đúng chuẩn thiết kế.
Trong bài học trước về Dependency Injection: So Sánh 3 Hình Thức Nối Object, chúng ta đã biết Constructor Injection giúp nhận diện lỗi phụ thuộc vòng ngay tại thời điểm khởi động ứng dụng. Bài viết này sẽ mổ xẻ cơ chế hoạt động của Three-Level Cache bên dưới JVM và lý do tại sao Spring Boot quyết định vô hiệu hóa tính năng này theo mặc định.
Ba dạng phụ thuộc vòng (Circular Dependency) có hành vi khác nhau ra sao?
Cùng là mối quan hệ phụ thuộc vòng giữa A và B, nhưng cách thức khai báo tiêm phụ thuộc sẽ quyết định trực tiếp khả năng xử lý của Spring:

- Vòng lặp Constructor (A cần B, B cần A qua Constructor): Spring hoàn toàn bế tắc và lập tức ném ngoại lệ
BeanCurrentlyInCreationExceptionlúc khởi động. Đây là cơ chế bảo vệ hữu ích giúp phát hiện sớm lỗi kiến trúc. - Vòng lặp Field/Setter (A cần B, B cần A qua Field/Setter): Spring tự động tháo gỡ thành công nhờ bộ nhớ đệm 3 cấp (Three-Level Cache).
- Vòng lặp hỗn hợp (A dùng Constructor cần B, B dùng Field cần A): Kết quả phụ thuộc vào thứ tự bean nào được container chọn khởi tạo trước trong đồ thị, dễ phát sinh lỗi ngầm khó đoán.
Cơ chế Three-Level Cache trong DefaultSingletonBeanRegistry gồm những tầng nào?
Toàn bộ logic giải cứu vòng lặp Singleton nằm tại lớp DefaultSingletonBeanRegistry thông qua ba bảng băm:
DefaultSingletonBeanRegistry
├── singletonObjects (L1 Cache): ConcurrentHashMap<String, Object>
│ └─ Bean HOÀN CHỈNH: Đã tiêm xong thuộc tính, hoàn tất @PostConstruct
├── earlySingletonObjects (L2 Cache): ConcurrentHashMap<String, Object>
│ └─ Bean THÔ (Early Reference): Đã gọi constructor nhưng chưa tiêm thuộc tính
└── singletonFactories (L3 Cache): HashMap<String, ObjectFactory<?>>
└─ FACTORY TẠO BEAN SỚM: Chứa lambda sinh AOP Proxy khi phát hiện chu trình

Tại sao Spring cần đến 3 cấp cache thay vì 2 cấp? Điểm mấu chốt nằm ở AOP Proxy (@Transactional, @Async). Nếu Bean A cần tạo proxy bọc ngoài, early reference truyền cho Bean B phải chính là đối tượng Proxy đó chứ không được là instance nguyên bản (raw object). Tầng L3 lưu giữ một lambda trì hoãn: chỉ khi thực sự xảy ra phụ thuộc vòng thì logic sinh early proxy mới được kích hoạt.
Quy trình Spring giải cứu vòng lặp Field Injection diễn ra như thế nào?
Giả sử hai service sử dụng Field Injection và container bắt đầu khởi tạo Bean A trước:
@Service
public class A {
@Autowired private B b;
}
@Service
public class B {
@Autowired private A a;
}
Chu trình 4 bước tháo gỡ diễn ra tuần tự:
- Tạo thô Bean A: Container gọi constructor rỗng của A. Đối tượng A ra đời trên Heap (các field còn
null). Container đăng ký mộtObjectFactorycủa A vào cache L3 (singletonFactories). - Tiêm phụ thuộc cho A: Container phát hiện A cần B. Nó tạm dừng tiến trình của A và chuyển sang tạo Bean B.
- Bean B yêu cầu Bean A: B gọi constructor rỗng. Khi B cần tiêm
a, container tìm kiếm A qua hàmgetSingleton("a"). Hàm này kiểm tra lần lượt: L1 (chưa có) -> L2 (chưa có) -> L3 (tìm thấy ObjectFactory). Container kích hoạt Factory, thu được early reference của A, lưu vào L2 và xóa khỏi L3. Bean B tiêm thành công tham chiếu sớm của A, hoàn tất khởi tạo và chuyển lên cache L1. - Hoàn tất Bean A: Quay trở lại tiến trình của A, lúc này B đã hoàn chỉnh tại L1. Container tiêm B vào A, chạy các callback vòng đời và đưa A lên cache L1. Vòng lặp được gỡ bỏ hoàn toàn.
Tại sao Constructor Injection không thể hưởng lợi từ Three-Level Cache?
Với Constructor Injection:
@Service public class A { public A(B b) {} }
@Service public class B { public B(A a) {} }
Để hoàn tất lệnh gọi new A(b), đối tượng b buộc phải tồn tại từ trước trên bộ nhớ. Do đó, trước khi constructor của A hoàn tất và trả về, container chưa hề có bất kỳ tham chiếu this hợp lệ nào của A để đăng ký vào L3 (singletonFactories).
Khi A quay sang tạo B, B lại đòi hỏi A. Lúc này cả 3 tầng cache L1, L2, L3 đều hoàn toàn trống rỗng thông tin về A. Container kiểm tra thấy tên bean a đã nằm sẵn trong danh sách theo dõi singletonsCurrentlyInCreation, xác nhận bế tắc và lập tức ném lỗi BeanCurrentlyInCreationException.
Vì sao Spring Boot 2.6+ tắt tính năng tự động gỡ vòng lặp theo mặc định?
Bắt đầu từ phiên bản Spring Boot 2.6, thuộc tính cấu hình mặc định được thiết lập:
spring.main.allow-circular-references=false
Khi thuộc tính này tắt, cờ earlySingletonExposure nhận giá trị false, ngăn không cho container đăng ký Factory vào cache L3. Do đó, ngay cả Field hay Setter Injection cũng sẽ ném lỗi khi gặp phụ thuộc vòng.
Nguyên nhân cốt lõi: Phụ thuộc vòng là một mùi mã nguồn (design smell) nghiêm trọng, vi phạm nguyên lý Single Responsibility. Việc framework âm thầm giải quyết giúp che giấu khiếm khuyết kiến trúc, dẫn tới những lỗi tiềm ẩn lúc chạy thực tế (như truy cập trường dữ liệu null trong hàm khởi tạo hoặc sai lệch tham chiếu proxy).
Hai phương pháp giải quyết triệt để phụ thuộc vòng trong thực tế là gì?
1. Tái cấu trúc tách logic dùng chung sang Class C (Khuyến nghị chuẩn)
Trong đa số trường hợp, hai class phụ thuộc lẫn nhau vì chúng cùng chia sẻ một phương thức chung. Giải pháp căn cơ là trích xuất phương thức đó sang một service độc lập:
// Giai phap chuan: Trich xuat logic dung chung sang lop thu ba
@Service
public class SharedService {
public void executeSharedTask() { /* Logic nghiep vu dung chung */ }
}
@Service
public class OrderService {
private final SharedService shared;
public OrderService(SharedService shared) { this.shared = shared; }
}
@Service
public class BillingService {
private final SharedService shared;
public BillingService(SharedService shared) { this.shared = shared; }
}
2. Sử dụng chú thích @Lazy để trì hoãn khởi tạo
Nếu chưa thể tái cấu trúc ngay lập tức trên hệ thống legacy, bạn có thể gắn @Lazy vào tham số constructor:
@Service
public class OrderService {
private final BillingService billing;
public OrderService(@Lazy BillingService billing) {
this.billing = billing; // Spring inject CGLIB dynamic proxy
}
}
Spring sẽ tiêm một Dynamic Proxy đại diện thay vì bean thực tế. Khi phương thức nghiệp vụ đầu tiên được gọi trên proxy, bean thực tế mới được truy xuất từ container, nhờ đó phá vỡ bế tắc khởi tạo tại thời điểm startup.
Tóm tắt
- Three-Level Cache trong
DefaultSingletonBeanRegistryxử lý phụ thuộc vòng cho Field và Setter Injection của các bean Singleton. - Tầng cache L3 (
singletonFactories) chứa lambda giúp tạo sớm AOP Proxy khi phát hiện chu trình phụ thuộc. - Constructor Injection không thể gỡ vòng vì không thể tạo tham chiếu sớm trước khi hàm khởi tạo thực thi xong.
- Spring Boot 2.6+ vô hiệu hóa giải vòng lặp ngầm; hãy ưu tiên tái cấu trúc tách lớp độc lập hoặc dùng
@Lazytạm thời.
Tự kiểm tra
- Q1Ba tầng cache trong DefaultSingletonBeanRegistry đảm nhiệm vai trò gì theo thứ tự L1, L2, L3?
- Q2Tại sao Spring không thể giải quyết phụ thuộc vòng đối với Constructor Injection?
- Q3Tại sao tầng cache L3 phải lưu trữ ObjectFactory (lambda) thay vì lưu trực tiếp instance thô của Bean?
- Q4Tại sao từ Spring Boot 2.6 trở đi, cấu hình cho phép phụ thuộc vòng lại bị tắt mặc định (allow-circular-references=false)?
- Q5Cơ chế hoạt động của chú thích @Lazy khi đặt trên tham số constructor để phá vỡ phụ thuộc vòng là gì?
Bài tiếp theo: Tổng Kết Module: Làm Chủ Bản Chất IoC Và DI Trong Spring
Bài này có giúp bạn hiểu bản chất không?
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.
Dependency Injection: So Sánh 3 Hình Thức Nối Object
Bài tiếpTổng Kết Module: Làm Chủ Bản Chất IoC Và DI Trong Spring
Lộ trình Junior Java Backend Developer
Chặng 13/21 · Spring IoC, DI & Bean — bài 5/17 của chặ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.
Thảo luận & góp ý
Hỏi đáp về bài này