1. 程式人生 > >跟著別人學設計模式-----(一)單例模式詳解

跟著別人學設計模式-----(一)單例模式詳解

    作者:zuoxiaolong8810(左瀟龍),轉載自:http://www.cnblogs.com/zuoxiaolong/p/pattern2.html

            上一章,我們學習了設計模式的概念,以及為什麼要學習設計模式,還有在進行系統設計時應當遵守的六大原則,本章我們就來開始一一的學習GOF當中的二十三鍾設計模式。

            我一直在思考如何去詮釋這麼多設計模式,因為網上有很多現成的,可供學習的資料,我在想有什麼地方可以讓各位跟著我的節奏去學習,而不是網上的那些資料,優勢在哪裡,思考很久,我覺得唯一的優勢,或者說我能有的優勢,就是簡單通俗易懂。

            遵循著中心思想通俗易懂,我們首先來回顧一下單例模式為何要出現,又或者說什麼樣的類可以做成單例的。

            在我的工作過程中,我發現所有可以使用單例模式的類都有一個共性,那就是這個類沒有自己的狀態,換句話說,這些類無論你例項化多少個,其實都是一樣的,而且更重要的一點是,這個類如果有兩個或者兩個以上的例項的話,我的程式竟然會產生程式錯誤或者與現實相違背的邏輯錯誤。

            這樣的話,如果我們不將這個類控制成單例的結構,應用中就會存在很多一模一樣的類例項,這會非常浪費系統的記憶體資源,而且容易導致錯誤甚至一定會產生錯誤,所以我們單例模式所期待的目標或者說使用它的目的,是為了儘可能的節約記憶體空間,減少無謂的GC消耗,並且使應用可以正常運作。

            我稍微總結一下,一般一個類能否做成單例,最容易區別的地方就在於,這些類,在應用中如果有兩個或者兩個以上的例項會引起錯誤,又或者我換句話說,就是這些類,在整個應用中,同一時刻,有且只能有一種狀態。

            一般實踐當中,有很多應用級別的資源會被做成單例,比如配置檔案資訊,邏輯上來講,整個應用有且只能在同在時間有一個,當然如果你有多個,這可能並不會引起程式級別錯誤,這裡指的錯誤特指異常或者ERROR。但是當我們試圖改變配置檔案的時候,問題就出來了。

            你有兩種選擇,第一種,將所有的例項全部更新成一模一樣的狀態。第二種,就是等著出現問題。

            然而出現的問題大部分是邏輯層次上的錯誤,個人覺得這是比程式錯誤更加嚴重的錯誤,因為它不會告訴你空指標,不會告訴你非法引數,很多時候要等到影響到客戶使用時才會被發現。

            下面,我們就來看一下做成單例的幾種方式。

            第一種方式,我們來看一下最標準也是最原始的單例模式的構造方式。

複製程式碼

public class Singleton {

    //一個靜態的例項
    private static Singleton singleton;
    //私有化建構函式
    private Singleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public static Singleton getInstance(){
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

複製程式碼

            這是在不考慮併發訪問的情況下標準的單例模式的構造方式,這種方式通過幾個地方來限制了我們取到的例項是唯一的。

            1.靜態例項,帶有static關鍵字的屬性在每一個類中都是唯一的。

            2.限制客戶端隨意創造例項,即私有化構造方法,此為保證單例的最重要的一步。

            3.給一個公共的獲取例項的靜態方法,注意,是靜態的方法,因為這個方法是在我們未獲取到例項的時候就要提供給客戶端呼叫的,所以如果是非靜態的話,那就變成一個矛盾體了,因為非靜態的方法必須要擁有例項才可以呼叫。

            4.判斷只有持有的靜態例項為null時才呼叫構造方法創造一個例項,否則就直接返回。

            假如你去面試一家公司,給了你一道題,讓你寫出一個單例模式的例子,那麼如果你是剛出大學校門的學生,你能寫出上面這種示例,假設我是面試官的話,滿分100的話,我會給90分,剩下的那10分算是給更優秀的人一個更高的臺階。但如果你是一個有過兩三年工作經驗的人,如果你寫出上面的示例,我估計我最多給你30分,甚至心情要是萬一不好的話可能會一分不給。

           為什麼同樣的示例放到不同的人身上差別會這麼大,就是因為前面我提到的那個情況,在不考慮併發訪問的情況下,上述示例是沒有問題的。

           至於為什麼在併發情況下上述的例子是不安全的呢,我在這裡給各位製造了一個併發的例子,用來說明,上述情況的單例模式,是有可能造出來多個例項的,我自己測試了約莫100次左右,最多的一次,竟然造出了3個例項。下面給出程式碼,大約執行10次(併發是具有概率性的,10次只是保守估計,也可能一次,也可能100次)就會發現我們創造了不只一個例項。

複製程式碼

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestSingleton {
    
    boolean lock ;
    
    public boolean isLock() {
        return lock;
    }

    public void setLock(boolean lock) {
        this.lock = lock;
    }
    
    public static void main(String[] args) throws InterruptedException {
        final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>());
        final TestSingleton lock = new TestSingleton();
        lock.setLock(true);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) {
            executorService.execute(new Runnable() {
                
                public void run() {
                    while (true) {
                        if (!lock.isLock()) {
                            Singleton singleton = Singleton.getInstance();
                            instanceSet.add(singleton.toString());
                            break;
                        }
                    }
                }
            });
        }
        Thread.sleep(5000);
        lock.setLock(false);
        Thread.sleep(5000);
        System.out.println("------併發情況下我們取到的例項------");
        for (String instance : instanceSet) {
            System.out.println(instance);
        }
        executorService.shutdown();
    }
}

