+6

Design pattern - Singleton (Phần 1)

Mayfest2023

1. Giới thiệu về Design Pattern Singleton

Design Pattern (mẫu thiết kế) là một giải pháp tổng quát cho các vấn đề thường gặp trong lập trình phần mềm. Singleton là một trong những Design Pattern phổ biến nhất, thuộc nhóm Creational Pattern - một nhóm mẫu thiết kế liên quan đến cách tạo ra đối tượng.

Singleton là một mẫu thiết kế tạo ra một lớp có chính xác một instance và cung cấp một cách để truy cập nó một cách global. Nói cách khác, Singleton đảm bảo rằng một lớp chỉ có một instance duy nhất, và cung cấp một điểm truy cập global đến instance đó.

Singleton được sử dụng khi chúng ta cần chắc chắn rằng một lớp chỉ có một instance duy nhất, và muốn cung cấp một cách dễ dàng để truy cập đến instance đó. Việc sử dụng Singleton có thể giúp tiết kiệm tài nguyên và đảm bảo tính nhất quán trong quá trình thực thi ứng dụng.

Singleton thường được sử dụng trong các tình huống mà việc tạo ra nhiều instance của một lớp có thể gây ra vấn đề, như việc quản lý kết nối đến một cơ sở dữ liệu. Khi sử dụng Singleton, toàn bộ ứng dụng sẽ chia sẻ một instance duy nhất, giúp quản lý tài nguyên một cách hiệu quả hơn.

Việc hiểu rõ về Singleton Pattern, cũng như biết khi nào và làm thế nào để sử dụng nó, sẽ giúp bạn tạo ra các ứng dụng phần mềm mạnh mẽ, hiệu quả hơn. Trong các chương sau, chúng ta sẽ đi sâu vào cách hoạt động của Singleton, cách triển khai nó, và các vấn đề có thể gặp phải khi sử dụng mẫu thiết kế này.

2. Cách hoạt động của Singleton Pattern

Singleton Pattern hoạt động dựa trên việc kiểm soát quá trình tạo instance của một lớp. Thông qua việc đảm bảo chỉ có duy nhất một instance và cung cấp một điểm truy cập global đến instance đó, Singleton Pattern giúp ngăn chặn việc tạo thêm các instance khác từ lớp Singleton.

2.1. Đảm bảo chỉ có một instance duy nhất

Trong Singleton Pattern, chúng ta tạo ra một lớp Singleton có một biến private static để chứa một instance duy nhất của lớp đó. Biến này là private để ngăn cản việc truy cập trực tiếp từ bên ngoài lớp, và là static để đảm bảo rằng nó chỉ được tạo một lần và tồn tại suốt thời gian chạy của ứng dụng.

Để tạo instance này, chúng ta sử dụng một hàm tạo (constructor) private. Hàm tạo private ngăn chặn việc tạo thêm các instance từ bên ngoài lớp. Thay vào đó, instance duy nhất này được tạo ra bên trong lớp Singleton, thông qua một hàm public static.

2.2. Cung cấp điểm truy cập chung

Để truy cập vào instance duy nhất này từ bên ngoài lớp Singleton, chúng ta sử dụng một hàm public static. Hàm này giúp cung cấp một điểm truy cập global đến instance duy nhất của lớp Singleton.

Hàm này thường được gọi là getInstance(), vì nó trả về instance duy nhất của lớp Singleton. Khi hàm này được gọi, nếu instance duy nhất này chưa được tạo, hàm sẽ tạo ra instance đó. Nếu instance duy nhất này đã được tạo, hàm sẽ trả về instance đó.

Như vậy, thông qua việc kiểm soát quá trình tạo instance và cung cấp một điểm truy cập global, Singleton Pattern đảm bảo rằng chỉ có duy nhất một instance của lớp Singleton tồn tại trong ứng dụng, và giúp chúng ta dễ dàng truy cập vào instance đó mọi lúc, mọi nơi.

3. Triển khai Singleton Pattern trong Java

Phần này sẽ trình bày cách triển khai Singleton Pattern trong ngôn ngữ lập trình Java.

3.1. Cách triển khai cơ bản

