Command Palette

Search for a command to run...

[Spring Boot Basics] Bean scope và lifecycle trong Spring: singleton, prototype, @PostConstruct và @PreDestroy

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.

Một bean trên đường đời, vào qua @PostConstruct và ra qua @PreDestroy

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@PreDestroy nằm trong jakarta.annotation, không phải javax.annotation như ngày xưa — artifact là jakarta.annotation-api:3.0.0spring-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:

src/main/java/com/example/lab/twocontexts/Registry.java
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:

Java
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);
Text
context A, 1st lookup : Registry@5aebe890
context A, 2nd lookup : Registry@5aebe890
context B, 1st lookup : Registry@65d09a04
a1 == a2 : true
a1 == b1 : false

Hai 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.

ScopeBao nhiêu instanceAi tạo, và tạo lúc nàoBị destroy khi nào
singletonmột cái cho mỗi ApplicationContextcontainer, tạo sẵn trong refresh()context.close()
prototypemột cái cho mỗi injection point và mỗi lần gọi getBean()container, tạo khi được yêu cầukhông bao giờ — container không giữ reference
requestmột cái cho mỗi HTTP requestcontainer, lần đầu được truy cập trong requestkhi response ghi xong
sessionmột cái cho mỗi HTTP sessioncontainer, lần đầu được truy cập trong sessionkhi session bị invalidate hoặc hết hạn
applicationmột cái cho mỗi ServletContextcontainer, lần đầu được truy cậpkhi ServletContext bị destroy
websocketmột cái cho mỗi WebSocket sessioncontainer, lần đầu được truy cậpkhi 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.

Bốn scope trên cùng một lần chạy container, cho thấy có bao nhiêu instance và mỗi cái sống bao lâu

Tên scope là hằng số chứ không phải chuỗi ma thuật — ConfigurableBeanFactory.SCOPE_SINGLETONSCOPE_PROTOTYPE, WebApplicationContext.SCOPE_REQUEST, SCOPE_SESSIONSCOPE_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:

Resource.java
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()); }
}
ScopeConfig.java
@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:

Text
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 above

Mườ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:

ScopeController.java
@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, PerSessionPerApplication mang @RequestScope, @SessionScope@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:

Bash
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
Text
--- 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:

Text
#HttpOnly_localhost	FALSE	/	FALSE	0	JSESSIONID	0A6A7989C5DC95834FFFE903071B2761

Bỏ 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:

Text
1st read in this request : request    @30808d08
2nd read in this request : request    @30808d08

Log 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:

Text
  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    @7ec828fe

Mỗ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()@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 new ra, chứ không phải một bean.
  • Tự trả lại. Cho class implement AutoCloseable rồ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:

src/main/java/com/example/lab/trap/Worker.java
package com.example.lab.trap;
 
@Component
@Scope("prototype")
public class Worker {
    public String id() { return "Worker@" + Integer.toHexString(System.identityHashCode(this)); }
}
BrokenService.java
@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:

Text
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@3336e6b6

Cộ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.

Singleton giữ mãi một prototype đã inject, đặt cạnh ObjectProvider lấy instance mới ở mỗi lần gọ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.

ProviderService.java
@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:

Text
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.

LookupService.java
@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 đó:

Text
Worker@4bc222e
Worker@16c069df
Worker@26adfd2d

Cá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:

ProxiedWorker.java
@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:

Text
ProxiedWorker@13bc8645
ProxiedWorker@2bec854f
ProxiedWorker@3336e6b6

Thứ thật sự được inject vào không phải là một ProxiedWorker:

Text
the injected field's runtime class : com.example.lab.trap.ProxiedWorker$$SpringCGLIB$$0

Spring đă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:

RawRequestBean.java
@Component
@Scope("request")   // no proxyMode
public class RawRequestBean { /* ... */ }

rồi inject vào một singleton. Khởi động chết ngay:

Text
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@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:

Java
@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@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ự:

Gadget.java
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)"); }
}
LifecycleConfig.java
@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:

Text
    ---- 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 ----

Mười bốn bước lifecycle theo thứ tự, chú thích hook nào chạy ở mỗi bước

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:

Java
// 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:

Text
 5  ApplicationContextAware
 6  UNORDERED BPP.postProcessBeforeInitialization
 7  @PostConstruct
 8  InitializingBean.afterPropertiesSet

Vậ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 đó.

