常見設計模式一:單例模式

在 23 種設計模式中,咱們平時接觸使用的最多的可能就是單例模式了,雖然這個設計模式你們都會,也很簡單,可是裏面仍是有些東西值得探討一下的,最終目的是可以結合實際須要寫出最適合的單例代碼。java

單例模式的特色

單例模式是爲了保證一個類只有一個實例,而且提供一個訪問該實例的全局訪問點。那麼最起碼要有如下的特色:設計模式

  1. 不能被其餘對象初始化(構造方法須要私有)
  2. 全局只有一個實例(本身自己只能建立一個實例)
  3. 對外提供統一訪問方法(提供靜態方法供外部訪問惟一實例)

知道了上面的幾個特色,下面依次看下常見的幾種單例實現方式。安全

ps:暫不考慮序列化傳輸和反射建立單例對象的狀況數據結構

常見的幾種實現方式

1、餓漢式

特色

  1. 線程安全
  2. 非懶加載

實現思路:

  1. 首先將構造方法私有化,保證不能被其餘類的對象建立對象,
  2. 而後 new 出一個對象,保存在內部的一個私有的靜態屬性 INSTANCE,
  3. 對外提供一個靜態方法用於外部訪問

代碼實現:

線程安全和非懶加載的原理

代碼實現起來很簡單,可是這種實現方式有個很大的問題:無論咱們本次程序運行是否使用到了這個單例,都會進行單例類的初始化,建立單例對象多線程

由於咱們在代碼中有一個靜態屬性 INSTANCE ,在 JVM 虛擬機裝載類信息的時候,會對其進行初始化,也就是會執行 new Sington() 建立一個 Sington 的對象,也就是說在程序啓動加載類信息的時候,對象的實例就會被建立,一樣因爲在類加載階段就初始化了對象,也能夠保證線程安全。併發

上面一段話說了,在類加載階段的靜態區中能夠保證線程安全,咱們並無給其加鎖,是怎麼保證線程安全的呢?函數

這是由於 JVM 加載過程的保護機制。和普通類的實例被分配到 Java 堆中不一樣,類的靜態屬性和靜態方法都保存在方法區的靜態區,建立的過程是在類加載過程,因此會受到類加載過程的影響。性能

類的加載過程大體分爲:加載、驗證、準備、解析、初始化、使用、卸載共七個部分。spa

其中加載、驗證、準備、初始化、卸載這 5 個階段的順序是肯定的,而解析階段不必定,由於解析階段在某些狀況下能夠在初始化階段之後再開始,這是爲了支持 Java 的運行時綁定。線程

關於初始化:JVM 明確規定,有且只有 5 種狀況必須執行對類的初始化(加載、驗證、準備 三個階段確定是在此以前要發生)

  1. 遇到 new、getstatic、putstatic、invokestatic,若是類沒有初始化,則必須初始化,這幾條指令分別是指:new 新對象、讀取靜態變量、設置靜態變量,調用靜態函數。
  2. 使用 java.lang.reflect 包的方法對類進行反射調用時,若是類沒初始化,則須要初始化
  3. 當初始化一個類時,若是發現父類沒有初始化,則須要先觸發父類初始化。
  4. 當虛擬機啓動時,用戶須要制定一個執行的主類(包含 main 函數的類),虛擬機會先初始化這個類。
  5. 可是用 JDK1.7 啓的動態語言支持時,若是一個MethodHandle實例最後解析的結果是 REF_getStatic、REF_putStatic、Ref_invokeStatic 的方法句柄時,而且這個方法句柄所對應的類沒有進行初始化,則要先觸發其初始化。

再來看下具體的幾個階段:

加載階段

加載階段主要作一下三件事情:

  1. 經過一個類的全限定名稱來獲取此類的二進制流
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構
  3. 在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據訪問入口。
驗證階段

這個階段主要是爲了確保 Class 文件字節流中包含信息符合當前虛擬機的要求,而且不會出現危害虛擬機自身的安全。

