徹底搞懂設計模式之—單例模式

單例簡介

單例模式(Singleton Pattern),是一種創建型設計模式。它的定義爲:保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。它適用於以下場景:

  • 需要頻繁的進行創建和銷燬的對象;
  • 創建對象時耗時過多或耗費資源過多,但又經常用到的對象;
  • 工具類對象;
  • 頻繁訪問數據庫或文件的對象。

一般情況下我們會以爲單例模式是GoF設計模式中最簡單的,但實際上想要真正的寫好一個適合自己系統的單例模式,還是需要一番考量的。比較經典的單例模式UML圖如下:
經典單例

從UML圖來看,單例模式是最簡單的設計模式。需要注意的點

  • 私有的成員變量

  • 私有的無參構造器

  • 公有的獲取單例的靜態方法。

一般情況下單例模式分爲懶漢式和餓漢式兩種類型,但在這兩種類型中又有很多種寫法,並且除了常見的這兩種類型還有一些我們平常想不到的類型。接下來將跟大家一起深入淺出這個單例大家族。

單例的9種寫法及優缺點

餓漢式(均可用)

餓漢式單例顧名思義,就是漢子比較餓,得先給他吃的才能幹活。也就是在應用啓動的時候,就已經實例化好了。具體有下面的兩種實現方式:

靜態代碼塊實現
public class HungrySingleton {
    private HungrySingleton(){
    }
    private final static HungrySingleton hungrySingleton;
    static{
        hungrySingleton = new HungrySingleton();
    }
    public static HungrySingleton getInstances(){
        return hungrySingleton;
    }
}
靜態常量實現
public class HungrySingleton{
    private HungrySingleton(){
    }
    private final static HungrySingleton hungrySingleton = new HungrySingleton();
    public static HungrySingleton getInstance(){
        return hungrySingleton;
    }
}

優點:類加載的時候就已經完成了初始化,調用方不用等待即可使用。在內存中也不存在線程安全問題。

缺點:因爲沒有lazy-loading的效果,如果該單例在應用當中從未被使用過,那就會造成內存的浪費現象。

懶漢式

懶漢式單例,顧名思義,這個漢子因爲吃飽了比較懶,你只有在叫他的時候他才幹活。應用在初始化完畢之後,懶漢式單例並不會初始化到內存中,只有第一次使用纔會進行初始化。

經典餓漢式(不可用)
public class LazySingleton {
    private static LazySingleton lazySingleton=null;
    private LazySingleton(){}
    public static LazySingleton getInstance(){
        if (lazySingleton==null){
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

優點:寫法簡單,適合單線程。

缺點:在併發情況下拿到的實例就不一定是同一個了,這樣也就失去了單例的效果。

因爲在第1個線程執行if (lazySingleton==null)的時候,第二個或者第N個線程也有可能同樣執行到了這一句,造成多個線程拿到多種實例的問題(可以在IDEA中通過多線程debug的方式明顯的看出來)。那是不是在執行該語句的時候加鎖就能解決呢?也就是下面的這種寫法:

經典加鎖懶漢式(可用,不推薦)
public class LazySingleton {
    private static LazySingleton lazySingleton=null;
    private LazySingleton(){}
    public synchronized static LazySingleton getInstance(){
        if (lazySingleton==null){
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

通過使用同步鎖synchronized爲getInstance()方法加鎖,但是因爲getInstance()是靜態方法,那麼synchronized就相當於鎖住了整個LazySingleton類,所有的其他線程在使用getInstance()方法的時候必須等待第1個使用getInstance()方法的線程釋放鎖。它等效於下面的這種寫法:

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

如果將加鎖的語句寫在了if (lazySingleton==null)這個判斷裏面,由於第1個線程在執行加鎖之前,第2個或者第N個線程也進入到了getInstance()方法的if (lazySingleton==null)判斷中,導致加鎖失去意義,這點需要注意。

由於使用synchronized有加鎖和解鎖的開銷,比較消耗資源。而且是對單例類的加鎖,範圍較大,對性能會造成一定影響。那如何去取得性能和多線程實例之間的平衡呢?接下來就是double-check模式的懶漢式單例登場了:

雙重檢查懶漢式(可用,推薦)

在synchronized同步鎖的外部我們加一個實例是否爲空的判斷,就可以有一定的概率降低鎖的使用頻率。寫法如下:

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

注意:上面的寫法有個坑,在執行到lazySingleton = new LazyDoubleCheckSingleton()這一行的時候,JVM的操作具體有以下幾個步驟:

① 爲即將產生的對象分配內存,併產生特定的內存地址

② 初始化對象

③ 將初始化的對象lazySingleton指向第①步中的內存地址

由於JVM爲了提高性能,存在指令重排序的功能,第②和第③個步驟可能會進行調換。也就是說當第1個線程在執行創建對象的時候,如果第2個線程也進入到了if (lazySingleton==null)這一行,那麼lazySingleton會被認爲不爲空的,就會直接返回lazySingleton。但實際上這個實例還並沒有創建好,第2個線程在使用這個實例的時候會產生問題。那如何解決這個問題呢?有兩種思路:

​ 第1種就是禁止這種重排序;

​ 第2種就是將重排序的過程對線程的訪問隔離開來(參見靜態內部類懶漢式)。

首先看一下第1種寫法,使用volatile修飾這個靜態變量,禁止重排:

public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazySingleton=null;
    private LazyDoubleCheckSingleton(){}
    public static LazyDoubleCheckSingleton getInstance(){
        if (lazySingleton==null){
            synchronized (LazyDoubleCheckSingleton.class){
                if (lazySingleton==null){
                    lazySingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazySingleton;
    }
}

優點:能夠實現懶漢式所要求的延遲加載功能,也是線程安全的,同時能夠降低鎖定整個類帶來的性能開銷。

靜態內部類懶漢式(可用,推薦)

通過靜態內部類的方法將重排序的過程對線程的訪問隔離開來:

public class LazyStaticInnerClassSingleton {
    private LazyStaticInnerClassSingleton(){}
    private static class InnerClass{
        private static LazyStaticInnerClassSingleton singleton = new LazyStaticInnerClassSingleton();
    }
    public static LazyStaticInnerClassSingleton getInstance(){
        return InnerClass.singleton;
    }
}

上面的寫法原理跟上面提到的**經典加鎖餓漢式(可用,不推薦)**類似,使用靜態內部類InnerClass也具有lazy-loading的效果,因爲JVM初始化LazyStaticInnerClassSingleton的時候InnerClass並沒有被裝載,當第1個線程調用InnerClass的時候纔會進行裝載,而在裝載的時候會對InnerClass進行加鎖,其餘的線程均需等待。當InnerClass中的靜態變量singleton實例化完畢,鎖纔會釋放,這樣也就保證了線程的安全性。

容器單例(根據實際情況)

我們在實際應用當中,還會碰到一種集中式的單例:容器單例。當應用進行初始化的時候,將多種需要單例化的實例都放到一個容器當中。示例代碼如下:

public class ContainerSingleton {
    private ContainerSingleton(){

    }
    private static Map<String,Object> singletonMap = new HashMap<String,Object>();
    public static void putInstance(String key,Object instance){
        if(StringUtils.isNotBlank(key) && instance != null){
            if(!singletonMap.containsKey(key)){
                singletonMap.put(key,instance);
            }
        }
    }
    public static Object getInstance(String key){
        return singletonMap.get(key);
    }
}

由於HashMap不是線程安全的,上面的容器單例不適合於多線程。可以使用HashTable代替,但由於HashTable每次存取都會使用同步鎖,會有性能損耗。替代方案是使用ConcurrentHashMap。

在容器單例中有一種變種”單例“,它不是多線程安全的,但對每個獨立的線程來說是安全的。比如線程A每次都能獲取到單例1,線程B每次都會獲取到單例2。不會出現線程A獲取到單例2或者線程B獲取到單例1的情況。實現方案就是使用ThreadLocal:

public class ThreadLocalInstance {
    private static final ThreadLocal<ThreadLocalInstance> threadLocalInstanceThreadLocal
            = new ThreadLocal<ThreadLocalInstance>(){
        @Override
        protected ThreadLocalInstance initialValue() {
            return new ThreadLocalInstance();
        }
    };
    private ThreadLocalInstance(){

    }
    public static ThreadLocalInstance getInstance(){
        return threadLocalInstanceThreadLocal.get();
    }

}

ThreadLocal對每個線程進行了鎖定,隔離了多個線程對數據之間的訪問衝突,線程之間獲取的數據不會相互干擾,比加鎖的方式要好一些。它不能夠保證全局唯一,但能保證線程唯一。比如在org.apache.ibatis.executor.ErrorContext中,就使用了ThreadLocal。因爲每個線程都保留了各自的錯誤上下文,所以在單個線程中產生錯誤的時候,不會出現在其他線程當中。如果都同步鎖synchronized因爲要排隊,其實就是以時間換空間的方式。而Threadlocal其實就是以空間換時間的方式了。

我們常說的Spring容器跟這個又不太一樣,容器中的Bean跟容器(ApplicationContext)密切相關,因爲Spring容器的構造方法不是私有的,如果實例化了多個容器,那麼Bean也會有多種實例。跟Bean的作用域是Singleton還是Prototype沒有關係。如下面的例子:

//  第一個Spring Bean容器
ApplicationContext context_1 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
UserBean1 userBean_1 = context_1.getBean("userBean1", UserBean1.class);
//  第二個Spring Bean容器
ApplicationContext context_2 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
UserBean1 userBean_2 = context_2.getBean("userBean1", UserBean1.class);
//  這裏絕對不會相等,因爲創建了多個實例
System.out.println(userBean_1 == userBean_2);

結果爲:false。

對單例的破壞

上面討論的幾種餓漢式和懶漢式中可用的單例看似沒有問題,但在以下兩種情況中還是會存在拿到多個實例問題,因爲它們均會破壞單例。

序列化與反序列化

這裏使用上面的餓漢式單例作爲演示,序列化需要實現Serializable接口,代碼示例如下:

HungrySingleton instance = HungrySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
oos.writeObject(instance);

File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton serilizeInstance = (HungrySingleton)ois.readObject();

System.out.println(instance == serilizeInstance);

輸出結果爲:false。問題出在ois.readObject()方法中。原因在於該方法調用的方法java.io.ObjectInputStream.readOrdinaryObject中,返值之前會調用java.io.ObjectStreamClass.isInstantiable方法進行判斷是否通過反射產生一個新的對象,其判斷依據就是實例是否實現了序列化。在這裏HungrySingleTon肯定實現了序列化接口,導致產生新的實例。如果我們不做處理,就會返回新產生的實例。當產生新的實例之後,會在下面有個判斷,原來的實例中是否有readResolve()這個方法,如果有,則返回原來的實例。感興趣的讀者可以跟着源碼走一遍。

那麼解決方法就是在HungrySingleton類中添加這個readResolve()方法:

public class HungrySingleton implements Serializable {
    private HungrySingleton(){
    }
    private final static HungrySingleton hungrySingleton = new HungrySingleton();
    public static HungrySingleton getInstance(){
        return hungrySingleton;
    }

    /**添加該方法可以防止反序列化造成的單例失效問題*/
    private Object readResolve(){
        return hungrySingleton;
    }
}

通過上面的分析我們知道,序列化和反序列化的過程中還是會生成一個新的實例,但不會被用上。我們最終拿到的還是原來的實例。所以,對於單例模式,最好不要使用序列化和反序列化,容易造成資源的浪費。

反射

其實通過上面的序列化與反序列化對單例的破壞分析過程就能看出來,萬能的反射可以通過無參構造器獲得一個新的實例來破壞單例。我們也可以自己寫一段代碼驗證:

HungrySingleton instance = HungrySingleton.getInstance();
Class classObject =HungrySingleton.class;
Constructor constructor = classObject.getDeclaredConstructor();
//私有構造器的權限給放開
constructor.setAccessible(true);
HungrySingleton newHungryInstance = (HungrySingleton)constructor.newInstance();
System.out.println(instance==newHungryInstance);

結果不用猜想了:false。那如何解決呢?在使用構造器反射出一個新的HungrySingleton的時候,加一個是否爲空的判斷不就行了?

private HungrySingleton(){
    if (hungrySingleton!=null){
        throw new RuntimeException("私有構造器禁止反射!");
    }
}

測試結果達到了我們想要的結果,禁止了反射。那麼,序列化和反序列化也將不會成功。這個方法適用於餓漢式單例,那對於懶漢式單例是否會生效呢?答案是不一定。如下例:

if (lazySingleton!=null){
    throw new RuntimeException("私有構造器禁止反射!");
}

Class clazz = LazySingleton.class;
Constructor lazyConstructor = clazz.getDeclaredConstructor();
lazyConstructor.setAccessible(true);
LazySingleton reflectLazySingleton = (LazySingleton)lazyConstructor.newInstance();

LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(lazySingleton==reflectLazySingleton);

輸出結果:fasle。爲什麼呢?因爲反射先於懶漢調用,這個時候單例還沒有實例化呢。當然,如果將上面的順序反過來那就能禁止了,但這種先後順序我們控制不了。如果我們在懶漢式單例中定義一個私有成員變量flag,然後根據flag進行判斷是否禁止反射呢?答案是不行。因爲反射也可以通過setAccessible拿到私有化的成員變量。那麼如何徹底解決這個問題呢?答案是:枚舉單例。

枚舉單例(可用,推薦)

public enum EnumSingleton {
    INSTANCE;
    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
}

枚舉是我們常用的一種類型,但很少人會想到使用它作爲單例。其實枚舉作爲單例不但能夠解決序列化與反序列化的問題,還能解決反射的問題,同時枚舉也是線程安全的。因爲在jdk源碼中,枚舉根本沒有無參私有構造器,而且會有個判斷:如果是枚舉類,則不創建新的實例。加上jdk對枚舉的序列化和反射都進行了特殊的處理,從而保證單例的唯一性。通過jad反編譯查看:

public final class EnumSingleton extends Enum
{

    public static EnumSingleton[] values()
    {
        return (EnumSingleton[])$VALUES.clone();
    }

    public static EnumSingleton valueOf(String name)
    {
        return (EnumSingleton)Enum.valueOf(com/lc/test/designpattern/singleton/EnumSingleton, name);
    }

    private EnumSingleton(String s, int i)
    {
        super(s, i);
    }

    public static EnumSingleton getInstance()
    {
        return INSTANCE;
    }

    public static final EnumSingleton INSTANCE;
    private static final EnumSingleton $VALUES[];

    static 
    {
        INSTANCE = new EnumSingleton("INSTANCE", 0);
        $VALUES = (new EnumSingleton[] {
            INSTANCE
        });
    }
}

可以看出,枚舉是通過靜態代碼塊形成的餓漢式單例。枚舉中其實也支持方法,例如在下例中:

public enum EnumSingleton {
    INSTANCE{
        @Override
        protected void testPrint(){
            System.out.println("this is lc print!");
        }
    };

    protected abstract void testPrint();
    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
}

可以看出,枚舉也能做到其他單例中的事情。需要注意的是:枚舉單例中寫的方法必須是抽象的且至少是protected級別,在INSTANCE內部重寫這個方法,否則外部是無法調到INSTANCE中的方法的。

枚舉單例的優點:真正的實現了多線程安全,防止了序列化反序列化和反射對單例的破壞。確保了內存中該類只存在一個對象,節省了系統資源。枚舉單例,寫起來還是比較優雅的。

缺點嘛,就是實例化一個單例類的時候,必須要記住使用相應的獲取對象的方法,而不是使用new,可能會給其他開發人員造成困擾,特別是看不到源碼的時候。不過這個不是啥大問題,使用文檔說明一下即可。

總結

通過對以上種種單例的分析過程,我們對單例有了一個比較全面的認識。在實際當中,需要根據系統的實際情況來選擇到底使用哪一種。當然,本文也只是一家之言,如有錯誤和紕漏之處,還請大家批評指正,也歡迎大家補充和說明。

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