Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

singleton pattern

Tung Bui Thanh (TungDaDev)11 mins read

Singleton pattern.webp

Singleton nhìn qua là mẫu dễ nhất trong 23 mẫu GoF: 'một private constructor và một static method getInstance()'. Nhưng khi chạy đa luồng, nó lộ ra khá nhiều chỗ tinh vi: việc đảo lệnh làm lộ đối tượng chưa khởi tạo xong (partially-constructed object), bị phá bằng reflection và deserialization, và cả chuyện nó bị coi là anti-pattern vì đi ngược nguyên lý Dependency Inversion.

Dev Java nào cũng từng nghe Double-Checked Locking (DCL). Nhưng vì sao thiếu volatile thì DCL thành quả bom nổ chậm? Vì sao Joshua Bloch (tác giả Effective Java) nói Enum Singleton thường là cách triển khai tốt nhất? Và vì sao dự án Spring gần như không ai tự viết getInstance() nữa mà để IoC Container quản lý?

Mình sẽ đi vào cơ chế bộ nhớ của JVM đằng sau Singleton, rồi tới cách làm trong ứng dụng thực tế.


# tại sao double-checked locking phải có volatile?

Đây là đoạn DCL kinh điển, thiếu volatile thì sẽ dính lỗi lúc có lúc không (Heisenbug):

public class UnsafeDCLSingleton {
    // THẢM HỌA: Thiếu từ khóa volatile!
    private static UnsafeDCLSingleton instance;
 
    private UnsafeDCLSingleton() {}
 
    public static UnsafeDCLSingleton getInstance() {
        if (instance == null) { // Lần check 1 (Không lock để tối ưu tốc độ)
            synchronized (UnsafeDCLSingleton.class) {
                if (instance == null) { // Lần check 2 (Có lock để đảm bảo duy nhất)
                    instance = new UnsafeDCLSingleton(); // ĐIỂM CHẾT NẰM Ở ĐÂY!
                }
            }
        }
        return instance;
    }
}

# bản chất của lỗi instruction reordering

instance = new Singleton(); không phải thao tác nguyên tử (atomic). Ở tầng bytecode nó gồm các lệnh new, dup, invokespecial, putstatic, về mặt ý nghĩa là 3 bước:

  1. new: cấp vùng nhớ trên heap (các field mang giá trị mặc định null/0).
  2. invokespecial <init>: chạy constructor để khởi tạo các field.
  3. putstatic: gán địa chỉ vùng nhớ vừa cấp vào biến instance.

Trong cùng một thread, thứ tự này luôn được tôn trọng. Nhưng Java Memory Model không bắt thread khác phải thấy các thao tác ghi theo đúng thứ tự đó nếu giữa hai thread không có quan hệ happens-before. Thực tế, JIT được phép sắp xếp lại các lệnh ghi (vd inline constructor rồi gán instance trước khi ghi xong các field), và trên một số kiến trúc như ARM thì CPU cũng có thể làm vậy, nên thread khác có thể thấy thứ tự 1 -> 3 -> 2. Lần check 1 đọc instance mà không lock, nên không có happens-before nào cả.

  • Khi bước 3 xong mà bước 2 chưa chạy, biến instance đã khác null.
  • Thread 2 gọi getInstance(), lần check 1 thấy instance != null nên trả về đối tượng luôn.
  • Kết quả: Thread 2 dùng một đối tượng bán phần (Partially-Constructed Object), gây lỗi dữ liệu khó hiểu hoặc NullPointerException ngẫu nhiên trên Production.

# vai trò của volatile và memory barrier

Từ Java 5 (JSR-133), ngữ nghĩa của volatile được làm chặt lại, và JVM hiện thực nó bằng các memory barrier (hàng rào bộ nhớ):

  • Mọi lệnh ghi đứng trước một lệnh ghi volatile không được dời ra sau nó; mọi lệnh đọc đứng sau một lệnh đọc volatile không được dời lên trước nó.
  • Tạo quan hệ happens-before: ghi vào biến volatile happens-before mọi lần đọc sau đó thấy giá trị vừa ghi. Thread nào đọc được instance != null thì cũng thấy được mọi thứ constructor đã ghi.