複製程式碼

               我在程式中同時開啟了100個執行緒,去訪問getInstance方法,並且把獲得例項的toString方法獲得的例項字串裝入一個同步的set集合,set集合會自動去重,所以看結果如果輸出了兩個或者兩個以上的例項字串,就說明我們在併發訪問的過程中產生了多個例項。

               程式當中讓main執行緒睡眠了兩次,第一次是為了給足夠的時間讓100個執行緒全部開啟,第二個是將鎖開啟以後,保證所有的執行緒都已經呼叫了getInstance方法。

               好了,這下我們用事實說明了,上述的單例寫法,我們是可以創造出多個例項的,至於為什麼在這裡要稍微解釋一下,雖說我一直都喜歡用事實說話,包括看書的時候,我也不喜歡作者跟我解釋為什麼,而是希望給我一個例子,讓我自己去印證。

              造成這種情況的原因是因為,當併發訪問的時候,第一個呼叫getInstance方法的執行緒A,在判斷完singleton是null的時候,執行緒A就進入了if塊準備創造例項,但是同時另外一個執行緒B線上程A還未創造出例項之前,就又進行了singleton是否為null的判斷,這時singleton依然為null,所以執行緒B也會進入if塊去創造例項,這時問題就出來了,有兩個執行緒都進入了if塊去創造例項,結果就造成單例模式並非單例。

              為了避免這種情況,我們就要考慮併發的情況了,我們最容易想到的方式應該是下面這樣的方式,直接將整個方法同步。

複製程式碼

public class BadSynchronizedSingleton {

    //一個靜態的例項
    private static BadSynchronizedSingleton synchronizedSingleton;
    //私有化建構函式
    private BadSynchronizedSingleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public synchronized static BadSynchronizedSingleton getInstance(){
        if (synchronizedSingleton == null) {
            synchronizedSingleton = new BadSynchronizedSingleton();
        }
        return synchronizedSingleton;
    }
    
}

複製程式碼

               上面的做法很簡單,就是將整個獲取例項的方法同步,這樣在一個執行緒訪問這個方法時,其它所有的執行緒都要處於掛起等待狀態,倒是避免了剛才同步訪問創造出多個例項的危險,但是我只想說,這樣的設計實在是糟糕透了,這樣會造成很多無謂的等待,所以為了表示我的憤怒,我在類名上加入Bad。

               其實我們同步的地方只是需要發生在單例的例項還未建立的時候,在例項建立以後,獲取例項的方法就沒必要再進行同步控制了,所以我們將上面的示例改為很多教科書中標準的單例模式版本,也稱為雙重加鎖

複製程式碼

public class SynchronizedSingleton {

