劍指offer-面試題2 實現單例模式

我也不知道面試題1去哪兒了。。

面試題2 實現單例模式

1. 單例模式的定義

  單例模式最初的定義出現於《設計模式》(艾迪生維斯理,1994):“保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。”
  另一個常見的定義是:一個類只有一個實例,並且自行實例化向整個系統提供。
  這兩句話的意思就是,當我們需要用到某個實例的時候,我們無需進行其它多餘操作,而是直接通過某個接口獲取到它的實例,並且這個實例在整個系統中保證唯一。
  舉個簡單的例子:我們在平時使用電腦時,我們希望點擊“設置”按鈕,就可以直接訪問設置,而且要求設置在整個系統中是唯一的(這是廢話),電腦的設置在這裏就是一個單例。
  
  我們通過定義,得出完成單例模式需要滿足下面兩個條件:
  1. 生成類的實例要唯一。也就是生成代碼只能執行一次,“阻止”所有想要生成新對象的操作;
  2. 生成實例的方法必須是全局方法(也就是靜態)。原因是非靜態方法必須通過實例進行調用,如果已經有了實例,我們還需要生成實例的方法幹什麼呢?
  
  那麼如何具體實現單例模式呢? 

2. 一個小例子

  我們有個小需求:要獲取電腦的現在時間,試着寫一個MyTime 類。
  實現1:

    import java.util.Date;
    public class MyTime{
        ...
        private static Date time = new Date();
        public static Date getTime() {
            return time;
        }
        ...
    }

  實現2:

    ...
    private static Date time;
    public static Date getTime() {
        time = new Date();
        return time;
    }
    ...

  代碼簡單調用Date() 接口實現了MyTime 類,其中兩個實現都滿足了要求。
  它們有什麼區別呢?第一個例子直接獲取了當前時間,而第二個例子中,當我們需要time 時,調用getTime() 再進行創建,降低了初始化時間,但是每次調用都會新獲取新的Date(),事實上與單例模式的定義相悖。
  這裏介紹一個概念,延遲加載。延遲加載(lazy loading),就是Java虛擬機在進行類加載的時候不創建對象,當我們需要時再進行創建。這樣做可以減少運行時間,提高系統的性能。
  爲了提高系統性能,單例模式中應該儘量實現延遲加載(lazy loading)。
  第二個例子實現了延遲加載。但是,它不是一個單例模式,而且它是線程不安全的。後面我們會對它進行改良,實現線程安全的單例模式。

3. 餓漢模式(線程安全)

  在實現1的基礎上改進一下:

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

  我們先創建一個Ex02Singleton 的實例,之後在調用getInstance() 方法中返回這個實例就可以了。這樣實例的唯一性就得到了保證 ,這是一種可行的方法。

  這種辦法爲什麼是線程安全的呢?這涉及到JVM在類的初始化階段給出的線程安全性保證。因爲JVM在類初始化階段,會獲取一個鎖,並且每個線程都會至少獲取一次這個鎖以確保這個類已經加載。
  在靜態初始化期間,內存的寫入操作自動對所有線程可見,而singleton 的初始化就是屬於靜態初始化。因此,在構造期間或者被引用時,靜態初始化的對象都不需要顯式的同步。
  但是這個規則只適用於在構造時的狀態,如果對象可變,那麼在其它地方對該對象的訪問還是需要使用同步來確保對對象的修改操作是可見的。
  
  優點:線程安全,代碼簡單;
  缺點:不能延遲加載,系統性能會有所降低。  

4. 懶漢模式( 線程不安全)

  根據實現2:

    public class Ex02Singleton {
        private Ex02Singleton(){}

        public static Ex02Singleton getInstance(){
            return new Ex02Singleton();
        }
    }

  這個例子的構造函數Ex02Singleton() 是私有的,因爲一旦公有,任何人都能通過構造函數創建新的實例,這樣就不能保證實例的唯一性。
  但現在的問題是:每當我們調用getInstace() 方法時,它都會返回一個新的Ex02Singleton 實例。多次調用就會產生多個Ex02Singleton 實例,這和單例模式中實例的唯一性相悖。
  如何改進呢?
  事實上我們應該先對singleton 先進行判斷,如果不爲null ,就直接返回;爲null 時再去創建,之後返回,這樣就不會有實現1的問題了。
  改進後:

    public class Ex02Singleton {
        private Ex02Singleton(){}
        private static Ex02Singleton singleton;
        public static Ex02Singleton getInstance(){
            if(singleton == null)
                singleton = new Ex02Singleton();
            return singleton;
        }
    }

  代碼很簡單,先聲明靜態的Ex02Singleton 型變量singleton ,不進行實例化,在調用getInstance() 方法時進行判斷,如果singleton 還沒有被實例化就進行實例化,這樣做實現了延遲加載。這就是與餓漢模式相對應的懶漢模式。

