Trong mọi codebase Java doanh nghiệp đều có vài đối tượng "chỉ nên có một": bộ cấu hình, connection pool, bộ đệm danh mục sản phẩm, trình ghi log. Singleton pattern là cách kinh điển nhất để bảo đảm điều đó, và cũng là mẫu thiết kế bị viết sai nhiều nhất, vì lỗi của nó chỉ lộ ra khi nhiều luồng chạm vào cùng lúc. Bài này so sánh sáu cách cài đặt Singleton pattern trong Java, kiểm chứng bằng 16 luồng thật, giải thích bằng Java Memory Model (JMM) vì sao double-checked locking cần volatile, và chỉ ra ba cách làm vỡ Singleton pattern: serialization, reflection và nhiều ClassLoader. Mọi đoạn mã biên dịch với --release 17 (API và ngôn ngữ của Java 17 LTS) và chạy thật, máy 16 GB RAM chạy macOS.
Nếu bạn chưa đọc phần nguyên lý, hãy xem Nguyên lý SOLID trong Java qua ví dụ thực tế: phần cuối bài này sẽ quay lại câu hỏi vì sao Singleton pattern thường vi phạm SOLID.
Singleton pattern giải quyết vấn đề gì
Theo mô tả trong trang Singleton pattern của Refactoring.Guru, ý đồ của mẫu này là bảo đảm một class chỉ có một thể hiện và cung cấp một điểm truy cập toàn cục tới nó. Cấu trúc gồm ba thành phần: constructor private để không ai gọi new từ bên ngoài, một trường static giữ thể hiện duy nhất, và một method static (thường là getInstance()) trả về thể hiện đó. Trong code đa luồng, việc tạo thể hiện phải được bảo vệ, và đây là chỗ mọi rắc rối bắt đầu.
Điều cần nói rõ từ đầu: "một thể hiện" trong Java luôn có nghĩa là một thể hiện trong một ClassLoader. Spring Framework phân biệt điều này khá rõ trong tài liệu về bean scope: Singleton pattern của GoF cố định phạm vi là một thể hiện cho mỗi ClassLoader, còn singleton của Spring là một thể hiện cho mỗi container và mỗi bean definition. Hai khái niệm trùng tên nhưng không giống nhau.
Sáu cách cài đặt Singleton pattern và kết quả khi chạy 16 luồng
Để so sánh công bằng các cách cài đặt Singleton pattern, mỗi cài đặt có constructor "chậm" (ngủ 50 ms để mô phỏng việc đọc cấu hình) và đếm số lần constructor thực sự chạy. Bộ kiểm tra khởi động 16 luồng cùng lúc bằng CountDownLatch, mỗi luồng gọi getInstance() và ghi lại identityHashCode của đối tượng nhận được:
static int distinct(Callable<Object> getter, int threads) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
Set<Integer> ids = ConcurrentHashMap.newKeySet();
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
start.await();
ids.add(System.identityHashCode(getter.call()));
return null;
});
}
start.countDown();
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
return ids.size();
}1. Eager initialization
Tạo thể hiện ngay khi class được nạp. Đơn giản và an toàn luồng nhờ cơ chế khởi tạo class của JVM, nhược điểm là tạo ngay cả khi không ai dùng:
static class Eager {
private static final Eager INSTANCE = new Eager();
private Eager() { slowInit(); }
static Eager getInstance() { return INSTANCE; }
}2. Lazy không an toàn luồng (sai)
static class LazyUnsafe {
private static LazyUnsafe instance;
private LazyUnsafe() { slowInit(); }
static LazyUnsafe getInstance() {
if (instance == null) instance = new LazyUnsafe();
return instance;
}
}Hai luồng cùng thấy instance == null và cùng gọi new. Đây là kiểu race condition "kiểm tra rồi mới hành động" kinh điển.
3. Lazy với synchronized toàn method
static class LazySync {
private static LazySync instance;
private LazySync() { slowInit(); }
static synchronized LazySync getInstance() {
if (instance == null) instance = new LazySync();
return instance;
}
}Đúng nhưng mọi lần gọi, kể cả sau khi đã khởi tạo xong, đều phải qua khoá. Ở Java hiện đại chi phí này nhỏ khi không tranh chấp, nhưng với đường nóng (hot path) nhiều luồng nó vẫn là điểm nghẽn tiềm tàng.
4. Double-checked locking với volatile
static class Dcl {
private static volatile Dcl instance;
private Dcl() { slowInit(); }
static Dcl getInstance() {
Dcl s = instance; // một lần đọc volatile
if (s == null) {
synchronized (Dcl.class) {
s = instance;
if (s == null) instance = s = new Dcl();
}
}
return s;
}
}5. Initialization-on-demand holder
static class Holder {
private Holder() { slowInit(); }
private static class H { static final Holder INSTANCE = new Holder(); }
static Holder getInstance() { return H.INSTANCE; }
}6. Enum singleton
enum EnumSingleton {
INSTANCE;
EnumSingleton() { slowInit(); }
}Kết quả chạy thật:
$ java -cp out Variants
Eager 16 luong -> 1 doi tuong khac nhau, constructor chay 1 lan
LazyUnsafe 16 luong -> 14 doi tuong khac nhau, constructor chay 16 lan
LazySync 16 luong -> 1 doi tuong khac nhau, constructor chay 1 lan
Dcl 16 luong -> 1 doi tuong khac nhau, constructor chay 1 lan
Holder 16 luong -> 1 doi tuong khac nhau, constructor chay 1 lan
Enum 16 luong -> 1 doi tuong khac nhau, constructor chay 1 lanSố liệu của cách thứ hai (lazy không an toàn luồng) dao động giữa các lần chạy: tôi chạy 8 lần liên tiếp và thấy từ 13 đến 16 đối tượng khác nhau, còn constructor luôn chạy đủ 16 lần (bảng trên là một lần chạy).
Dòng Eager cũng cho đúng 1 đối tượng: trong Java, "eager" nghĩa là khởi tạo ở bước khởi tạo class, và JVM đảm bảo bước này chạy một lần, an toàn luồng, ngay khi class được dùng lần đầu. Con số đối tượng lệch vì một luồng có thể đọc phải thể hiện do luồng khác vừa ghi đè lên trường instance; điều đáng chú ý là constructor chạy 16 lần thay vì 1, nghĩa là hoàn toàn không còn là Singleton pattern. Bốn cách còn lại đều đúng với tải này. Lưu ý quan trọng: một lần chạy sạch không chứng minh code đúng, đặc biệt với trường hợp DCL không có volatile. Phần tiếp theo giải thích vì sao cần phải dựa vào đặc tả chứ không dựa vào thử nghiệm.
Java Memory Model và lý do volatile là bắt buộc trong DCL
Đặc tả ngôn ngữ Java định nghĩa quan hệ happens-before trong JLS 17.4.5. Nếu hành động x happens-before hành động y thì x hiển thị và được xếp trước y. Các quy tắc cần nhớ gồm thứ tự chương trình trong cùng một luồng; mở khoá một monitor happens-before mọi lần khoá tiếp theo của chính monitor đó; và ghi vào trường volatile happens-before mọi lần đọc tiếp theo của trường đó. Hai truy cập xung đột (có ít nhất một lần ghi) mà không có quan hệ happens-before gọi là data race, và khi có data race, JMM không đảm bảo luồng đọc thấy giá trị nào cụ thể.
Bây giờ xét DCL không có volatile:
static class Broken {
private static Broken instance; // KHÔNG volatile
int value;
private Broken() { value = 42; }
static Broken get() {
if (instance == null) { // (1) đọc không đồng bộ
synchronized (Broken.class) {
if (instance == null) instance = new Broken();
}
}
return instance;
}
}Câu lệnh instance = new Broken() gồm ba bước logic: cấp phát bộ nhớ, chạy constructor (ghi value = 42), rồi gán tham chiếu vào instance. Trình biên dịch hoặc CPU được phép sắp xếp lại bước gán tham chiếu lên trước bước ghi value vì trong cùng một luồng kết quả không đổi. Một luồng khác chạy dòng (1) có thể thấy instance != null và trả về đối tượng chưa dựng xong, đọc value == 0. Dòng (1) là một lần đọc không có quan hệ happens-before với lần ghi trong khối synchronized, tức là một data race.
Thêm volatile vào instance thì ghi volatile happens-before mọi lần đọc volatile sau đó, và các ghi trong constructor đứng trước ghi volatile theo thứ tự chương trình, nên chúng cũng hiển thị với luồng đọc. Đó là lý do cài đặt DCL ở mục 4 đúng, và dùng biến cục bộ s để chỉ đọc volatile một lần trên đường nhanh.
Tôi đã chạy đoạn Broken trên máy:
$ java -cp out Jmm
Broken DCL: 42 (JMM khong bao dam luon la 42 khi doc khong dong bo)Kết quả là 42, và đó chính là điểm cần hiểu: bản này gọi từ một luồng nên không thể tái hiện lỗi; ngay cả khi có nhiều luồng, việc sắp xếp lại lệnh phụ thuộc vào JIT và kiến trúc CPU (ARM yếu hơn x86 về thứ tự bộ nhớ), nên lỗi có thể không bao giờ xuất hiện trên máy bạn nhưng xuất hiện trên môi trường khác. Một đoạn mã "chạy được" không phải là bằng chứng cho tính đúng, cho nên chỉ dựa vào happens-before của đặc tả mới đáng tin.
Ngoài ra JLS 17.5 có điều khoản riêng cho trường final: nếu mọi trường của đối tượng đều final và constructor không làm rò rỉ this, luồng nào thấy tham chiếu cũng thấy đúng giá trị các trường đó. Điều này giải thích vì sao đối tượng bất biến an toàn khi chia sẻ, nhưng đừng dựa vào nó để thay thế volatile cho đối tượng có trạng thái thay đổi.
Vì sao holder idiom và enum không cần khoá
Hai cách cuối của Singleton pattern, holder và enum, không dùng synchronized hay volatile trong code của bạn mà vẫn an toàn luồng, vì chúng dựa vào cơ chế khởi tạo class của JVM mô tả ở JLS 12.4.2. Mỗi class có một initialization lock riêng; nếu một luồng đang khởi tạo class, luồng khác gọi tới sẽ chờ cho đến khi khởi tạo xong, và hoàn tất khởi tạo happens-before mọi truy cập sau đó. Theo JLS 12.4.1, class chỉ được khởi tạo ngay trước lần đầu một static field không phải hằng số được dùng, một static method được gọi hoặc một thể hiện được tạo.
Với holder idiom, Holder.H chỉ được khởi tạo khi getInstance() truy cập H.INSTANCE lần đầu. Đó là sự lười biếng (lazy) miễn phí và an toàn. Khi constructor ném ngoại lệ, class H chuyển sang trạng thái lỗi: lần đầu nhận ExceptionInInitializerError, các lần sau nhận NoClassDefFoundError. Hãy nhớ điều này khi chẩn đoán lỗi lúc khởi động: lỗi thứ hai thường che lỗi gốc, hãy tìm lỗi đầu tiên trong log.
Enum là cách ngắn nhất để viết Singleton pattern và được Joshua Bloch khuyến nghị trong Effective Java (mục "Enforce the singleton property with a private constructor or an enum type"): JVM bảo đảm mỗi hằng enum chỉ có một thể hiện, kể cả khi bị serialization hay reflection tấn công, như phần tiếp theo cho thấy.
Ba cách làm vỡ Singleton pattern
Singleton pattern kiểu "constructor private, trường static" tưởng chắc chắn nhưng có ba lối thoát. Đoạn mã sau thử cả ba, kèm bản vá cho từng lối:
public static class Config implements Serializable {
private static final Config INSTANCE = new Config();
final String env = "prod";
private Config() {}
public static Config getInstance() { return INSTANCE; }
}
static class ConfigFixed implements Serializable {
private static final ConfigFixed INSTANCE = new ConfigFixed();
private ConfigFixed() {
if (INSTANCE != null) throw new IllegalStateException("Da khoi tao");
}
static ConfigFixed getInstance() { return INSTANCE; }
private Object readResolve() { return INSTANCE; }
}
enum Registry { INSTANCE }$ java -cp out Breakers out
Serialization, khong readResolve : same=false
Serialization, co readResolve : same=true
Serialization, enum : same=true
Reflection vao constructor private: same=false
Reflection khi co guard : java.lang.IllegalStateException: Da khoi tao
Reflection vao enum : java.lang.IllegalArgumentException: Cannot reflectively create enum objects
Hai ClassLoader : same=false, class.same=false- Serialization: ghi rồi đọc lại một đối tượng
Serializabletạo ra bản sao mới, nênsame=false. Thêm methodreadResolve()trả vềINSTANCEsửa được. Với enum không cần làm gì: đặc tả Java Object Serialization quy định dạng tuần tự hoá của hằng enum chỉ gồm tên, và khi đọc lại JVM gọiEnum.valueOf, vì vậy luôn nhận lại đúng hằng cũ. - Reflection:
setAccessible(true)mở được constructor private, tạo thể hiện thứ hai (same=false). Có thể chặn bằng cách ném ngoại lệ trong constructor nếuINSTANCEđã tồn tại, cách này đúng với eager singleton. Enum bị chặn sẵn:Cannot reflectively create enum objects. Từ Java 17, các module của JDK còn đóng kínsetAccessiblevới class bên trong JDK (JEP 403), nhưng class của ứng dụng bạn vẫn mở. - Nhiều ClassLoader: tôi nạp cùng class
Configbằng haiURLClassLoaderriêng, kết quả là hai đối tượng và hai class khác nhau về định danh (class.same=false). Điều này xảy ra thật trong môi trường application server hoặc hệ thống plugin, khi mỗi ứng dụng có ClassLoader riêng. "Singleton" lúc này chỉ duy nhất trong từng ClassLoader.
Singleton pattern trong JDK và trong Spring
JDK có ví dụ Singleton pattern quen thuộc là java.lang.Runtime.getRuntime(), trả về thể hiện duy nhất của Runtime cho mỗi tiến trình Java. Tuy vậy, trong ứng dụng Spring Boot bạn gần như không tự viết Singleton pattern nữa. Bean mặc định có scope singleton và container tạo một thể hiện cho mỗi bean definition, rồi tiêm vào nơi cần qua constructor. Cách này bỏ được trạng thái toàn cục ẩn, cho phép thay bean bằng đối tượng giả trong test, và vẫn đạt mục tiêu "chỉ một thể hiện". Nhưng bean singleton của Spring được dùng chung bởi mọi luồng yêu cầu, nên nó không được giữ trạng thái thay đổi theo từng yêu cầu trong trường instance; đó là một nguồn lỗi race condition rất hay gặp trong ứng dụng web.
Khi nào không nên dùng Singleton pattern
Singleton pattern thường bị gọi là "anti-pattern" vì những lý do cụ thể, và Refactoring.Guru cũng liệt kê chúng: nó đảm nhiệm cả việc kiểm soát số thể hiện lẫn nghiệp vụ (vi phạm Single Responsibility trong SOLID), che giấu phụ thuộc vì code gọi Config.getInstance() ở bất cứ đâu, và khó test vì không thay thế được bằng đối tượng giả. Vài gợi ý thực dụng:
- Trạng thái thay đổi toàn cục (bộ đếm, cache có thể ghi) là chỗ Singleton pattern gây hại nhất: test này làm bẩn test khác. Nếu bắt buộc, cho phép reset trong test hoặc tiêm thể hiện thay vì gọi static.
- Có DI container: đừng tự viết Singleton pattern, hãy khai báo bean singleton và tiêm qua constructor thay vì tự cài
getInstance(). - Cần một thể hiện cho mỗi tenant hoặc mỗi người dùng: đó không phải Singleton; hãy dùng một
Mapcác thể hiện hoặc factory (xem bài Factory Method tiếp theo). - Đối tượng không có trạng thái: Singleton pattern không đem lại lợi ích gì; nếu không giữ trạng thái, gọi method static hoặc tạo mới vẫn rẻ; không cần tốn công "singleton hoá".
Chọn cách cài đặt Singleton pattern nào
| Cách | Lazy | An toàn luồng | Chống serialization | Chống reflection | Độ phức tạp |
|---|---|---|---|---|---|
| Eager | Không | Có | Cần readResolve | Cần guard | Thấp |
| Lazy không đồng bộ | Có | Không | Cần readResolve | Cần guard | Thấp, nhưng sai |
| synchronized toàn method | Có | Có | Cần readResolve | Cần guard | Thấp |
| DCL với volatile | Có | Có | Cần readResolve | Cần guard | Cao |
| Holder idiom | Có | Có | Cần readResolve | Cần guard | Thấp |
| Enum | Có (khi truy cập lần đầu) | Có | Có sẵn | Có sẵn | Thấp nhất |
Khuyến nghị khi chọn Singleton pattern: dùng enum khi Singleton pattern không cần kế thừa class khác; dùng holder idiom khi cần lazy mà phải kế thừa; chỉ dùng DCL khi bạn hiểu rõ lý do và có lý do chính đáng. Trong ứng dụng có Spring, hãy ưu tiên bean singleton do container quản lý.
Lỗi thường gặp
- DCL thiếu
volatile: lỗi hiếm gặp, khó tái hiện, chỉ đúng "tình cờ" trên một số CPU. - Constructor làm việc nặng hoặc có thể ném ngoại lệ trong holder/eager: lỗi khởi tạo class biến thành
NoClassDefFoundErrorở các lần sau, rất khó đọc. - Singleton pattern giữ trạng thái thay đổi mà không đồng bộ: nhiều luồng ghi cùng lúc làm hỏng dữ liệu; dùng cấu trúc an toàn luồng như
ConcurrentHashMaphoặc để bất biến. - Quên
readResolvekhi class Singleton pattern implementsSerializable(thường vì kế thừa interface nào đó). - Nhầm singleton của Spring với Singleton pattern GoF: hai ClassLoader hoặc hai container cho hai thể hiện.
- Kiểm thử đúng bằng một lần chạy: lần chạy xanh không chứng minh tính đúng của luồng bộ nhớ; cần suy luận theo happens-before hoặc dùng công cụ như jcstress.
Tóm tắt
- Singleton pattern bảo đảm một thể hiện duy nhất trong một ClassLoader, không phải toàn JVM.
- Lazy không đồng bộ tạo 16 đối tượng trong 16 luồng;
synchronized, DCL vớivolatile, holder và enum đều cho đúng 1. volatiletrong DCL có mặt để thiết lập happens-before giữa ghi tham chiếu và các ghi trong constructor (JLS 17.4.5).- Serialization, reflection và ClassLoader đều có thể tạo thể hiện thứ hai; enum chặn hai lối đầu, không chặn được ClassLoader.
- Trong ứng dụng Spring, hãy dùng bean singleton và tiêm phụ thuộc thay vì cài Singleton pattern bằng
getInstance()tĩnh.
Mã nguồn đầy đủ (Variants, Breakers, Jmm) biên dịch bằng javac --release 17 -d out *.java và chạy bằng java -cp out Variants, java -cp out Breakers out, java -cp out Jmm.
Bài tiếp theo
Đọc tiếp: Factory Method và Abstract Factory: tách việc tạo đối tượng khỏi logic.