Triển khai Singleton Pattern trong Java đòi hỏi việc thực hiện các bước sau:

  1. Tạo một class Singleton: Đầu tiên, chúng ta cần tạo một class, ví dụ Singleton, để chứa instance duy nhất.
    public class Singleton {
        // ...
    }
    
  2. Tạo một biến private static: Bên trong class này, chúng ta tạo một biến private static để chứa instance duy nhất của lớp Singleton. Biến này là private để ngăn cản việc truy cập trực tiếp từ bên ngoài lớp, và là static để đảm bảo rằng nó chỉ được tạo một lần và tồn tại suốt thời gian chạy của ứng dụng.
    public class Singleton {
        private static Singleton uniqueInstance;
        // ...
    }
    
  3. Tạo một hàm tạo private: Để ngăn chặn việc tạo thêm các instance từ bên ngoài lớp, chúng ta tạo một hàm tạo private.
    public class Singleton {
        private static Singleton uniqueInstance;
    
        private Singleton() {
            // ...
        }
        // ...
    }
    
  4. Tạo một hàm public static để truy xuất biến uniqueInstance: Cuối cùng, chúng ta tạo một hàm public static để truy xuất biến uniqueInstance. Hàm này cung cấp một điểm truy cập global đến instance duy nhất của lớp Singleton.
    public class Singleton {
        private static Singleton uniqueInstance;
    
        private Singleton() {
            // ...
        }
    
        public static Singleton getInstance() {
            if (uniqueInstance == null) {
                uniqueInstance = new Singleton();
            }
            return uniqueInstance;
        }
    }
    

Khi hàm getInstance() được gọi, nếu instance duy nhất này chưa được tạo (tức là uniqueInstance là null), hàm sẽ tạo ra instance đó bằng cách gọi new Singleton(). Nếu instance duy nhất này đã được tạo, hàm sẽ trả về instance đó.

Dưới đây là một ví dụ về cách triển khai Singleton Pattern trong Java:

public class Singleton {
    // Khai báo instance duy nhất
    private static Singleton uniqueInstance;

    // Constructor private để ngăn cản việc tạo mới từ bên ngoài
    private Singleton() {
    }

    // Phương thức truy cập global đến instance
    public static Singleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }

    // Phương thức khác của class
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

Đoạn mã trên tạo ra một class Singleton với một instance duy nhất được quản lý bên trong class. Để sử dụng instance này, chúng ta sẽ gọi hàm getInstance(), như sau:

public class Main {
    public static void main(String[] args) {
        // Lấy instance của Singleton
        Singleton singleton = Singleton.getInstance();

        // Gọi phương thức doSomething
        singleton.doSomething();
    }
}

3.2. Cải tiến với Lazy Initialization và Thread-Safe

Triển khai cơ bản của Singleton Pattern đã giúp chúng ta đảm bảo rằng chỉ có duy nhất một instance của lớp Singleton tồn tại trong ứng dụng. Tuy nhiên, nếu ứng dụng của chúng ta sử dụng đa luồng (multithreading), có thể xảy ra vấn đề khi hai luồng cùng lúc truy cập vào phương thức getInstance(). Để giải quyết vấn đề này, chúng ta có thể cải tiến triển khai của Singleton Pattern bằng cách sử dụng Lazy Initialization và Thread-Safe.

Lazy Initialization là kỹ thuật tạo ra instance khi nó thực sự cần được sử dụng, thay vì tạo ra ngay từ đầu. Điều này giúp tiết kiệm tài nguyên, đặc biệt khi việc tạo instance tốn kém về mặt tài nguyên.

Thread-Safe Singleton: Để làm cho Singleton Pattern an toàn khi sử dụng trong môi trường đa luồng, chúng ta cần đồng bộ hóa phương thức getInstance(). Điều này đảm bảo rằng chỉ có duy nhất một luồng có thể truy cập phương thức getInstance() tại một thời điểm, ngăn chặn việc tạo ra nhiều hơn một instance.

Dưới đây là cách cải tiến triển khai Singleton Pattern với Lazy Initialization và Thread-Safe:

public class Singleton {
    private static Singleton uniqueInstance;

    private Singleton() {
    }

    // Synchronized method to control simultaneous access
    synchronized public static Singleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }

    // Other methods
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

Tuy nhiên, việc đồng bộ hóa toàn bộ phương thức getInstance() có thể làm giảm hiệu suất do mỗi lần truy cập phương thức này đều phải chờ. Một giải pháp tốt hơn là Double-Checked Locking, tuy nhiên cần lưu ý vì giải pháp này có thể gây ra vấn đề trong một số phiên bản cũ của Java.

3.3. Cải tiến với Double-Checked Locking

Double-Checked Locking là một kỹ thuật mà chúng ta thực hiện kiểm tra hai lần để đảm bảo rằng instance chỉ được tạo một lần khi chúng ta sử dụng Lazy Initialization trong môi trường đa luồng.

Ở kiểm tra đầu tiên, chúng ta kiểm tra xem instance đã được tạo chưa. Nếu chưa, chúng ta đồng bộ hóa phần mã tạo instance. Trong phần mã đồng bộ hóa này, chúng ta tiếp tục kiểm tra một lần nữa để đảm bảo rằng không có luồng nào khác đã tạo instance trong thời gian chúng ta đang đợi để vào phần mã đồng bộ hóa.

Dưới đây là cách cải tiến triển khai Singleton Pattern với Double-Checked Locking:

public class Singleton {
    // Sử dụng từ khóa volatile để đảm bảo rằng nhiều luồng xử lý biến đúng cách
    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // Kiểm tra lần đầu
        if (uniqueInstance == null) {
            // Đồng bộ hóa trong trường hợp instance chưa được tạo
            synchronized (Singleton.class) {
                // Kiểm tra lần thứ hai
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }

    // Other methods
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

Như vậy, bằng cách sử dụng Double-Checked Locking, chúng ta chỉ cần đồng bộ hóa phần mã tạo instance một lần đầu tiên instance được tạo, thay vì mỗi lần chúng ta truy cập phương thức getInstance(). Điều này giúp cải thiện hiệu suất so với việc đồng bộ hóa toàn bộ phương thức getInstance().

3.4. Cải tiến với Bill Pugh Singleton Implementation

Triển khai Singleton sử dụng cách tiếp cận của Bill Pugh sử dụng một nested class tĩnh và khởi tạo instance duy nhất của Singleton trong nested class này. Điều này tận dụng cơ chế class loading của Java để đảm bảo rằng instance được tạo một cách an toàn khi lớp Singleton được tải lần đầu tiên, và không cần sử dụng từ khóa synchronized, giúp cải thiện hiệu suất.

Dưới đây là cách cải tiến triển khai Singleton Pattern với Bill Pugh Singleton Implementation:

public class Singleton {
    private Singleton() {
    }

    // nested class tĩnh (static helper)
    private static class SingletonHelper {
        // Khởi tạo instance duy nhất
        private static final Singleton UNIQUE_INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHelper.UNIQUE_INSTANCE;
    }

    // Other methods
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

Khi chúng ta gọi Singleton.getInstance(), lớp SingletonHelper sẽ được tải và tạo ra instance duy nhất. Việc tải lớp trong Java là an toàn theo luồng (thread-safe), vì vậy phương thức này không cần đồng bộ hóa và vẫn giữ được hiệu suất cao.

Phương pháp này được coi là cách triển khai Singleton Pattern hiệu quả nhất, và nên được sử dụng nếu bạn không cần tạo instance trước (early initialization), hoặc không cần sử dụng Java 1.4 trở xuống.

3.5. Phá vỡ cấu trúc Singleton Pattern bằng Reflection

Reflection trong Java cho phép chúng ta truy cập vào các thành phần của class như constructor, method, field... ngay cả khi chúng được khai báo là private. Điều này có thể được sử dụng để phá vỡ cấu trúc Singleton Pattern.

Hãy xem xét ví dụ sau, trong đó chúng ta sử dụng Reflection để tạo ra một instance thứ hai của lớp Singleton:

import java.lang.reflect.Constructor;

public class SingletonBreaker {
    public static void main(String[] args) {
        Singleton instanceOne = Singleton.getInstance();
        Singleton instanceTwo = null;
        try {
            Constructor[] constructors = Singleton.class.getDeclaredConstructors();
            for (Constructor constructor : constructors) {
                // Cho phép truy cập vào constructor private
                constructor.setAccessible(true);
                instanceTwo = (Singleton) constructor.newInstance();
                break;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("instanceOne hashCode: " + instanceOne.hashCode());
        System.out.println("instanceTwo hashCode: " + instanceTwo.hashCode());
    }
}

Trong ví dụ trên, chúng ta đã tạo ra hai instance instanceOneinstanceTwo từ lớp Singleton. Mặc dù phương thức getInstance() đã được thiết kế để chỉ tạo ra một instance duy nhất, nhưng với sự giúp đỡ của Reflection, chúng ta vẫn có thể tạo ra instance thứ hai.

Chúng ta sẽ thấy rằng instanceOneinstanceTwo có hai hashCode khác nhau, chứng tỏ chúng là hai instance khác nhau. Điều này phá vỡ cấu trúc Singleton Pattern, vì vậy chúng ta cần phải cẩn thận khi sử dụng Reflection trong ứng dụng của mình.

Một giải pháp để ngăn chặn việc này là sử dụng Enum để triển khai Singleton Pattern, vì Enum giúp đảm bảo Java duy trì một instance duy nhất cho mỗi giá trị Enum, và Reflection không thể được sử dụng để tạo thêm instance.

3.6. Sử dụng Enum để triển khai Singleton Pattern

Enum trong Java là một loại đặc biệt của class và có thể chứa các thành phần dữ liệu và phương thức, giống như một class bình thường. Điểm đặc biệt là mỗi giá trị Enum đều là một instance của lớp Enum đó, và mỗi giá trị Enum chỉ được khởi tạo một lần. Điều này giúp chúng ta có thể sử dụng Enum để triển khai Singleton Pattern một cách dễ dàng và an toàn.

Dưới đây là cách triển khai Singleton Pattern sử dụng Enum:

public enum Singleton {
    INSTANCE;

    public void doSomething() {
        System.out.println("Doing something...");
    }
}

Để sử dụng instance duy nhất của Singleton, chúng ta chỉ cần gọi Singleton.INSTANCE. Ví dụ:

public class Main {
    public static void main(String[] args) {
        Singleton.INSTANCE.doSomething();
    }
}

Phương pháp này không chỉ đơn giản mà còn an toàn với Reflection, bởi vì không thể sử dụng Reflection để tạo thêm instance của Enum. Bên cạnh đó, Enum còn an toàn với Serialization và Thread-Safe mà không cần bất kỳ cố gắng nào từ phía chúng ta. Vì vậy, nếu bạn không cần tạo instance trước hoặc không cần tinh chỉnh việc tạo instance, việc sử dụng Enum để triển khai Singleton Pattern là một lựa chọn tốt.

3.7. Serialization và Singleton

Serialization là quá trình chuyển đổi đối tượng thành chuỗi byte để lưu trữ hoặc gửi qua mạng. Deserialization là quá trình ngược lại, chuyển đổi chuỗi byte thành đối tượng. Tuy nhiên, trong quá trình deserialization, một instance mới của lớp Singleton có thể được tạo, dẫn đến việc phá vỡ cấu trúc Singleton.

Ví dụ sau đây minh họa việc này:

import java.io.*;

public class Singleton implements Serializable {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

Trong lớp Singleton trên, nếu chúng ta serialize và deserialize instance của nó, chúng ta sẽ nhận được một instance mới của Singleton.

public class Main {
    public static void main(String[] args) {
        Singleton instanceOne = Singleton.getInstance();
        
        try {
            ObjectOutput out = new ObjectOutputStream(new FileOutputStream("file.ser"));
            out.writeObject(instanceOne);
            out.close();

            ObjectInput in = new ObjectInputStream(new FileInputStream("file.ser"));
            Singleton instanceTwo = (Singleton) in.readObject();
            in.close();

            System.out.println("instanceOne hashCode: " + instanceOne.hashCode());
            System.out.println("instanceTwo hashCode: " + instanceTwo.hashCode());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Trong ví dụ trên, instanceOneinstanceTwo có hai hashCode khác nhau, chứng tỏ chúng là hai instance khác nhau.

Để ngăn chặn việc này, chúng ta có thể cung cấp phương thức readResolve() trong lớp Singleton. Phương thức này sẽ được gọi khi deserialization diễn ra, cho phép chúng ta thay thế instance được tạo trong quá trình deserialization bằng instance gốc của lớp Singleton.

public class Singleton implements Serializable {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }

    protected Object readResolve() {
        return getInstance();
    }
}

Bây giờ, dù chúng ta có thực hiện serialization và deserialization, chúng ta vẫn chỉ có một instance duy nhất của lớp Singleton.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí