Singleton Và Prototype Scope: Cạm Bẫy Scope Mismatch
So sánh Singleton và Prototype scope trong Spring, phân tích cạm bẫy scope mismatch khi tiêm prototype vào singleton và 3 giải pháp xử lý.
TL;DR: singleton (mặc định) nghĩa là Spring cache đúng 1 instance trong map singletonObjects của container để chia sẻ giữa mọi thread. Trái lại, prototype tạo instance mới mỗi khi gọi getBean() hoặc thực hiện dependency injection. Cạm bẫy nghiêm trọng nhất là scope mismatch: tiêm một prototype bean vào thuộc tính của singleton bean khiến Spring chỉ tiêm đúng 1 lần lúc startup, biến prototype thành singleton vĩnh viễn. Ba giải pháp chuẩn gồm: ObjectProvider<T> (khuyến nghị), @Lookup, hoặc chuyển sang scoped proxy.
Hiểu rõ hai phạm vi cốt lõi này giúp bạn tránh được những lỗi rò rỉ dữ liệu (state corruption) vô hình giữa các request của người dùng trong hệ thống thực tế.
1. Cơ chế Singleton trong Spring lưu trữ đối tượng ở đâu?
Trong DefaultListableBeanFactory, Spring quản lý các singleton bean thông qua một cấu trúc dữ liệu ConcurrentHashMap<String, Object> mang tên singletonObjects. Khái niệm singleton của Spring được định nghĩa là mỗi tên bean ánh xạ tới đúng một entry trong map này:
// Kiem tra tham chieu cua singleton bean
var instanceA = context.getBean(OrderService.class);
var instanceB = context.getBean(OrderService.class);
System.out.println(instanceA == instanceB); // Luon tra ve true
Khác với GoF Singleton Pattern sử dụng biến static trên toàn ClassLoader của JVM, Singleton trong Spring chỉ có phạm vi duy nhất trong một ApplicationContext. Nếu bạn khởi tạo 2 context độc lập trong cùng một ứng dụng, mỗi context sẽ sở hữu một instance riêng biệt.
2. Vì sao Spring chọn Singleton làm phạm vi mặc định?
Spring chọn Singleton làm phạm vi mặc định vì đa số các Service và Repository trong ứng dụng doanh nghiệp là stateless (không duy trì trạng thái riêng biệt theo từng lời gọi):
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public Order createOrder(OrderRequest request) {
Order order = Order.from(request);
return repository.save(order);
}
}
Việc tái sử dụng một instance duy nhất mang lại hai lợi ích lớn:
- Tiết kiệm bộ nhớ: Không phải cấp phát hàng triệu đối tượng ngắn hạn trên heap cho mỗi request, giảm áp lực dọn rác (GC).
- Tăng tốc truy xuất: Container chỉ khởi tạo bean một lần lúc khởi động, các lần truy xuất sau đều đạt độ phức tạp $O(1)$ từ map.
Nếu bạn khai báo một biến mutable (như private int counter = 0;) trong singleton bean, nhiều thread đồng thời sẽ gây ra hiện tượng tranh chấp dữ liệu (Race Condition). Bạn bắt buộc phải đẩy trạng thái ra cơ sở dữ liệu bên ngoài hoặc dùng các kiểu dữ liệu an toàn như AtomicInteger.
3. Prototype Scope hoạt động ra sao và vì sao không được lưu cache?
Khi khai báo @Scope("prototype"), Spring đảo ngược hoàn toàn hành vi lưu trữ:

Bên trong phương thức AbstractBeanFactory.doGetBean(), nhánh xử lý của prototype bỏ qua việc kiểm tra và ghi vào singletonObjects:
// Trich doan logic AbstractBeanFactory
if (mbd.isSingleton()) {
sharedInstance = getSingleton(beanName, () -> createBean(beanName, mbd, args));
return adaptBeanInstance(name, sharedInstance, requiredType);
}
if (mbd.isPrototype()) {
// Tao moi truc tiep va tra ve ngay, khong luu vao cache
Object prototypeInstance = createBean(beanName, mbd, args);
return adaptBeanInstance(name, prototypeInstance, requiredType);
}
Mỗi lần gọi getBean(), Spring khởi tạo một instance mới toanh và bàn giao ngay cho caller mà không giữ lại bất kỳ tham chiếu nào.
4. Cạm bẫy Scope Mismatch phát sinh như thế nào trong thực tế?
Lỗi phổ biến và nguy hiểm nhất xuất hiện khi ta tiêm trực tiếp một Prototype Bean vào một thuộc tính của Singleton Bean:
@Component
@Scope("prototype")
public class ReportBuilder {
private final List<String> lines = new ArrayList<>();
public void addLine(String line) { lines.add(line); }
public String build() { return String.join("\n", lines); }
}
@Service // Singleton bean mac dinh
public class ReportService {
@Autowired
private ReportBuilder builder; // Sai lam: chi tiem dung 1 lan luc startup
public String generateReport(List<String> content) {
content.forEach(builder::addLine);
return builder.build();
}
}

