天天看點

【設計模式】好好聊一聊單例模式

? 對于系統中的某些類來說,隻有一個執行個體很重要,例如,一個系統中可以存在多個列印任務,但是隻能有一個正在工作的任務;一個系統隻能有一個視窗管理器或檔案系統;一個系統隻能有一個計時工具或ID(序号)生成器。

如何保證一個類隻有一個執行個體并且這個執行個體易于被通路呢?定義一個全局變量可以確定對象随時都可以被通路,但不能防止我們執行個體化多個對象。一個更好的解決辦法是

讓類自身負責儲存它的唯一執行個體

。這個類可以保證沒有其他執行個體被建立,并且它可以提供個通路該執行個體的方法。這就是單例模式的模式動機。

單例模式是一種對象建立型模式,又名單件模式或單态模式。單例模式( Singleton Pattern)的定義如下:單例模式確定某一個類隻有一個執行個體,而且自行執行個體化并向整個系統提供這個執行個體,這個類稱為單例類,它提供全局通路的方法。

單例模式的要點有三個:一是

某個類隻能有一個執行個體

;二是

它必須自行建立這個執行個體

;三是

它必須自行向整個系統提供這個執行個體

單例模式包含如下角色:

  • Singleton:單例

? 我們下面來看一下它的實作,因為單例模式是最重要的一種設計模式,這裡要好好地說一下:

懶漢式寫法:

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
        if(lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}           

關鍵就是将構造器私有,限制隻能通過内部靜态方法來擷取一個執行個體。

但是這種寫法,很明顯不是線程安全的。如果多個線程在該類初始化之前,有大于一個線程調用了getinstance方法且lazySingleton == null 判斷條件都是正确的時候,這個時候就會導緻new出多個LazySingleton執行個體。可以這麼改一下:

這種寫法叫做DoubleCheck。針對類初始化之前多個線程進入 if(lazySingleton == null) 代碼塊中情況

這個時候加鎖控制,再次判斷 if(lazySingleton == null) ,如果條件成立則new出來一個執行個體,輪到其他的線程判斷的時候自然就就為假了,問題大緻解決。

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();           

執行的時候是這樣的:

1.配置設定記憶體給這個對象

2.初始化對象

3.設定LazyDoubleCheckSingleton指向剛配置設定的記憶體位址。

但是編譯優化後,可能是這種樣子

2 步驟 和 3 步驟一反,就出問題了。(前提條件,編譯器進行了編譯優化)

比如說有兩個線程,名字分别是線程1和線程2,線程1進入了 if(lazySingleton == null) 代碼塊,拿到了鎖,進行了

new LazyDoubleCheckSingleton()

的執行,在加載構造類的執行個體的時候,設定LazyDoubleCheckSingleton指向剛配置設定的記憶體位址,但是還沒有初始化對象。線程2判斷 if(lazySingleton == null) 為假,直接傳回了lazySingleton,又進行了使用,使用的時候就會出問題了。

畫兩張圖吧:

重排序的情況如下:

【設計模式】好好聊一聊單例模式

再看出問題的地方

【設計模式】好好聊一聊單例模式

當然這個很好改進,從禁用重排序方面下手,添加一個volatile。不熟悉線程安全可以參考這篇文章

【Java并發程式設計】線程安全性詳解
private volatile static LazyDoubleCheckSingleton lazySingleton = null;           

方法不止一種嘛,也可以利用對象初始化的“可見性”來解決,具體來說是利用靜态内部類基于類初始化的延遲加載,名字很長,但是了解起來并不困難。(使用這種方法,不必擔心上面編譯優化帶來的問題)

類初始化的延遲加載與JVM息息相關,我們示範的例子的

隻是被加載了而已,而沒有連結和初始化。

我們看一下實作方案:

定義一個靜态内部類,其靜态字段執行個體化了一個單例。擷取單例需要調用getInstance方法間接擷取。

public class StaticInnerClassSingleton {

    private static class InnerClass{
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

    public static StaticInnerClassSingleton getInstance() {
        return InnerClass.staticInnerClassSingleton;
    }
}           

如果對内部類不熟悉,可以參考這篇文章

【Java核心技術卷】深入了解Java的内部類
【設計模式】好好聊一聊單例模式

懶漢式的介紹就到這裡吧,下面再看看另外一種單例模式的實作

餓漢式

示範一下基本的寫法

public class HungrySingleton {

    // 類加載的時候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在靜态塊裡進行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}           

餓漢式在類加載的時候就完成單例的執行個體化,如果用不到這個類會造成記憶體資源的浪費,因為單例執行個體引用不可變,是以是線程安全的

同樣,上面的餓漢式寫法也是存在問題的

我們依次看一下:

首先是序列化破壞單例模式

先保證餓漢式能夠序列化,需要繼承Serializable 接口。

import java.io.Serializable;

public class HungrySingleton implements Serializable {