Trước Java 5, kể cả có volatile thì DCL vẫn hỏng, đó là lý do nhiều tài liệu cũ bảo "đừng bao giờ dùng DCL".

TIP

Quy tắc cần nhớ: Dùng Double-Checked Locking (DCL) trong Java thì biến instance phải có volatile:

private static volatile SafeDCLSingleton instance;

Không có volatile thì DCL là quả bom nổ chậm trên CPU đa lõi.


# bill pugh holder: không cần lock

Muốn khởi tạo lười (Lazy Initialization), an toàn đa luồng mà không tốn chi phí synchronized hay volatile, thì dùng Initialization-on-demand Holder Idiom do Bill Pugh đề xuất:

public class BillPughSingleton {
 
    private BillPughSingleton() {}
 
    // Static nested class chỉ được nạp khi phương thức getInstance() được gọi lần đầu
    private static class LazyHolder {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }
 
    public static BillPughSingleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

JVM luôn đồng bộ việc khởi tạo một class (initialization lock, mô tả ở JLS §12.4.2): chỉ một thread chạy static initializer, các thread khác chờ, và việc khởi tạo xong happens-before mọi lần dùng class sau đó. Nhờ vậy không có race condition, mà sau lần đầu thì getInstance() chỉ là đọc một field static bình thường.

Nhược điểm: constructor mà ném exception thì LazyHolder hỏng vĩnh viễn, các lần gọi sau nhận NoClassDefFoundError. Singleton cần khởi tạo có thể thất bại (đọc file, gọi mạng) thì cân nhắc kỹ.


# các cách phá singleton và cách phòng thủ

Có 4 cách phá tính duy nhất của Singleton mà bạn nên biết để phòng:

# cách 1: reflection attack

Dùng Reflection để gọi private constructor:

Constructor<BillPughSingleton> constructor = BillPughSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
BillPughSingleton fakeInstance = constructor.newInstance(); // Phá vỡ Singleton!

Phòng thủ: ném exception ngay trong constructor nếu instance đã tồn tại. Cách này chạy được cả khi kẻ tấn công gọi reflection trước getInstance(): đọc LazyHolder.INSTANCE trong constructor sẽ kích hoạt khởi tạo holder (tạo instance thật), rồi lần gọi reflection đó bị chặn. Nhưng nó chỉ là rào chắn, ai đã chủ động dùng reflection thì vẫn có thể sửa luôn field INSTANCE:

private BillPughSingleton() {
    if (LazyHolder.INSTANCE != null) {
        throw new IllegalStateException("Hành vi tạo đối tượng qua Reflection bị cấm!");
    }
}

# cách 2: serialization attack

Khi Singleton implements Serializable, mỗi lần deserialize sẽ tạo một instance mới mà không gọi constructor của class (JVM chỉ gọi constructor không tham số của lớp cha gần nhất không Serializable, ở đây là Object).

Phòng thủ: định nghĩa method readResolve(), và đánh dấu các field là transient:

@Serial
private Object readResolve() {
    // Thay thế đối tượng vừa deserialize bằng instance duy nhất trong bộ nhớ
    return getInstance();
}

Hai cách còn lại ngắn hơn. Clone chỉ là vấn đề khi Singleton (hoặc lớp cha của nó) implements Cloneable; phòng thủ bằng cách không implement, hoặc override clone() để ném CloneNotSupportedException. Nhiều ClassLoader thì không chặn được từ bên trong class: mỗi ClassLoader nạp class là một class khác nhau với static riêng, nên có bao nhiêu ClassLoader là bấy nhiêu "singleton". Chuyện này hay gặp ở app server có nhiều webapp, hoặc khi Spring Boot DevTools dùng restart ClassLoader.


# enum singleton

Trong Effective Java (Item 3, bản 3), Joshua Bloch viết: "A single-element enum type is often the best way to implement a singleton." Chữ "often" là có lý do: enum không kế thừa được class khác, và không khởi tạo lười được như holder (instance tạo ngay khi enum được nạp).

public enum EnterpriseConfigurationManager {
    INSTANCE;
 
    private final Map<String, String> configs = new ConcurrentHashMap<>();
 