Vì ReportService là singleton nên nó chỉ được khởi tạo một lần duy nhất lúc container khởi động. Tại thời điểm đó, Spring tiêm một instance ReportBuilder (gọi là B1) vào trường builder. Từ thời điểm đó trở đi, mọi request gửi tới generateReport() đều dùng chung instance B1 này. Kết quả là dữ liệu báo cáo của các request sau bị dính chồng chéo lên dữ liệu của các request trước.
Spring hoàn toàn im lặng, không đưa ra cảnh báo hay ngoại lệ nào lúc khởi động vì việc gán trường này hoàn toàn hợp lệ về mặt kỹ thuật.
5. Làm thế nào để khắc phục triệt để lỗi Scope Mismatch?
Có 3 phương pháp chính để giải quyết vấn đề này trong thực tế:
Giải pháp 1: Sử dụng ObjectProvider (Chuẩn khuyến nghị từ Spring 4.3+)
ObjectProvider<T> cung cấp một cơ chế lấy bean theo nhu cầu (on-demand) an toàn kiểu dữ liệu:
@Service
public class ReportService {
private final ObjectProvider<ReportBuilder> builderProvider;
public ReportService(ObjectProvider<ReportBuilder> builderProvider) {
this.builderProvider = builderProvider;
}
public String generateReport(List<String> content) {
// Lay instance prototype moi moi khi method nay duoc goi
ReportBuilder builder = builderProvider.getObject();
content.forEach(builder::addLine);
return builder.build();
}
}
Mỗi lần phương thức getObject() được gọi bên trong hàm xử lý, Spring Container sẽ thực hiện một lần lookup mới và trả về một instance ReportBuilder độc lập.
Giải pháp 2: Sử dụng Method Injection với @Lookup
Spring sử dụng CGLIB để sinh subclass và ghi đè một phương thức trừu tượng tại runtime:
@Service
public abstract class ReportService {
public String generateReport(List<String> content) {
ReportBuilder builder = getReportBuilder(); // Goi method duoc Spring override
content.forEach(builder::addLine);
return builder.build();
}
@Lookup
protected abstract ReportBuilder getReportBuilder();
}
Phương pháp này yêu cầu lớp hoặc phương thức không được mang từ khóa final để CGLIB có thể kế thừa và can thiệp.
Giải pháp 3: Sử dụng Scoped Proxy
Bằng cách cấu hình proxyMode = ScopedProxyMode.TARGET_CLASS trên định nghĩa bean:
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ReportBuilder { /* ... */ }
Tuy nhiên, với prototype bean, scoped proxy khiến mỗi lần gọi phương thức trên proxy lại lấy một instance mới. Nếu bạn gọi builder.addLine() rồi gọi builder.build(), hai phương thức này sẽ chạy trên hai instance hoàn toàn khác nhau. Do đó, scoped proxy chỉ phù hợp cho Request hoặc Session scope.
6. Tại sao @PreDestroy không bao giờ hoạt động trên Prototype Bean?
Đối với các singleton bean, container duy trì danh sách các disposable beans để kích hoạt phương thức dọn dẹp khi ứng dụng dừng. Nhưng với prototype, Spring giao quyền sở hữu đối tượng hoàn toàn cho phía gọi:
@Component
@Scope("prototype")
public class TempConnection {
@PostConstruct
public void init() { /* Chay binh thuong khi tao bean */ }
@PreDestroy
public void cleanup() { /* KHONG BAO GIO duoc Spring tu dong goi */ }
}
Nếu prototype bean chiếm dụng tài nguyên hệ thống (như socket, file handle), bạn bắt buộc phải hiện thực AutoCloseable và tự giải phóng tài nguyên trong khối try-with-resources.
7. Giải quyết bài toán rò rỉ dữ liệu PDF Report giữa các người dùng?
Giả sử hệ thống tạo tài liệu PDF xuất hiện lỗi: người dùng A tải báo cáo tài chính thì thấy xuất hiện một số trang tài liệu của người dùng B:
// Giai phap xu ly triet de voi ObjectProvider
@Service
public class PdfExportService {
private final ObjectProvider<PdfDocumentBuilder> documentProvider;
public PdfExportService(ObjectProvider<PdfDocumentBuilder> documentProvider) {
this.documentProvider = documentProvider;
}
public byte[] export(List<String> pages) {
PdfDocumentBuilder builder = documentProvider.getObject(); // Moi request nhan 1 builder sach
pages.forEach(builder::addPage);
return builder.render();
}
}
Việc gọi documentProvider.getObject() trực tiếp bên trong phương thức export() đảm bảo mỗi luồng xử lý nhận một instance builder riêng biệt, loại bỏ hoàn toàn nguy cơ nhiễm chéo dữ liệu.
Tóm tắt
- Singleton là phạm vi mặc định của Spring, lưu trữ đối tượng duy nhất trong map
singletonObjectsđể chia sẻ giữa các luồng. - Prototype tạo đối tượng mới cho mỗi lần yêu cầu; Spring không quản lý vòng đời hủy (
@PreDestroy) của prototype bean. - Cạm bẫy Scope Mismatch xảy ra khi tiêm prototype bean vào thuộc tính của singleton bean, khiến prototype chỉ khởi tạo một lần duy nhất.
ObjectProvider<T>.getObject()là giải pháp chuẩn mực nhất để yêu cầu một instance prototype mới theo nhu cầu thực tế.
Tự kiểm tra
- Q1Sự khác biệt cốt lõi giữa Singleton trong Spring Container và GoF Singleton Pattern là gì?
- Q2Tại sao phương thức callback @PreDestroy không được Spring Container kích hoạt trên các Prototype Bean?
- Q3Hiện tượng gì xảy ra khi bạn tiêm trực tiếp một Prototype Bean vào trường của một Singleton Bean thông qua @Autowired?
- Q4Tại sao việc sử dụng ScopedProxyMode.TARGET_CLASS lại không phù hợp để sửa lỗi Scope Mismatch trên Prototype Bean có lưu trạng thái?
- Q5Giải pháp nào được khuyến nghị từ Spring 4.3+ để Singleton Bean có thể lấy Prototype Bean mới theo nhu cầu một cách an toàn nhất?
Bài tiếp theo: Request & Session Scope: Cơ Chế Scoped Proxy Cho Web
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.
Cơ Chế AOP Dynamic Proxy: So Sánh JDK Proxy Và CGLIB
Bài tiếpRequest & Session Scope: Cơ Chế Scoped Proxy Cho Web
Lộ trình Junior Java Backend Developer
Chặng 13/21 · Spring IoC, DI & Bean — bài 10/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