懶漢模式的問題
  懶漢模式個致命的問題,這是由多線程訪問時出現的線程不安全問題。看下圖:

懶漢模式多線程

  有兩個線程A和B,當A線程往下執行,執行完命令

    if(singleton == null)

  判定結果爲true 。此時,線程被中止(這個過程是系統隨機的,也有可能不中止一直執行下去)。然後線程B開始執行,它也執行到這句:

    if(singleton == null)

  因爲線程A判斷完就中止了,還沒來得及創建實例,B執行這句的結果也會是true ,接着它創建了一個實例,到A繼續執行時還會創建新的Ex02Singleton 實例。這樣就有兩個實例存在。
  爲了避免類似的情況發生,Java中出現了同步關鍵字synchronized ,來保證被其修飾的代碼塊會被加同步鎖,同一時間段內只能有一個線程訪問它,直到代碼執行完畢,纔會釋放這部分代碼。
  修改一下代碼,如下:

    public static synchronized Ex02Singleton getInstance(){
        if(singleton == null)
            singleton = new Ex02Singleton();
        return singleton;
    }

  這樣看似不錯,多線程問題得到了解決。但是同步加鎖是一種耗費時間的操作,getInstance() 方法不是什麼敏感操作,我們只需要在第一次實例化時需要加鎖,之後調用getInstance() 方法都沒有必要加鎖。
  所以這種方法雖然能保證線程安全和延遲加載,但實際應用中由於效率太低,不會有人去用它。
  我們得想個方法,既能保證在實例化時加同步鎖,又在每次調用getInstance() 方法時正常執行。

  優點:能延遲加載,對單線程程序無影響;
  缺點:線程不安全。

5. 雙重檢查鎖定+volatile關鍵字(線程安全)

(1)DCL(雙重檢查加鎖)
  基於上面懶漢式 + synchronized 關鍵字加鎖的思想,我們對代碼進行改進:

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

  這種雙重判斷被稱爲雙重檢查加鎖(DCL,double check lock)。
  其中用了兩個if() 判斷,第一個if 先判斷singleton 是否爲null :如果不爲null ,說明singleton 已經被初始化了,直接返回singleton
  如果singletonnull ,說明singleton 還沒有被初始化,這樣纔會去執行synchronized 修飾的代碼塊內容,只在其初始化的時候調用一次。這樣的設計既能保證只產生一個實例,並且只在初始化的時候加同步鎖,也實現了延遲加載。
  這個就是我們需要的操作了,可在實際操作中還是會發生問題,這又是怎麼回事呢?

(2)指令重排序
  指令重排序的作用是爲了優化指令,提高程序運行效率。指令重排序包括編譯器重排序和運行時重排序。
  JVM規範規定,指令重排序可以在不影響單線程程序執行結果前提下進行。例如

    instance = new Singleton();

可分解爲如下僞代碼:

    memory = allocate();   //1:分配對象的內存空間  
    ctorInstance(memory);  //2:初始化對象  
    instance = memory;     //3:設置instance指向剛分配的內存地址  