    // 類加載的時候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在靜态塊裡進行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}           

我們測試一下:

package com.example.demo.example.count.singleton;

import lombok.extern.slf4j.Slf4j;

import java.io.*;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
       HungrySingleton hungrySingleton = HungrySingleton.getInstance();
       ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
       oos.writeObject(hungrySingleton);

       File file = new File("singleton");
       ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject();

        log.info("結果 {}",hungrySingleton);
        log.info("結果 {}",newHungrySingleton);
        log.info("對比結果 {}",hungrySingleton == newHungrySingleton);
    }
}           

結果:

【設計模式】好好聊一聊單例模式

結果發現對象不一樣,原因就涉及到序列化的底層原因了,我們先看解決方式:

餓漢式代碼中添加下面這段代碼

private Object readResolve() {
        return hungrySingleton;
    }           

重新運作,這個時候的結果:

【設計模式】好好聊一聊單例模式

原因出在readResolve方法上,下面去ObjectInputStream源碼部分找找原因。(裡面都涉及到底層實作,不要指望看懂)

在一個讀取底層資料的方法上有一段描述

就是序列化的Object類中可能定義有一個readResolve方法。我們在二進制資料讀取的方法中看到了是否判斷

【設計模式】好好聊一聊單例模式

private Object readOrdinaryObject()方法中有這段代碼,如果存在ReadResolve方法,就去調用。不存在,不調用。聯想到我們在餓漢式添加的代碼,大緻能猜到怎麼回事了吧。

【設計模式】好好聊一聊單例模式

另外一種情況就是反射攻擊破壞單例

示範一下

package com.example.demo.example.count.singleton;

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;

        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true); // 強行打開構造器權限
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        log.info("結果{}",instance);
        log.info("結果{}",newInstance);
        log.info("比較結果{}",newInstance == instance);
    }
}           
【設計模式】好好聊一聊單例模式

這裡強行破開了private的構造方法的權限,使得能new出來一個單例執行個體,這不是我們想看到的。

解決方法是在構造方法中抛出異常

private HungrySingleton() {
        if( hungrySingleton != null) {
            throw new RuntimeException("單例構造器禁止反射調用");
        }
    }           

這個時候再運作一下

【設計模式】好好聊一聊單例模式

其實對于懶漢式也是有反射破壞單例的問題的,也可以采用類似抛出異常的方法來解決。

餓漢式單例與懶漢式單例類比較

  • 餓漢式單例類在自己被加載時就将自己執行個體化。單從資源利用效率角度來講,這個比懶漢式單例類稍差些。從速度和反應時間角度來講,則比懶漢式單例類稍好些。
  • 懶漢式單例類在執行個體化時,必須處理好在多個線程同時首次引用此類時的通路限制問題,特别是當單例類作為資源控制器在執行個體化時必然涉及資源初始化,而資源初始化很有可能耗費大量時間,這意味着出現多線程同時首次引用此類的機率變得較大,需要通過同步化機制進行控制。

? 看一下單例模式的優缺點:

單例模式的優點

  • 提供了對唯一執行個體的受控通路。因為單例類封裝了它的唯一執行個體,是以它可以嚴格控制客戶怎樣以及何時通路它,并為設計及開發團隊提供了共享的概念。
  • 由于在系統記憶體中隻存在一個對象,是以可以節約系統資源,對于一些需要頻繁建立和銷毀的對象,單例模式無疑可以提高系統的性能。
  • 允許可變數目的執行個體。我們可以基于單例模式進行擴充,使用與單例控制相似的方法來獲得指定個數的對象執行個體

單例模式的缺點

  • 由于單例模式中沒有抽象層,是以單例類的擴充有很大的困難。
  • 單例類的職責過重,在一定程度上違背了“單一職責原則”。因為單例類既充當了工廠角色,提供了工廠方法,同時又充當了産角色包含一些業務方法,将産品的建立和産品的本身的功能融合到一起。
  • 濫用單例将帶來一些負面問題,如為了節省資源将資料庫連接配接池對象設計為單例類,可能會導緻共享連接配接池對象的程式過多而出現連接配接池溢出;現在很多面向對象語言(如Java、C#)的運作環境都提供了自動垃圾回收的技術,是以,如果執行個體化的對象長時間不被利用,系統會認為它是垃圾,會自動銷毀并回收資源,下次利用時又将重新執行個體化,這将導緻對象狀态的丢失。

? 在以下情況下可以使用單例模式:

  • 系統隻需要一個執行個體對象

    ,如系統要求提供一個唯一的序列号生成器,或者需要考慮資源消耗太大而隻允許建立一個對象。
  • 客戶調用類的單個執行個體

    隻允許使用一個公共通路點

    ,除了該公共通路點,不能通過其他途徑通路該執行個體。
  • 在一個系統中要求一個類隻有一個執行個體時才應當使用單例模式反過來,如果一個類可以有幾個執行個體共存,就需要對單例模式進行改進,使之成為多例模式。