準備階段

準備階段是正式爲類變量分配內存並設置類靜態變量初始值的階段。這些變量所使用的內存都在方法區中分配。

首先,這個時候分配內存僅僅是指的是靜態變量,不包括實例變量。實例變量會在對象實例化的時候隨着對象一塊兒分配在堆內存中

須要注意的是,這個時候初始值,一般指的是默認值,並非具體設置的值。好比:

public static int key = 123;
複製代碼

在準備階段完了之後,key 的值是 0,而不是 123,由於尚未執行任何 java 方法,而把 key 賦值爲 123 是在程序編譯後,存放在類構造函數 <clinit>() 方法中。(在初始化階段)

解析階段

解析階段是把虛擬機中的常量池的符號引用替換爲直接引用的過程。

初始化階段

類初始化階段是類加載的最後一步,前面的類加載過程當中,除了加載階段用戶能夠經過自定義類加載器參與之外,其他都是 JVM 虛擬機主導和控制進行的,到了初始化階段纔是真正執行類中定義 Java 代碼的地方。

在準備階段中,靜態變量已經賦值爲系統要求的默認值,而在初始化階段,根據靜態變量就要被賦值爲咱們在 Java 代碼中定義的值。

這個過程(初始化階段)就是執行類構造器 <clinit>() 方法的過程,也就是說靜態變量的賦值操做是發生在 <clinit>()內的

<clinit>()方法是由編譯器自動收集類中全部靜態變量的賦值動做和靜態語句塊中的語句合併產生的,收集的順序是按照語句在源文件中出現的順序。靜態語句塊中只能訪問定義在靜態語句塊以前的變量,定義在它以後的變量能夠賦值,但不能訪問,以下:

public class Test{
    static{
        i=0;//給變量賦值,能夠經過編譯
        System.out.print(i);//這句編譯器會提示:「非法向前引用」
    }
    static int i=1;

}
複製代碼

<clinit>()方法執行的過程當中,JVM 會對類加鎖,保證在多線程環境下,只有一個線程能成功執行 <clinit>() 方法,其餘線程都將被擁塞,而且 <clinit>() 方法只能被執行一次,被擁塞的線程被喚醒以後也不會再去執行<clinit>()方法。<clinit>()方法與類構造函數(或者說實例構造器<init>()方法)不一樣,他不須要顯式地調用父類構造器,虛擬機會保證子類的<clinit>()方法執行以前,父類的<clinit>()已經執行完畢。

因而可知,靜態代碼的多線程安全是由 JVM 在類加載階段爲其加鎖實現的。

其實這裏也就解釋了爲何不容許類中的靜態屬性使用非靜態屬性的緣由,由於非靜態屬性的初始化是在建立對象的時候才賦值的,而靜態的屬性在類加載的初始化階段已經被賦值,並加載到了內存中,類的對象的建立是在 類加載初始化階段 以後進行,天然是不能賦值了。

2、懶漢式

懶漢式的最大特色是懶加載,能夠在咱們使用的時候再進行初始化,既然是咱們用的時候再進行初始化而不是 JVM 類加載的時候進行初始化,那麼就沒有 JVM 幫咱們加鎖保證線程安全的機制,須要咱們本身去實現。下面依次從線程不安全,到線程安全依次實現。

寫法一

這種寫法很明顯能夠實現懶加載,在調用 getInstance() 的時候,若是這個時候 INSTANCE 靜態變量不爲 null,直接返回,可是爲 null 的狀況就存在問題了。

假如這個時候 2 個線程同時進入if 語句,執行 INSTANCE = new Sington1(),這個時候就可能會建立兩個 Sington1 對象,很明顯不是咱們想要的,因此這種是線程不安全的。

那麼既然是線程不安全的,那麼使用 synchronized 關鍵字是否是能夠解決問題呢?

接下來看寫法二:

寫法二

或者這樣寫:

這兩種寫法實際上是同樣的,本質上都是對類進行加鎖。

