Ngày 24 tháng 3 năm 2024 - Máy tính
Trong Java, một lớp singleton là một lớp mà chỉ tồn tại duy nhất một thể hiện (instance) trong JVM (Java Virtual Machine), và lớp này cung cấp một điểm truy cập toàn cục để lấy được thể hiện đó. Singleton chủ yếu được sử dụng để đảm bảo rằng trên toàn bộ ứng dụng chỉ có duy nhất một thể hiện tồn tại, từ đó giúp quản lý các tài nguyên chia sẻ, trạng thái toàn cục và chức năng đơn lẻ dễ dàng hơn.
Một số lớp phổ biến trong JDK cũng áp dụng mô hình singleton, chẳng hạn như java.lang.Runtime
, java.lang.System
và java.sql.DriverManager
.
Bài viết này sẽ liệt kê các cách thức khác nhau để thực hiện singleton, đồng thời phân tích ưu và nhược điểm của từng phương pháp.
Để tạo ra một lớp singleton, chúng ta cần làm cho constructor của lớp đó là riêng tư (private) và cung cấp một phương thức tĩnh (static factory method) để truy xuất thể hiện duy nhất.
Dưới đây là một ví dụ trực tiếp về cách tải game 789 club tài xỉu triển khai singleton:
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
Cách triển khai này rất rõ ràng và đơn giản. Thể hiện của lớp Singleton
được khởi tạo khi lớp được tải vào bộ nhớ, và nó chỉ được khởi tạo một lần duy nhất. Thể hiện này sau đó được gán cho một biến tĩnh riêng tư không thay đổi được. Constructor của Singleton
là riêng tư, vì vậy khách hàng chỉ có thể lấy được thể hiện thông qua phương thức Singleton.getInstance()
. Dù truy xuất nhiều lần hay từ nhiều luồng cùng lúc, phương thức này luôn trả về cùng một thể hiện.
Đoạn mã kiểm tra dưới đây tạo ra 10 luồng để gọi phương thức Singleton.getInstance()
và in ra kết quả. Chúng ta thấy rằng tất cả các giá trị hashCode
đều giống nhau, chứng tỏ đó là cùng một thể hiện.
public class SingletonTest {
@Test
public void testMultiThreadedAccessing() {
for(int i = 0; i < 10; i++) {
new Thread(() -> {
System.out.println(Singleton.getInstance());
}).start();
}
}
}
Tuy nhiên, cách triển khai này có một nhược điểm: thể hiện của lớp được khởi tạo ngay khi lớp được tải vào bộ nhớ, nhưng có thể thể hiện đó không bao giờ được sử dụng. Điều này đặc biệt quan trọng nếu singleton được sử dụng để quản lý tài nguyên toàn cầu, vì quá trình khởi tạo có thể tiêu tốn nhiều tài nguyên. Vì vậy, việc trì hoãn khởi tạo đến khi thật sự cần thiết sẽ hiệu quả hơn.
1. Triển khai với Khởi tạo Trì hoãn
Dưới đây là một ví dụ về cách triển khai singleton với khởi tạo trì hoãn:
public class LazyInitializationSingleton {
private static LazyInitializationSingleton INSTANCE = null;
private LazyInitializationSingleton() {}
public static LazyInitializationSingleton getInstance() {
if(INSTANCE == null) {
INSTANCE = new LazyInitializationSingleton();
}
return INSTANCE;
}
}
Trong đoạn mã trên, biến INSTANCE
được khởi tạo ban đầu là null
, và logic khởi tạo đối tượng được chuyển vào phương thức getInstance()
. Như vậy, đối tượng chỉ được khởi tạo khi khách hàng chủ động yêu cầu.
Tuy nhiên, cách này chỉ hoạt động đúng trong trường hợp truy cập tuần tự từ một luồng duy nhất. Khi nhiều luồng cùng truy cập phương thức getInstance()
đồng thời, có khả năng nhiều thể hiện khác nhau được tạo ra. Nguyên nhân là do hai hoặc nhiều luồng có thể cùng đến điều kiện if (INSTANCE == null)
trước khi bất kỳ luồng nào kịp khởi tạo đối tượng.
Đoạn mã kiểm tra dưới đây minh họa vấn đề này:
public class LazyInitializationSingletonTest {
@Test
public void testMultiThreadedAccessing() {
for(int i = 0; i < 10; i++) {
new Thread(() -> {
System.out.println(LazyInitializationSingleton.getInstance());
}).start();
}
}
}
Kết quả cho thấy rằng một số luồng in ra các giá trị hashCode
khác nhau, chứng tỏ các luồng đã nhận được những thể hiện khác nhau.
1.1 Làm thế nào để đảm bảo An toàn Đa luồng?
Để khắc phục vấn đề trên, chúng ta cần đảm bảo an toàn đa luồng. Cách đơn giản nhất là thêm từ khóa synchronized
vào phương thức getInstance()
:
public class ThreadSafeSingleton {
private static ThreadSafeSingleton INSTANCE = null;
private ThreadSafeSingleton() {}
public static synchronized ThreadSafeSingleton getInstance() {
if(INSTANCE == null) {
INSTANCE = new ThreadSafeSingleton();
}
return INSTANCE;
}
}
Việc thêm từ khóa synchronized
biến phương thức thành một phương thức đồng bộ, nghĩa là mỗi lần một luồng gọi phương thức này, các luồng khác phải chờ. Điều này ngăn chặn việc tạo ra nhiều thể hiện khác nhau. Tuy nhiên, cách này có nhược điểm là giảm hiệu suất khi truy cập từ nhiều luồng, vì mọi luồng đều phải đợi lượt.
Mục đích của chúng ta là ngăn chặn tình huống hai hoặc nhiều luồng cùng đến điều kiện if
để khởi tạo đối tượng, nhưng sau khi đối tượng đã được khởi tạo thì việc truy cập không cần bị khóa. Giải pháp là sử dụng cơ chế kiểm tra đôi với khóa (double-checked locking
):
public class ThreadSafeSingleton {
private static ThreadSafeSingleton INSTANCE = null;
private ThreadSafeSingleton() {}
public static ThreadSafeSingleton getInstance() {
if(INSTANCE == null) {
synchronized(ThreadSafeSingleton.class) {
if(INSTANCE == null) {
INSTANCE = new ThreadSafeSingleton();
}
}
}
return INSTANCE;
}
}
Tại sao cần kiểm tra hai Khuyến Mãi 88king lần? Lý do là vì khi hai luồng cùng đến lần kiểm tra đầu tiên, luồng đầu tiên sẽ giành được khóa và thực hiện khởi tạo đối tượng, trong khi luồng thứ hai sẽ chờ. Sau khi luồng đầu tiên hoàn thành khởi tạo và giải phóng khóa, nếu luồng thứ hai không thực hiện kiểm tra lần hai, nó sẽ lại khởi tạo một đối tượng mới. Việc kiểm tra lần hai đảm bảo rằng nếu đối tượng đã tồn tại, luồng sẽ bỏ qua phần khởi tạo và trả về đối tượng đã có.
2. Làm thế nào để phá vỡ một Singleton?
2.1 tải game 789win Sử dụng Phản xạ (Reflection)
Chúng ta có thể phá vỡ thiết kế singleton bằng cách sử dụng phản xạ để thay đổi quyền truy cập của constructor và tạo ra các thể hiện khác nhau. Phương pháp này hiệu quả với tất cả các cách triển khai singleton được thảo luận trước đó.
Dưới đây là một ví dụ sử dụng phản xạ để tạo ra một thể hiện khác của singleton:
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class SingletonReflectionTest {
@Test
public void testTwoInstancesCreation() throws InvocationTargetException, InstantiationException, IllegalAccessException {
ThreadSafeSingleton singleton1 = ThreadSafeSingleton.getInstance();
ThreadSafeSingleton singleton2 = null;
Constructor<?>[] constructors = ThreadSafeSingleton.class.getDeclaredConstructors();
for(Constructor<?> constructor : constructors) {
constructor.setAccessible(true);
singleton2 = (ThreadSafeSingleton) constructor.newInstance();
}
System.out.println(singleton1);
System.out.println(singleton2);
}
}
Kết quả cho thấy singleton1
và singleton2
có hashCode
khác nhau, chứng tỏ chúng là hai thể hiện khác nhau.
2.2 Sử dụng Chuỗi hóa (Serialization)
Một cách khác để phá vỡ singleton là chuỗi hóa rồi tái tạo đối tượng. Đối tượng tái tạo sẽ là một thể hiện hoàn toàn mới.
Dưới đây là ví dụ về cách triển khai lớp singleton hỗ trợ chuỗi hóa:
public class ThreadSafeSingleton implements Serializable {
// ...
}
Đoạn mã kiểm tra dưới đây sẽ chuỗi hóa và tái tạo đối tượng:
import org.junit.jupiter.api.Test;
import java.io.*;
public class SingletonSerializationTest {
@Test
public void testTwoInstancesCreation() throws IOException, ClassNotFoundException {
ThreadSafeSingleton singleton1 = ThreadSafeSingleton.getInstance();
// Chuỗi hóa đối tượng
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oss = new ObjectOutputStream(bos);
oss.writeObject(singleton1);
oss.close();
// Tái tạo đối tượng
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
ThreadSafeSingleton singleton2 = (ThreadSafeSingleton) ois.readObject();
ois.close();
// In kết quả
System.out.println(singleton1);
System.out.println(singleton2);
}
}
Để đảm bảo chuỗi hóa không phá vỡ đặc điểm singleton, chúng ta cần cung cấp phương thức readResolve()
:
import java.io.Serial;
import java.io.Serializable;
public class ThreadSafeSingleton implements Serializable {
// ...
@Serial
private Object readResolve() {
return getInstance();
}
}
Phương thức này đảm bảo rằng khi tái tạo đối tượng, phương thức readObject
sẽ sử dụng thể hiện đã tồn tại thay vì tạo ra một thể hiện mới.
3. Cách Triển khai Singleton Tối ưu
Sau khi thảo luận về các cách triển khai singleton khác nhau và cách phá vỡ chúng, chúng ta có thể tìm thấy một cách triển khai đơn giản và mạnh mẽ hơn: sử dụng kiểu liệt kê (enum). Một lớp enum đơn nguyên tử là cách triển khai singleton tối ưu nhất trong Java. Nó tự động đảm bảo rằng chỉ có duy nhất một thể hiện tồn tại, không cần phải lo lắng về các vấn đề đồng bộ hóa hoặc việc bị phá vỡ bởi phản xạ hay chuỗi hóa.
Ví dụ:
public enum SingletonEnum {
INSTANCE;
public void doSomething() {}
}
Tóm lại, bài viết này đã giới thiệu về mô hình singleton trong Java, các cách triển khai khác nhau, so sánh ưu và nhược điểm của từng cách, cách phá vỡ singleton và cách triển khai tối ưu bằng enum. Tất cả các ví dụ mã nguồn đã được đăng tải lên GitHub cá nhân, mời bạn đọc theo dõi hoặc fork.
[1] Effective Java (Phiên bản thứ 3): Thực thi thuộc tính singleton bằng constructor riêng tư hoặc kiểu enum - [2] Oracle: Khi nào một Singleton không còn là Singleton? - [3] DigitalOcean: Các thực hành tốt nhất về mẫu thiết kế Singleton trong Java với ví dụ - [4] Refactoring.Guru: Singleton trong Java - [5] GeeksforGeeks: Phương pháp Thiết kế Singleton trong Java - [6] Baeldung: Singletons trong Java -
#Java #Thiết_kế_mẫu