單例模式就是如此簡單

TimberLiu發表於2019-05-08

在面試中相信很多人會被問到:說說你最瞭解的三個設計模式,日常開發中使用過哪些設計模式等等。最近幾篇文章就來學習一下設計模式,這是第一篇文章,也是最常見的模式——單例模式。

什麼是單例

單例模式(Singleton Pattern),顧名思義,即保證一個類僅有一個例項,並在全域性中提供一個訪問點。

在實現單例時,要保證一個類僅有一個例項,就不能提供公有的構造方法,任由其他類建立例項,對應變數也需要為 static,只在載入時初始化一次。另外呢,要在全域性中都能訪問到,還需要提供一個靜態的公有方法來進行訪問。

具體實現方式比較多,對於不同的場景,也應該選擇不同的方式,例如是否需要保證執行緒安全,是否需要延遲載入。下面具體來看一下。

餓漢式(執行緒安全)

根據上面對單例模式實現的說明,可以很容易地想到如下實現:

public class Singleton1 {

    private static Singleton1 instance = new Singleton1();

    private Singleton1() { }

    public static Singleton1 getInstance() {
        return instance;
    }
}
複製程式碼

這種方式在該類第一次被載入時,就會建立好該例項。這就是所謂的餓漢式,也就是,在想要使用例項時,立刻就能拿到,而不需要進行等待。

另外這種方式,由 JVM 保證其執行緒安全。但是這種方式可能會造成資源消耗,因為有可能這個例項根本就用不到,而進行不必要的載入。

懶漢式(非執行緒安全)

上述方式在類載入時就進行例項化,可能會造成不必要的載入。那麼我們可以在其真正被訪問的時候,再進行例項化,於是可以寫出如下方式:

public class Singleton2 {

    private static Singleton2 instance;

    private Singleton2() { }

    public static Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}
複製程式碼

getInstance 方法中,第一次訪問時,由於沒有初始化,才去進行進行初始化,在後續訪問時,直接返回該例項即可。這就是所謂的懶漢式,也就是,它不會提前把例項建立出來,而是將其延遲到第一次被訪問的時候。

但是懶漢式存線上程安全問題,如下圖:

單例模式就是如此簡單

在多執行緒場景下,如果有兩個執行緒同時進入 if 語句中,則這兩個執行緒分別建立了一個物件,在兩個執行緒從 if 中退出時,就建立了兩個不一樣的物件。

懶漢式(執行緒安全)

既然普通的懶漢式會出現執行緒安全問題,那麼給建立物件的方法加鎖即可:

public class Singleton3 {

    private static Singleton3 instance;

    private Singleton3() { }

    public static synchronized Singleton3 getInstance() {
        if (instance == null) {
            instance = new Singleton3();
        }
        return instance;
    }
}
複製程式碼

上述這種做法雖然在多執行緒場景下也能正常工作,也具備延遲載入。但由於 synchronized 方法鎖住了整個方法,效率比較低。於是,聰明的小夥伴,可以很容易想到,使用同步方法塊,來減小加鎖的粒度。

看下面兩種做法,加鎖粒度確實減小了,但是它們卻並不能保證執行緒安全:

synchronized (Singleton4.class) {
    if (instance == null) {
        instance = new Singleton4();
    }
}
複製程式碼

由於指定重排序出現問題,後面介紹雙重校驗鎖時會詳細說。

if (instance == null) {
    synchronized (Singleton4.class) {
        instance = new Singleton4();
    }
}
複製程式碼

如果 synchronized 加在 if 語句外面,這和普通的懶漢式做法一樣,沒有區別。如果有兩個執行緒分別進入 if 語句,雖然也有加鎖操作,但是兩個執行緒都會執行例項化,也就是會進行兩次例項化。

雙重校驗鎖(執行緒安全)

於是引出了雙重校驗鎖方式,可以先判斷物件是否例項化,如果沒有再進行加鎖,再加鎖之後,再次判斷是否例項化,如果仍然沒有例項化,才例項化物件。

這種做法的完整程式碼如下:

public class Singleton5 {
    
    private static volatile Singleton5 instance;
    
    private Singleton5() { }
    
    public static Singleton5 getInstance() {
        // 如果已經例項化,則直接返回,不用加鎖,提升效能
        if (instance == null) {
            synchronized (Singleton5.class) {
                // 再次檢查,保證執行緒安全
                if (instance == null) {
                    instance = new Singleton5();
                }
            }
        }
        return instance;
    }
}
複製程式碼

可以看到,在 synchronized 語句前後,有兩個 if 判斷,這就是所謂的雙重校驗鎖。

使用 volatile

其實,如果僅僅是雙重校驗的話,仍然不能保證執行緒安全問題。這就要分析 instance = new Singleton5(); 這段程式碼。

雖然程式碼只有一句,但在 JVM 中它其實被分為三步執行:

  1. instance 分配記憶體空間;
  2. instance 進行初始化;
  3. instance 指向分配的記憶體地址;

但由於編譯器或處理器可能會對指令重排序,執行的順序就有可能變成 1->3->2。這在單執行緒環境下不會出現問題,但是在多執行緒環境下可能會導致一個執行緒獲得還沒有初始化的例項。

單例模式就是如此簡單

例如,執行緒 A 執行了第 13 步後,此時執行緒 B 呼叫 getInstance() 方法,判斷 instance 不為空,因此返回 instance。但此時 instance 還未被初始化。

所以,就需要使用 volatile 關鍵字來修飾 instance,禁止編譯器的指令重排序,保證在多執行緒環境下也能正常執行。

靜態內部類式(執行緒安全)

目前雙重校驗鎖的做法看起來不錯,使用延遲載入,在保證執行緒安全的同時,加鎖粒度也比較小,效率還不錯。那還有沒有其他方法呢?

那就是使用靜態內部類來實現,來看一下它的實現:

public class Singleton6 {

    private Singleton6() { }

    private static class InnerSingleton {
        private static final Singleton6 INSTANCE = new Singleton6();
    }

    public static Singleton6 getInstance() {
        return InnerSingleton.INSTANCE;
    }
}
複製程式碼

在這種實現中,當外部類 Singleton6 類被載入時,靜態內部類 InnerSingleton 並沒有被載入。

而是隻有當呼叫 getInstance 方法,從而訪問類的靜態變數時,才會載入內部類,從而例項化 INSTANCE。並且 JVM 能確保 INSTANCE 只能被例項化一次,即它也是執行緒安全的。

列舉式(執行緒安全)

另外,使用列舉實現單例也是一種不錯的方式,程式碼非常簡單:

public enum Singleton6 {
    INSTANCE();

    Singleton6() { }
}
複製程式碼

列舉的實現中,類被定義為 final,其列舉值被定義為 static final,對列舉值的初始化放在靜態語句塊中。所以,物件在該類第一次被載入時例項化,這不僅避免了執行緒安全問題,而且也避免了下面提到的反序列化對單例的破壞。

單例與序列化

現在來看一下,物件在序列化和反序列化時,是否還能夠保證單例。

這裡使用雙重校驗鎖實現的單例類,對 Singleton5 類新增 Serializable 介面,然後進行測試:

public class SingletonTest {

    public static void main(String[] args) {
        Singleton5 instance1 = Singleton5.getInstance();
        try ( ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile")) ){
            oos.writeObject(instance1);
        } catch (IOException e) {
            e.printStackTrace();
        }

        Singleton5 instance2 = null;
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("tempFile"))) ){
            instance2 = (Singleton5) ois.readObject();
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

        System.out.println(instance1 == instance2);
    }
}
// false
複製程式碼

可以看到,對 Singleton5 進行反序列得到的是一個新的物件,如此就破壞了 Singleton5 的單例性。

我們可以在 Singleton5 類中新增一個 readResolve() 方法,並在該方法中指定要返回的物件的生成策略:

public class Singleton5 implements Serializable {

    private static volatile Singleton5 instance;

    private Singleton5() { }

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

    // 新增 readResolve 方法
    private Object readResolve() {
        return instance;
    }
}
複製程式碼

通過 debug 方法檢視原始碼,在 readObject 方法的呼叫棧中,可以看到 ObejctStreamClass 類的 invokeReadResolve 方法:

單例模式就是如此簡單

如果定義了 readResolve 方法,會通過反射進行呼叫,根據指定的策略來生成物件。

有哪些好的單例模式實踐

JDK#Runtime

該類用於獲取應用執行時的環境。可以看到這是一個餓漢式的單例。

public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}
}
複製程式碼

Spring#Singleton

Spring 中定義 Bean 時,可以指定是單例還是多例(預設為單例):

@Scope("singleton")
複製程式碼

檢視其原始碼,單例模式實現如下:

public abstract class AbstractFactoryBean<T>
		implements FactoryBean<T>, BeanClassLoaderAware, BeanFactoryAware, InitializingBean, DisposableBean {

    private T singletonInstance;
    
    @Override
	public void afterPropertiesSet() throws Exception {
	    // 掃描配置時,單例模式
	    // 就會將 initialized 置為 true
		if (isSingleton()) {
			this.initialized = true;
			// 呼叫子類方法建立物件
			this.singletonInstance = createInstance();
			this.earlySingletonInstance = null;
		}
	}
    
    @Override
	public final T getObject() throws Exception {
		if (isSingleton()) {
			return (this.initialized ? this.singletonInstance : getEarlySingletonInstance());
		}
		else {
			return createInstance();
		}
	}
}
複製程式碼

參考資料

相關文章