Singleton có thực sự dễ?

Khi nói về Design Patterns, gần 100% những người tôi tiếp xúc đều thực hành Singleton như một design pattern phổ biến và dễ nhất (nhiều người chỉ biết/nhớ mỗi Singleton trong GoF). Tôi thường có 2 câu hỏi:

  1. Sử dụng Singleton và static trong class có gì khác nhau?
  2. Có tự tin cài đặt đúng Singleton?

Thường thì mọi người gặp khó khăn ở câu hỏi #1, và rất chắc chắn ở câu hỏi #2. Tôi thấy, #2 khó hơn #1 nhiều, và nếu bạn không tự tin trả lời #2 thì tôi khuyên bạn nên sử dụng static trong class thay vì Singleton. Tại sao? Đọc tiếp nhé.

Sử dụng Singleton và static trong class có gì khác nhau?

Nếu bạn chưa rõ về câu hỏi thì tôi giải thích thêm một chút: Mục đích của Singleton là tạo ra một object duy nhất trên toàn ứng dụng, kéo theo các thuộc tính của nó cũng là duy nhất; điều này hoàn toàn giống / có thể thực hiện bởi thuộc tính static của class – là nơi duy nhất lưu trữ dữ liệu. Ví dụ:

// Using Singleton 
public class AppConfig { 
    public int appId;
    private static AppConfig self;

    public static AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self; 
    } 
}

config = AppConfig.getInstance();
appId = config.appId;

// Using static
public class AppConfig {
    public static int appId; 
}

appId = AppConfig.appId;

Cả 2 cách cài đặt trên, appId đều chỉ có một nơi duy nhất để lưu trữ và truy xuất giá trị. Sử dụng static theo #2 gọn và tường minh hơn, vậy cần gì Singleton?

Để ý một chút, một cách cài đặt sử dụng class, một cách sử dụng object. Có 2 sự khác nhau giữa class và object trong trường hợp này:

  1. Life-time: Life cycle của class gắn với ứng dụng, life cycle của object gắn với việc sử dụng. Sử dụng static trong class, những thuộc tính này sẽ được load theo class khi chạy ứng dụng; sử dụng Singleton, những thuộc tính này sẽ được load chỉ khi object được khởi tạo. Trong cả 2 trường hợp, thuộc tính không được tự động giải phóng cho tới khi ứng dụng kết thúc. (do object vẫn có reference được giữ bởi self, tồn tại theo class).
  2. Abstraction: Sử dụng static trong class là concrete implement, sử dụng object cho một tầng abstraction nữa nên dễ thay đổi hơn. Ví dụ, chúng ta cần thay đổi AppConfig sang WindowsAppConfig, theo #2, tất cả mọi nơi truy cập tới appId đều phải sửa đổi; theo #1, nơi duy nhất cần thay đổi là AppConfig.getInstance() (có thể không cần thay đổi nếu áp dụng với những design pattern khác như Factory, DI).

Chẳng có gì đặc biệt, lý thuyết thôi. Chính xác, và vì thế bạn vẫn cài đặt Singleton như đã học? Nhưng thử tìm hiểu thêm nhé.

Có tự tin cài đặt đúng Singleton?

Cài đặt Singleton thế nào?

Dễ ợt, có 2 việc:

  1. Đặt constructor là private để object không thể khởi tạo được từ bên ngoài. 
  2. Cung cấp 1 method duy nhất trả về giá trị static 

Code thường được cài đặt như sau:

public class AppConfig {
    private static AppConfig self;
    public int appId; 
    
    private AppConfig() {
    } 
    
    public static AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
}

// Use
AppConfig config = AppConfig.getInstance();
appId = config.appId;

Có một vài câu hỏi phía dưới.

Chỉ có đúng 1 object được tạo ra?

Bạn có chắc rằng với cách cài đặt trên chỉ có đúng 1 object có thể được tạo ra? Còn cách nào có thể tạo ra object không?

Reflection là một trong những thứ rất hay ho của một số ngôn ngữ (những ngôn ngữ khác nhau có thể có những tên gọi khác nhau: runtime reference…), và cũng là một thứ khó bởi nó nâng abstraction lên mức runtime. Những pattern như Object Mapper hay DI… không dễ cài đặt nếu không có reflection. Thử đoạn code sau nhé.

public class AppConfig {
    private static AppConfig self;
    public int appId; 
    
    private AppConfig() {
        appId = new Random().nextInt(100);
    } 
    
    public static AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
    
    public static void main(String[] args) {
        AppConfig config = AppConfig.getInstance();
        System.out.println(config.appId);
        
        AppConfig appConfig = null;
        try {
            Class<AppConfig> clazz = AppConfig.class;
            Constructor<AppConfig> constructor = clazz.getDeclaredConstructor();
            constructor.setAccessible(true);
            appConfig = constructor.newInstance();
        } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
        System.out.println(appConfig.appId);
    }
}

Output của đoạn code trên thường sẽ là 2 số nguyên khác nhau, chính là appId. Về lý thuyết, appId chỉ được gán giá trị một lần duy nhất khi khởi tạo object. Như vậy đã có 2 object configappConfig được tạo ra. Điều chúng ta mong muốn là configappConfig là 2 biến cùng tham chiếu tới 1 object trong bất cứ trường hợp nào. Vậy nên giải pháp là kiểm soát constructor.

private AppConfig() {
    if (self != null) { 
        throw new UnsupportedOperationException("Use getInstance() to create object."); 
    }
    appId = new Random().nextInt(100);
}

