Java 單例模式的線程安全實現

單例模式概念

引用維基百科:

單例(Singleton)模式,也叫單子模式,是一種常用的軟件設計模式。在應用這個模式時,單例對象的類必須保證只有一個實例存在。許多時候整個系統只需要擁有一個的全局對象,這樣有利於我們協調系統整體的行爲。比如在某個服務器程序中,該服務器的配置信息存放在一個文件中,這些配置數據由一個單例對象統一讀取,然後服務進程中的其他對象再通過這個單例對象獲取這些配置信息。這種方式簡化了在複雜環境下的配置管理。還有就是我們經常使用的servlet就是單例多線程的。使用單例能夠節省很多內存。

可見單例模式是設計模式的一種,爲了保證一個類僅有一個實例,並提供該實例的唯一全局訪問點。

關於如何實現單例模式,維基百科也有解釋:

實現單例模式的思路是:一個類能返回對象一個引用(永遠是同一個)和一個獲得該實例的方法(必須是靜態方法,通常使用 getInstance 這個名稱);當我們調用這個方法時,如果類持有的引用不爲空就返回這個引用,如果類保持的引用爲空就創建該類的實例並將實例的引用賦予該類保持的引用;同時我們還將該類的構造函數定義爲私有方法,這樣其他處的代碼就無法通過調用該類的構造函數來實例化該類的對象,只有通過該類提供的靜態方法來得到該類的唯一實例。

簡言之,單例模式中一定要遵循的規則無非三點:

  1. 獲取單例的方法一定爲靜態方法,單例對象一定使用私有靜態成員變量來聲明。
  2. 可以不用在類加載階段(準確地說,靜態成員變量初始化階段)就創建單例,但如果單例爲 null 時需要新建一個單例。
  3. 構造函數一定定義爲私有方法,防止通過構造函數來實例化對象。

單例模式代碼

餓漢式

public class EagerSingleton {

    // jvm保證在任何線程訪問uniqueInstance靜態變量之前一定先創建了此實例
    private static EagerSingleton uniqueInstance = new EagerSingleton();

    // 私有的默認構造子,保證外界無法直接實例化
    private EagerSingleton() {
    }

    // 提供全局訪問點獲取唯一的實例
    public static EagerSingleton getInstance() {
        return uniqueInstance;
    }
    
}

之所以命名爲餓漢式,因爲該方法在類加載的初始化階段就實例化了對象,此時尚未調用 getInstance() 方法。

優點

  • 實現簡單
  • 線程安全

缺點

  • 在類加載階段就實例化對象,對內存的開銷較大。
  • 如果單例的初始化依賴於某些外部資源(比如數據庫)時,那麼就需要考慮延遲加載而不能考慮餓漢式了。

懶漢式

public class LazySingleton {

    private static LazySingleton uniqueInstance;

    private LazySingleton() {
    }

    public static synchronized LazySingleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new LazySingleton();
        }
        return uniqueInstance;
    }

}

針對餓漢式在內存開銷上存在的缺點,懶漢式對其進行改進:

  • 僅僅聲明瞭單例對象,沒有初始化;
  • getInstance() 方法中,當且僅當單例對象爲 null 時,才實例化一個新對象。

以上優化既可以避免單例對內存的早期開銷,又能夠保證單例的唯一性。

注意,getInstance() 方法之前需要加上 synchronized 關鍵字。如果不加 synchronized 關鍵字,可能存在線程安全問題:

  • 線程 A 調用 getInstance() 方法,uniqueInstancenull,此時尚未執行 uniqueInstance = new LazySingleton()
  • 線程B也開始調用 getInstance() 方法,uniqueInstance 也爲 null,又準備執行一次 uniqueInstance = new LazySingleton()
  • 這樣線程 A 和線程 B 都分別創建了一個 uniqueInstance,但並非同一實例。

但加入 synchronized 關鍵字使得每次調用 getInstance() 方法獲取單例時都加鎖,導致程序運行效率下降。事實上我們只想保證第一次獲取單例時初始化成功而已,以後快速返回即可,如果在 getInstance() 頻繁使用的地方就要考慮重新優化了。

雙重檢測鎖

public class DoubleCheckedLockingSingleton {

    private static volatile DoubleCheckedLockingSingleton uniqueInstance;

    private DoubleCheckedLockingSingleton() {
    }

    public static DoubleCheckedLockingSingleton getInstance() {
        if (uniqueInstance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return uniqueInstance;
    }

}

雙重檢測鎖對懶漢式的 getInstance() 方法做了優化,不在方法上加鎖,而是在更小的代碼塊內加鎖。這個方法首先判斷變量是否被初始化,沒有被初始化,再去獲取鎖。獲取鎖之後,再次判斷變量是否被初始化。第二次判斷目的在於有可能其他線程獲取過鎖,已經初始化改變量。第二次檢查還未通過,纔會真正初始化變量。第一次判斷主要針對已經經過了單例對象的初次(也是唯一一次)實例化後,以後調用 getInstance() 方法獲取單例對象時不用反覆進入判斷體,到達同步代碼塊處。第二次判斷主要針對單例對象的初次實例化時多線程存在的競爭問題,即不讓多個線程去同時實例化對象。
這個方法檢查判定兩次,並使用鎖,所以被形象地稱爲雙重檢查鎖定模式。

volatile 和指令重排序

當然,我們不能忘記給 uniqueInstance 加上 volatile 修飾符。
首先明確 volatile 的功能主要有兩點:

  1. 保證可見性。使用 volatile 定義的變量,將會保證對所有線程的可見性。
  2. 禁止指令重排序優化。

另外,假如 uniqueInstance 沒有被 volatile 修飾,那麼對於 uniqueInstance = new DoubleCheckedLockingSingleton() 這一實例化語句,會被編譯器編譯成以下三條 JVM 指令:

1、 分配對象的內存空間

memory = allocate();

2、初始化對象

ctorInstance(memory); 

3、設置 instance 指向剛分配的內存地址

instance = memory;

但是這些指令順序並非一成不變,有可能會經過 JVM 和 CPU 的優化,指令重排成下面的順序:

1、分配對象的內存空間

memory = allocate();

3、設置 instance 指向剛分配的內存地址

instance = memory;

2、初始化對象

ctorInstance(memory); 

當線程A執行完 1、3 時,uniqueInstance 對象還未完成初始化,但已經不再指向 null。此時如果線程B搶佔到CPU資源,執行外層的 if (uniqueInstance == null) 的結果會是 false,從而返回一個沒有初始化完成的uniqueInstance 對象。如下圖所示:
在這裏插入圖片描述
在這裏插入圖片描述
而當 uniqueInstance (圖中的 instance)被 volatile 修飾後,JVM 的指令就會嚴格遵照以下順序執行:

1、 分配對象的內存空間

memory = allocate();

2、初始化對象

ctorInstance(memory); 

3、設置 instance 指向剛分配的內存地址

instance = memory;

如此在線程B看來,uniqueInstance 對象的引用要麼指向 null,要麼指向一個初始化完畢的 new DoubleCheckedLockingSingleton(),而不會出現某個中間態,保證了線程安全。
綜上所述,雙重檢測鎖是一種較爲嚴謹、也是常用的創建單例對象的方式,原因總結起來有兩點:

  1. 使用了雙重檢測鎖機制保證了初次實例化時不會有多個線程同時創建對象,對象被實例化後不會反覆進入同步代碼塊。
  2. 使用 volatile 修飾單例對象,從而禁止 JVM 指令重排序,保證沒有獲取到鎖的線程不會在外層空判斷時做出誤判而直接返回一個尚未被獲取到鎖的線程初始化好的對象。

靜態內部類

public class LazyInitHolderSingleton {

    private LazyInitHolderSingleton() {
    }

    private static class SingletonHolder {
        private static final LazyInitHolderSingleton INSTANCE = new LazyInitHolderSingleton();
    }

    public static LazyInitHolderSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
}

這種方法的優點:

  • 外部類加載時並不需要立即加載內部類,內部類不被加載則不去初始化INSTANCE,故而不佔內存。這意味着即使使用 class.forName() 加載 LazyInitHolderSingleton,也不會加載 SingletonHolder
  • 當第一次調用 LazyInitHolderSingleton.getInstance() 時,纔會加載 SingletonHolder 並初始化 INSTANCE
    這樣的特性延遲了單例的實例化。
  • 因爲 INSTANCE 爲常量,所以在這種情況下獲取單例是線程安全的。

反射獲取不同的單例

靜態內部類的實現方式雖好,但也存在着單例模式共同的問題:無法防止利用反射來重複構建對象。

@Test
public void getDifferentInstanceByReflection() throws Exception {
    Constructor<LazyInitHolderSingleton> con = LazyInitHolderSingleton.class.getDeclaredConstructor();
    con.setAccessible(true);
    LazyInitHolderSingleton singleton1 = con.newInstance();
    LazyInitHolderSingleton singleton2 = con.newInstance();
    assertNotSame(singleton1, singleton2);
}

反射獲取不同單例的代碼可以簡單歸納爲三個步驟:

  1. 獲得單例類的構造器。
  2. 把構造器設置爲可訪問。
  3. 使用 newInstance() 方法構造對象。

枚舉

那麼如何防止使用暴力反射來獲取單例對象呢?枚舉是一個 《Effective Java》 推薦的的優雅的實現單例的方法。

public enum SingletonEnum {

    INSTANCE;
    
    private EnumSingleton uniqueInstance;
    
    SingletonEnum() {
        uniqueInstance = new EnumSingleton();
    }
    
    public EnumSingleton getInstance() {
        return uniqueInstance;
    }
    
}

class EnumSingleton {
}

有了 enum 語法糖,JVM 會阻止反射獲取枚舉類的私有構造方法。
再試圖使用反射來獲取 SingletonEnum 實例:

@Test
public void getDifferentInstanceByReflection() throws Exception {
    Constructor<SingletonEnum> con = SingletonEnum.class.getDeclaredConstructor();
    con.setAccessible(true);
    SingletonEnum singleton1 = con.newInstance();
    SingletonEnum singleton2 = con.newInstance();
    assertNotSame(singleton1, singleton2);
}

運行後控制檯報錯:

java.lang.NoSuchMethodException: singleton.SingletonEnum.<init>()

	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at singleton.LazyInitHolderSingletonTest.getDifferentInstanceByReflection(LazyInitHolderSingletonTest.java:34)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)

而且枚舉是線程安全的,其具體原因可以參考:深度分析Java的枚舉類型—-枚舉的線程安全性及序列化問題

參考博客

發佈了85 篇原創文章 · 獲贊 335 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章