既然對類進行了加鎖,那麼就保證了在多線程狀況下的安全,同時也實現了懶加載。

那麼是否是這種寫法就是完美的呢?

答案是 不是,由於這種寫法還有個問題: synchronized 是比較耗費性能的,咱們每次調用這個 getInstance() 方法的時候,都會進入 synchronized 包裹的代碼塊內,即便這個時候單例對象已經生成,再也不須要建立對象也會進入 synchronized 內部,是不合理的。

因此爲了解決這個問題,有了下面的寫法三:

寫法三

能夠看到在這種寫法的 getInstance() 方法上,把 synchronized 關鍵字放到了 方法內部,而且放到了 if (null == INSTANCE) {} 內部,這樣在多線程訪問的時候,分下面兩種狀況:

1. 單例對象未生成

這個時候即便多個線程進入了 if (null == INSTANCE) {} 內部,可是遇到了 synchronized 代碼塊,這個時候只能由一個線程去執行建立對象的操做,這樣一旦有對象生成,即便下個線程進入了 synchronized 代碼塊內, if (null == INSTANCE) 爲 false ,也不會再去建立對象。

因此就保證了對象的惟一。

2. 單例對象已生成

這個時候若是單例對象已經生成,那麼就不會執行到了 synchronized 代碼塊內,直接返回單例對象。

這裏也是和寫法二相對改進的地方,不用每次調用 getInstance() 方法都會進入 synchronized 代碼塊內,提升了性能。

寫法三也是你們常常說的雙重鎖定 DCL(Double Check Locking) 寫法。

寫法三看起來已經可以實現懶加載和線程安全了,可是還存在一個問題,那就是沒有考慮到 JVM 編譯器的指令重排序,咱們用寫法四來改進。

寫法四

先來看指令重排序的問題,分析完在來看實現代碼:

JVM 編譯指令重排序

在程序運行過程當中,編譯器和處理器會對指定作重排序。可是 JMM (Java Memory Model)可以確保在不一樣的編譯器和不一樣的處理器平臺上,經過插入指定類型的 Memory Barrier 來禁止特定類型的編譯器重排序和處理器重排序,爲上層提供一致的內存可見性保證。

指令重排序可能會改變代碼的執行順序,可以保證不影響代碼在單線程中的結果,可是多線程中併發執行的結果則是不可控的。

好比線程 A:

context = getContext();
inited = true
複製代碼

線程 B:

while(!inited){
    sleep();
}
doSomeThingWithContext(context);
複製代碼

正常狀況下,若是沒發生指令重排序,代碼執行是沒問題的,線程 A 在執行完 getContext()給 context 賦值結束之後,inited 爲 true,線程 B 這個時候讀取到 inited 爲 true,就會跳出循環,去執行 doSomeThingWithContext(context),是沒有任何問題的。

可是若是 A 發生指令重排序,執行就不同了:

假設線程 A 發生了重排序,變成:

inited = true
context = getContext();
複製代碼

先執行 inited 爲 true ,假如這個時候線程 B 正好拿到了執行權,會跳出 while 循環,而後執行 doSomeThingWithContext(context);,可是 A 的 context = getContext();還沒初始化完成,這個時候 線程 B 拿到的 context 就是一個空的,會引發不可控的錯誤。

這個是咱們本身編的例子,只是爲了理解指令重排序,下面看下對應寫法三種的可能引起的問題。

對應寫法三中的問題

上面講的指令重排序問題,其實就對應到了 寫法三 中的這行代碼:

INSTANCE = new Sington1();
複製代碼

在執行這行代碼的時候,JVM 其實作了三個步驟:

  1. 給 INSTANCE 分配內存
  2. 調用 Sington1 的構造函數來初始化變量
  3. 講 INSTANCE 對象指向分配的內存空間,也就是非 NULL 對象了

結合 JVM 指令重排序,可能發生的執行順序就是 : 第一種: 1 -> 2 -> 3 第二種: 1 -> 3 -> 2