經過重新排序後:

    memory = allocate();   //1:分配對象的內存空間  
    instance = memory;     //3:設置instance指向剛分配的內存地址  
                           //注意:此時對象還沒有被初始化!  
    ctorInstance(memory);  //2:初始化對象  

  將第2步和第3步調換順序,在單線程情況下不會影響程序執行的結果,但是在多線程情況下就不一樣了。
  這裏需要明確的一點是:對於synchronized 關鍵字,當一個線程訪問對象的一個synchronized(xx.class) 同步代碼塊時,另一個線程仍然可以訪問該對象中的非synchronized(xx.class) 同步代碼塊。
  線程A執行了

    instance = memory;  //這對另一個線程B來說是可見的

  此時線程B執行外層

    if (instance == null) {
        ...
    }

  發現singleton不爲空,隨即返回,但是得到的卻是未被完全初始化的實例,在使用的時候必定會有風險,這正是雙重檢查鎖定的問題所在。

  在JDK1.5之後,新增了volatile 關鍵字禁止指令重排序的功能:

    public class Ex02Singleton {
        private Ex02Singleton(){}
        private static volatile Ex02Singleton singleton;

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

  volatile 關鍵字禁止指令重排序的做法是在對被其修飾的變量進行操作時,增加一個內存屏障(Memory Barrier或Memory Fence,指重排序時不能把後面的指令重排序到內存屏障之前的的位置)用以保證一致性。這樣我們就解決了指令重排序的問題。
  (關於volatile 關鍵字的用法在此不詳述,參見《深入理解Java虛擬機》第十二章即可。)
  
  優點:能延遲加載,也能保證線程安全;
  缺點:代碼較複雜。

6. 延遲初始化佔位(Holder)類模式(推薦)

  單例模式還有以下實現:

    public class Ex02Singleton {
        private Ex02Singleton(){}

        private static class InstanceHolder{
            public static final Ex02Singleton singleton = new Ex02Singleton();  
        }
        public static Ex02Singleton getInstance(){          
            return InstanceHolder.singleton;
        }
    }

  這種方式成爲延遲初始化佔位(Holder)類模式。該模式引入了一個內部靜態類(佔位類)內部靜態類只有在調用時纔會加載,既保證了Ex02Singleton 實例的延遲初始化,又保證了實例的唯一性。是一種提前初始化(餓漢式)和延遲初始化(飽漢式)的綜合模式,推薦使用這種操作。

  這種方法基於在懶漢模式中提出的,JVM在類的初始化階段給出的線程安全性保證,將singleton 的實例化操作放置到一個靜態內部類中,在第一次調用getInstance() 方法時,JVM纔會去加載InstanceHolder 類,同時初始化singleton 實例,因此,即使我們不採取任何同步策略,getInstance() 方法也是線程安全的。
  
  優點:能延遲加載,也能保證線程安全。

7. 枚舉

  枚舉(enum ,全稱爲 enumeration), 是 JDK 1.5 中引入的新特性,存放在java.lang 包中。枚舉的詳細用法見枚舉的詳細用法
  

     public enum ExSingleton {  
         INSTANCE;  
         public void someMethod() {  
         }  
     }  

  需要時使用Singleton.INSTANCE 即可實現調用。
  這種方式是Effective Java作者Josh Bloch 提倡的方式,它不僅能避免多線程同步問題,而且還能防止反序列化重新創建新的對象,可謂是很堅強的壁壘。
  優點:代碼十分簡潔,而且便於操作;
  缺點:較爲不常見。
  

總結

單例模式線程安全的寫法有以下幾種:
  1. 餓漢式(不能延遲加載);
  2. 雙重檢查鎖(DLC)+volatile 關鍵字;
  3. 延遲初始化佔位類模式(Holder);
  4. 枚舉。

補充

  在看Android源碼時發現一個方法

    package android.os;
    public abstract class AsyncTask<Params, Progress, Result> {
        …
        private static Handler getHandler() {
            synchronized (AsyncTask.class) {
                if (sHandler == null) {
                    sHandler = new InternalHandler();
                }
                return sHandler;
            }
        }
        …
    }

getHandler() 方法中,直接對if操作進行了同步鎖定。這引出了一個問題:synchronized 關鍵字的不同操作方式:synchronized(xx.class) 和方法中帶有synchronized 關鍵字的異同,這篇文章會抽空補上,參見 初步探究synchronized的用法

參考文章

[1] 百度百科:單例模式
[2] Java設計模式系列之單例模式
[3] 雙重檢查鎖定(double-checked locking)與單例模式
[4] Java:單例模式的七種寫法
[5] java併發中的延遲初始化
[6] java synchronized詳解
[7] Java枚舉enum以及應用:枚舉實現單例模式

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章