Effective Java學習筆記(三)單例模式

本文對應原書條目3,原書僅僅提到了如何實現單例模式,本文想在此基礎上作必定的拓展,力求較爲全面地介紹單例模式,探討單例模式的應用場景、優缺點及多種實現方式,以及如何防範序列化和反射致使的安全性問題。若有問題或建議,歡迎指教,謝謝~html

什麼是單例模式

單例模式是一個只會被實例化一次的類,它會自行實例化,並提供可全局訪問的方法。java

單例模式的適用場景

  • 一個系統中只須要存在一個的對象,例如文件管理器
  • 須要頻繁適用但建立成本過高的對象,如數據庫的鏈接

單例模式的實現方式

有三種實現單例的方式,公共靜態不可變成員、靜態工廠方法和枚舉。前兩種比較相似,都是經過私有構造方法+公共靜態成員的方式提供單例。而第三種枚舉的方式是在Java1.5之後引入的,事實上咱們在後面會看到這是Java語言實現單例的最佳實踐。程序員

公共靜態不可變成員

這種方式的具體實現以下:數據庫

public class Singleton {
    public static final Singleton INSTANCE = new Singleton();

    private Singleton() {}
}

這種方式實現起來比較簡單,並且能夠清楚地標明這是個單例類,可是缺點正如第一篇學習筆記中提到的,對它的訪問不如靜態工廠方法來得清晰,因此就有了下面使用靜態工廠方法實現單例的方式。設計模式

靜態工廠方法

咱們先來看看最典型的靜態工廠方法實現的單例。數組

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

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

靜態工廠方法實現單例有幾個好處[1]。首先,它具有靈活性,在不改變對外發布的API的前提下,咱們能夠改變它內部的實現,好比從單例變成非單例,或是每一個線程一個單例。其次,它的可擴展性強,可使用泛型單例工廠的方式提供單例的訪問(這個咱們之後再討論)。最後是它的便利性,能夠支持方法引用,像Singleton::instance這樣。安全

使用靜態工廠方法實現單例有不少種玩法[2],上面那種被稱爲餓漢式,它的優勢是線程安全、便於使用;缺點是應用初始化時較慢,若是這個單例對象一直沒有使用,會浪費內存空間。多線程

下面是它的一個變種:併發

public class Singleton {
    private static final Singleton INSTANCE;

    static {
        // 一些前置操做
        INSTANCE = new Singleton();
    }

    private Singleton() {}

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

這種變種其實就是把單例對象的初始化過程放到了靜態代碼塊中,優缺點同上。我理解主要適用於實例化單例類須要一些前置操做的狀況。性能

除此以外,還有其餘的寫法——

1. 懶漢式

public class Singleton {
    private static final Singleton INSTANCE;

    private Singleton() {}

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

懶漢式將初始化單例變量的時機放在了第一次調用的時候(懶加載),這樣作的優勢在於能夠加快啓動速度,且不會像餓漢式那樣形成可能的內存空間浪費,可是缺點在於沒法保證線程安全性。

2. 懶漢式變種

public class Singleton {
    private static final Singleton INSTANCE;

    private Singleton() {}

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

這一變種形式的優勢同上,並解決了上面的線程不安全問題,可是缺點在於對getInstance()方法進行了同步,併發性能較差。

3. 雙重檢查鎖

public class Singleton {
    // 這裏加了volatile關鍵字修飾
    private static volatile final Singleton INSTANCE;

    private Singleton() {}

