Singleton và Prototype Scope
Xem thửDịch vụ giỏ hàng chạy tốt khi một người thử, nhưng khi hai người dùng mua cùng lúc, hàng của người này xuất hiện trong giỏ của người kia. Nhóm điều tra nghi ngờ cơ sở dữ liệu, cache, rồi đến tải nền, cho tới khi một người nhìn vào lớp CartService và thấy một field List<Item> items nằm trong một bean singleton. Mọi người dùng đang chia sẻ một danh sách duy nhất. Nguyên nhân thường là một bean có trạng thái bị khai báo ở scope sai, và triệu chứng chỉ lộ khi có đồng thời, thứ mà test thủ công không bao giờ gây ra.
Bài này đo scope bằng thí nghiệm trên Spring Boot 4.1.1: danh tính của singleton và prototype, bẫy singleton giữ prototype và bốn cách sửa (ObjectProvider, @Lookup, scoped proxy, scope web), hai kết quả bất ngờ (một @RestController đặt scope prototype thật sự tạo controller mới cho từng request, và một scoped proxy prototype tạo đối tượng mới cho mỗi lời gọi phương thức), và một phép đo mất cập nhật của singleton chia sẻ trạng thái giữa tám luồng.
Sau bài này, bạn có thể
- Phân biệt singleton và prototype, và nêu khi nào dùng từng loại.
- Nhận biết lỗi singleton giữ prototype và chọn cách sửa phù hợp trong bốn cách.
- Dùng scope web (
request,session) với scoped proxy, và biết lỗi khi thiếu proxy. - Giải thích vì sao singleton của Spring không an toàn luồng, và định lượng hậu quả.
Scope quyết định số instance và thời gian sống
Spring hỗ trợ sáu scope: singleton (mặc định), prototype, và bốn scope chỉ có trong context web: request, session, application, websocket. Ngoài ra có scope thread (SimpleThreadScope) nhưng không được đăng ký mặc định.
Cách đọc hình
Phần trên: hai nơi cần PricingService nhận cùng một instance, duy nhất cho mỗi container. Đây là scope mặc định và hợp với service không giữ trạng thái. Phần dưới: mỗi nơi cần ShoppingCart nhận một instance mới. Tài liệu Spring nêu quy tắc: dùng prototype cho bean có trạng thái và singleton cho bean không trạng thái. Lưu ý singleton của Spring là "một instance cho mỗi container và mỗi định nghĩa bean", khác với mẫu singleton của GoF (một instance cho mỗi ClassLoader).
@Service
public class PricingService { }
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ShoppingCart { }
Spring không quản lý toàn bộ vòng đời của prototype: callback khởi tạo vẫn chạy, nhưng callback hủy thì không (bài vòng đời bean đã đo). Người dùng bean phải tự dọn tài nguyên.
Thí nghiệm 1: danh tính, và bẫy singleton giữ prototype
@Component
public static class TrapFacade {
private final Cart cart;
public TrapFacade(Cart cart) {
this.cart = cart;
}
public int cartId() {
return cart.id();
}
}
SCOPE singleton: same instance=true
SCOPE prototype: same instance=false
SCOPE singleton holding prototype: cartIds=[4, 4, 4]
Singleton: hai lần getBean trả cùng một đối tượng. Prototype: hai lần getBean trả hai đối tượng khác nhau. Nhưng khi một singleton (TrapFacade) nhận một prototype (Cart) qua constructor, dependency được giải quyết một lần lúc tạo TrapFacade, và TrapFacade chỉ được tạo một lần. Ba lời gọi liên tiếp đều thấy giỏ số 4: giỏ hàng thực chất trở thành singleton, dùng chung cho mọi người dùng. Đây là chính sự cố mở đầu.
Đang tải Animation…
Thử và quan sát
Dự đoán trước: một singleton nhận một bean prototype qua constructor, ba lời gọi liên tiếp thấy bao nhiêu giỏ khác nhau? Chạy inject trực tiếp và nhìn giỏ hàng bị dùng chung; sau đó bật biến thể ObjectProvider.
Bốn cách lấy đúng scope tại lúc dùng
Lab thử ba cách trong ngữ cảnh prototype:
@Component
public static class ProviderFacade {
private final ObjectProvider<Cart> carts;
public ProviderFacade(ObjectProvider<Cart> carts) {
this.carts = carts;
}
public int cartId() {
return carts.getObject().id();
}
}
@Component
public abstract static class LookupFacade {
@Lookup
protected abstract Cart newCart();
public int cartId() {
return newCart().id();
}
}
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public static class ProxiedCart { }
SCOPE ObjectProvider: cartIds=[7, 8, 9]
SCOPE @Lookup: cartIds=[10, 11, 12]
SCOPE scoped proxy (prototype): ids=[13, 14, 15]
ObjectProvider<Cart>: mỗigetObject()lấy một giỏ mới (7, 8, 9). Cách rõ ràng nhất, phụ thuộc Spring ở mức interface nhỏ, được khuyến nghị.@Lookup: Spring sinh lớp con ghi đè phương thức trừu tượng, mỗi lần gọi trả giỏ mới (10, 11, 12). Hợp khi bạn không muốn lộObjectProvidertrong chữ ký; cần lớp khôngfinal.- Scoped proxy (
proxyMode = TARGET_CLASS): inject một proxy, và proxy tìm đối tượng đúng scope cho mỗi lời gọi. Với prototype, điều đó có nghĩa là mỗi lời gọi phương thức là một đối tượng mới (13, 14, 15): mọi trạng thái bạn đặt qua lời gọi thứ nhất không còn ở lời gọi thứ hai. Scoped proxy phù hợp với scope web (request, session), nơi "đối tượng của scope hiện tại" có nghĩa ổn định; với prototype nó thường không là điều bạn muốn. - Scope web (dưới đây): nếu trạng thái thuộc về một request hay phiên, hãy dùng đúng scope đó.
Scope web
Trong ứng dụng web, trạng thái theo người dùng thuộc về scope request hoặc session, không phải prototype:
@Component
@RequestScope
public static class RequestContext {
private final String id = UUID.randomUUID().toString().substring(0, 8);
public String id() {
return id;
}
}
@RequestScope là annotation ghép đã bật scoped proxy kiểu lớp đích theo mặc định, nên inject được vào singleton. Lab inject nó vào Holder (singleton) và gọi /request ba lần:
SCOPE request-scoped bean in singleton, 3 requests: [8e23ac89 via Beans$RequestContext$$SpringCGLIB$$0, e56ab00c via ..., 03f5ddeb via ...]
Mỗi request thấy một mã khác nhau, và lớp của đối tượng được inject là một proxy CGLIB ($$SpringCGLIB$$0): singleton Holder giữ một proxy cố định, và proxy trỏ tới RequestContext của request hiện tại mỗi lần gọi. Nếu bạn tự viết @Scope("request") mà không có proxyMode, việc inject vào singleton thất bại ngay lúc khởi động (lab dùng @RequestScope(proxyMode = ScopedProxyMode.NO)):
SCOPE request scope without proxy: 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ý do: lúc khởi động không có request nào, nên bean scope request không thể được tạo để inject; proxy là cách trì hoãn việc tìm đối tượng tới lúc có request. Thông báo cũng gợi ý một lỗi thứ hai: dùng bean scope request trong thread khác (ví dụ @Async, CompletableFuture) thì request không còn gắn vào thread đó.
Kết quả bất ngờ: @RestController đặt scope prototype
Nhiều tài liệu nói đặt @Scope("prototype") lên controller không làm mỗi request có controller mới. Lab đo ngược lại:
SCOPE singleton controller: [controller@1378267362 hits=1, controller@1378267362 hits=2]
SCOPE prototype controller: [controller@239250971 hits=1, controller@788778173 hits=1]
Controller singleton: cùng một đối tượng (1378267362), bộ đếm hits tăng (1, 2). Controller prototype: hai đối tượng khác nhau cho hai request, bộ đếm luôn là 1. Lý do: Spring MVC lưu tên bean của controller và lấy bean từ container mỗi request, nên prototype cho đối tượng mới. Hệ quả: nếu bạn vô tình đặt @Scope("prototype") lên controller có trạng thái, trạng thái đó biến mất giữa các request thay vì bị chia sẻ, và bạn trả giá tạo đối tượng cho mọi request. Cả hai kiểu đều là dấu hiệu thiết kế sai: controller nên không có trạng thái và để singleton; trạng thái theo request thuộc về scope request hoặc tham số của phương thức.
Singleton không an toàn luồng
Spring không bảo đảm an toàn luồng cho singleton: nó chỉ tạo một đối tượng và để mọi luồng dùng chung. Lab cho tám luồng cùng gọi hit() hai trăm nghìn lần trên một singleton có cả int thường và AtomicInteger:
SCOPE singleton shared by 8 threads x 200000 hits: plain int=1497016 AtomicInteger=1600000 expected=1600000
Biến int thường cho 1497016, mất 102984 bản cập nhật (hơn 6%), vì plain++ là ba bước đọc, cộng, ghi và các luồng xen kẽ. AtomicInteger cho đúng 1600000. Số mất mát thay đổi giữa các lần chạy, nhưng không bao giờ bằng không dưới tải thật. Quy tắc: singleton chỉ chứa dependency (cũng là singleton) và trạng thái bất biến; trạng thái thay đổi phải nằm ở nơi khác (tham số, scope request, cơ sở dữ liệu, Atomic*/ConcurrentHashMap khi thật sự cần).
Scope session và application: ai chia sẻ với ai
Bài này đo singleton, prototype và request. Hai scope web còn lại quyết định phạm vi chia sẻ ở hai đầu: session (mỗi người dùng một bản, sống qua nhiều request) và application (một bản cho cả ứng dụng, giống singleton nhưng gắn với ServletContext). Lab dựng giỏ hàng @SessionScope và bộ đếm @ApplicationScope, rồi gọi /add ba lần từ hai client có cookie riêng:
user A: [cart d5bb15 items=1 appHits=1, cart d5bb15 items=2 appHits=2, cart d5bb15 items=3 appHits=3]
user B: [cart d3b461 items=1 appHits=4, cart d3b461 items=2 appHits=5, cart d3b461 items=3 appHits=6]
Mỗi người dùng nhận giỏ riêng (d5bb15 và d3b461, đếm items độc lập từ 1) và giỏ sống qua các request của cùng người đó nhờ cookie phiên; còn bộ đếm ứng dụng chạy liên tục 1 đến 6 qua cả hai người. Cả hai bean đều được inject vào controller singleton, hoạt động nhờ scoped proxy mà @SessionScope và @ApplicationScope bật sẵn. Ba lưu ý vận hành của scope session: (1) trạng thái phiên nằm ở bộ nhớ máy chủ, nên nhiều bản sao ứng dụng cần phiên dính (sticky session) hoặc lưu phiên chung (Redis, bài Cache), nếu không giỏ hàng "biến mất" khi request sang bản sao khác; (2) phiên hết hạn theo thời gian không hoạt động, bean theo phiên bị hủy theo; (3) bean phiên phải tuần tự hóa được nếu phiên được lưu ngoài hoặc sao chép. Với API không trạng thái (REST thuần), tránh scope session: đưa trạng thái vào token hay kho dữ liệu.
Bốn cách làm bộ đếm an toàn luồng, đo thời gian
Phần trước chứng minh int++ mất cập nhật trong singleton. Cách sửa phụ thuộc vào mức tranh chấp, và lab đo bốn cách với tám luồng, mỗi luồng một triệu lần tăng (thời gian trung bình mỗi thao tác tính trên tổng số thao tác):
int thường : 5 ns/op (SAI: mất cập nhật)
synchronized : 44 ns/op tổng = 8.000.000 (đúng)
AtomicLong : 19 ns/op tổng = 8.000.000 (đúng)
LongAdder : 9 ns/op tổng = 8.000.000 (đúng)
Con số 5 ns của int thường là cái giá của sự sai: nhanh vì không đồng bộ gì cả. Trong các cách đúng, LongAdder nhanh nhất (9 ns) vì mỗi luồng cộng vào một ô riêng rồi gộp khi đọc, tránh tranh chấp; AtomicLong (19 ns) dùng một biến chia sẻ với thao tác nguyên tử; synchronized (44 ns) chậm nhất vì các luồng xếp hàng chờ khóa. Quy tắc chọn: bộ đếm chỉ ghi nhiều, đọc ít (số liệu, thống kê) dùng LongAdder; cần giá trị chính xác tức thời cho mỗi thao tác (sinh số thứ tự) dùng AtomicLong; khi phải bảo vệ nhiều biến cùng nhau (bất biến phức tạp) dùng synchronized hoặc ReentrantLock. Các con số này đo trên một lần chạy ngắn của máy lab, chỉ có giá trị về bậc độ lớn và thứ tự, và không thay thế được JMH khi cần so sánh chính xác. Nhưng bài học không đổi: sửa lỗi an toàn luồng của singleton bằng công cụ đúng có giá vài chục nano giây, rẻ hơn nhiều so với một lần phải điều tra số liệu sai trên production.
Quyết định thiết kế và đánh đổi
| Nhu cầu | Cách | Đánh đổi |
|---|---|---|
| Dịch vụ không trạng thái | Singleton (mặc định) | Không có trạng thái thay đổi |
| Đối tượng có trạng thái, mỗi nơi một cái | Prototype lấy qua ObjectProvider | Người gọi dọn tài nguyên |
| Trạng thái theo request hay phiên | @RequestScope, @SessionScope | Cần proxy khi inject vào singleton |
Không muốn lộ ObjectProvider | @Lookup | Cần lớp không final |
| Scoped proxy với prototype | Tránh | Mỗi lời gọi là đối tượng mới |
| Trạng thái chung có đồng thời | Atomic*, ConcurrentHashMap, khóa | Độ phức tạp |
| Controller | Singleton, không trạng thái | Trạng thái đi vào tham số |
| Dữ liệu theo người dùng | Cơ sở dữ liệu hay phiên | Không để trong bean |
Sổ tay xử lý sự cố
- Triệu chứng: dữ liệu của người này hiện ở người kia. Nguyên nhân: trạng thái người dùng trong singleton. Sửa: scope request hoặc session, hoặc tham số.
- Triệu chứng: prototype inject vào singleton không đổi. Nguyên nhân: inject một lần. Sửa:
ObjectProviderhoặc@Lookup. - Triệu chứng:
IllegalStateException: No thread-bound request found. Nguyên nhân: bean scope request không có proxy, hoặc dùng ở thread khác. Sửa:@RequestScope, hoặc truyền dữ liệu qua tham số khi chuyển thread. - Triệu chứng: scoped proxy prototype mất trạng thái giữa các lời gọi. Nguyên nhân: mỗi lời gọi là đối tượng mới. Sửa: bỏ proxy, dùng
ObjectProvider. - Triệu chứng: cập nhật bị mất dưới tải. Nguyên nhân: field thay đổi trong singleton không đồng bộ. Sửa:
Atomic*hoặc bỏ trạng thái. - Triệu chứng: controller mất trạng thái giữa các request. Nguyên nhân:
@Scope("prototype")trên controller. Sửa: singleton không trạng thái. - Triệu chứng: rò rỉ tài nguyên của prototype. Nguyên nhân: không ai hủy. Sửa: người gọi hủy, hoặc đổi thiết kế.
- Triệu chứng: hành vi chỉ lỗi khi có tải. Nguyên nhân: điều kiện đua, test thủ công không lộ. Sửa: test đồng thời như lab.
Hiệu năng, bảo mật và vận hành
- Prototype tạo đối tượng mỗi lần lấy; với đường nóng, chi phí tạo và gom rác cộng lại. Singleton rẻ nhất khi không có trạng thái.
- Dữ liệu theo người dùng trong singleton là rủi ro rò rỉ dữ liệu (cơ chế của sự cố mở đầu): coi nó như lỗ hổng bảo mật, không chỉ lỗi logic.
- Scope session giữ trạng thái ở máy chủ: cân nhắc dung lượng và nhân bản phiên giữa các bản sao.
- Với thread ảo hay thread khác, scope request không tự theo (lỗi
No thread-bound request): truyền giá trị cần thiết bằng tham số. - Thêm test đồng thời cho mọi bean có trạng thái và bắt đầu từ câu hỏi "bean này có cần trạng thái không?".
Bài thực hành
- Dựng
Pricing(singleton) vàCart(prototype) và so sánh danh tính quagetBeanhai lần. - Inject
Cartvào một singleton bằng constructor và gọi ba lần để thấy cùng một giỏ. - Sửa bằng
ObjectProvider,@Lookupvà scoped proxy; ghi mã giỏ qua ba lời gọi và giải thích khác biệt. - Dựng
@RequestScopebean inject vào một singleton và gọi ba request; rồi thửproxyMode = NOvà đọc lỗi. - Dựng hai controller, một singleton và một
@Scope("prototype"), mỗi cái có biến đếm; gọi hai lần mỗi cái. - Cho tám luồng gọi hai trăm nghìn lần một singleton có
intthường vàAtomicInteger; so sánh với giá trị mong đợi.
Tiêu chí hoàn thành: bước 1 cho true và false; bước 2 cho ba mã bằng nhau; bước 3 cho ba mã tăng dần ở cả ba cách; bước 4 cho ba mã khác nhau, lớp proxy, rồi IllegalStateException; bước 5 cho hits=2 ở singleton và hits=1 hai lần ở prototype; bước 6 cho int nhỏ hơn 1600000 và Atomic bằng 1600000. Gợi ý nếu bước 6 không mất cập nhật: tăng số lần lặp hoặc số luồng, đảm bảo các luồng chạy song song thật sự.
Tự kiểm tra
- Khác biệt giữa singleton của Spring và mẫu singleton của GoF là gì?
- Vì sao inject prototype vào singleton qua constructor làm mất ý nghĩa prototype?
- Nêu bốn cách lấy đúng scope tại lúc dùng.
- Scoped proxy với prototype khác scoped proxy với request ở điểm nào?
- Vì sao
@Scope("request")không có proxy inject vào singleton bị lỗi lúc khởi động? - Đặt
@Scope("prototype")lên controller làm gì trong Spring MVC? - Vì sao singleton của Spring không an toàn luồng?
- Bạn cần mỗi request có một
RequestContextriêng. Bạn khai báo scope nào?
Gợi ý đáp án. (1) Spring: một instance cho mỗi container và định nghĩa bean; GoF: một instance cho mỗi ClassLoader. (2) Inject chỉ xảy ra một lần lúc singleton được tạo. (3) ObjectProvider, @Lookup, scoped proxy, và scope web phù hợp. (4) Với prototype mỗi lời gọi phương thức là đối tượng mới; với request thì cùng đối tượng trong suốt request. (5) Lúc khởi động không có request nào để tạo bean scope request. (6) Controller được lấy từ container mỗi request, nên mỗi request có controller mới, và trạng thái biến mất giữa các request. (7) Spring chỉ tạo một đối tượng và mọi luồng dùng chung; đồng bộ hóa là việc của bạn. (8) @RequestScope.
Điều cần nhớ
- Singleton cho bean không trạng thái; prototype hoặc scope web cho bean có trạng thái.
- Prototype không được container hủy; muốn lấy tại lúc dùng thì
ObjectProviderhoặc@Lookup. - Scope web cần proxy khi inject vào singleton; scoped proxy prototype tạo đối tượng mới mỗi lời gọi.
- Singleton không an toàn luồng: trạng thái thay đổi phải đồng bộ hoặc không tồn tại.
- Controller nên là singleton không trạng thái.
Học liệu chọn lọc
- Spring Framework Reference: Bean Scopes
- Spring Framework Reference: Scoped Beans as Dependencies
- Spring Framework Reference: Method Injection (@Lookup)
- Spring Framework Reference: Request, Session, Application, and WebSocket Scopes
- The Java Tutorials (Oracle): Synchronization
- The Java Tutorials (Oracle): Atomic Variables
- Java SE API (Oracle): java.util.concurrent.atomic
- Sách: Java Concurrency in Practice (Brian Goetz), chương về chia sẻ đối tượng và an toàn luồng.