    //一個靜態的例項
    private static SynchronizedSingleton synchronizedSingleton;
    //私有化建構函式
    private SynchronizedSingleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public static SynchronizedSingleton getInstance(){
        if (synchronizedSingleton == null) {
            synchronized (SynchronizedSingleton.class) {
                if (synchronizedSingleton == null) {
                    synchronizedSingleton = new SynchronizedSingleton();
                }
            }
        }
        return synchronizedSingleton;
    }
}

複製程式碼

                這種做法與上面那種最無腦的同步做法相比就要好很多了,因為我們只是在當前例項為null,也就是例項還未建立時才進行同步,否則就直接返回,這樣就節省了很多無謂的執行緒等待時間,值得注意的是在同步塊中,我們再次判斷了synchronizedSingleton是否為null,解釋下為什麼要這樣做。

               假設我們去掉同步塊中的是否為null的判斷,有這樣一種情況,假設A執行緒和B執行緒都在同步塊外面判斷了synchronizedSingleton為null,結果A執行緒首先獲得了執行緒鎖,進入了同步塊,然後A執行緒會創造一個例項,此時synchronizedSingleton已經被賦予了例項,A執行緒退出同步塊,直接返回了第一個創造的例項,此時B執行緒獲得執行緒鎖,也進入同步塊,此時A執行緒其實已經創造好了例項,B執行緒正常情況應該直接返回的,但是因為同步塊裡沒有判斷是否為null,直接就是一條建立例項的語句,所以B執行緒也會創造一個例項返回,此時就造成創造了多個例項的情況。

              經過剛才的分析,貌似上述雙重加鎖的示例看起來是沒有問題了,但如果再進一步深入考慮的話,其實仍然是有問題的。

              如果我們深入到JVM中去探索上面這段程式碼,它就有可能(注意,只是有可能)是有問題的。

              因為虛擬機器在執行建立例項的這一步操作的時候,其實是分了好幾步去進行的,也就是說建立一個新的物件並非是原子性操作。在有些JVM中上述做法是沒有問題的,但是有些情況下是會造成莫名的錯誤。

              首先要明白在JVM建立新的物件時,主要要經過三步。

              1.分配記憶體

              2.初始化構造器

              3.將物件指向分配的記憶體的地址

              這種順序在上述雙重加鎖的方式是沒有問題的,因為這種情況下JVM是完成了整個物件的構造才將記憶體的地址交給了物件。但是如果2和3步驟是相反的(2和3可能是相反的是因為JVM會針對位元組碼進行調優,而其中的一項調優便是調整指令的執行順序),就會出現問題了。

              因為這時將會先將記憶體地址賦給物件,針對上述的雙重加鎖,就是說先將分配好的記憶體地址指給synchronizedSingleton,然後再進行初始化構造器,這時候後面的執行緒去請求getInstance方法時,會認為synchronizedSingleton物件已經例項化了,直接返回一個引用。如果在初始化構造器之前,這個執行緒使用了synchronizedSingleton,就會產生莫名的錯誤。

             所以我們在語言級別無法完全避免錯誤的發生,我們只有將該任務交給JVM,所以有一種比較標準的單例模式。如下所示。

複製程式碼

package com.oneinstance;

public class InnerClassSingleton {
    
    public static Singleton getInstance(){
        return Singleton.singleton;
    }

    private static class Singleton{
        
        protected static Singleton singleton = new Singleton();
        
    }
}

複製程式碼

              首先來說一下,這種方式為何會避免了上面莫名的錯誤,主要是因為一個類的靜態屬性只會在第一次載入類時初始化,這是JVM幫我們保證的,所以我們無需擔心併發訪問的問題。所以在初始化進行一半的時候,別的執行緒是無法使用的,因為JVM會幫我們強行同步這個過程。另外由於靜態變數只初始化一次,所以singleton仍然是單例的。

              上面這種寫法是我們使用靜態的內部類作為單例,這樣不太符合我們的習慣。所以我們改為以下形式。

複製程式碼

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

複製程式碼

             好了,進行到這裡,單例模式算是已經完成了。最終的產物就是如上述的形式。上述形式保證了以下幾點。

 

             1.Singleton最多隻有一個例項,在不考慮反射強行突破訪問限制的情況下。

             2.保證了併發訪問的情況下,不會發生由於併發而產生多個例項。

             3.保證了併發訪問的情況下,不會由於初始化動作未完全完成而造成使用了尚未正確初始化的例項。

             以下為不太常用的方式,這裡給出來只是給各位參考,不建議使用下述方式。

             第一種,就是俗稱的餓漢式載入

複製程式碼

public class Singleton {
    
    private static Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInstance(){
        return singleton;
    }
    
}

複製程式碼

              上述方式與我們最後一種給出的方式類似,只不過沒有經過內部類處理,這種方式最主要的缺點就是一旦我訪問了Singleton的任何其他的靜態域,就會造成例項的初始化,而事實是可能我們從始至終就沒有使用這個例項,造成記憶體的浪費。

              不過在有些時候,直接初始化單例的例項也無傷大雅,對專案幾乎沒什麼影響,比如我們在應用啟動時就需要載入的配置檔案等,就可以採取這種方式去保證單例。

              第二種我就不貼了,與雙重鎖定一模一樣,只是給靜態的例項屬性加上關鍵字volatile,標識這個屬性是不需要優化的。

              這樣也不會出現例項化發生一半的情況,因為加入了volatile關鍵字,就等於禁止了JVM自動的指令重排序優化,並且強行保證執行緒中對變數所做的任何寫入操作對其他執行緒都是即時可見的。這裡沒有篇幅去介紹volatile以及JVM中變數訪問時所做的具體動作,總之volatile會強行將對該變數的所有讀和取操作繫結成一個不可拆分的動作。如果讀者有興趣的話,可以自行去找一些資料看一下相關內容。

             不過值得注意的是,volatile關鍵字是在JDK1.5以及1.5之後才被給予了意義,所以這種方式要在JDK1.5以及1.5之後才可以使用,但仍然還是不推薦這種方式,一是因為程式碼相對複雜,二是因為由於JDK版本的限制有時候會有諸多不便。

             好了,以上基本上就是常見的所有單例模式的構造方式,如果下次再有面試讓你去寫一個單例模式,有時間的話就把上面所有的全部寫給面試官並一一將優劣講給他聽吧,這樣的話估計offer已經離你不遠了。

             本次單例模式的分享就到此結束了,感謝各位的收看。

             下期預告,代理模式。

              

 

版權宣告

 


作者:zuoxiaolong(左瀟龍)

出處:部落格園左瀟龍的技術部落格--http://www.cnblogs.com/zuoxiaolong

您的支援是對博主最大的鼓勵,感謝您的認真閱讀。

本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。

分類: 設計模式

標籤: 設計模式單例模式

好文要頂 關注我 收藏該文  

左瀟龍
關注 - 0
粉絲 - 2651

榮譽:推薦部落格

+加關注

18

« 上一篇:設計模式詳解(總綱)
» 下一篇:(二)代理模式詳解(包含原理詳解)

posted @ 2013-08-16 19:21 左瀟龍 閱讀(11534) 評論(10) 編輯 收藏

評論列表

  

#1樓 2013-10-23 16:41 Tony Zhao  

分析的很到位,一個單例模式的講究也不少呢

支援(0)反對(0)

  

#2樓 2014-05-19 23:45 永遠改不完的bug  

還有一種使用列舉型別的單例模式沒講

支援(2)反對(0)

  

#3樓 2014-08-17 09:00 stephansun  我在自己的Java環境下測試TestSingleton沒有出現列印多個例項的情況,而是直接掛起,所有的執行緒都是while在空跑,然後我嘗試將while寫成

作者:zuoxiaolong8810(左瀟龍),轉載請註明出處,特別說明:本博文來自博主原部落格,為保證新部落格中博文的完整性,特複製到此留存,如需轉載請註明新部落格地址即可。

            上一章,我們學習了設計模式的概念,以及為什麼要學習設計模式,還有在進行系統設計時應當遵守的六大原則,本章我們就來開始一一的學習GOF當中的二十三鍾設計模式。

            我一直在思考如何去詮釋這麼多設計模式,因為網上有很多現成的,可供學習的資料,我在想有什麼地方可以讓各位跟著我的節奏去學習,而不是網上的那些資料,優勢在哪裡,思考很久,我覺得唯一的優勢,或者說我能有的優勢,就是簡單通俗易懂。

            遵循著中心思想通俗易懂,我們首先來回顧一下單例模式為何要出現,又或者說什麼樣的類可以做成單例的。

            在我的工作過程中,我發現所有可以使用單例模式的類都有一個共性,那就是這個類沒有自己的狀態,換句話說,這些類無論你例項化多少個,其實都是一樣的,而且更重要的一點是,這個類如果有兩個或者兩個以上的例項的話,我的程式竟然會產生程式錯誤或者與現實相違背的邏輯錯誤。

            這樣的話,如果我們不將這個類控制成單例的結構,應用中就會存在很多一模一樣的類例項,這會非常浪費系統的記憶體資源,而且容易導致錯誤甚至一定會產生錯誤,所以我們單例模式所期待的目標或者說使用它的目的,是為了儘可能的節約記憶體空間,減少無謂的GC消耗,並且使應用可以正常運作。

            我稍微總結一下,一般一個類能否做成單例,最容易區別的地方就在於,這些類,在應用中如果有兩個或者兩個以上的例項會引起錯誤,又或者我換句話說,就是這些類,在整個應用中,同一時刻,有且只能有一種狀態。

            一般實踐當中,有很多應用級別的資源會被做成單例,比如配置檔案資訊,邏輯上來講,整個應用有且只能在同在時間有一個,當然如果你有多個,這可能並不會引起程式級別錯誤,這裡指的錯誤特指異常或者ERROR。但是當我們試圖改變配置檔案的時候,問題就出來了。

            你有兩種選擇,第一種,將所有的例項全部更新成一模一樣的狀態。第二種,就是等著出現問題。

            然而出現的問題大部分是邏輯層次上的錯誤,個人覺得這是比程式錯誤更加嚴重的錯誤,因為它不會告訴你空指標,不會告訴你非法引數,很多時候要等到影響到客戶使用時才會被發現。

            下面,我們就來看一下做成單例的幾種方式。

            第一種方式,我們來看一下最標準也是最原始的單例模式的構造方式。

複製程式碼

public class Singleton {

    //一個靜態的例項
    private static Singleton singleton;
    //私有化建構函式
    private Singleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public static Singleton getInstance(){
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

複製程式碼

            這是在不考慮併發訪問的情況下標準的單例模式的構造方式,這種方式通過幾個地方來限制了我們取到的例項是唯一的。

            1.靜態例項,帶有static關鍵字的屬性在每一個類中都是唯一的。

            2.限制客戶端隨意創造例項,即私有化構造方法,此為保證單例的最重要的一步。

            3.給一個公共的獲取例項的靜態方法,注意,是靜態的方法,因為這個方法是在我們未獲取到例項的時候就要提供給客戶端呼叫的,所以如果是非靜態的話,那就變成一個矛盾體了,因為非靜態的方法必須要擁有例項才可以呼叫。

            4.判斷只有持有的靜態例項為null時才呼叫構造方法創造一個例項,否則就直接返回。

            假如你去面試一家公司,給了你一道題,讓你寫出一個單例模式的例子,那麼如果你是剛出大學校門的學生,你能寫出上面這種示例,假設我是面試官的話,滿分100的話,我會給90分,剩下的那10分算是給更優秀的人一個更高的臺階。但如果你是一個有過兩三年工作經驗的人,如果你寫出上面的示例,我估計我最多給你30分,甚至心情要是萬一不好的話可能會一分不給。

           為什麼同樣的示例放到不同的人身上差別會這麼大,就是因為前面我提到的那個情況,在不考慮併發訪問的情況下,上述示例是沒有問題的。

           至於為什麼在併發情況下上述的例子是不安全的呢,我在這裡給各位製造了一個併發的例子,用來說明,上述情況的單例模式,是有可能造出來多個例項的,我自己測試了約莫100次左右,最多的一次,竟然造出了3個例項。下面給出程式碼,大約執行10次(併發是具有概率性的,10次只是保守估計,也可能一次,也可能100次)就會發現我們創造了不只一個例項。

複製程式碼

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestSingleton {
    
    boolean lock ;
    
    public boolean isLock() {
        return lock;
    }

    public void setLock(boolean lock) {
        this.lock = lock;
    }
    
    public static void main(String[] args) throws InterruptedException {
        final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>());
        final TestSingleton lock = new TestSingleton();
        lock.setLock(true);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) {
            executorService.execute(new Runnable() {
                
                public void run() {
                    while (true) {
                        if (!lock.isLock()) {
                            Singleton singleton = Singleton.getInstance();
                            instanceSet.add(singleton.toString());
                            break;
                        }
                    }
                }
            });
        }
        Thread.sleep(5000);
        lock.setLock(false);
        Thread.sleep(5000);
        System.out.println("------併發情況下我們取到的例項------");
        for (String instance : instanceSet) {
            System.out.println(instance);
        }
        executorService.shutdown();
    }
}

複製程式碼

               我在程式中同時開啟了100個執行緒,去訪問getInstance方法,並且把獲得例項的toString方法獲得的例項字串裝入一個同步的set集合,set集合會自動去重,所以看結果如果輸出了兩個或者兩個以上的例項字串,就說明我們在併發訪問的過程中產生了多個例項。

               程式當中讓main執行緒睡眠了兩次,第一次是為了給足夠的時間讓100個執行緒全部開啟,第二個是將鎖開啟以後,保證所有的執行緒都已經呼叫了getInstance方法。

               好了,這下我們用事實說明了,上述的單例寫法,我們是可以創造出多個例項的,至於為什麼在這裡要稍微解釋一下,雖說我一直都喜歡用事實說話,包括看書的時候,我也不喜歡作者跟我解釋為什麼,而是希望給我一個例子,讓我自己去印證。

              造成這種情況的原因是因為,當併發訪問的時候,第一個呼叫getInstance方法的執行緒A,在判斷完singleton是null的時候,執行緒A就進入了if塊準備創造例項,但是同時另外一個執行緒B線上程A還未創造出例項之前,就又進行了singleton是否為null的判斷,這時singleton依然為null,所以執行緒B也會進入if塊去創造例項,這時問題就出來了,有兩個執行緒都進入了if塊去創造例項,結果就造成單例模式並非單例。

              為了避免這種情況,我們就要考慮併發的情況了,我們最容易想到的方式應該是下面這樣的方式,直接將整個方法同步。

複製程式碼

public class BadSynchronizedSingleton {

    //一個靜態的例項
    private static BadSynchronizedSingleton synchronizedSingleton;
    //私有化建構函式
    private BadSynchronizedSingleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public synchronized static BadSynchronizedSingleton getInstance(){
        if (synchronizedSingleton == null) {
            synchronizedSingleton = new BadSynchronizedSingleton();
        }
        return synchronizedSingleton;
    }
    
}

複製程式碼

               上面的做法很簡單,就是將整個獲取例項的方法同步,這樣在一個執行緒訪問這個方法時,其它所有的執行緒都要處於掛起等待狀態,倒是避免了剛才同步訪問創造出多個例項的危險,但是我只想說,這樣的設計實在是糟糕透了,這樣會造成很多無謂的等待,所以為了表示我的憤怒,我在類名上加入Bad。

               其實我們同步的地方只是需要發生在單例的例項還未建立的時候,在例項建立以後,獲取例項的方法就沒必要再進行同步控制了,所以我們將上面的示例改為很多教科書中標準的單例模式版本,也稱為雙重加鎖

複製程式碼

public class SynchronizedSingleton {

    //一個靜態的例項
    private static SynchronizedSingleton synchronizedSingleton;
    //私有化建構函式
    private SynchronizedSingleton(){}
    //給出一個公共的靜態方法返回一個單一例項
    public static SynchronizedSingleton getInstance(){
        if (synchronizedSingleton == null) {
            synchronized (SynchronizedSingleton.class) {
                if (synchronizedSingleton == null) {
                    synchronizedSingleton = new SynchronizedSingleton();
                }
            }
        }
        return synchronizedSingleton;
    }
}

