Java單例模式之雙檢鎖深入思考

Java單例模式之雙檢鎖深入思考

鎮樓小姐姐

36份一線互聯網Java面試電子書

84個Java稀缺面試題視頻


Java單例模式之雙檢鎖剖析

前言

單例模式在Java開發中是非常經典和實用的一種設計模式,在JDK的內部包的好多api都採用了單例模式,如我們熟悉的Runtime類,單例模式總的來說有兩種創建方式,一種是延遲加載的模式,一種是非延遲加載的模式,今天我們來學習一下基於雙檢鎖延遲加載的單例模式。

什麼是單例模式

顧名思義,單例模式指的是在整個程序運行期間,我們只能初始化某個類一次,然後一直使用這個實例,尤其是在多線程的環境下,也要保證如此。

基於雙檢鎖的單例模式

在介紹基於雙檢鎖的單例模式下,我們先思考下在使用延遲加載的情況下,如何實現一個單例模式,可能有一些比較年輕的小夥伴,不假思索的就寫下了下面的一段代碼: 、

Java代碼

```

private static DoubleCheckSingleton instance;

//私有的構造方法

private DoubleCheckSingleton() {}

public static DoubleCheckSingleton getErrorInstance(){

if (instance==null){

instance=new DoubleCheckSingleton();

}

return instance;

}

```

上面的代碼在單線程的環境下是沒有問題的,但是在多線程的環境下是不能保證只創建一個實例的,

然後小夥伴想了下,這還不簡單,加個同步關鍵字就可以了:

Java代碼

```

private static DoubleCheckSingleton instance;

//私有的構造方法

private DoubleCheckSingleton() {}

public synchronized static DoubleCheckSingleton getErrorInstance(){

if (instance==null){

instance=new DoubleCheckSingleton();

}

return instance;

}

```

嗯,這下看起來沒問題,但唯一的不足就是,這段代碼雖然可以保證只創建一個單例,但其性能不高,因為每次訪問這個方法的時候都需要執行同步操作,那麼有沒有方法可以避免這一個缺點呢?這個時候我們就可以用雙檢鎖的模式了:

Java代碼

```

private volatile static DoubleCheckSingleton instance;

//私有的構造方法

private DoubleCheckSingleton() {}

public static DoubleCheckSingleton getInstance(){

if(instance==null){ //第一層檢查

synchronized (DoubleCheckSingleton.class){

if(instance==null){ //第二層檢查

instance=new DoubleCheckSingleton();

}

}

}

return instance;

}

```

想要徹底理解雙檢鎖模式的原理,首先要明白在Java裡面一個線程對共享變量的修改,對於另外一個線程是不可預知的,也就是說它可能看不到變化,也有可能會看到,雖然在大多數時候是看不到的,但這不能證明它總是會被看到,除非正確的使用同步,否則是沒法掌控的。

上面的基礎認知非常重要,我原來就理解錯誤了,因為我通過代碼檢測出來,一個線程的修改對於另外一個線程是不可見的,所以就一直認為總是不可見的。但其實這是不正確的認識,因為編寫多線程代碼可能是容易的,但測試多線程程序是非常複雜的,或者說在一些情況下,沒有人知道應該怎麼測和怎麼復現多線程bug,這也是多線程程序很難調試的的原因。

關於雙檢鎖裡面為什麼必須要加volatile關鍵字,主要用來避免重排序問題導致其他的線程看到了一個已經分配內存和地址但沒有初始化的對象,也就是說這個對象還不是處於可用狀態,就被其他線程引用了。

下面的代碼在多線程環境下不是原子執行的。

Java代碼

```

instance=new DoubleCheckSingleton();

```

正常的底層執行順序會轉變成三步:

Java代碼

```java

(1) 給DoubleCheckSingleton類的實例instance分配內存

(2) 調用實例instance的構造函數來初始化成員變量

(3) 將instance指向分配的內存地址

```

上面的三步,無論在A線程當前執行到那一步驟,對B線程來說可能看到A的狀態只能是兩種1,2看到的都是null,3看到的非null,這是沒問題的。

但是如果線程A在重排序的情況下,上面的執行順序會變成1,3,2。現在假設A線程按1,3,2三個步驟順序執行,當執行到第二步的時候。B線程開始調用這個方法,那麼在第一個null的檢查的時候,就有可能看到這個實例不是null,然後直接返回這個實例開始使用,但其實是有問題的,因為對象還沒有初始化,狀態還處於不可用的狀態,故而會導致異常發生。

要解決這個問題,可以通過volatile關鍵詞來避免指令重排序,這裡相比可見性問題主要是為了避免重排序問題。如果使用了volatile修飾成員變量,那麼在變量賦值之後,會有一個內存屏障。也就說只有執行完1,2,3步操作後,讀取操作才能看到,讀操作不會被重排序到寫操作之前。這樣以來就解決了對象狀態不完整的問題。

那麼volatile到底如何保證可見性和禁止指令重排序的

 在《深入理解Java虛擬機》一書中有描述:

“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”

lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

Java代碼

  1. ```
  2. 1)它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,
  3. 也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時
  4. ,在它前面的操作已經全部完成;
  5. 2)它會強制將對緩存的修改操作立即寫入主存;
  6. 3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。
  7. ```

從上面可以看到volatile不保證原子性,保證可見性和部分有序性,這一點需要謹記。

此外這裡需要注意的是在JDK5之前,就算加了volatile關鍵字也依然有問題,原因是之前的JMM模型是有缺陷,volatile變量前後的代碼仍然可以出現重排序問題,這個問題在JDK5之後才得到解決,所以現在才可以這麼使用。

正是因為雙檢鎖的單例模式涉及的底層知識比較多,所以在面試中也是經常被問的一個話題。

### 其他的單例實現

前面說到過,單例模式從創建方式來說有懶漢(延遲加載)和非懶漢就是餓漢的單例模式。關於懶漢模式的除了雙檢鎖模式,還有通過靜態內部類實現的如下:

Java代碼

```

public class HolderFactory {

public static Singleton get() {

return

Holder.instance;

}

private static class Holder {

public static final Singleton instance = new Singleton();

}

}

```

靜態內部類是由JVM內部的鎖機制來保證不會創建多個實例,非常巧妙的避開了多線程問題。

關於餓漢的單例模式形象點說,就是我不管你到底用不用得到都提前給你準備好。相比懶漢需要考慮各種線程問題,餓漢就比較簡單了,第一種,非常簡單:

Java代碼

```

private static SimpleSingleton ourInstance = new SimpleSingleton();

public static SimpleSingleton getInstance() {

return ourInstance;

}

private SimpleSingleton() {

}

```

第二種,基於枚舉方式:

Java代碼

```

public enum EnumSingleton {

SINGLETON;

}

```

基於枚舉的方式非常簡潔,而且非常安全由jvm內部保證,自帶私有的構造方法並且序列化和反射都不會破壞單例的安全性,據說是JDK5之後最好的單例創建方式,這個具體還是分應用場景。

總結

本篇文章重點介紹了在Java裡面雙檢鎖模式如何實現懶漢的單例模式,並分析其背後的原理和JMM的相關的一些知識,此外還介紹了其他的一些常用的單例模式供大家參考,感興趣的小夥伴可以自己動手嘗試一下。最後文中所有的代碼已經上傳到我的github,需要的朋友可以去fork運行。

https://github.com/qindongliang/Java-Note


分享到:


相關文章: