Singleton vs Prototype scope — cơ chế cache và pitfall scope-mismatch
Singleton scope không phải singleton pattern — nó là một entry trong singletonObjects map của container. Prototype tạo instance mới mỗi lookup. Pitfall nguy hiểm nhất: inject prototype vào singleton field → chỉ tạo 1 lần, prototype semantics mất. Ba cách fix: ObjectProvider, @Lookup, scoped proxy.
TL;DR: singleton (default) = Spring cache 1 instance trong singletonObjects map của container — stateless service chia sẻ giữa mọi thread mà không cần sync. prototype = tạo instance mới mỗi lần getBean() / inject. Pitfall lớn nhất là scope-mismatch: inject prototype vào singleton field thường → Spring chỉ inject 1 lần lúc startup, sau đó singleton giữ mãi instance đó, prototype semantics biến mất. Ba cách fix chuẩn: ObjectProvider<T> (khuyến nghị), @Lookup, hoặc scoped proxy.
Spring có 6 scope built-in. Bài này đào sâu cơ chế bên dưới hai scope cốt lõi nhất: tại sao singleton là default, tại sao prototype không cache, và cơ chế cụ thể khiến scope-mismatch xảy ra + 3 cách fix. Request/session scope sẽ có bài 04 — Request/Session scope riêng.
1. Singleton — một entry trong singletonObjects map
Từ bài BeanFactory vs ApplicationContext, ta đã thấy DefaultListableBeanFactory giữ một ConcurrentHashMap<String, Object> tên singletonObjects. Singleton scope là định nghĩa thao tác trên map đó: mỗi tên bean ánh xạ tới đúng 1 entry.
flowchart TB
GB["getBean(OrderService.class)"] --> Check{"singletonObjects<br/>co entry?"}
Check -->|"CO"| Return["return instance da cache<br/>(O(1))"]
Check -->|"CHUA"| Create["createBean:<br/>chon constructor<br/>resolve dependencies<br/>goi @PostConstruct"]
Create --> Cache["dat vao singletonObjects[name]"]
Cache --> ReturnLần đầu getBean: entry chưa có, container chạy createBean, cache kết quả vào map rồi trả về. Lần hai trở đi: tra map tìm thấy ngay, trả về cùng reference. Đây là định nghĩa chính xác của "singleton trong Spring": một entry trong một map của một container cụ thể — không phải singleton pattern static getInstance().
// Minh hoa: 2 lan getBean tra ve CUNG object
var a = ctx.getBean(OrderService.class);
var b = ctx.getBean(OrderService.class);
System.out.println(a == b); // true -- cung 1 entry trong singletonObjects
1.1 Vì sao singleton là default
Spring chọn singleton vì 90% bean là stateless — không có field thay đổi sau init. Service chỉ chứa logic; repository chỉ wrap DB call; configuration chỉ chứa setting. Object stateless có thể share giữa nghìn thread đồng thời mà không cần synchronize.
@Service
public class OrderService {
private final OrderRepository repo; // dependency injected, khong doi sau init
private final PaymentGateway payment;
public OrderService(OrderRepository repo, PaymentGateway payment) {
this.repo = repo;
this.payment = payment;
}
public Order place(OrderRequest req) { // method stateless -- khong co shared mutable state
var order = Order.from(req);
repo.save(order);
payment.charge(order.total());
return order;
}
}
Lợi ích kép: tiết kiệm memory (1 instance thay vì N) và startup nhanh (tạo 1 lần, sau đó O(1) lookup). Đây là lý do container không cần tạo lại bean mỗi request — đó sẽ là overhead vô nghĩa cho stateless object.
Cảnh báo quan trọng: singleton không tự động thread-safe. Nếu bạn đặt field mutable:
@Service
public class BadService {
private int requestCount = 0; // mutable -- shared giua moi thread
public void handle() {
requestCount++; // race condition: read + increment + write khong atomic
}
}
Ba thread tăng requestCount đồng thời có thể chỉ cộng thêm 1 thay vì 3. Fix: AtomicInteger, hoặc synchronized, hoặc tốt nhất — đẩy state ra ngoài (vào DB, metric counter, Redis).
2. Prototype — tạo mới mỗi lookup, không cache
Prototype scope đảo ngược hoàn toàn hành vi map:
flowchart TB GB1["getBean(Workflow.class) -- lan 1"] --> C1["createBean: new instance A"] GB2["getBean(Workflow.class) -- lan 2"] --> C2["createBean: new instance B"] GB3["getBean(Workflow.class) -- lan 3"] --> C3["createBean: new instance C"] style C1 fill:#fef3c7 style C2 fill:#fef3c7 style C3 fill:#fef3c7
Mỗi getBean() tạo instance mới, không đặt vào singletonObjects. Container không track prototype instance sau khi giao cho caller — hệ quả trực tiếp: @PreDestroy không bao giờ chạy với prototype. Caller chịu trách nhiệm cleanup.
@Component
@Scope("prototype")
public class ReportBuilder {
private final List<Section> sections = new ArrayList<>();
public ReportBuilder add(Section s) { sections.add(s); return this; }
public Report build() { return new Report(sections); }
}
Khai báo đúng: mỗi caller tạo một ReportBuilder riêng, state sections không rò rỉ sang caller khác.
2.1 Cơ chế bên dưới: vì sao prototype không cache
Source AbstractBeanFactory.doGetBean (rút gọn):
// File: AbstractBeanFactory.java
protected <T> T doGetBean(String name, ...) {
// Buoc 1: kiem tra singleton cache
Object sharedInstance = getSingleton(beanName);
if (sharedInstance != null) {
return adaptBeanInstance(name, sharedInstance, requiredType); // tra cache
}
// Buoc 2: singleton hay prototype?
if (mbd.isSingleton()) {
// getSingleton(name, factory) -- tao 1 lan va dat vao singletonObjects
sharedInstance = getSingleton(beanName, () -> createBean(beanName, mbd, args));
return adaptBeanInstance(name, sharedInstance, requiredType);
}
if (mbd.isPrototype()) {
// Khong co cache -- tao truc tiep, khong goi getSingleton
Object prototypeInstance = createBean(beanName, mbd, args);
return adaptBeanInstance(name, prototypeInstance, requiredType); // tra thang
}
// ... other scopes
}
Prototype bỏ qua hoàn toàn bước tra/ghi singletonObjects. Đây là lý do prototype "mới mỗi lần" — không phải magic, chỉ là một nhánh if trong doGetBean không ghi cache.
3. Pitfall scope-mismatch — nguyên nhân gốc và 3 cách fix
Đây là bug phổ biến nhất của prototype scope. Tình huống: bạn có ReportService singleton cần dùng ReportBuilder prototype (stateful, mỗi report cần builder riêng):
@Service // SINGLETON -- tao 1 lan
public class ReportService {
@Autowired private ReportBuilder builder; // PROTOTYPE -- nhung...
public Report generateReport(List<Section> sections) {
sections.forEach(builder::add);
return builder.build(); // dung CUNG instance moi lan goi!
}
}
Vì sao bug xảy ra: khi Spring khởi tạo ReportService (singleton), nó inject ReportBuilder một lần duy nhất. Field builder trỏ vào instance B1. Sau đó mọi call generateReport đều dùng B1. Kết quả: sections từ report lần trước tích lũy vào lần sau — logic sai, data corrupt.
sequenceDiagram participant Spring participant RS as ReportService (singleton) participant B1 as ReportBuilder B1 Spring->>RS: khoi tao ReportService Spring->>B1: tao ReportBuilder (prototype, 1 lan) Spring->>RS: inject B1 vao field builder Note over RS,B1: Sau do, moi call generateReport dung B1 cu RS->>B1: sections.forEach(builder::add) RS->>B1: builder.build() RS->>B1: sections.forEach(builder::add) -- lan 2 tich luy tren B1!
Cơ chế chính xác: singleton được tạo đúng một lần trong singletonObjects. Dependency injection xảy ra trong createBean của singleton đó. Spring gọi getBean(ReportBuilder.class) tại thời điểm đó và inject kết quả. Sau này không có cơ chế nào gọi lại getBean cho field đó.
Fix 1 — ObjectProvider (khuyến nghị, Spring 4.3+)
ObjectProvider<T> là wrapper type-safe quanh getBean(). Mỗi lần gọi getObject() thực hiện một getBean() mới → tạo instance prototype mới.
@Service
public class ReportService {
private final ObjectProvider<ReportBuilder> builderProvider;
public ReportService(ObjectProvider<ReportBuilder> builderProvider) {
this.builderProvider = builderProvider;
}
public Report generateReport(List<Section> sections) {
ReportBuilder builder = builderProvider.getObject(); // instance moi moi lan
sections.forEach(builder::add);
return builder.build();
}
}
Tại sao đây là cách chuẩn: ObjectProvider là API chính thức Spring, type-safe, hỗ trợ ifAvailable() / ifUnique() cho optional dependency. Inject thẳng vào constructor → dễ test (inject mock provider). Không cần annotation đặc biệt trên bean.
Fix 2 — @Lookup method injection
Spring override method abstract createX() tại runtime bằng CGLIB subclass, body của override là getBean(X.class):
@Service
public abstract class ReportService {
public Report generateReport(List<Section> sections) {
ReportBuilder builder = createBuilder(); // goi method duoc Spring override
sections.forEach(builder::add);
return builder.build();
}
@Lookup
protected abstract ReportBuilder createBuilder(); // Spring CGLIB tao instance moi
}
Lưu ý kỹ thuật quan trọng: class phải abstract (Spring tạo subclass concrete). Không dùng final. Cú pháp cũ hơn ObjectProvider, ít gặp trong code mới nhưng vẫn valid.
Fix 3 — Scoped proxy (ít khuyến nghị cho prototype)
Khai báo proxy trên bean prototype:
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ReportBuilder { ... }
@Service
public class ReportService {
@Autowired private ReportBuilder builder; // inject PROXY, khong phai instance that
public Report generateReport(List<Section> sections) {
// moi method call qua proxy -> proxy goi getBean() -> instance moi
sections.forEach(builder::add);
return builder.build();
}
}
Cách này inject một CGLIB proxy vào field builder. Mỗi method call qua proxy thực hiện một getBean() mới, trả về instance prototype mới. Tuy nhiên có vấn đề về semantic: mỗi method call là instance khác nhau. Gọi builder.add(s1) trên instance B1, gọi builder.build() trên instance B2 — B2 không có s1, kết quả rỗng.
Scoped proxy phù hợp hơn cho request/session scope (mỗi method call trong cùng một request dùng cùng instance), không phải prototype. Dùng ObjectProvider thay thế.
So sánh 3 cách fix
| Cách | Cơ chế | Khi nào dùng |
|---|---|---|
ObjectProvider<T> | Gọi getBean() tường minh khi cần | Khuyến nghị. Rõ ràng, type-safe, testable |
@Lookup | CGLIB override method trả getBean() | Bean abstract, muốn ẩn provider trong method |
| Scoped proxy | Proxy delegate mỗi method call | Request/session scope — không phải prototype |
4. @PreDestroy và prototype — cạm bẫy im lặng
Container không track destroy lifecycle của prototype. @PreDestroy sẽ không bao giờ chạy:
@Component
@Scope("prototype")
public class DatabaseCursor {
private Connection conn;
@PostConstruct
public void init() { conn = openConnection(); } // CHAY -- Spring tao instance
@PreDestroy
public void close() { conn.close(); } // KHONG CHAY -- Spring khong track destroy
}
Nếu DatabaseCursor cần cleanup, caller phải chịu trách nhiệm:
public void processData() {
DatabaseCursor cursor = cursorProvider.getObject();
try {
cursor.process();
} finally {
beanFactory.destroyBean(cursor); // explicit destroy -- chay @PreDestroy
}
}
Hoặc implement AutoCloseable và dùng try-with-resources (pattern sạch hơn). Đây là trade-off của prototype: linh hoạt tạo mới, nhưng caller phải quản lý lifecycle.
5. Cơ chế bên dưới — tại sao scope-mismatch im lặng mà không throw exception
Câu hỏi hợp lý: vì sao Spring không phát hiện và cảnh báo scope-mismatch lúc startup?
Spring có ghi cảnh báo log level DEBUG khi phát hiện bean singleton inject bean prototype:
WARN DefaultListableBeanFactory - Bean 'reportService' of type ...
depends on scoped bean 'reportBuilder' -- prototype injected as singleton dependency
Nhưng không throw exception vì Spring không thể biết ý định của bạn: bạn có thể chủ ý inject prototype như singleton (vd: "tôi chỉ cần 1 instance, tạo bằng prototype factory"). Spring không đủ context để phán xét.
Đây là lý do bug này khó phát hiện: code chạy không lỗi, chỉ sai logic khi state tích lũy qua nhiều call.
Liên hệ các bài khác
- BeanFactory vs ApplicationContext:
singletonObjectsmap nơi singleton sống — bài này cho thấy cơ chếgetBeantra map đó; singleton scope chính là định nghĩa "một entry trong map đó". - Request/Session scope: hai web scope còn lại (request/session) và scoped proxy — phần còn lại của bức tranh scope.
- Request/Session scope: scope web-tier dùng ThreadLocal và proxy — cùng cơ chế proxy như Fix 3 nhưng semantic đúng hơn với request/session.
- Bean lifecycle: giai đoạn
createBeanmà bài này tham chiếu được mổ đầy đủ ở đó —@PostConstruct,@PreDestroy,BeanPostProcessorxử lý tại pha nào.
Tóm tắt
- Singleton = 1 entry trong
singletonObjects(ConcurrentHashMap) của container. Không phảistatic getInstance(). Scope singleton = "phạm vi một container". - Singleton là default vì 90% bean stateless — share an toàn giữa thread. Singleton không tự thread-safe: field mutable cần
AtomicXxx/synchronized. - Prototype = không cache —
doGetBeanbỏ quasingletonObjects, gọicreateBeantrực tiếp mỗi lần. @PreDestroykhông chạy với prototype — container không track destroy. Caller cleanup quadestroyBean()hoặcAutoCloseable.- Scope-mismatch: inject prototype vào singleton field → Spring inject 1 lần lúc startup → field giữ mãi instance đó → prototype semantics mất.
- Ba cách fix:
ObjectProvider<T>.getObject()(chuẩn),@Lookupabstract method (legacy), scoped proxy (dành cho request/session scope, không phải prototype). - Spring cảnh báo scope-mismatch qua log DEBUG — không throw exception vì không đủ context phán xét ý định.
Tự kiểm tra
Q1Định nghĩa chính xác "singleton scope trong Spring" là gì? Tại sao nó khác với singleton pattern (static getInstance()) trong GoF?▸
static getInstance()) trong GoF?Singleton scope trong Spring nghĩa là một bean name ánh xạ tới đúng 1 instance trong singletonObjects map của một container cụ thể. Không phải "1 instance toàn JVM".
Singleton GoF dùng static field + getInstance() — đảm bảo 1 instance trên toàn ClassLoader / JVM. Spring không làm vậy: nếu bạn tạo 2 AnnotationConfigApplicationContext, mỗi context có singletonObjects map riêng → 2 instance của cùng bean class. Không có gì static trong cơ chế Spring.
Hệ quả thực tế: tạo 2 context trong cùng process (vô tình hoặc trong test) → 2 bộ singleton độc lập → state không đồng bộ. Mỗi process nên có 1 root context.
Q2Đoạn code sau in gì? Giải thích theo cơ chế inject xảy ra lúc nào.@Component
@Scope("prototype")
public class Counter {
private int n = 0;
public int next() { return ++n; }
}
@Service
public class App {
@Autowired Counter c;
public void run() {
System.out.println(c.next());
System.out.println(c.next());
System.out.println(c.next());
}
}
▸
@Component
@Scope("prototype")
public class Counter {
private int n = 0;
public int next() { return ++n; }
}
@Service
public class App {
@Autowired Counter c;
public void run() {
System.out.println(c.next());
System.out.println(c.next());
System.out.println(c.next());
}
}In 1, 2, 3 — không phải 1, 1, 1.
Dù Counter là prototype, nó được inject vào singleton App một lần duy nhất lúc Spring khởi tạo App. Sau đó field c trỏ mãi vào instance đó. Mỗi next() tăng n trên cùng instance.
Đây chính là scope-mismatch: prototype inject vào singleton field thường → mất prototype semantics. Để in 1, 1, 1 (mỗi lần instance mới), cần ObjectProvider<Counter> và gọi provider.getObject().next() mỗi lần.
Q3Trong source AbstractBeanFactory.doGetBean, sự khác biệt cụ thể giữa nhánh xử lý singleton và prototype là gì? Tại sao prototype không được đặt vào singletonObjects?▸
AbstractBeanFactory.doGetBean, sự khác biệt cụ thể giữa nhánh xử lý singleton và prototype là gì? Tại sao prototype không được đặt vào singletonObjects?Nhánh singleton gọi getSingleton(beanName, factory) — method này tra singletonObjects trước, nếu chưa có thì gọi factory (tức createBean) rồi đặt kết quả vào singletonObjects trước khi trả về.
Nhánh prototype gọi createBean(beanName, mbd, args) trực tiếp rồi trả về ngay, không ghi cache. Không có lời gọi getSingleton, không ghi singletonObjects.
Lý do design: prototype tồn tại để tạo object mới mỗi lần — đặt vào cache sẽ vi phạm ngữ nghĩa đó. Ngoài ra, container không biết khi nào caller xong với instance → không thể biết khi nào xoá khỏi cache → memory leak nếu cache prototype. Đó cũng là lý do @PreDestroy không chạy.
Q4Bạn có bean EmailTemplate (prototype) và NotificationService (singleton) inject EmailTemplate qua field. Scope-mismatch đang xảy ra. Hãy viết lại NotificationService dùng ObjectProvider để fix.▸
EmailTemplate (prototype) và NotificationService (singleton) inject EmailTemplate qua field. Scope-mismatch đang xảy ra. Hãy viết lại NotificationService dùng ObjectProvider để fix.Trước (bug):
@Service
public class NotificationService {
@Autowired EmailTemplate template; // inject 1 lan, dung mai
public void send(String msg) {
template.setBody(msg);
template.deliver();
}
}Sau (fix):
@Service
public class NotificationService {
private final ObjectProvider<EmailTemplate> templateProvider;
public NotificationService(ObjectProvider<EmailTemplate> templateProvider) {
this.templateProvider = templateProvider;
}
public void send(String msg) {
EmailTemplate template = templateProvider.getObject(); // instance moi
template.setBody(msg);
template.deliver();
}
}Điểm quan trọng: inject ObjectProvider qua constructor (không phải field injection) → dễ test bằng cách truyền mock provider. Gọi getObject() bên trong method — không phải trong constructor (nếu gọi trong constructor lại rơi vào bẫy cũ: 1 instance duy nhất).
Q5Vì sao scoped proxy (proxyMode = TARGET_CLASS) không phù hợp để fix scope-mismatch khi bean là prototype, dù nó hoạt động đúng cho request scope?▸
proxyMode = TARGET_CLASS) không phù hợp để fix scope-mismatch khi bean là prototype, dù nó hoạt động đúng cho request scope?Scoped proxy delegate mỗi method call về getBean() để lấy instance "hiện tại" của scope đó. Với request scope, "instance hiện tại" là 1 instance duy nhất cho request này — tất cả method call trong cùng request đều dùng chung instance → semantic đúng (accumulate state trong request).
Với prototype, mỗi getBean() tạo instance mới. Nghĩa là mỗi method call qua proxy dùng một instance khác nhau. Ví dụ: builder.add(section1) trên instance B1, builder.build() trên instance B2 — B2 không biết section1 nên trả về report rỗng. State không tích lũy được.
Vì vậy prototype cần caller kiểm soát thời điểm tạo instance (ObjectProvider.getObject() tường minh) — không phải proxy tự động. Proxy prototype chạy không lỗi nhưng logic sai.
Bài tiếp theo: Request/Session scope & scoped proxy
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
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