複製程式碼

                這種做法與上面那種最無腦的同步做法相比就要好很多了,因為我們只是在當前例項為null,也就是例項還未建立時才進行同步,否則就直接返回,這樣就節省了很多無謂的執行緒等待時間,值得注意的是在同步塊中,我們再次判斷了synchronizedSingleton是否為null,解釋下為什麼要這樣做。

               假設我們去掉同步塊中的是否為null的判斷,有這樣一種情況,假設A執行緒和B執行緒都在同步塊外面判斷了synchronizedSingleton為null,結果A執行緒首先獲得了執行緒鎖,進入了同步塊,然後A執行緒會創造一個例項,此時synchronizedSingleton已經被賦予了例項,A執行緒退出同步塊,直接返回了第一個創造的例項,此時B執行緒獲得執行緒鎖,也進入同步塊,此時A執行緒其實已經創造好了例項,B執行緒正常情況應該直接返回的,但是因為同步塊裡沒有判斷是否為null,直接就是一條建立例項的語句,所以B執行緒也會創造一個例項返回,此時就造成創造了多個例項的情況。

              經過剛才的分析,貌似上述雙重加鎖的示例看起來是沒有問題了,但如果再進一步深入考慮的話,其實仍然是有問題的。

              如果我們深入到JVM中去探索上面這段程式碼,它就有可能(注意,只是有可能)是有問題的。

              因為虛擬機器在執行建立例項的這一步操作的時候,其實是分了好幾步去進行的,也就是說建立一個新的物件並非是原子性操作。在有些JVM中上述做法是沒有問題的,但是有些情況下是會造成莫名的錯誤。

              首先要明白在JVM建立新的物件時,主要要經過三步。

              1.分配記憶體

              2.初始化構造器

              3.將物件指向分配的記憶體的地址

              這種順序在上述雙重加鎖的方式是沒有問題的,因為這種情況下JVM是完成了整個物件的構造才將記憶體的地址交給了物件。但是如果2和3步驟是相反的(2和3可能是相反的是因為JVM會針對位元組碼進行調優,而其中的一項調優便是調整指令的執行順序),就會出現問題了。

              因為這時將會先將記憶體地址賦給物件,針對上述的雙重加鎖,就是說先將分配好的記憶體地址指給synchronizedSingleton,然後再進行初始化構造器,這時候後面的執行緒去請求getInstance方法時,會認為synchronizedSingleton物件已經例項化了,直接返回一個引用。如果在初始化構造器之前,這個執行緒使用了synchronizedSingleton,就會產生莫名的錯誤。

             所以我們在語言級別無法完全避免錯誤的發生,我們只有將該任務交給JVM,所以有一種比較標準的單例模式。如下所示。

複製程式碼

package com.oneinstance;

public class InnerClassSingleton {
    
    public static Singleton getInstance(){
        return Singleton.singleton;
    }

    private static class Singleton{
        
        protected static Singleton singleton = new Singleton();
        
    }
}

複製程式碼

              首先來說一下,這種方式為何會避免了上面莫名的錯誤,主要是因為一個類的靜態屬性只會在第一次載入類時初始化,這是JVM幫我們保證的,所以我們無需擔心併發訪問的問題。所以在初始化進行一半的時候,別的執行緒是無法使用的,因為JVM會幫我們強行同步這個過程。另外由於靜態變數只初始化一次,所以singleton仍然是單例的。

              上面這種寫法是我們使用靜態的內部類作為單例,這樣不太符合我們的習慣。所以我們改為以下形式。

複製程式碼

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

複製程式碼

             好了,進行到這裡,單例模式算是已經完成了。最終的產物就是如上述的形式。上述形式保證了以下幾點。

 

             1.Singleton最多隻有一個例項,在不考慮反射強行突破訪問限制的情況下。

             2.保證了併發訪問的情況下,不會發生由於併發而產生多個例項。

             3.保證了併發訪問的情況下,不會由於初始化動作未完全完成而造成使用了尚未正確初始化的例項。

             以下為不太常用的方式,這裡給出來只是給各位參考,不建議使用下述方式。

             第一種,就是俗稱的餓漢式載入

複製程式碼

public class Singleton {
    
    private static Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInstance(){
        return singleton;
    }
    
}