HookDùng khiVì sao không nên dùng
constructordependency bắt buộc và mọi state suy ra được từ chúng
@PostConstructviệc cần object đã inject đầy đủ: validate config, làm ấm cache, mở connectionchạy khi object đã ở trạng thái nửa vời, nên không thể làm field final
InitializingBeanbạn đang viết code frameworkmộ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 đượcnhìn từ chính class đó thì không thấy gì
@PreDestroytrả 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
DisposableBeanlại là code frameworkcù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 đó.

InferConfig.java
@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()
}
Text
closing the context:
  cache.shutdown() ran
  inferredPool.close() ran
done

Hai 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: ApplicationRunnerCommandLineRunner 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:

Bash
kill -TERM $(pgrep -f demo-0.0.1-SNAPSHOT.jar)
Text
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    @31dacf93

Tomcat 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 đó:

Bash
kill -9 $(pgrep -f demo-0.0.1-SNAPSHOT.jar)
Text
  request scope   @PostConstruct request    @6e5b477b
  request scope   @PreDestroy    request    @6e5b477b

Log đơ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ọi close(), và là lý do nên dùng dạng try-with-resources trong một method main.

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:

Heavy.java
@Lazy
@Component
public class Heavy {
    public Heavy() { System.out.println("  Heavy constructed"); }
}
Text
refresh():
  Eager constructed
refresh() finished — Heavy has not been built yet
first getBean(Heavy.class):
  Heavy constructed
  Heavy@5fa07e12

spring.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:

Text
lazy=false   0.555 s   0.582 s   0.609 s
lazy=true    0.537 s   0.551 s   0.554 s

Con 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:

Broken.java
@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:

Text
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 missing

Bật spring.main.lazy-initialization=true thì nó lên hoàn hảo:

Text
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:

Text
HTTP/1.1 500
{"timestamp":"2026-09-11T04:05:09.055Z","status":500,"error":"Internal Server Error","path":"/ping"}
Text
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 missing

Mộ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)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@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()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.

Bài viết liên quan

[Spring Boot Basics] Bean và ApplicationContext trong Spring: @Component, stereotype và component scanning

Bean trong Spring thực chất là gì và object nào không bao giờ nên là bean, chứng minh @Component là meta-annotation nằm dưới @Service, @Repository và @Controller, mỗi stereotype thật sự thêm gì lúc runtime, component scanning biến file .class thành BeanDefinition ra sao, quy tắc đặt tên bean kể cả trường hợp hai chữ hoa, API của ApplicationContext cùng số bean thật của một app Spring Boot 4.1.1, và ba kiểu đăng ký bean thất bại kèm thông báo lỗi thật.

[Spring Boot Basics] Các cách inject dependency trong Spring: constructor, setter, field, @Qualifier và @Primary

Ba injection point của Spring so sánh theo đúng một tiêu chí — container ghi giá trị vào lúc nào: vì sao @Autowired không cần trên constructor duy nhất, vì sao chỉ constructor injection mới cho field final và test JUnit 5 thuần, @Autowired(required=false) so với Optional và ObjectProvider, lỗi NoUniqueBeanDefinitionException thật cùng bốn cách sửa bằng @Primary, @Qualifier, tên parameter và custom qualifier annotation, inject List và Map kèm @Order, và vì sao vòng lặp qua constructor chết ngay lúc khởi động còn vòng lặp qua field thì không.

[Spring Boot Basics] @Configuration và @Bean trong Spring: khi nào dùng thay cho annotation stereotype

Vì sao @Component không đăng ký được một class nằm trong jar của thư viện khác, cách @Bean factory method trên @Configuration class làm thay, bảng quyết định chọn giữa hai cách, và proxyBeanMethods chứng minh bằng identity hash code thật trên Spring Boot 4.1.1 — full mode trả về một singleton dùng chung, lite mode tạo ba object riêng biệt — cùng @Import, static @Bean method và ba lỗi dựng lại kèm message thật.

[Spring Boot Basics] Xử lý exception tập trung trong Spring Boot: @RestControllerAdvice, @ExceptionHandler và ProblemDetail

Xử lý exception tập trung trong Spring Boot 4.1.1, kiểm chứng trên project thật: body /error mặc định và BasicErrorController, spring.web.error.* thay cho server.error.*, @ResponseStatus và ResponseStatusException, @ExceptionHandler trong controller và trong @RestControllerAdvice, cách Spring chọn một handler theo khoảng cách type, controller, @Order và cause, ProblemDetail (RFC 9457) với application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler trả 422 kèm danh sách lỗi theo field, và một handler catch-all giữ nguyên các response 4xx của framework.