若是是第一種,那麼執行沒有什麼問題。

若是是第二種,就可能發生問題,假如此時有兩個線程A 和 B。

A 線程執行到了完了步驟3,在執行步驟 2 以前,B 線程拿到了執行權,這個時候 INSTANCE 是非 NULL 的(INSTANCE 有指向堆內存中的具體地址),可是沒有進行對象初始化,因此 B 線程會返回 INSTANCE, 這個時候是沒有具體的對象的,因此在接下來再去調用單例裏面的方法的時候,天然就會報錯了。

那麼咱們要作的就是禁止 JVM 的指令重排序,其實很簡單,使用 volatile 關鍵字便可解決,由於 volatile 能夠禁止指令重排序,因而有了寫法四以下:

到這裏懶漢式已經有了最佳寫法,也就是寫法四了。

通過上面的分析,會發現懶加載和線程安全是咱們本身經過加鎖和 volatile 關鍵字實現的,那麼有沒有讓 JVM 幫咱們實現線程安全和懶加載呢?

答案是有的,那就是下面的靜態內部類寫法。

3、靜態內部類

實現

這種實現一樣是利用了 Java 的類加載機制。

首先在 JVM 進行類加載的時候,只是加載了 Sington2 類,並不會去執行其中的靜態方法,也不會去加載 Sington2 內的靜態內部類 Sington2Holder。因此也就是並不會在初次類加載的時候建立單例對象。

在咱們使用 getInstance() 的時候,咱們使用 Sington2Holder 的靜態屬性,這個時候會對 Sington2Holder 這個靜態內部類進行加載,這個時候,就回到了第一種寫法 餓漢式中的原理,在類加載的初始化階段,會對建立單例對象,而且賦值給 INSTANCE 屬性。一樣,這些操做是發生在類加載階段的,由 JVM 保證了線程安全,而且是在使用的時候進行加載的,也實現了懶加載。

我我的是比較偏心這種方式實現單例的,可是這種方式實現有個缺點就是:初始化的時候無法傳值給單例類。這個時候就可使用上面懶加載的寫法四去實現單例了。

4、枚舉

能夠看到用枚舉實現單例很是簡單,使用也很簡單:

// 獲取單例對象
Sington3.INSTANCE
// 假如枚舉類中有一個方法 getString(),就能夠這樣調用
Sington3.INSTANCE.getString()
複製代碼

看下優缺點:

優勢:

  1. 能夠保證單例在序列化傳輸中的問題(實現了序列化接口)
  2. 保證單例不被反射建立對象(JVM 層面禁止反射)
  3. 線程安全
  4. 書寫簡單

缺點:

  1. 不能懶加載
  2. 運行時佔用內存比非枚舉的大不少

總結

上面分別介紹了餓漢式、懶漢式、靜態內部類、枚舉四種單例實現方式,在總結下特色

單例實現方式 代碼量 線程安全 是否懶加載 序列化傳輸可否保證對象惟一 可否反射建立對象
餓漢式 較少
懶漢式 較多
靜態內部類 較多
枚舉 極少

其實關於後面的序列化傳輸在 Android 開發中基本用不到(可能我還沒接觸到),關於反射建立對象也屬於很是規手段,也不太能遇到,真的要考慮這種狀況,也是能夠經過代碼去實現序列化傳輸和禁止反射建立對象的。

其實開發中咱們最多的考慮因素就是 線程安全和懶加載 ,我我的是比較喜歡靜態內部類形式的,可是仍是要根據實際業務代碼選擇,好比我須要在建立單例對象的時候接受參數,而且還要求單例懶加載,靜態內部類就不合適了,用懶漢式會更好點。

至於枚舉實現單例,我以爲 Android 裏面仍是少用枚舉,首先不能懶加載,其次佔用的內存還大,看起來也沒其餘方式有安全感,何須呢。

歡迎關注個人公衆號:

個人公衆號
相關文章
相關標籤/搜索