    public static Singleton getInstance() {
        // 雙重檢查
        if (INSTANCE == null) {
            synchronized(Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

這種方式的優勢是在保證線程安全的前提下提升了多線程訪問的性能。由於採用了volatile關鍵字+代碼塊加鎖+兩次是否null檢查,當一個線程初始化了INSTANCE後,其餘線程立刻可見了。它的缺點是實現起來比較複雜。

4.靜態內部類

public class Singleton {

    private Singleton() {}

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

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

這種方式本質上也是懶加載的,擁有懶加載方式的優勢。它採用類加載的機制實現懶加載和保證線程安全,只有第一次調用getInstance()方法的時候纔會裝載內部類SingletonHolder

5.枚舉

public enum Singleton {
        INSTANCE;
        public void yourOwnMethod() {}
    }

你或許會以爲枚舉這種方式很奇怪,可是它事實上兼具了上述全部的優勢,加載效率高,併發性能好,並且易於編寫。而且在後面咱們還能夠看到,它的安全性也很是高,不須要咱們採起額外的防範。

單例模式的安全問題

有一些手段可以破壞類的單例模式,好比經過序列化反射的方式。

序列化破壞單例

Java語言的序列化主要依靠ObjectOutputStreamObjectInputStream這兩個類。前者負責將對象序列化爲二進制數組,然後者負責反序列化。經過ObjectOutputStreamwriteObject()方法將單例對象寫入外部文件,再經過ObjectInputStreamreadObject()方法從外部讀取一個二進制數組進來寫入單例中,這個時候單例就成了另一個對象了。以下面的代碼所示[2]

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton singleton1 = Singleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_bin_file"));
        oos.writeObject(singleton1); // 序列化
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_bin_file"));
        Singleton singleton2 = (Singleton) ois.readObject(); // 反序列化
        System.out.println(singleton1 == singleton2); // 會返回false
    }
}

爲了防止被這種方式攻擊,咱們能夠在單例類中加入readResolve()方法。以下所示:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

    private Object readResolve() {
        return INSTANCE;
    }

}

爲何這樣可行呢?由於Java的序列化機制在容許類本身實現一個readResolve()方法,在ObjectInputStream執行了readObject()以後,若是存在readResolve()方法,則會調用,並對readObject()的結果進行處理,以後做爲最終的結果返回。像咱們上面那樣在readResolve()中返回了本來的INSTANCE,這樣就能保證不會因readObject()生成新的對象,從而確保了單例機制不被破壞[2]

另外,若是單例中有成員變量,應當聲明爲transient類型[1],這樣,在序列化的時候會跳過這個字段,而反序列化時會得到一個默認值或者null。我理解這樣作的目的是保護單例的成員變量,不讓它們泄露出去,也不會被亂賦值。沒有值總比被賦了錯值要好。

反射破壞單例

反射對單例的破壞主要是經過調用成員變量或者構造方法的setAccessible()方法,來訪問本來private的變量或者方法,從而破壞了單例模式。

對反射攻擊的防護能夠經過在構造方法中增長校驗的方式實現,以下所示:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
        if (INSTANCE != null) {
            throw new RuntimeException("INSTANCE already exists!");
        }
    }

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

這種方式只對餓漢式單例實現有效,而對懶漢式無效。由於前者的單例在類加載時即被初始化了,類加載的時機必定是在反射前的;然後者則是在getInstance()被調用時才初始化單例,不能保證在反射以前執行。[2]

最好的單例模式實現

不管是經過公共靜態不可變成員仍是靜態工廠方法來實現單例,都有缺陷,須要程序員本身去保證性能和安全。然而,正如前面所看到的,還有一種更好的方式來實現單例,那就是枚舉

public enum Singleton {

        INSTANCE;

        private String yourOwnField;

        public String getYourOwnField() {
            return yourOwnField;
        }

        public void setYourOwnField(String yourOwnField) {
            this.yourOwnField = yourOwnField;
        }

        public void yourOwnMethod() {}
    }

枚舉有以下幾個優勢[2]

  • 寫法簡單
  • 線程安全:編譯成class文件後的枚舉類中,INSTANCE變量會被public static final修飾,而靜態變量會在類加載時被初始化,所以JVM會保證其線程安全性。
  • 懶加載:JVM會在類被引用到的時候纔去加載它,因此枚舉自帶懶加載效果
  • 避免序列化攻擊:在序列化枚舉類型時,Java僅會序列化枚舉對象的name,而後在反序列化時根據這個name獲得具體的枚舉對象,因此是能夠自然防護序列化攻擊的。
  • 避免反射攻擊:反射不容許建立枚舉對象
序列化、反射和枚舉這幾部分參考資料[2]中講得很透徹,建議你們閱讀下~

總結

單例模式提供了對某一對象的受控訪問,適用於不少場景。用枚舉來實現單例是最好的方式。下面是單例模式的優缺點[2][3]

優勢

  • 節省頻繁建立和銷燬對象的性能開銷
  • 實現對某些臨界資源的單一受控訪問

缺點

  • 單例機制沒法被繼承
  • 違背了單一職責原則,單例類既要維護單例邏輯,又要實現其餘內部邏輯
  • 當一個單例對象長期未被訪問,可能會被GC,這樣一些共享數據就丟失了
小小的感慨:雖然Effective Java上面這個條目的內容很是少,可是本身去深挖之後發現竟然有這麼多值得研究的東西。我的感受書上講得仍是太簡單,好多地方都沒有講透,也沒有相應的例子。仍是得靠本身多搜索資料,多思考,才能吃透一塊知識。

聲明

本文僅用於學習交流,請勿用於商業用途。轉載請註明出處,謝謝。

參考資料

  1. 《Effective Java(第3版)》
  2. 設計模式 | 單例模式及典型應用 https://www.jianshu.com/p/8f6...
  3. 單例模式 https://www.runoob.com/design...
相關文章
相關標籤/搜索