1. 程式人生 > >漫畫:什麼是單例模式?

漫畫:什麼是單例模式?

—————  第二天  —————

單例模式第一版:

public class Singleton {
    private Singleton() {}  //私有建構函式
    private static Singleton instance = null;  //單例物件
    //靜態工廠方法
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

為什麼這樣寫呢?我們來解釋幾個關鍵點:

1.要想讓一個類只能構建一個物件,自然不能讓它隨便去做new操作,因此Signleton的構造方法是私有的。

2.instance是Singleton類的靜態成員,也是我們的單例物件。它的初始值可以寫成Null,也可以寫成new Singleton()。至於其中的區別後來會做解釋。

3.getInstance是獲取單例物件的方法。

如果單例初始值是null,還未構建,則構建單例物件並返回。這個寫法屬於單例模式當中的懶漢模式。

如果單例物件一開始就被new Singleton()主動構建,則不再需要判空操作,這種寫法屬於餓漢模式

這兩個名字很形象:餓漢主動找食物吃,懶漢躺在地上等著人喂。


為什麼說剛才的程式碼不是執行緒安全呢?

假設Singleton類剛剛被初始化,instance物件還是空,這時候兩個執行緒同時訪問getInstance方法:



因為Instance是空,所以兩個執行緒同時通過了條件判斷,開始執行new操作:


這樣一來,顯然instance被構建了兩次。讓我們對程式碼做一下修改:

單例模式第二版:

public class Singleton {
    private Singleton() {}  //私有建構函式
   private static Singleton instance = null;  //單例物件
   //靜態工廠方法
   public static Singleton getInstance() {
        if (instance == null) {      //雙重檢測機制
         synchronized (Singleton.class){  //同步鎖
           if (instance == null) {     //雙重檢測機制
             instance = new Singleton();
               }
            }
         }
        return instance;
    }
}

為什麼這樣寫呢?我們來解釋幾個關鍵點:

1.為了防止new Singleton被執行多次,因此在new操作之前加上Synchronized 同步鎖,鎖住整個類(注意,這裡不能使用物件鎖)。

2.進入Synchronized 臨界區以後,還要再做一次判空。因為當兩個執行緒同時訪問的時候,執行緒A構建完物件,執行緒B也已經通過了最初的判空驗證,不做第二次判空的話,執行緒B還是會再次構建instance物件。






像這樣兩次判空的機制叫做雙重檢測機制













假設這樣的場景,當兩個執行緒一先一後訪問getInstance方法的時候,當A執行緒正在構建物件,B執行緒剛剛進入方法:


這種情況表面看似沒什麼問題,要麼Instance還沒被執行緒A構建,執行緒B執行 if(instance == null)的時候得到true;要麼Instance已經被執行緒A構建完成,執行緒B執行 if(instance == null)的時候得到false。

真的如此嗎?答案是否定的。這裡涉及到了JVM編譯器的指令重排

指令重排是什麼意思呢?比如java中簡單的一句 instance = new Singleton,會被編譯器編譯成如下JVM指令:

memory =allocate();    //1:分配物件的記憶體空間 

ctorInstance(memory);  //2:初始化物件 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

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

memory =allocate();    //1:分配物件的記憶體空間 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

ctorInstance(memory);  //2:初始化物件 

當執行緒A執行完1,3,時,instance物件還未完成初始化,但已經不再指向null。此時如果執行緒B搶佔到CPU資源,執行  if(instance == null)的結果會是false,從而返回一個沒有初始化完成的instance物件。如下圖所示:






如何避免這一情況呢?我們需要在instance物件前面增加一個修飾符volatile。

單例模式第三版:

public class Singleton {
    private Singleton() {}  //私有建構函式
    private volatile static Singleton instance = null;  //單例物件
    //靜態工廠方法
    public static Singleton getInstance() {
          if (instance == null) {      //雙重檢測機制
         synchronized (Singleton.class){  //同步鎖
           if (instance == null) {     //雙重檢測機制
             instance = new Singleton();
                }
             }
          }
          return instance;
      }
}






The volatile keyword indicates that a value may change between different accesses, it prevents an optimizing compiler from optimizing away subsequent reads or writes and thus incorrectly reusing a stale value or omitting writes.



經過volatile的修飾,當執行緒A執行instance = new Singleton的時候,JVM執行順序是什麼樣?始終保證是下面的順序:

memory =allocate();    //1:分配物件的記憶體空間 

ctorInstance(memory);  //2:初始化物件 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

如此線上程B看來,instance物件的引用要麼指向null,要麼指向一個初始化完畢的Instance,而不會出現某個中間態,保證了安全。




用靜態內部類實現單例模式:

public class Singleton {
    private static class LazyHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

假設這樣的場景,當兩個執行緒一先一後訪問getInstance方法的時候,當A執行緒正在構建物件,B執行緒剛剛進入方法:

這裡有幾個需要注意的點:

1.從外部無法訪問靜態內部類LazyHolder,只有當呼叫Singleton.getInstance方法的時候,才能得到單例物件INSTANCE。

2.INSTANCE物件初始化的時機並不是在單例類Singleton被載入的時候,而是在呼叫getInstance方法,使得靜態內部類LazyHolder被載入的時候。因此這種實現方式是利用classloader的載入機制來實現懶載入,並保證構建單例的執行緒安全。







如何利用反射打破單例模式的約束?其實很簡單,我們來看下程式碼。

利用反射打破單例:

//獲得構造器
Constructor con = Singleton.class.getDeclaredConstructor();
//設定為可訪問
con.setAccessible(true);
//構造兩個不同的物件
Singleton singleton1 = (Singleton)con.newInstance();
Singleton singleton2 = (Singleton)con.newInstance();
//驗證是否是不同物件
System.out.println(singleton1.equals(singleton2));

程式碼可以簡單歸納為三個步驟:

第一步,獲得單例類的構造器。

第二步,把構造器設定為可訪問。

第三步,使用newInstance方法構造物件。

最後為了確認這兩個物件是否真的是不同的物件,我們使用equals方法進行比較。毫無疑問,比較結果是false。

怎麼樣才可以阻止反射?


用列舉實現單例模式:

public enum SingletonEnum {
    INSTANCE;
}

enum有且僅有private的構造器,防止外部的額外構造,這恰好和單例模式吻合,也為保證單例性做了一個鋪墊。這裡展開說下這個private構造器,如果我們不去手寫構造器,則會有一個預設的空參構造器,我們也可以通過給列舉變數參量來實現類的初始化,
在我們訪問列舉例項時會執行構造方法,同時每個列舉例項都是static final型別的,也就表明只能被例項化一次。在呼叫構造方法時,我們的單例被例項化。 
也就是說,因為enum中的例項被保證只會被例項化一次,所以我們的INSTANCE也被保證例項化一次
class Resource{
}

public enum SomeThing {
    INSTANCE;
    private Resource instance;
    SomeThing() {
        instance = new Resource();
    }
    public Resource getInstance() {
        return instance;
    }
}

需要注意的是,private修飾符對於構造器是可以省略的,但這不代表構造器的許可權是預設許可權。

使用列舉單例模式不僅能夠防止反射構造物件,而且可以保證執行緒安全,這種方式唯一的缺點就是並非使用懶載入,其單例物件是列舉類被載入的時候進行初始化的。



幾點補充:

1. volatile關鍵字不但可以防止指令重排,也可以保證執行緒訪問的變數值是主記憶體中的最新值。有關volatile的詳細原理,我在以後的漫畫中會專門講解。

2.使用列舉實現的單例模式,不但可以防止利用反射強行構建單例物件,而且可以在列舉類物件被反序列化的時候,保證反序列的返回結果是同一物件。

對於其他方式實現的單例模式,如果既想要做到可序列化,又想要反序列化為同一物件,則必須實現readResolve方法。




假設這樣的場景,當兩個執行緒一先一後訪問getInstance方法的時候,當A執行緒正在構建物件,B執行緒剛剛進入方法: