1. 程式人生 > 實用技巧 >設計模式【1】-- 單例模式到底幾種寫法?

設計模式【1】-- 單例模式到底幾種寫法?

目錄

單例模式,是一種比較簡單的設計模式,也是屬於建立型模式(提供一種建立物件的模式或者方式)。
要點:

  • 1.涉及一個單一的類,這個類來建立自己的物件(不能在其他地方重寫建立方法,初始化類的時候建立或者提供私有的方法進行訪問或者建立,必須確保只有單個的物件被建立)。
  • 2.單例模式不一定是執行緒不安全的。
  • 3.單例模式可以分為兩種:懶漢模式(在第一次使用類的時候才建立,可以理解為類載入的時候特別懶,要用的時候才去獲取,要是沒有就建立,由於是單例,所以只有第一次使用的時候沒有,建立後就可以一直用同一個物件),餓漢模式(在類載入的時候就已經建立,可以理解為餓漢已經餓得飢渴難耐,肯定先把資源緊緊拽在自己手中,所以在類載入的時候就會先建立例項)

關鍵字:

  • 單例:singleton
  • 例項:instance
  • 同步: synchronized

餓漢模式

1.私有屬性

第一種singlepublic,可以直接通過Singleton類名來訪問。

 public class Singleton {
    // 私有化構造方法,以防止外界使用該構造方法建立新的例項
    private Singleton(){
    }
    // 預設是public,訪問可以直接通過Singleton.instance來訪問
    static Singleton instance = new Singleton();
}

2.公有屬性

第二種是用private

修飾singleton,那麼就需要提供static 方法來訪問。

public class Singleton {
    private Singleton(){
    }
    // 使用private修飾,那麼就需要提供get方法供外界訪問
    private static Singleton instance = new Singleton();
    // static將方法歸類所有,直接通過類名來訪問
    public static Singleton getInstance(){
        return instance;.
    }
}

3. 懶載入

餓漢模式,這樣的寫法是沒有問題的,不會有執行緒安全問題(類的static

成員建立的時候預設是上鎖的,不會同時被多個執行緒獲取到),但是是有缺點的,因為instance的初始化是在類載入的時候就在進行的,所以類載入是由ClassLoader來實現的,那麼初始化得比較早好處是後來直接可以用,壞處也就是浪費了資源,要是隻是個別類使用這樣的方法,依賴的資料量比較少,那麼這樣的方法也是一種比較好的單例方法。
在單例模式中一般是呼叫getInstance()方法來觸發類裝載,以上的兩種餓漢模式顯然沒有實現lazyload(個人理解是用的時候才觸發類載入)
所以下面有一種餓漢模式的改進版,利用內部類實現懶載入。
這種方式Singleton類被載入了,但是instance也不一定被初始化,要等到SingletonHolder被主動使用的時候,也就是顯式呼叫getInstance()方法的時候,才會顯式的裝載SingletonHolder類,從而例項化instance。這種方法使用類裝載器保證了只有一個執行緒能夠初始化instance,那麼也就保證了單例,並且實現了懶載入。

值得注意的是:靜態內部類雖然保證了單例在多執行緒併發下的執行緒安全性,但是在遇到序列化物件時,預設的方式執行得到的結果就是多例的。

public class Singleton {
    private Singleton(){
    }
    //內部類
    private static class SingletonHolder{
        private static final Singleton instance = new Singleton();
    }
    //對外提供的不允許重寫的獲取方法
    public static final Singleton getInstance(){
        return SingletonHolder.instance;
    }
}

懶漢模式

最基礎的程式碼(執行緒不安全)

public class Singleton {
    private static Singleton instance = null;
    private Singleton(){
    }
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

這種寫法,是在每次獲取例項instance的時候進行判斷,如果沒有那麼就會new一個出來,否則就直接返回之前已經存在的instance。但是這樣的寫法不是執行緒安全的,當有多個執行緒都執行getInstance()方法的時候,都判斷是否等於null的時候,就會各自建立新的例項,這樣就不能保證單例了。所以我們就會想到同步鎖,使用synchronized關鍵字:
加同步鎖的程式碼(執行緒安全,效率不高)

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

這樣的話,getInstance()方法就會被鎖上,當有兩個執行緒同時訪問這個方法的時候,總會有一個執行緒先獲得了同步鎖,那麼這個執行緒就可以執行下去,而另一個執行緒就必須等待,等待第一個執行緒執行完getInstance()方法之後,才可以執行。這段程式碼是執行緒安全的,但是效率不高,因為假如有很多執行緒,那麼就必須讓所有的都等待正在訪問的執行緒,這樣就會大大降低了效率。那麼我們有一種思路就是,將鎖出現等待的概率再降低,也就是我們所說的雙重校驗鎖(雙檢鎖)。

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

1.第一個if判斷,是為了降低鎖的出現概率,前一段程式碼,只要執行到同一個方法都會觸發鎖,而這裡只有singleton為空的時候才會觸發,第一個進入的執行緒會建立物件,等其他執行緒再進入時物件已建立就不會繼續建立,如果對整個方法同步,所有獲取單例的執行緒都要排隊,效率就會降低。
2.第二個if判斷是和之前的程式碼起一樣的作用。

上面的程式碼看起來已經像是沒有問題了,事實上,還有有很小的概率出現問題,那麼我們先來了解:原子操作指令重排

1.原子操作

