設計模式之單例模式

一、簡單介紹

1、定義

Ensure a class has only one instance, and provide a global point of
access of it.

翻譯過來的意思就是:確保一個類只有一個實例,要自行實例化並且想整個系統提供這個實例。

2、UML類圖

單例模式UML類圖

3、實現的關鍵點

  • 構造函數不對外開放,一般未private;
  • 通過一個靜態方法或者枚舉返回單例對象;
  • 確保單例類的對象有且只有一個,尤其是多線程環境下;
  • 確保單例類對象在反序列化時候不會重新構建對象;

二、實現方式

1、餓漢模式

public class Singleton {
    private static Singleton sInstance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return sInstance;
    }
}

優點:

  • 實現簡單。
  • 多線程安全。

缺點:

  • 不能傳遞參數給構造函數。
  • 需要考慮反序列化問題(見下文的補充材料)。

2、懶漢模式

public class Singleton {
    private static Singleton sInstance = null;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (sInstance == null) {
            sInstance = new Singleton();
        }
        return sInstance;
    }
}

優點:

  • 單例只有在使用的時候纔會被實例化,一定程度上節約了資源。

缺點:

  • 第一次加載的時候需要及時進行實例化,反應稍慢;
  • 每次調用getInstance()方法都需要進行同步處理,造成不必要的同步開銷。
  • 需要考慮反序列化問題(見下文的補充材料)。

3、Double Check Lock (DCL) 模式

public class Singleton {
    private volatile static Singleton sInstance = null;

    private Singleton() {
    }

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

優點:

  • 資源利用率高。第一次執行getInstance()方法時候對象纔會被實例化,效率高。

缺點:

  • 第一次加載時反應慢。
  • 由於Java內存模型的原因偶爾會失敗,低於JDK 5版本下不可使用(這個問題被稱爲雙重檢查失效問題,見下文的補充材料)。
  • 需要考慮反序列化問題(見下文的補充材料)。

4、靜態內部類模式

public class Singleton {
    private Singleton() {
    }

    public static Singleton getInstance() {
        return SingletonHolder.sInstance;
    }

    private static class SingletonHolder {
        private static final Singleton sInstance = new Singleton();
    }
}

優點:

  • 只有第一次調用Singleton的getInstance()方法時,導致虛擬機加載SingletonHolder類,此時 sInstance 纔會被初始化,。
  • 確保線程安全。

缺點:

  • 不能給構造函數傳遞參數。
  • 需要考慮反序列化問題(見下文的補充材料)。

5、枚舉方式實現

public enum Singleton {
   INSTANCE;

   public void otherMethod(){
       //do something.
   }
}

優點:

  • 寫法簡單;
  • 線程安全(得益於枚舉實例的創建是線程安全的);
  • 不用考慮反序列話問題。

三、使用場景

  • 要求生成唯一序列號的環境。
  • 在整個項目中需要一個共享的訪問點或者共享數據。
  • 創建一個對象需要耗費的資源過多,如要訪問IO或者數據庫資源。
  • 需要定義大量的靜態常量和靜態方法的環境(也可以直接聲明爲static的方式)。

四、單例模式優缺點

1、優點

  • 減少了內存開支。單例模式在內存中只有一個實例,特別是一個對象需要頻繁的創建和銷燬的時候,而且創建和銷燬時性能又無法優化時,單例模式的有點就非常明顯。
  • 減少系統的性能開銷。當一個對象的產生需要較多的資源的時候,如讀取配置文件、產生其他依賴對象的時候,則可以在應用啓動的時候直接創建一個單例,然後用永久留內存的方式解決(注意JVM的垃圾回收機制)。
  • 避免了對資源的多重佔用。如一個寫文件操作,就避免了多個對象對同一對象的寫入操作。
  • 可以在系統設置安全的訪問點,優化和共享資源的訪問。

2、缺點

  • 單例模式一般沒有接口,擴展很困難,如若想擴展就只能修改代碼。
  • 測試不方便。並行開發環境下,如果單例模式沒有寫完,是不能進行測試的。
  • 單例模式與單一職責原則有衝突的。一個類應該只實現一個邏輯,而不關心它是否是單例的,單例模式把”要單例“和業務邏輯融合在一個類中(但是我個人認爲這並不是一個缺點,畢竟原則是死的,人是活的,一切都要從實際環境出發)。

五、補充

補充 1、反序列化問題

通過序列化可以將一個單例的對象寫到磁盤裏,然後再讀出來,從而獲得一個實例,即使構造函數是私有的,反序列化時依然可以通過特殊的途徑去創建類的一個新的實例(兩者的hashcode是不同的)。
那如何解決這個問題呢,如何才能保證單例類對象在反序列化時候不會重新構建對象呢?
反序列化操作提供了一個很特別的鉤子函數,類中具有一個私有的、被實例化的方法readResolve(),這個方法可以讓開發人員控制對象的反序列化。要杜絕單例對象在被反序列化時重新生成新的對象,那麼只需要在單例類中加入如下代碼:

private Object readResolve() throws ObjectStreamException {
        return sInstance;
}

方法readResolve會在ObjectInputStream已經讀取一個對象並在準備返回前調用。ObjectInputStream 會檢查對象的class是否定義了readResolve方法。如果定義了,將由readResolve方法指定返回的對象。返回對象的類型一定要是兼容的,否則會拋出ClassCastException 。這部分的源代碼(可以在ObjectInputStream類裏找到)以及分析如下:

/**
     * Reads and returns "ordinary" (i.e., not a String, Class,
     * ObjectStreamClass, array, or enum constant) object, or null if object's
     * class is unresolvable (in which case a ClassNotFoundException will be
     * associated with object's handle).  Sets passHandle to object's assigned
     * handle.
     */
    private Object readOrdinaryObject(boolean unshared)
        throws IOException
    {
        if (bin.readByte() != TC_OBJECT) {
            throw new InternalError();
        }

        ObjectStreamClass desc = readClassDesc(false);
        desc.checkDeserialize();
        //獲取單例類的Class對象
        Class<?> cl = desc.forClass();
        if (cl == String.class || cl == Class.class
                || cl == ObjectStreamClass.class) {
            throw new InvalidClassException("invalid class descriptor");
        }

        Object obj;
        try {
            //構造單例對象
            obj = desc.isInstantiable() ? desc.newInstance() : null;
        } catch (Exception ex) {
            throw (IOException) new InvalidClassException(
                desc.forClass().getName(),
                "unable to create instance").initCause(ex);
        }

        passHandle = handles.assign(unshared ? unsharedMarker : obj);
        ClassNotFoundException resolveEx = desc.getResolveException();
        if (resolveEx != null) {
            handles.markException(passHandle, resolveEx);
        }
        //數據的讀取
        if (desc.isExternalizable()) {
            readExternalData((Externalizable) obj, desc);
        } else {
            readSerialData(obj, desc);
        }
        //執行到了這一步說明已經利用從磁盤裏讀取的數據成功創建了單例對象。
        handles.finish(passHandle);
        //注意這個if的最後一個判斷條件。
        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())
        {
            //只用單例類裏面創建了readResolve方法,纔會執行到這一步。
            Object rep = desc.invokeReadResolve(obj);
            if (unshared && rep.getClass().isArray()) {
                rep = cloneArray(rep);
            }
            if (rep != obj) {
                // Filter the replacement object
                if (rep != null) {
                    if (rep.getClass().isArray()) {
                        filterCheck(rep.getClass(), Array.getLength(rep));
                    } else {
                        filterCheck(rep.getClass(), -1);
                    }
                }
                //用readResolve返回的對象重新賦值給obj對象。
                handles.setObject(passHandle, obj = rep);
            }
        }

        return obj;
    }

補充 2、雙重檢查(DCL)機制失效問題

先附上DCL實現的單例模式(未加volatile關鍵詞)代碼以供查看:

public class Singleton {
    private static Singleton sInstance = null;

    private Singleton() {
    }

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

爲什麼會出現DCL失效問題呢?我們來分析下。假設線程A執行到sInstance = new Singleton();語句,這裏看起來是一句代碼,但實際上他並不是一個原子操作,這句代碼會被編譯成多條彙編指令(如何查看class的彙編代碼請看補充3),主要做下面三件事:

  1. Singleton的實例分配內存。
  2. 調用Singleton()的構造函數,初始化成員字段。
  3. sInstnce對象指向分配的內存空間(此時sInstance就不是null了)。

由於Java編譯器允許處理器亂序執行,以及JDK1.5之前JMM(Java Memory Model,即Java內存模型)中Cache、寄存器到主內存回寫順序的規定,上面的第二步驟和第三步驟是不能保證的,也就是說上述步驟的執行順序可能是1-2-3也可能是1-3-2。如果是後者,並且在3執行完畢、2未執行的情況下,sInstance已經非null,此時如果切換到B線程,由於sInstance已經非null,所以線程B會直接取走sInstance,再使用時候會出錯,因爲他的成員字段還未初始化,這就是DCL失效問題的原因。

那麼,該 如何解決 DCL失效問題呢?SUN官方在JDK1.5之後調整了JVM,具體化了volatile關鍵詞,因此在JDK1.5或者之後的版本中只要將sInstance的定義修改成private volatile static Singleton sInstance = null;就可以保證sInstance對象每次都是從主內存裏讀取,繼而解決了DCL失效的問題。

那麼,volatile解決這個問題的原理是什麼呢?原因是volatile的兩個特性:

  1. 保證了此變量的對所有線程的可見性,當一個線程修改了這個變量的值,新值對於其他線程是立即可見的。而普通的變量的值在線程間傳遞均需要通過主內存來完成,例如,線程A修改了一個普通變量的值,然後向主內存進行回寫,另外一個線程B在線程A回寫完成了之後再從主內存進行讀取操作,新變量值纔會對線程B可見。
  2. volatile禁止指令重排序優化,普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中執行的順序一致。通過查看彙編代碼會發現,加了volatile的時候,彙編代碼會多執行一個lock操作,這個操作相當於一個內存屏障(指重排序的時候不能把後面的指令重排序到內存屏障之前的位置),這個lock的作用是使得本CPU 的Cache寫入了主內存,相當於對Cache中的變量做了一次store和write操作,該寫入操作也會引起別的CPU或者別的內核無效化其Cache,通過這個操作,可以使得volatile變量的修改對其他CPU立即可見。

下面附上本篇博文中DCL實現單例模式的彙編代碼(如何查看class的彙編代碼請看補充3)
1、沒有加 volatile關鍵字
這裏寫圖片描述
2、加了 volatile關鍵字
這裏寫圖片描述

補充 3、查看class文件的彙編代碼

先說一下我的環境:
Open Jdk1.7
Ubantu 16.04
(其他環境請點這個鏈接或者這個鏈接)。

下面是詳細步驟

  1. 在單例模式代碼中加入main方法,裏面需要調用getInstance方法,完整代碼如下:
public class Singleton {
    private static volatile Singleton sInstance;

    private Singleton() {
    }

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

    public static void main(String[] args) {
        //這裏必須要調用getInstance方法,調用後纔會對其進行編譯,從而才能查看彙編代碼。
        Singleton.getInstance();
    }
}
  1. 終端執行sudo apt-get install libhsdis0-fcml,目的是下載缺少的庫。
  2. 終端執行java -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Singleton.getInstance -Xcomp Singleton > 1.txt,執行完這步,便可以在文件 1.txt 裏查看彙編代碼了(java -XX參數的設定可以參考這裏)。

學習資料:
- 《設計模式之禪》;
- 《Android 源碼設計模式解析與實戰》;
- 《深入理解Java虛擬機》;

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