複製程式碼

              上述方式與我們最後一種給出的方式類似,只不過沒有經過內部類處理,這種方式最主要的缺點就是一旦我訪問了Singleton的任何其他的靜態域,就會造成例項的初始化,而事實是可能我們從始至終就沒有使用這個例項,造成記憶體的浪費。

              不過在有些時候,直接初始化單例的例項也無傷大雅,對專案幾乎沒什麼影響,比如我們在應用啟動時就需要載入的配置檔案等,就可以採取這種方式去保證單例。

              第二種我就不貼了,與雙重鎖定一模一樣,只是給靜態的例項屬性加上關鍵字volatile,標識這個屬性是不需要優化的。

              這樣也不會出現例項化發生一半的情況,因為加入了volatile關鍵字,就等於禁止了JVM自動的指令重排序優化,並且強行保證執行緒中對變數所做的任何寫入操作對其他執行緒都是即時可見的。這裡沒有篇幅去介紹volatile以及JVM中變數訪問時所做的具體動作,總之volatile會強行將對該變數的所有讀和取操作繫結成一個不可拆分的動作。如果讀者有興趣的話,可以自行去找一些資料看一下相關內容。

             不過值得注意的是,volatile關鍵字是在JDK1.5以及1.5之後才被給予了意義,所以這種方式要在JDK1.5以及1.5之後才可以使用,但仍然還是不推薦這種方式,一是因為程式碼相對複雜,二是因為由於JDK版本的限制有時候會有諸多不便。

             好了,以上基本上就是常見的所有單例模式的構造方式,如果下次再有面試讓你去寫一個單例模式,有時間的話就把上面所有的全部寫給面試官並一一將優劣講給他聽吧,這樣的話估計offer已經離你不遠了。

             本次單例模式的分享就到此結束了,感謝各位的收看。

 


加了一條列印語句後,就出現文章說的結果了。

最後,我基於樓主的思想,重寫了一下TestSingleton類

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

public class TestSingleton {

     

    public static void main(String[] args) throws InterruptedException {

         

        final Set<String> set = Collections.synchronizedSet(new HashSet<String>());

        final CountDownLatch cdl = new CountDownLatch(1);

         

        ExecutorService executor = Executors.newCachedThreadPool();

        for (int i = 0; i < 100; i++) {

            executor.execute(new Runnable() {

                @Override

                public void run() {

                    try {

                        cdl.await();

                         

                        Singleton singleton = Singleton.getInstance();

                        set.add(singleton.toString());

                    catch (InterruptedException e) {

                        e.printStackTrace();

                    }

                }

            });

        }

         

         

        Thread.sleep(1000);

        cdl.countDown();

        Thread.sleep(1000);

         

        System.out.println("一共有" + set.size() + "個例項");

        for (String str : set) {

            System.out.println(str);

        }

         

        executor.shutdown();

         

    }

 

}


/**
* 測試初級單例模式的併發弊端
@author Administrator
*
*/
public class TestSingleton_easy {

public static void main(String[] args) throws Exception {
int num=100;
final CyclicBarrier cyclicBarrier = new CyclicBarrier(num);
final Set<String> set=Collections.synchronizedSet(new HashSet<String>());
ExecutorService executorService = Executors.newCachedThreadPool();
for(int i=0;i<num;i++){
executorService.execute(new Runnable() {
public void run() {
try {
cyclicBarrier.await();//阻塞等待所有執行緒建立完畢,然後同時執行獲取例項的操作
Singleton_easy singleton = Singleton_easy.getInstance();
set.add(singleton.toString());
} catch (Exception e) {
e.printStackTrace();

}
});
}
Thread.sleep(2000);
System.out.println("------併發情況下我們取到的例項------");
for (String instance : set) {
System.out.println(instance);
}
executorService.shutdown();

}

}

支援(0)反對(0)

  

#8樓 2016-11-08 15:14 修願三秋  

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

/**

 * 測試初級單例模式的併發弊端

 * <a href="http://home.cnblogs.com/u/28131/" target="_blank">@author</a> Administrator

 *

 */

public class TestSingleton_easy {

 

    public static void main(String[] args) throws Exception {

        int num=100;

        final CyclicBarrier cyclicBarrier = new CyclicBarrier(num);

        final Set<String> set=Collections.synchronizedSet(new HashSet<String>());

        ExecutorService executorService = Executors.newCachedThreadPool();

        for(int i=0;i<num;i++){

            executorService.execute(new Runnable() {

                public void run() {

                    try {

                        cyclicBarrier.await();//阻塞等待所有執行緒建立完畢,然後同時執行獲取例項的操作

                        Singleton_easy singleton = Singleton_easy.getInstance();

                        set.add(singleton.toString());

                    catch (Exception e) {

                        e.printStackTrace();

                    }

                }

            });

        }

        Thread.sleep(2000);

        System.out.println("------併發情況下我們取到的例項------");

        for (String instance : set) {

            System.out.println(instance);

        }

        executorService.shutdown();

     

    }

 

}