搞懂設計模式-單例模式

1376317341_517173-1680x1050.jpg

設計模式-單例模式

單例模式在網上已經是被寫爛的一種設計模式了,筆者也看了不少的有關單例模式的文章,但是在實際生產中使用的并不是很多,如果一個知識點,你看過100遍,但是一次也沒實踐過,那么它終究不是屬于你的。因此我借助這篇文章來復習下設計模式中的單例模式。

單例模式的作用在于保證整個程序在一次運行的過程中,被單例模式聲明的類的對象要有且只有一個。針對不同的應用場景,單例模式的實現要求也不同。下文將描述幾種單例模式的實現方案,從性能和實現上將有所差異,他們在一定程度上都能保證單例的存在,但是要在生產環境的角度來看待哪一種實現才是最合適的。

最基本的實現方案

單例模式的從實現步驟上來講,分為三步:

  1. 構造方法私有,保證無法從外部通過 new 的方式創建對象。
  2. 對外提供獲取該類實例的靜態方法
  3. 類的內部創建該類的對象,通過第 2 步的靜態方法返回

通過上述三點要求我們可以幾乎就可以寫出一個最最基本的單例實現方案,也就是各種資料中所描述的「餓漢式」。

public class BasicSingleTon {
    
    //創建唯一實例
    private static final BasicSingleTon instance = new BasicSingleTon();
    
    //第二部暴露靜態方法返回唯一實例
    public static BasicSingleTon getInstance() {
        return instance;
    }
    
    //第一步構造方法私有
    private BasicSingleTon() {
    }
}

該方法實現簡單,也是最常用的一種,在不考慮線程安全的角度來說此實現也算是較為科學的,但是存在一個很大缺點就是,在虛擬機加載改類的時候,將會在初始化階段為類靜態變量賦值,也就是在虛擬機加載該類的時候(此時可能并沒有調用 getInstance 方法)就已經調用了 new BasicSingleTon(); 創建了改對象的實例。但是如果追求代碼的效率那么就需要采用下面這種方式,即延遲加載的方式。

也許這里看過看多例子的讀者可能對 Instance 變量的聲明為 static final 有所疑問,因為有的文章里之聲明為 static,其實筆者認為在此單例模式的基本應用場景下,二者沒有很大的區別,聲明為 final 只是為了保證對象在方法區中的地址無法改變。而對對象的初始化時機沒有影響。

延遲加載的單例模式

延遲加載的方式,是在我們編碼過程中盡可能晚的實例化話對象,也就是避免在類的加載過程中,讓虛擬機去創建這個實例對象。這種實現也就是我們所說的「懶漢式」。他的實現也很簡單,將對象的創建操作后置到 getInstance 方法內部,最初的靜態變量賦予 null ,而 在第一次調用 getInstance 的時候創建對象。

public class LazyBasicSingleTon {

    private static LazyBasicSingleTon singleTon = null;

    public static LazyBasicSingleTon getInstance() {
        //延遲初始化 在第一次調用 getInstance 的時候創建對象
        if (singleTon == null) {
            singleTon = new LazyBasicSingleTon();
        }
        
        return singleTon;
    }

    private LazyBasicSingleTon() {
    }
}

多線程模式下的單例實現

對于單線程模式上述的延遲加載已經算的上是很好的單例實踐方式了。一方面Java 是一個多線程的內存模型。而靜態變量存在于虛擬機的方法區中,該內存空間被線程共享,上述實現無法保證對單例對象的修改保證內存的可見性,原子性。而另一方面,newInstance 方法本身就不是一個原子類操作(分為兩步第一步判空,第二步調用 new 來創建對象),所以結論是上述兩種實現方式不適合多線程的引用場景。

那么對于多線程環境下單例實現模式,存在的問題,我們可以舉個簡單的例子,假設有兩個線程都需要這個單例的對象,線程 A 率先進入語句 if (singleTon == null) 得到的結果為 true,此時 CPU 切換線程 B 去執行,由于 A 線程并沒有進行 new LazyBasicSingleTon();的操作,那么 B 線程在執行語句 singleTon == null的結果認為 true,緊接著 B 線程創建了改類的實例對象,當 CPU 重新回到 A 線程去執行的時候,又會創建一個類的實例,這就導致了,所謂的單例并不真正的唯一,也就會產生錯誤。

為了解決這個缺點,我們能想到方法首先就是加鎖,使用 synchronized 關鍵字來保證,在執行 getInstance 的時候不會發生線程的切換。

public class SyncSingleTon {

    private static SyncSingleTon singleTon = null;

    /** 使用 synchronized 保證線程在創建對象的時候讓其他線程阻塞*/
    public static synchronized SyncSingleTon getInstance() {
        if (singleTon == null) {
            singleTon = new SyncSingleTon();
        }

        return singleTon;
    }

    private SyncSingleTon() {
    }
}

其實 synchronized關鍵字也可以加在判空操作上,這樣本質上并沒有區別,只是別的資料中有這種實現方式,因此在這里給出實現:

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

雙重判空操作的多線程單例實現

上面的例子給出的多線程下的單例實現,也可以保證在大多數情況下??梢员WC單例的唯一性,但是對于效率會產生影響,因為如果我們可預料的線程切換場景并不是那么頻繁,那么synchronizedgetInstance方法加鎖,將會帶來很大效率丟失,比如單線程的模式下。

我們繼續深入思考一下,可以想到,是因為在第一次獲取該實例的時候,如果剛好發生了線程的切換將會早上我們所描述的單例不唯一的結果,在之后的調用過程中將會不會造成這樣的結果。所以我們可以在 synchronized 語句之前,額外添加一次判空操作,來優化上述方案帶來的效率損失。

public class SyncSingleTon {

    private static SyncSingleTon singleTon = null;
    
    public static SyncSingleTon getInstance() {
        
        //這次判空是避免了,保證的多線程只有第一次調用getInstance 的時候才會加鎖初始化
        if (singleTon == null) {
            synchronized (SyncSingleTon.class) {
                if (singleTon == null) {
                    singleTon = new SyncSingleTon();
                }
            }
        }
        return singleTon;
    }

    private SyncSingleTon() {
    }
}

上述方案很好的解決了,最開始的實現在效率上的損失,比如在多個線程場景中,即使在第一次if (singleTon == null) 判空操作中讓出 CPU 去執行,那么在另一個線程中也會在同步代碼中初始化改單例對象,待 CPU 切換回來的時候,也會在第二次判空的時候得到正確結果。

什么?指令重排?

當我們都認為這一切的看上去很完美的時候,JVM 又給我提出了個難題,那就是指令重排。

什么是指令重排,指令重排的用大白話來簡單的描述,就是說在我們的代碼運行時,JVM 并不一定總是按照我們想讓它按照編碼順序去執行我們所想象的語義,它會在 "不改變" 原有代碼語句含義的前提下進行代碼,指令的重排序。

對于指令重排Java 語言規范給出來了下面的定義:

根據《The Java Language Specification, Java SE 7 Edition》(簡稱為java語言規范),所有線程在執行java程序時必須要遵守 intra-thread semantics(譯為 線程內語義是一個單線程程序的基本語義)。intra-thread semantics 保證重排序不會改變單線程內的程序執行結果。換句話來說,intra-thread semantics 允許那些在單線程內,不會改變單線程程序執行結果的重排序。

那么我們上述雙重檢驗鎖的單例實現問題主要出在哪里呢?問題出在 singleTon = new SyncSingleTon();這句話在執行的過程。首先應該進行對象的創建操作大體可以分為三步:

(1)分配內存空間。

(2)初始化對象即執行構造方法。

(3)設置 Instance 引用指向該內存空間。
 
 那么如果有指令重排的前提下,這三部的執行順序將有可能發生變化:
 
?。?)分配內存空間。
 
 (2)設置 Instance 引用指向該內存空間。
 
?。?)初始化對象即執行構造方法。
 
上面類初始化描述的步驟 2 和 3 之間雖然被重排序了, 但是這個重排序在沒有改變單線程程序的執行結果。那么再多線程的前提下這將會造成什么樣的后果呢?我們假設有兩個線程同時想要初始化這個類, 這兩個線程的執行如下圖所示:

image

如果按照上述的語義去執行,單看線程 A 中的操作雖然指令重排了,但是返回結果并不影響。但是這樣造成的問題也顯而易見,b 線程將返回一個空的 Instance,可怕的是我們認為這一切是正常執行的。

為了解決上述問題我們可以從兩個方面去考慮:

  1. 避免指令重排
  2. 讓 A 線程完成對象初始化后,B 再去判斷 instance == null

通過 Volatile 避免指令重排序

對于 Volatile 關鍵字,這里不做詳細的描述,讀者需要了解的是,volatile 作用有以下兩點:

  1. 可以保證多線程條件下,內存區域的可見性,即使用 volatile 聲明的變量,將對在一個線程從內主內存(線程共享的內存區域)讀取變量,并寫入后,通知其他線程,改變量被我改變了,別的線程在使用的時候,將會重新從主內存中去讀改變量的最新值。

  2. 可以保證再多線程的情況下,指令重排這個操作將會被禁止。

那么改造完成的雙重檢鎖的單例將會是這樣的:

public class VolatileSingleTon {

    //使用 Volatile 保證了指令重排序在這個對象創建的時候不可用
    private volatile static  VolatileSingleTon singleTon = null;

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

由于 volatile 關鍵字是在 JDK 1.5 之后被明確了有禁止指令重排的語義的,那么有沒有可能不用 volatile 就能解決我們上述描述的指令重排造成的問題呢,答案是肯定的。

靜態內部類方式的單例實現

上述我們使用 Volatile 關鍵字去解決指令重排的方法是從避免指令重排的思路出發來解決問題的。那么對于第二種 讓 A 線程完成對象初始化后,B 再去判斷 instance == null 思路聽起來好像有一定的加鎖韻味,那么我們怎么去給一個對象的初始化過程去加鎖呢,看起來好像沒思路。

這里我們需要補充一個知識點,是有關 JVM 在類的初始化階段期間,將會去獲取一個鎖,這個鎖的作用是可以同步多個線程對同一個類的初始化操作。JVM 在類初始化期間會獲得一個稱做初始化鎖的東西,并且每個線程至少獲取一次鎖來確保這個類已經被初始化過了。

我們可以理解為:如果一個線程在初始化一個類的時候,將會為這個初始化過程上鎖,當此時有其他的線程嘗試初始化這個類的時候,將會查看這個鎖的狀態,如果這個鎖沒有被釋放,那么將會處于等待鎖釋放的狀態。這和我們用的 synchronized 機制很相似,只是被用在類的初始化階段。

對于靜態內部類,相信讀者一定清除它不依靠外部類的存在而存在。在編譯階段將作為獨立的一個類,生成自己的 .class 文件。并且在初始化階段也是獨立的,也就是說擁有上述所說的初始化鎖。

那么我們可以有如下思路:

  1. 返回該類的對象依賴于一個靜態內部類的初始化操作。
  2. 在這個靜態內部類初始化的時候,生成外部類的對象,然后在 getInstance 中返回

注意這里的初始化是指在JVM 類加載過程中 加載->鏈接(驗證,準備,解析)->初始化 中的初始化。這個初始化過程將為類的靜態變量付具體的值。

對于一個類的初始化時機有一下幾種情況:

1) 使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。

2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

我們先來看下這里的具體實現:

public class StaticInnerSingleTon {
    
    private static class InnerStaticClass{
        private static StaticInnerSingleTon singleTon  = new StaticInnerSingleTon();
    }

    public StaticInnerSingleTon getInstance(){
        //  //引用一個類的靜態成員,將會觸發該類的初始化 符合1)規則
        return InnerStaticClass.singleTon;
    }
    
    private StaticInnerSingleTon() {
    }
}

單例的最簡單實現 Enum

上述講了這么多實現方法,也講了各個實現的缺點。直到我們說了靜態內部類的實現單例的思路后我們仿佛打開了新世界的大門。

為什么說枚舉實現單例的方法最簡單,這是因為 Enum 類的創建本身是就是線程安全的,這一點和靜態內部類相似,因此我們不必去關心什么 DCL 問題,而是拿拿起鍵盤直接干:

public enum  EnumSingleTon {
    INSTANCE
}

public class SingleTon {
    public static void main(String[] args) {
        EnumSingleTon instance = EnumSingleTon.INSTANCE;
        EnumSingleTon instance1 = EnumSingleTon.INSTANCE;

        System.out.println("instance1 == instance = " + (instance1 == instance));//輸出結果為 true
    }
}

枚舉的思想其實是通過共有的靜態 final 與為每個枚舉常量導出實例的類,由于沒有可訪問的構造器,所以不能調用枚舉常量的構造方法去生成對應的對象,因此在《Effective Java》 中,枚舉類型為類型安全的枚舉模式,枚舉也被稱為單例的泛型化。

總結

一篇行文下來,對于單例模式的理解變的更加深刻了,尤其是 DSL(double checked locking)) 的問題的解決思路上,更是涉及到,指令重排和類的加載機制的方面的知識。面試的時候,面試官也經常由此引出更深的只是,比如JVM 類加載的相關知識點,volatile 關鍵字的作用,以及多線程方面的知識點。其實對于面試者來說這也許是個好事,畢竟有跡可循了。

筆者最近加班加傻了,文章都半個月沒跟新了。但是年初定下的目標沒有忘卻。個人這種層層深入的了解比業務代碼更能帶來快感。但是這都是一些拾人牙慧的東西了,看到別的大佬都在研究 gradle 和插件化組件化,筆者也是眼紅... 精力就那么多,這可如何是好呀。

參考

雙重檢查鎖定與延遲初始化 InfoQ

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容