  • 原子操作,可以理解為不可分割的操作,就是它已經小到不可以再切分為多個操作進行,那麼在計算機中要麼它完全執行了,要麼它完全沒有執行,它不會存在執行到中間狀態,可以理解為沒有中間狀態。比如:賦值語句就是一個原子操作:
 n = 1; //這是一個原子操作 

假設n的值以前是0,那麼這個操作的背後就是要麼執行成功n等於1,要麼沒有執行成功n等於0,不會存在中間狀態,就算是併發的過程中也是一樣的。
下面看一句不是原子操作的程式碼:

int n =1;  //不是原子操作

原因:這個語句中可以拆分為兩個操作,1.宣告變數n,2.給變數賦值為1,從中我們可以看出有一種狀態是n被聲明後但是沒有來得及賦值的狀態,這樣的情況,在併發中,如果多個執行緒同時使用n,那麼就會可能導致不穩定的結果。

2.指令重排

所謂指令重排,就是計算機會對我們程式碼進行優化,優化的過程中會在不影響最後結果的前提下,調整原子操作的順序。比如下面的程式碼:

int a ;   // 語句1 
a = 1 ;   // 語句2
int b = 2 ;     // 語句3
int c = a + b ; // 語句4

正常的情況,執行順序應該是1234,但是實際有可能是3124,或者1324,這是因為語句3和4都沒有原子性問題,那麼就有可能被拆分成原子操作,然後重排.
原子操作以及指令重排的基本瞭解到這裡結束,看回我們的程式碼:

主要是instance = new Singleton(),根據我們所說的,這個語句不是原子操作,那麼就會被拆分,事實上JVM(java虛擬機器)對這個語句做的操作:

  • 1.給instance分配了記憶體
  • 2.呼叫Singleton的建構函式初始化了一個成員變數,產生了例項,放在另一處記憶體空間中
  • 3.將instance物件指向分配的記憶體空間,執行完這一步才算真的完成了,instance才不是null。

在一個執行緒裡面是沒有問題的,那麼在多個執行緒中,JVM做了指令重排的優化就有可能導致問題,因為第二步和第三步的順序是不能夠保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以執行緒二會直接返回instance,然後使用,就會報空指標。
從更上一層來說,有一個執行緒是instance已經不為null但是仍沒有完成初始化中間狀態,這個時候有一個執行緒剛剛好執行到第一個if(instance==null),這裡得到的instance已經不是null,然後他直接拿來用了,就會出現錯誤。
對於這個問題,我們使用的方案是加上volatile關鍵字。

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

volatile的作用:禁止指令重排,把instance宣告為volatile之後,這樣,在它的賦值完成之前,就不會呼叫讀操作。也就是在一個執行緒沒有徹底完成instance = new Singleton();之前,其他執行緒不能夠去呼叫讀操作。

  • 上面的方法實現單例都是基於沒有複雜序列化和反射的時候,否則還是有可能有問題的,還有最後一種方法是使用列舉來實現單例,這個可以說的比較理想化的單例模式,自動支援序列化機制,絕對防止多次例項化。
public enum Singleton {
    INSTANCE;
    public void doSomething() {

    }
}

以上最推薦列舉方式,當然現在計算機的資源還是比較足夠的,餓漢方式也是不錯的,其中懶漢模式下,如果涉及多執行緒的問題,也需要注意寫法。

最後提醒一下,volatile關鍵字,只禁止指令重排序,保證可見性(一個執行緒修改了變數,對任何其他執行緒來說都是立即可見的,因為會立即同步到主記憶體),但是不保證原子性。

【作者簡介】
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。這個世界希望一切都很快,更快,但是我希望自己能走好每一步,寫好每一篇文章,期待和你們一起交流。

此文章僅代表自己(本菜鳥)學習積累記錄,或者學習筆記,如有侵權,請聯絡作者核實刪除。人無完人,文章也一樣,文筆稚嫩,在下不才,勿噴,如果有錯誤之處,還望指出,感激不盡~