如何在 Java 中實現線程安全的單例?
1. 簡介
單例模式是軟體開發中最廣泛使用的設計模式之一。它確保一個類別在整個應用程式生命週期中只有一個實例,並提供對該實例的全局存取。
單例模式的常見用例包括:
- 高效率管理有限資料庫連線的資料庫連線池
- 集中整個應用程式的日誌記錄功能的記錄器實例
- 儲存應用程式範圍設定的設定管理器
- 維護跨多個元件的共享資料的快取管理器
- 管理並發操作的工作執行緒的執行緒池
然而,在多執行緒環境中實作單例模式時,情況會變得異常複雜。如果沒有適當的執行緒安全保證,多個執行緒可能會同時建立單獨的實例,從而違反單例模式的核心承諾,並可能導致資源衝突或狀態不一致。這可能會導致資源衝突、狀態不一致以及應用程式行為的不可預測。
在本指南中,我們將探討在 Java 中實作執行緒安全的單例模式的各種方法,並研究它們的權衡和最佳實踐。
2. 單例模式的經典問題
讓我們先研究為什麼基本的延遲初始化的單例實作會在多執行緒環境中失敗。
這是一個典型的非線程安全的單例實作:
public class SimpleSingleton {
private static SimpleSingleton instance;
private SimpleSingleton() {
}
public static SimpleSingleton getInstance() {
if (instance == null) {
instance = new SimpleSingleton();
}
return instance;
}
}
此實作在單執行緒應用程式中運行良好。然而,在多執行緒環境中,可能會出現競爭條件:
- 線程 A
getInstance()
並發現instance
為null
- 線程 B 同時呼叫
getInstance()
並發現instance
為null
- 兩個執行緒繼續建立新實例
- 應用程式現在已經實例化了多個 Singleton 實例,違反了模式
讓我們透過一個測試來證明這一點,該測試使用CountDownLatch
暴露競爭條件,這將幫助我們並行運行執行緒:
@Test
void givenUnsafeSingleton_whenAccessedConcurrently_thenMultipleInstancesCreated() throws InterruptedException {
int threadCount = 1000;
Set<SimpleSingleton> instances = ConcurrentHashMap.newKeySet();
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
instances.add(SimpleSingleton.getInstance());
latch.countDown();
}).start();
}
latch.await();
assertTrue(instances.size() > 1, "Multiple instances were created");
}
此測試示範了並發存取如何建立多個實例,從而違反了單例模式的約定。在正確的單例模式中,實例數應該為 1。但由於競爭條件,我們可能會得到多個實例。
3.同步存取器:簡單且安全
我們可以讓getInstance()
方法同步:
public static synchronized SynchronizedSingleton getInstance() { ... }
這保證了互斥,但帶來了效能開銷,因為每次存取都會發生同步
@Test
void givenMultipleThreads_whenUsingSynchronizedSingleton_thenOnlyOneInstanceCreated() {
Set<Object> instances = ConcurrentHashMap.newKeySet();
IntStream.range(0, 100).parallel().forEach(i ->
instances.add(SynchronizedSingleton.getInstance()));
assertEquals(1, instances.size());
}
這種方法在低並發場景或很少訪問單例創建的情況下是簡單而有效的。
4. 預先初始化:透過類別載入實現線程安全
熱切的單例使用靜態欄位初始化:
private static final EagerSingleton INSTANCE = new EagerSingleton();
它本質上是線程安全的,因為 JVM 保證類別初始化是原子的。缺點是什麼?即使從未使用過,實例也會被創建,這對於昂貴的資源來說可能不是最佳選擇:
@Test
void whenCallingEagerSingleton_thenSameInstanceReturned() {
assertSame(EagerSingleton.getInstance(), EagerSingleton.getInstance());
}
當保證在啟動時需要單例時,這種模式是理想的。
5. 雙重檢查鎖(DCL):懶惰且高效
DCL 將延遲初始化與簡化同步結合在一起:
if (instance == null) {
synchronized (...) {
if (instance == null) {
instance = new Singleton();
}
}
}
這種模式既是惰性的又是線程安全的,但要求實例變數被宣告為 volatile
@Test
void givenDCLSingleton_whenAccessedFromThreads_thenOneInstanceCreated() {
List<Object> instances = Collections.synchronizedList(new ArrayList<>());
IntStream.range(0, 100).parallel().forEach(i ->
instances.add(DoubleCheckedSingleton.getInstance()));
assertEquals(1, new HashSet<>(instances).size());
}
這種方法避免了實例初始化volatile
的同步,從而提高了效能。 volatile 關鍵字確保了跨執行緒變更的可見性。在效能至關重要的高並發環境中,它有著良好的用例。
6.比爾普格辛格頓:慵懶而優雅
Bill Pugh Singleton 技術使用靜態內部類別:
public class BillPughSingleton {
private BillPughSingleton() {
}
private static class SingletonHelper {
private static final BillPughSingleton BILL_PUGH_SINGLETON_INSTANCE = new BillPughSingleton();
}
public static BillPughSingleton getInstance() {
return SingletonHelper.BILL_PUGH_SINGLETON_INSTANCE;
}
}
類別保持卸載狀態直到系統引用它,這確保了無需同步的惰性和線程安全:
@Test
void testThreadSafety() throws InterruptedException {
int numberOfThreads = 10;
CountDownLatch latch = new CountDownLatch(numberOfThreads);
Set<BillPughSingleton> instances = ConcurrentHashMap.newKeySet();
for (int i = 0; i < numberOfThreads; i++) {
new Thread(() -> {
instances.add(BillPughSingleton.getInstance());
latch.countDown();
}).start();
}
latch.await(5, TimeUnit.SECONDS);
assertEquals(1, instances.size(), "All threads should get the same instance");
}
7. 枚舉單例:最簡單的線程安全單例
枚舉提供了健壯的單例解決方案。 JVM 只會實例化一次枚舉值:
@Test
void givenEnumSingleton_whenAccessedConcurrently_thenSingleInstanceCreated()
throws InterruptedException {
Set<EnumSingleton> instances = ConcurrentHashMap.newKeySet();
CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
instances.add(EnumSingleton.INSTANCE);
latch.countDown();
}).start();
}
latch.await();
assertEquals(1, instances.size());
}
它也是序列化安全和反射安全的。
8. 結論
在並發 Java 應用中,單例模式實現的執行緒安全性至關重要。雖然同步方法實現起來很簡單,但它們也有成本——在高並發環境下擴展性不佳。目前最佳方案包括:
- 大多數用例都使用Bill Pugh Singleton
- 雙重檢查鎖定,用於性能關鍵的延遲初始化
- 枚舉單例,簡單且安全
每種方法解決的是同一個問題,但各有優缺點。請選擇最符合您應用需求的方法。與往常一樣,完整的原始程式碼和測試可在 GitHub 上取得。