Bean scope trả lời đúng một câu hỏi: container giữ bao nhiêu instance của class này, và giữ trong bao lâu. Chọn sai thì không có gì báo lỗi lúc khởi động cả — chương trình vẫn chạy, test vẫn xanh, và rồi một user nhìn thấy dữ liệu của user khác. Lifecycle trả lời câu hỏi đi kèm: code khởi tạo và code dọn dẹp của bạn chạy ở thời điểm nào so với injection, so với các post-processor, và so với lúc shutdown.
Đây là hai chỗ mà tutorial hay nói sai nhất, nên không có dòng nào dưới đây được viết theo trí nhớ. Mọi thứ tự, mọi con số instance và mọi hash code trong bài đều lấy từ một lần chạy có trace trên Spring Boot 4.1.1, và output được dán thẳng từ terminal.
![]()
Toolchain trong bài là Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) trên OpenJDK 21.0.6, build bằng Gradle wrapper. @PostConstruct và @PreDestroy nằm trong jakarta.annotation, không phải javax.annotation như ngày xưa — artifact là jakarta.annotation-api:3.0.0 và spring-boot-starter đã kéo sẵn về cho bạn.
singleton là mặc định, và nó không có nghĩa là một instance cho cả JVM
Khai báo một bean mà không đặt @Scope thì bạn nhận được singleton:
package com.example.lab.twocontexts;
import org.springframework.stereotype.Component;
@Component
public class Registry {
public String id() {
return getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(this));
}
}Chữ "singleton" ở đây tính theo container, không tính theo JVM. Container giữ đúng một instance cho mỗi ApplicationContext, và không có gì ngăn bạn tạo hai context. Dựng cả hai trong cùng một JVM rồi hỏi mỗi cái cùng một bean:
var a = new AnnotationConfigApplicationContext(Registry.class);
var b = new AnnotationConfigApplicationContext(Registry.class);
Registry a1 = a.getBean(Registry.class);
Registry a2 = a.getBean(Registry.class);
Registry b1 = b.getBean(Registry.class);context A, 1st lookup : Registry@5aebe890
context A, 2nd lookup : Registry@5aebe890
context B, 1st lookup : Registry@65d09a04
a1 == a2 : true
a1 == b1 : falseHai container, hai instance, một JVM. Đây không phải trường hợp hiếm phải cố tình đi tìm — @SpringBootTest với các configuration khác nhau, một web context có parent/child, và bất kỳ test class nào đổi một property đều tạo ra context riêng, còn context cache của Boot trong test thì giữ nhiều context sống cùng lúc.
Vậy nên Spring singleton không phải singleton pattern của Gang of Four. Pattern GoF ép một instance cho mỗi class loader bằng private constructor và một static accessor; chính class đưa ra lời bảo đảm đó, và lời bảo đảm mang tính toàn cục. Spring singleton chỉ là một class bình thường với constructor public bình thường — new Registry() vẫn chạy và vẫn cho bạn một object không được quản lý — còn lời hứa "chỉ một cái" chỉ là quyết định ghi sổ của một bean factory về một bean name. Đặt hai name cho cùng một class trong cùng một context thì bạn có hai singleton.
Hệ quả thực tế đáng nhớ nhất: một singleton bean được mọi thread trong container đó dùng chung, nên field của nó là shared mutable state trừ khi bạn chủ động làm khác đi. Giữ singleton stateless, hoặc làm cho state đó thread-safe một cách có chủ ý.
Sáu scope, và mỗi scope sống bao lâu
Hai scope chạy được ở mọi nơi. Ba scope nữa chỉ tồn tại trong web application, và một cái cuối chỉ có khi WebSocket nằm trên classpath.
| Scope | Bao nhiêu instance | Ai tạo, và tạo lúc nào | Bị destroy khi nào |
|---|---|---|---|
singleton | một cái cho mỗi ApplicationContext | container, tạo sẵn trong refresh() | context.close() |
prototype | một cái cho mỗi injection point và mỗi lần gọi getBean() | container, tạo khi được yêu cầu | không bao giờ — container không giữ reference |
request | một cái cho mỗi HTTP request | container, lần đầu được truy cập trong request | khi response ghi xong |
session | một cái cho mỗi HTTP session | container, lần đầu được truy cập trong session | khi session bị invalidate hoặc hết hạn |
application | một cái cho mỗi ServletContext | container, lần đầu được truy cập | khi ServletContext bị destroy |
websocket | một cái cho mỗi WebSocket session | container, lần đầu được truy cập | khi WebSocket session đóng |
application nhìn giống singleton nhưng không phải một thứ. Bean scope application được lưu như một attribute của ServletContext, nên mọi context bên trong servlet container đó đều thấy nó; còn singleton sống trong một bean factory. Trong một Boot application bình thường chỉ có một context nên hai cái trùng nhau — và đó chính là lý do sự khác biệt chỉ cắn bạn ở đúng những setup mà nó có ý nghĩa.

Tên scope là hằng số chứ không phải chuỗi ma thuật — ConfigurableBeanFactory.SCOPE_SINGLETON và SCOPE_PROTOTYPE, WebApplicationContext.SCOPE_REQUEST, SCOPE_SESSION và SCOPE_APPLICATION — còn @Scope("prototype") chỉ là viết thẳng giá trị ra.
singleton và prototype, đếm bằng getBean()
Cách sạch nhất để đếm instance là bắt chính bean tự khai báo. Hai bean cùng class, mỗi bean một scope, cả hai đều có lifecycle hook:
public class Resource {
private final String kind;
public Resource(String kind) { this.kind = kind; }
public String id() { return kind + "@" + Integer.toHexString(System.identityHashCode(this)); }
@PostConstruct
public void open() { System.out.println(" @PostConstruct " + id()); }
@PreDestroy
public void close() { System.out.println(" @PreDestroy " + id()); }
}@Configuration
public class ScopeConfig {
@Bean
public Resource singletonResource() { return new Resource("singleton"); }
@Bean
@Scope("prototype")
public Resource prototypeResource() { return new Resource("prototype"); }
}Gọi getBean() ba lần cho mỗi cái, rồi đóng context:
refresh:
@PostConstruct singleton@273e7444
three getBean() calls for each scope:
singleton -> singleton@273e7444
singleton -> singleton@273e7444
singleton -> singleton@273e7444
@PostConstruct prototype@3ba987b8
prototype -> prototype@3ba987b8
@PostConstruct prototype@3f191845
prototype -> prototype@3f191845
@PostConstruct prototype@5f049ea1
prototype -> prototype@5f049ea1
close:
@PreDestroy singleton@273e7444
done — count the @PreDestroy lines aboveMười bốn dòng đó cho ra bốn sự thật. Singleton được dựng trước lần getBean() đầu tiên — ngay trong refresh(), tạo sẵn — và đó là lý do một singleton hỏng làm chết lúc khởi động chứ không phải lúc request đầu tiên. Prototype được dựng trong mỗi lần getBean(), ba lần, ba hash code. @PostConstruct chạy trên từng prototype. Và cả lần chạy chỉ có đúng một dòng @PreDestroy, của singleton. Nhớ kỹ chi tiết cuối này, nó có hẳn một mục riêng ở dưới.
request, session và application, đếm bằng curl
Các web scope cần một HTTP request thật mới tồn tại được, nên cách trung thực duy nhất để minh họa là chạy application lên rồi gọi vào. Năm bean, mỗi scope một cái, inject vào cùng một controller:
@RestController
public class ScopeController {
private final AppSingleton singleton;
private final ObjectProvider<PerPrototype> prototypes;
private final PerRequest perRequest;
private final PerSession perSession;
private final PerApplication perApplication;
// constructor omitted
@GetMapping("/scopes")
public String scopes() {
return singleton.id() + "\n"
+ prototypes.getObject().id() + "\n"
+ perRequest.id() + "\n"
+ perSession.id() + "\n"
+ perApplication.id() + "\n";
}
}PerRequest, PerSession và PerApplication mang @RequestScope, @SessionScope và @ApplicationScope; riêng session bean tăng một bộ đếm mỗi lần được đọc. Chạy jar rồi gọi ba lần qua cùng một cookie jar, sau đó gọi lần thứ tư không kèm cookie:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8089
for i in 1 2 3; do curl -s -b jar.txt -c jar.txt http://localhost:8089/scopes; done
curl -s http://localhost:8089/scopes--- call 1
singleton @54574977
prototype @6536eb57
request @6bf11981
session @6fce8027 hits=1
application@668220eb
--- call 2
singleton @54574977
prototype @32bc6a3e
request @7306df4f
session @6fce8027 hits=2
application@668220eb
--- call 3
singleton @54574977
prototype @2e0dc9a
request @c2722b6
session @6fce8027 hits=3
application@668220eb
--- fourth call, no cookie jar
singleton @54574977
prototype @9f6a3e8
request @7315ca6
session @30ec898 hits=1
application@668220ebĐọc theo từng cột. Singleton và application bean không đổi qua cả bốn lần gọi. Prototype đổi ở mọi lần, vì controller xin một cái mới mỗi lần. Bean request đổi ở mọi lần, kể cả lần thứ tư. Bean session giống hệt nhau ở lần 1 tới 3 và hits leo 1, 2, 3 — ba request đó mang cùng một JSESSIONID:
#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID 0A6A7989C5DC95834FFFE903071B2761Bỏ cookie đi thì lần 4 nhận session mới và session bean mới với hits=1. Toàn bộ cơ chế chỉ có vậy: request scope lấy khóa từ servlet request, session scope lấy khóa từ session id trong cookie.
Trong phạm vi một request, bean scope request đúng là một object duy nhất chứ không phải mỗi lần đọc một cái:
1st read in this request : request @30808d08
2nd read in this request : request @30808d08Log phía server của hai lần gọi trong cùng một session, kết thúc bằng một lệnh session.invalidate(), cho thấy phía destroy:
request scope @PostConstruct request @7af7b378
session scope @PostConstruct session @7ec828fe
request scope @PreDestroy request @7af7b378
request scope @PostConstruct request @2c8f6553
request scope @PreDestroy request @2c8f6553
session scope @PreDestroy session @7ec828feMỗi request bean được tạo và destroy gọn trong request của nó. Session bean được tạo một lần và chỉ destroy khi session kết thúc — do invalidate(), do hết hạn, hoặc khi context shutdown.
Vì sao @PreDestroy không bao giờ chạy với prototype
Quay lại lần chạy getBean() ở trên: ba prototype được tạo, ba lần @PostConstruct chạy, và không lần nào @PreDestroy chạy. Đây không phải bug và cũng không phải một tùy chọn bạn bật lên được. Nó được nói thẳng trong tài liệu tham khảo của Spring, và nó là hệ quả trực tiếp của cách container làm việc.
Với mọi scope khác, container giữ một sổ đăng ký các instance nó đã tạo để sau này còn gọi được destruction callback. Riêng với prototype thì nó cố tình không giữ. Container tạo object, cấu hình nó, chạy các callback khởi tạo, trao cho bạn — rồi quên luôn. Reference duy nhất bây giờ là của bạn, và đó cũng là lý do một prototype bị garbage-collect ngay khi bạn thôi giữ nó, y hệt một object tạo bằng new.
Vì thế @PreDestroy, DisposableBean.destroy() và @Bean(destroyMethod = ...) đều chết lặng lẽ trên một bean scope prototype. Nếu prototype đó đang giữ thứ phải được trả lại — một socket, một file handle, một connection — bạn có ba lựa chọn:
- Đừng để nó là prototype. Phần lớn trường hợp, thứ cần vòng đời ngắn là một object thường do code bạn
newra, chứ không phải một bean. - Tự trả lại. Cho class implement
AutoCloseablerồi dùng try-with-resources, rõ ràng hơn mọi lifecycle hook. - Nhờ container làm một cách tường minh bằng
beanFactory.destroyBean(instance), lệnh này chạy destruction callback trên một object mà container không theo dõi. Cách này đòi bạn phải biết "lúc nào là sau", mà nếu đã biết thì có lẽ bạn không cần một bean.
⚠️ Một prototype được inject vào singleton còn ít được destroy hơn thế: nó thậm chí không bao giờ thành rác, vì singleton giữ reference suốt vòng đời application. Đó là nội dung mục tiếp theo, và cũng là lỗi scope phổ biến nhất trong Spring.
Singleton giữ một prototype
Đây là một bean scope prototype và một singleton service bình thường nhận nó qua constructor:
package com.example.lab.trap;
@Component
@Scope("prototype")
public class Worker {
public String id() { return "Worker@" + Integer.toHexString(System.identityHashCode(this)); }
}@Service
public class BrokenService {
private final Worker worker;
public BrokenService(Worker worker) { this.worker = worker; }
public String handle() { return worker.id(); }
}Code này compile được, chạy được và nhìn rất ổn. Nó sai, và lý do gói gọn trong một câu: injection chỉ xảy ra một lần, lúc singleton được tạo. Container resolve Worker đúng một lần — và ngay lúc đó nó tạo một prototype mới, vì prototype nghĩa là vậy — rồi cất kết quả vào một field final sống lâu bằng cả application. Mọi lần gọi handle() sau này đều đọc đúng field đó.
Gọi handle() ba lần, đặt cạnh ba cách sửa, ra thế này:
call injected prototype ObjectProvider @Lookup scoped proxy
1 Worker@19976a65 Worker@a1f72f5 Worker@4bc222e ProxiedWorker@13bc8645
2 Worker@19976a65 Worker@15a04efb Worker@16c069df ProxiedWorker@2bec854f
3 Worker@19976a65 Worker@31edaa7d Worker@26adfd2d ProxiedWorker@3336e6b6Cột một không bao giờ đổi. Annotation scope không bị bỏ qua — đúng là đã có một prototype được tạo ra thật — chỉ là nó làm việc đúng một lần rồi hết quyền phát biểu. Cột hai, ba và bốn đổi ở mọi lần gọi, đúng như cái annotation khiến bạn trông đợi.

Lỗi mà chuyện này gây ra trong code thật không phải là "object bị cũ". Nó là: prototype kia hầu như chắc chắn được đặt scope prototype vì nó giữ state riêng cho từng lần gọi — một builder, một request context, một list đang cộng dồn — và giờ state đó bị mọi thread trong application dùng chung.
ObjectProvider, cách sửa mặc định
Inject một provider thay vì inject chính bean. ObjectProvider<T> là một tay cầm cho thao tác lookup của container theo type đó; gọi getObject() là chạy lookup, mà lookup một prototype nghĩa là một instance mới.
@Service
public class ProviderService {
private final ObjectProvider<Worker> workers;
public ProviderService(ObjectProvider<Worker> workers) { this.workers = workers; }
public String handle() { return workers.getObject().id(); }
}Cột hai của lần chạy phía trên — ba lần gọi, ba instance:
Worker@a1f72f5
Worker@15a04efb
Worker@31edaa7dĐây là cách nên chọn mặc định. Không subclass, không proxy, không phải thêm @Bean nào, và chỗ gọi nói thẳng ra rằng một object mới đang được lấy về — mô tả trung thực đúng những gì đang diễn ra. Lưu ý trong bài này ObjectProvider được dùng thuần túy để điều khiển thời điểm lookup xảy ra; công dụng còn lại của nó, diễn đạt một dependency không bắt buộc, là chuyện khác.
@Lookup method injection
Cơ chế cũ hơn. Khai báo một method abstract trả về prototype rồi gắn annotation; Spring subclass class của bạn bằng CGLIB và cài đặt method đó thành một lệnh lookup vào container.
@Service
public abstract class LookupService {
@Lookup
protected abstract Worker newWorker();
public String handle() { return newWorker().id(); }
}Cột ba của cùng lần chạy đó:
Worker@4bc222e
Worker@16c069df
Worker@26adfd2dCách này chạy được, và giữ nguyên type của prototype ở chỗ gọi mà không có type nào của Spring nằm trong field. Cái giá là class của bạn phải không final và phải subclass được, thường là phải abstract, và method bị framework viết lại một cách âm thầm — một bất ngờ cho người đọc code sau bạn. Dùng nó khi bạn phải chiều theo một class không tái cấu trúc được; còn lại hãy ưu tiên ObjectProvider.
Scoped proxy đặt ngay trên bean
Hai cách trên sửa ở phía người dùng bean. Scoped proxy sửa ở chính bean, nghĩa là mọi injection point được sửa cùng lúc:
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ProxiedWorker {
public String id() { return "ProxiedWorker@" + Integer.toHexString(System.identityHashCode(this)); }
}Giờ ProxiedWorker inject như một field bình thường mà vẫn hành xử đúng như prototype — cột bốn:
ProxiedWorker@13bc8645
ProxiedWorker@2bec854f
ProxiedWorker@3336e6b6Thứ thật sự được inject vào không phải là một ProxiedWorker:
the injected field's runtime class : com.example.lab.trap.ProxiedWorker$$SpringCGLIB$$0Spring đăng ký một proxy dưới bean name đó và đặt bean thật ra phía sau. Proxy là một CGLIB subclass của class bạn viết — nên nó qua được kiểm tra type ở injection point — và mọi lời gọi method trên nó đi vào container trước để lấy target hiện tại của scope, rồi mới tới target đó. TARGET_CLASS là mode bạn muốn khi target là một class; ScopedProxyMode.INTERFACES tạo ra JDK dynamic proxy và chỉ chạy khi bean được inject theo type interface.
Đánh đổi ở đây là lớp trung gian hoàn toàn vô hình. Người đọc dòng private final ProxiedWorker worker; không có cách nào thấy được mỗi lần gọi chạm tới một object khác nhau, và method final thì CGLIB subclass không chặn được, nên một method final trên target sẽ lặng lẽ thoát khỏi proxy. Dùng scoped proxy khi bean được inject ở nhiều chỗ và bạn muốn scope được tôn trọng ở mọi nơi ngay từ thiết kế. Dùng ObjectProvider khi nó chỉ được inject ở một hai chỗ và bạn muốn chỗ gọi nói rõ ra.
Vì sao các web scope cũng cần proxy
Một @Controller hay @Service là singleton, nên inject một bean scope request vào đó chính là cùng một vấn đề — chỉ khác ở chỗ lần này nó không sai âm thầm mà từ chối khởi động luôn. Lấy một bean scope request không đặt proxy mode:
@Component
@Scope("request") // no proxyMode
public class RawRequestBean { /* ... */ }rồi inject vào một singleton. Khởi động chết ngay:
org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name
'rawRequestBean': Scope 'request' is not active for the current thread; consider defining a scoped
proxy for this bean if you intend to refer to it from a singleton
Caused by: java.lang.IllegalStateException: No thread-bound request found: Are you referring to
request attributes outside of an actual web request, or processing a request outside of the
originally receiving thread?Đó là mô tả rất chính xác tình huống: container đang dựng các singleton trong refresh(), trên main thread, nơi chưa hề có HTTP request nào, nên scope request không có gì để resolve dựa vào.
Đây chính là lý do @RequestScope, @SessionScope và @ApplicationScope tồn tại như những annotation riêng thay vì chỉ là cách viết khác của @Scope("request"). Mỗi cái là @Scope với proxyMode đã được đặt mặc định thành TARGET_CLASS:
@Scope(WebApplicationContext.SCOPE_REQUEST)
public @interface RequestScope {
@AliasFor(annotation = Scope.class)
ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}Dùng ba annotation đó thì proxy có sẵn, và đó là lý do controller ở mục trước nhận được PerRequest như một constructor parameter bình thường mà vẫn thấy instance khác nhau ở mỗi lần gọi. ObjectProvider<PerRequest> cũng chạy, và là lựa chọn thay thế khi bạn không muốn có proxy nào trong bức tranh.
Bean lifecycle theo đúng thứ tự nó chạy
Thứ tự này đáng được xác lập bằng thực nghiệm chứ không phải bằng trí nhớ. Một bean cài đặt mọi hook có thể có — BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean, DisposableBean, cộng thêm @PostConstruct và @PreDestroy — khai báo bằng @Bean(initMethod = ..., destroyMethod = ...), có một BeanPostProcessor đứng nhìn, và mỗi callback tự in tên mình ra theo thứ tự:
public class Gadget implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
InitializingBean, DisposableBean {
private final Engine engine;
private Tank tank;
public Gadget(Engine engine) {
this.engine = engine;
Trace.step("constructor");
}
@Autowired
public void setTank(Tank tank) { this.tank = tank; Trace.step("populate properties"); }
@Override public void setBeanName(String name) { Trace.step("BeanNameAware"); }
@Override public void setBeanFactory(BeanFactory bf) { Trace.step("BeanFactoryAware"); }
@Override public void setApplicationContext(ApplicationContext c) { Trace.step("ApplicationContextAware"); }
@PostConstruct public void postConstruct() { Trace.step("@PostConstruct"); }
@Override public void afterPropertiesSet() { Trace.step("InitializingBean.afterPropertiesSet"); }
public void customInit() { Trace.step("@Bean(initMethod)"); }
public void work() { Trace.step("IN SERVICE"); }
@PreDestroy public void preDestroy() { Trace.step("@PreDestroy"); }
@Override public void destroy() { Trace.step("DisposableBean.destroy"); }
public void customDestroy() { Trace.step("@Bean(destroyMethod)"); }
}@Configuration
public class LifecycleConfig {
@Bean
public static TraceBeanPostProcessor traceBpp() { return new TraceBeanPostProcessor(); }
@Bean public Engine engine() { return new Engine(); }
@Bean public Tank tank() { return new Tank(); }
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
public Gadget gadget(Engine engine) { return new Gadget(engine); }
}Chạy cái đó trên Boot 4.1.1 ra kết quả sau, và số thứ tự là do chính chương trình đánh:
---- context.refresh() ----
1 constructor (dependencies passed in: Engine)
2 populate properties (@Autowired setter: Tank)
3 BeanNameAware (bean name = gadget)
4 BeanFactoryAware
5 ApplicationContextAware
6 BPP.postProcessBeforeInitialization
7 @PostConstruct
8 InitializingBean.afterPropertiesSet
9 @Bean(initMethod) customInit()
10 BPP.postProcessAfterInitialization
---- application running ----
11 IN SERVICE work() called by the application
---- context.close() ----
12 @PreDestroy
13 DisposableBean.destroy
14 @Bean(destroyMethod) customDestroy()
---- JVM about to exit ----
Năm chi tiết trong trace đó đáng để ý.
Bước 1 và bước 2 chỉ tách rời vì bean này có cả hai. Constructor nhận Engine; setter gắn @Autowired nhận Tank sau đó, trên một object đã tồn tại rồi. Một bean chỉ dùng constructor injection thì không có bước 2 nào cả — dependency của nó tới ngay trong bước 1, và đó đúng là lý do constructor injection cho phép field final còn setter injection thì không.
Ba callback *Aware chạy sau khi object đã được nạp đủ và trước mọi hook khởi tạo, theo thứ tự cố định: name, factory, context. Bạn sẽ hiếm khi cài đặt chúng; biết chúng nằm ở đâu là thứ giúp bạn đọc được một stack trace đi qua chúng.
Ba hook khởi tạo cùng chạy, theo thứ tự cố định, và chúng không thay thế nhau. Nếu một bean khai báo cả ba thì cả ba đều nổ: @PostConstruct, rồi afterPropertiesSet(), rồi initMethod. Lúc shutdown cũng vậy, theo chiều ngược lại.
Bước 10 là nơi proxy xuất hiện. postProcessAfterInitialization được phép trả về một object khác, và đó chính là hook mà AOP dùng: bean mà container cất đi là cái proxy do bước này trả về, không phải object mà constructor của bạn dựng. Đó cũng là lý do một method @Transactional hay @Async gọi từ bên trong cùng class không đi qua proxy — this là target, không phải lớp bọc.
Bước 12 tới 14 là có điều kiện. Chúng chạy ở đây vì chương trình đã gọi ctx.close(). Để context mở thì không bước nào trong ba bước đó chạy, không bao giờ.
BeanPostProcessor chạy trước hay sau @PostConstruct?
Trace nói là trước — bước 6 so với bước 7 — và đáng biết rằng đây không phải một tình cờ về thứ tự mà bạn có thể đảo lại.
@PostConstruct không do container xử lý trực tiếp. Nó được cài đặt bởi CommonAnnotationBeanPostProcessor, mà bản thân class này là một BeanPostProcessor làm việc trong postProcessBeforeInitialization. Vậy câu hỏi "cái nào chạy trước" thực ra là "post-processor nào đứng trước trong chuỗi", và câu trả lời bị PostProcessorRegistrationDelegate chốt cứng:
// Finally, re-register all internal BeanPostProcessors.
sortPostProcessors(internalPostProcessors, beanFactory);
registerBeanPostProcessors(beanFactory, internalPostProcessors);Các annotation processor của chính Spring được rút khỏi phần sắp xếp và nối lại vào cuối chuỗi. Chạy lại đúng trace đó một lần với BeanPostProcessor khai báo PriorityOrdered ở mức ưu tiên cao nhất, và một lần nữa với cái không đặt thứ tự gì, kết quả tương đối vẫn như nhau:
5 ApplicationContextAware
6 UNORDERED BPP.postProcessBeforeInitialization
7 @PostConstruct
8 InitializingBean.afterPropertiesSetVậy một BeanPostProcessor do bạn viết luôn nhìn thấy bean trước khi @PostConstruct của nó chạy, dù bạn đặt thứ tự nào đi nữa. Nếu bạn cần thấy một bean đã khởi tạo xong, hãy dùng postProcessAfterInitialization.
Lần chạy thứ hai còn cho thấy một điều nữa: không có dòng customInit() và không có dòng customDestroy(), vì configuration đó khai báo @Bean mà không kèm initMethod hay destroyMethod. Hai bước ấy chỉ tồn tại khi bạn yêu cầu.
Nên dùng lifecycle hook nào?
Với gần như mọi code ứng dụng, câu trả lời là: dùng constructor, và chỉ dùng @PostConstruct khi constructor không làm được việc đó.
| Hook | Dùng khi | Vì sao không nên dùng |
|---|---|---|
| constructor | dependency bắt buộc và mọi state suy ra được từ chúng | — |
@PostConstruct | việc cần object đã inject đầy đủ: validate config, làm ấm cache, mở connection | chạy khi object đã ở trạng thái nửa vời, nên không thể làm field final |
InitializingBean | bạn đang viết code framework | một interface của Spring nằm trong class nghiệp vụ, mà chẳng lợi gì hơn @PostConstruct |
@Bean(initMethod = ...) | class bên thứ ba không gắn annotation được | nhìn từ chính class đó thì không thấy gì |
@PreDestroy | trả lại thứ bạn đã giữ: flush, close, hủy đăng ký | chỉ chạy khi shutdown êm, và không bao giờ chạy với prototype |
DisposableBean | lại là code framework | cùng vấn đề coupling như InitializingBean |
@Bean(destroyMethod = ...) | class bên thứ ba có close()/shutdown() | thường là thừa, vì Spring tự suy ra |
Dòng cuối là thứ ít người biết. destroyMethod trên @Bean mặc định là hằng AbstractBeanDefinition.INFER_METHOD, nghĩa là: nếu class của bean là AutoCloseable/Closeable, hoặc đơn giản là có một method public không có parameter tên close() hay shutdown(), Spring sẽ gọi nó lúc shutdown mà chẳng cần ai bảo. Đặt destroyMethod = "" để tắt hành vi đó.
@Configuration
public class InferConfig {
@Bean // destroyMethod defaults to "(inferred)"
public Pool inferredPool() { return new Pool("inferredPool"); }
@Bean(destroyMethod = "") // inference switched off
public Pool optedOutPool() { return new Pool("optedOutPool"); }
@Bean
public Cache cache() { return new Cache(); } // not Closeable, just has shutdown()
}closing the context:
cache.shutdown() ran
inferredPool.close() ran
doneHai bean cùng class, và chỉ cái không tắt suy luận mới được đóng. Cache chẳng implement gì cả mà vẫn bị shutdown, thuần túy vì method của nó tên là shutdown(). Sự suy luận đó rất tiện, cho tới lúc bạn có một close() mang nghĩa khác — một close() trên object nghiệp vụ dùng để chốt sổ một hóa đơn chẳng hạn — và khi đó destroyMethod = "" là cách sửa.
Hai hook không nằm trong bảng, vì chúng ở mức container chứ không phải mức bean: ApplicationRunner và CommandLineRunner chạy sau khi cả context đã sẵn sàng, đúng chỗ cho công việc cần các bean khác đã sống, còn @EventListener(ApplicationReadyEvent.class) làm cùng việc đó dưới dạng một event.
Destroy chỉ xảy ra khi shutdown êm
@PreDestroy không phải một lời bảo đảm. Nó là một callback container gọi trên đường đi qua close(), và container chỉ đi tới đó nếu JVM cho phép.
Spring Boot đăng ký sẵn một JVM shutdown hook, nên Ctrl+C — tức SIGINT — và SIGTERM đều kết thúc bằng việc context được đóng đàng hoàng. Gửi SIGTERM cho một application đang chạy:
kill -TERM $(pgrep -f demo-0.0.1-SNAPSHOT.jar)o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
>>> Bookkeeper.@PreDestroy — flushing before exit
session scope @PreDestroy session @31dacf93Tomcat ngừng nhận connection mới và xả nốt những cái đang dở, rồi context đóng và các destruction callback chạy — kể cả cho những bean scope session còn sống ở thời điểm đó.
SIGKILL không cho process cơ hội đó:
kill -9 $(pgrep -f demo-0.0.1-SNAPSHOT.jar) request scope @PostConstruct request @6e5b477b
request scope @PreDestroy request @6e5b477bLog đơn giản là dừng lại sau request cuối cùng. Không có dòng graceful shutdown, không có Bookkeeper, không có gì. kill -9 không phải con đường duy nhất dẫn tới đây: một lệnh dừng container quá thời gian ân hạn, một cú OOM kill từ kernel, mất điện, và cả System.exit() gọi từ trong một shutdown hook đều kết thúc như nhau.
Vậy có ba con đường riêng biệt khiến một @PreDestroy không bao giờ chạy, và nên nêu chúng cùng nhau:
- bean có scope prototype, nên container chưa bao giờ theo dõi nó;
- process bị giết chứ không phải được báo hiệu, nên
close()không bao giờ được chạm tới; - context không bao giờ được đóng — đúng những gì xảy ra với một
new AnnotationConfigApplicationContext(...)không gọiclose(), và là lý do nên dùng dạng try-with-resources trong một methodmain.
Nguyên tắc thiết kế rút ra: coi @PreDestroy là một cú dọn dẹp cố-gắng-hết-sức, không phải thứ giữ cho dữ liệu của bạn đúng. Cái gì phải sống sót qua kill -9 thì phải bền vững trước khi process chết — đã commit, đã flush, đã được xác nhận — chứ không phải xếp hàng chờ một callback có thể không bao giờ tới.
Lazy initialization, và cái giá phải trả
Singleton được dựng sẵn trong refresh(). @Lazy hoãn một cái lại cho tới lần đầu cần tới:
@Lazy
@Component
public class Heavy {
public Heavy() { System.out.println(" Heavy constructed"); }
}refresh():
Eager constructed
refresh() finished — Heavy has not been built yet
first getBean(Heavy.class):
Heavy constructed
Heavy@5fa07e12spring.main.lazy-initialization=true áp dụng đúng cách đó cho mọi bean trong application.
Cái nó mang lại là thời gian khởi động, và trên một application nhỏ thì gần như không đáng kể. Đo ở đây, ba lần chạy mỗi bên, trên demo chỉ có một controller:
lazy=false 0.555 s 0.582 s 0.609 s
lazy=true 0.537 s 0.551 s 0.554 sCon số chỉ mang tính tham khảo trên một máy, và khác biệt nằm trong nhiễu. Phần thắng tỉ lệ với số bean bạn không dùng trong lần chạy đó, nên nó đáng giá trên một application lớn lúc phát triển, còn ở chỗ khác thì nên đo trước khi tin.
Cái nó lấy đi mới là thứ phải cân. Một bean khởi tạo lỗi không còn làm chết lúc khởi động nữa — nó chết ở lần dùng đầu tiên. Đây là một bean có @PostConstruct ném exception, trong một application lẽ ra khởi động được:
@Component
public class Broken {
@PostConstruct
void validate() { throw new IllegalStateException("config key 'billing.url' is missing"); }
}Chạy kiểu eager thì application không bao giờ lên:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'broken':
Invocation of init method failed
Caused by: java.lang.IllegalStateException: config key 'billing.url' is missingBật spring.main.lazy-initialization=true thì nó lên hoàn hảo:
Tomcat started on port 8089 (http) with context path '/'
Started LazyFailApplication in 0.464 seconds (process running for 0.584)và rồi request đầu tiên chạm tới nó trả về 500:
HTTP/1.1 500
{"timestamp":"2026-09-11T04:05:09.055Z","status":500,"error":"Internal Server Error","path":"/ping"}Servlet.service() for servlet [dispatcherServlet] threw exception [Request processing failed:
UnsatisfiedDependencyException: Error creating bean with name 'lazyFailController': Unsatisfied
dependency expressed through constructor parameter 0: Error creating bean with name 'broken':
Invocation of init method failed] with root cause
java.lang.IllegalStateException: config key 'billing.url' is missingMột lỗi cấu hình mà health check lẽ ra đã chặn được trước khi instance nhận traffic nay trở thành lỗi runtime giáng vào một user thật. Đó là lý do lazy initialization bật toàn cục là tiện lợi lúc phát triển và là rủi ro lúc chạy thật, và là lý do @Lazy đặt lên đúng một bean thật sự nặng là một đánh đổi dễ bảo vệ hơn nhiều.
FAQ
Spring singleton có thread-safe không?
Bean thì không; container thì có. Spring bảo đảm bạn nhận đúng một instance dùng chung và không làm gì cho việc truy cập đồng thời vào field của nó. Một instance phục vụ mọi request nghĩa là mọi field mutable đều là shared state, nên hoặc giữ singleton stateless — dependency nằm trong field final, mọi thứ còn lại nằm ở method parameter và local variable — hoặc làm state đó thread-safe một cách tường minh bằng AtomicLong, ConcurrentHashMap hay synchronization đàng hoàng.
Vì sao bean prototype của tôi chỉ được tạo một lần?
Vì nó được inject vào một singleton, mà injection chỉ xảy ra một lần. Annotation scope vẫn được tôn trọng — đúng là có một prototype được tạo ra lúc inject — chỉ là sau đó không ai hỏi tới nó nữa. Sửa bằng ObjectProvider<T> ở injection point, @Lookup trên một method, hoặc @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) trên chính bean.
Nên dùng @PostConstruct hay constructor?
Constructor, bất cứ khi nào nó làm được việc. Nó chạy trước, nó gán được field final, nó làm object hợp lệ ngay khi vừa tồn tại, và nó test được bằng new thuần. Dùng @PostConstruct cho những thứ constructor thật sự không làm được: việc cần dependency inject qua setter hoặc field, việc phụ thuộc vào chuyện bean là instance do container quản lý, hoặc bất cứ thứ gì chậm tới mức bạn không muốn nó nằm trong constructor.
singleton scope và application scope khác nhau ở đâu?
singleton là một instance cho mỗi ApplicationContext; application là một instance cho mỗi ServletContext, lưu như một attribute của servlet context. Một Spring Boot application bình thường có một context nằm trong một servlet context, nên hai cái nhìn giống hệt nhau. Chúng tách ra khi có nhiều hơn một context trong cùng servlet container — nhiều DispatcherServlet, hoặc một bố trí parent/child — khi đó bean scope application được dùng chung còn các singleton thì không.
Có cần @PreDestroy để đóng DataSource không?
Không, và thêm vào thường là sai. HikariDataSource của HikariCP là AutoCloseable, nên cơ chế suy luận destroyMethod của Spring tự đóng nó lúc shutdown, và điều đó đúng với hầu hết resource dạng pool khai báo qua @Bean. Chỉ viết @PreDestroy cho thứ bạn tự tay giữ mà Spring không nhìn thấy — một background thread bạn khởi động, một subscription bạn đăng ký với hệ thống bên ngoài.
Tôi tự định nghĩa scope riêng được không?
Được. Implement org.springframework.beans.factory.config.Scope — hai method đáng quan tâm là get(String, ObjectFactory) và registerDestructionCallback — rồi đăng ký bằng một bean CustomScopeConfigurer hoặc beanFactory.registerScope("tenant", new TenantScope()). Sau đó @Scope("tenant") chạy y như mọi tên có sẵn. Đây là công cụ đúng cho một vòng đời thật sự theo-một-cái-gì-đó, theo tenant hay theo job, và là công cụ sai cho bất cứ thứ gì một Map trong singleton lo được.
Kết luận
Scope là quyết định về việc có bao nhiêu instance tồn tại và mỗi cái sống bao lâu, và singleton — giá trị mặc định — nghĩa là một cái cho mỗi container chứ không phải cho mỗi JVM, điều mà hai context trong một process chứng minh chỉ bằng ba dòng output. prototype được tạo ở mỗi lần được yêu cầu rồi bị bỏ rơi: container không bao giờ theo dõi nó, nên @PreDestroy không bao giờ chạy. Inject một prototype vào singleton thì scope lặng lẽ hết tác dụng, vì injection đã xảy ra đúng một lần; ObjectProvider là cách sửa mặc định, @Lookup là cách cũ, còn scoped proxy là cách sửa mọi injection point cùng lúc — và cũng là lý do @RequestScope và @SessionScope được giao sẵn với proxyMode = TARGET_CLASS.
Thứ tự lifecycle không phải chuyện truyền miệng. Trace trên Boot 4.1.1, nó chạy constructor, nạp property, ba callback *Aware, postProcessBeforeInitialization, @PostConstruct, afterPropertiesSet(), initMethod, postProcessAfterInitialization, phục vụ, rồi @PreDestroy, destroy() và destroyMethod — với BeanPostProcessor của chính bạn luôn đứng trước @PostConstruct, vì Spring nối lại các processor nội bộ của nó vào cuối chuỗi. Ưu tiên constructor, dùng @PostConstruct cho việc constructor không làm được, và nhớ rằng mọi thứ sau bước 11 đều phụ thuộc vào chuyện process được tắt một cách lịch sự.
Bài tiếp theo mở ra phần đã âm thầm làm việc suốt cả series: auto-configuration hoạt động ra sao — cơ chế conditional đằng sau nó, và báo cáo --debug cho bạn biết chính xác điều kiện nào khớp và điều kiện nào không.