    public void setConfig(String key, String value) {
        configs.put(key, value);
    }
 
    public String getConfig(String key) {
        return configs.get(key);
    }
}

# tại sao enum chống được hết?

  1. JDK chặn Reflection: Constructor.newInstance() có đoạn kiểm tra (gọi vào sẽ nhận IllegalArgumentException: Cannot reflectively create enum objects):
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
  2. Không dính Serialization: JVM serialize enum theo tên (name()), deserialize không bao giờ sinh đối tượng mới.
  3. Khởi tạo thread-safe sẵn nhờ cơ chế khởi tạo class của JVM.

Còn trạng thái bên trong thì enum không lo giúp bạn: configs ở trên dùng ConcurrentHashMap chính vì nhiều thread cùng đọc ghi.


# tại sao gof singleton bị xem là anti-pattern?

GoF Singleton là mẫu kinh điển, nhưng trong kiến trúc hiện đại (microservices, Clean Architecture, DDD), singleton kiểu static truyền thống thường bị xem là anti-pattern. Vấn đề không nằm ở chuyện "chỉ có một instance", mà ở chỗ ai cũng lấy được nó qua một lời gọi static, giống biến toàn cục đội lốt class:

# so sánh gof singleton vs spring singleton scope

Tiêu chíGoF Singleton (Static Instance)Spring Singleton (@Component / @Service)
Phạm vi duy nhất (Scope)Duy nhất trên mỗi ClassLoader của JVMDuy nhất trên mỗi Spring ApplicationContext
Khả năng Unit TestKhó, phải dùng Mockito.mockStatic (trước đây PowerMock)Rất dễ, truyền mock qua constructor là xong
Tính liên kết (Coupling)Phụ thuộc cứng (Tight Coupling)Khớp nối lỏng (Loose Coupling) qua Dependency Injection
Quản lý vòng đờiSống tới khi ClassLoader bị thu hồi (thường là hết JVM)Hỗ trợ @PostConstruct, @PreDestroy, Graceful Shutdown

# cách làm trong spring boot

public interface CurrencyExchangeRateProvider {
    BigDecimal getRate(String from, String to);
}
 
// Spring tự động quản lý Bean này như một Singleton Scope trong ApplicationContext
@Service
public class CentralBankExchangeRateProvider implements CurrencyExchangeRateProvider {
 
    private final RestClient restClient;
 
    public CentralBankExchangeRateProvider(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.baseUrl("https://api.centralbank.gov").build();
    }
 
    @Override
    public BigDecimal getRate(String from, String to) {
        // Thực thi gọi API
        return BigDecimal.ONE;
    }
}

# checklist

  • Kiểm tra volatile trong DCL: dùng Double-Checked Locking thì biến instance phải có volatile.
  • Ưu tiên Bill Pugh hoặc Enum: ứng dụng Java Core không dùng Spring thì nên dùng Initialization-on-demand Holder hoặc Enum Singleton thay cho DCL.
  • Phòng vệ Serialization: class Singleton buộc phải implements Serializable thì thêm readResolve().
  • Thay bằng Spring Bean: trong dự án Spring Boot, bỏ các lớp Singleton tự viết (getInstance()), chuyển sang @Service hoặc @Component để Spring IoC Container quản lý.
  • Bean singleton phải stateless hoặc thread-safe: một bean singleton được mọi request, mọi thread (và mọi tenant, nếu hệ thống multi-tenant) dùng chung. Đừng giữ dữ liệu theo request/user trong field của bean; cần thì dùng scope request, truyền qua tham số, hoặc cấu trúc thread-safe.

# kết luận

Singleton nhìn là chuyện tạo đúng một đối tượng, nhưng nó kéo theo cả Java Memory Model, cơ chế khởi tạo class, ClassLoader và cách thiết kế phụ thuộc.

Mỗi cách triển khai có cái giá riêng: DCL dễ sai khi thiếu volatile, holder gọn nhưng hỏng vĩnh viễn nếu constructor lỗi, enum phòng thủ tốt nhưng kém linh hoạt, còn Spring IoC Container thì dễ test nhất. Viết thư viện Java thuần thì holder hoặc enum; dự án Spring thì cứ để container lo.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.