Chỉ có đúng 1 object được tạo ra?

Bạn có chắc rằng với cách cài đặt trên chỉ có đúng 1 object có thể được tạo ra? Còn cách nào có thể tạo ra object không?

Threading là một trong những thứ rất hay ho nhưng cũng làm đau đầu developer vì nó không đi theo flow thông thường. Thử đoạn code sau nhé.

public class AppConfig {
    private static AppConfig self;
    public int appId; 
    
    private AppConfig() {
        if (self != null) { 
            throw new UnsupportedOperationException("Use getInstance()"); 
        }
        appId = new Random().nextInt(100);
    } 
    
    public static AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
    
    public static void main(String[] args) {
        Thread threadDownload = new Thread(new Runnable() {
            @Override
            public void run() {
                AppConfig config = AppConfig.getInstance();
                System.out.println(config.appId);
            }
        });

        Thread threadUpload = new Thread(new Runnable() {
            @Override
            public void run() {
                AppConfig config = AppConfig.getInstance();
                System.out.println(config.appId);
            }
        });

        threadDownload.start();
        threadUpload.start();
    }
}

Hãy thử chạy một vài lần vì cơ hội gặp trường hợp hai số khác nhau được in ra thấp hơn ví dụ trên. Tương tự ví dụ trên, ta có thể khẳng định: có 2 object đã được tạo ra. Sai lầm ở đâu? Hãy để ý method getInstance(), điều gì xảy ra nếu câu lệnh if (self == null) được thực thi đồng thời ở cả threadDownloadthreadUpload? Chúng đều đúng, và câu lệnh tạo object và return vẫn được tiếp tục trên 2 thread độc lập, và trả ra 2 object độc lập. Vậy nên cần phải synchronized việc tạo object.

public class AppConfig {
    private static volatile AppConfig self;
    public int appId; 
    
    private AppConfig() {
        if (self != null) { 
            throw new UnsupportedOperationException("Use getInstance()"); 
        }
        appId = new Random().nextInt(100);
    } 
    
    public static synchronized AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
}

Chỉ có đúng 1 object được tạo ra?

Bạn có chắc rằng với cách cài đặt trên chỉ có đúng 1 object có thể được tạo ra? Còn cách nào có thể tạo ra object không?

Serializable có thể là một vấn đề. Chúng ta muốn lưu lại cấu hình AppConfig xuống file sau đó load lại khi cần. Thử đoạn code sau nhé.

public class AppConfig implements Serializable {
    private static volatile AppConfig self;
    public int appId;
    
    private AppConfig() {
        if (self != null) { 
            throw new UnsupportedOperationException("Use getInstance()"); 
        }
        appId = new Random().nextInt(100);
    } 
    
    public static synchronized AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
    
    public static void main(String[] args) {
        AppConfig config = AppConfig.getInstance();
        
        try {
            // Save config to file
            ObjectOutput objectOutput = new ObjectOutputStream(new FileOutputStream("app.conf"));
            objectOutput.writeObject(config);
            objectOutput.close();
            System.out.println(config.appId);
            System.out.println(config);


            // Load config from file
            ObjectInput objectIutput = new ObjectInputStream(new FileInputStream("app.conf"));
            config = (AppConfig) objectIutput.readObject();
            objectIutput.close();
            System.out.println(config.appId);
            System.out.println(config);
        } catch (IOException | ClassNotFoundException ex) {
            ex.printStackTrace();
        }
    }
}

Bạn sẽ thấy output có dạng này:

28
singletontester.AppConfig@55f96302
28
singletontester.AppConfig@776ec8df

Tức là giá trị của object (các properties như appId) được giữ nguyên song object thực chất đã được tạo mới (tìm hiểu thêm về method toString()). Như vậy không đảm bảo 1 object được tạo ra duy nhất trên toàn bộ life cycle của ứng dụng. Vì cơ chế deserialize sẽ tạo mới object. Chúng ta cần sửa thành:

public class AppConfig implements Serializable {
    private static volatile AppConfig self;
    public int appId;
    
    private AppConfig() {
        if (self != null) { 
            throw new UnsupportedOperationException("Use getInstance()"); 
        }
        appId = new Random().nextInt(100);
    } 
    
    public static synchronized AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
    
    protected AppConfig readResolve() {
        return getInstance();
    }
}

Chỉ có đúng 1 object được tạo ra?

Bạn có chắc rằng với cách cài đặt trên chỉ có đúng 1 object có thể được tạo ra? Còn cách nào có thể tạo ra object không?

Đáng tiếc là vẫn còn, clone thì sao?

Kết

Singleton “chuẩn” nên được cài đặt như sau:

public class AppConfig {
    private static volatile AppConfig self;
    
    private AppConfig() {
        if (self != null) { 
            throw new UnsupportedOperationException("Use getInstance()"); 
        }
    } 
    
    public static synchronized AppConfig getInstance() {
        if (self == null) {
            self = new AppConfig();
        }
        return self;
    }
}
  • Bài viết này tôi lấy cảm hứng từ: https://medium.com/exploring-code/how-to-make-the-perfect-singleton-de6b951dfdb0, viết lại theo cách dễ đọc hơn.
  • Đến đây chắc bạn đã hiểu tại sao tôi khuyến nghị dùng static trong class nếu bạn không thực sự rõ về Singleton. Với class bạn không có vấn đề với serializable và reflection… Điều bạn gặp phải về threading có thể được IDE warning.
  • Hãy nhớ nhé, Singleton không dễ đâu.