首頁>技術>

前言

單例模式可以說是設計模式中最簡單和最基礎的一種設計模式了,哪怕是一個初級開發,在被問到使用過哪些設計模式的時候,估計多數會說單例模式。但是你認為這麼基本的”單例模式“真的就那麼簡單嗎?或許你會反問:「一個簡單的單例模式該是怎樣的?」哈哈,話不多說,讓我們一起拭目以待,堅持看完,相信你一定會有收穫!

餓漢式

餓漢式是最常見的也是最不需要考慮太多的單例模式,因為他不存線上程安全問題,餓漢式也就是在類被載入的時候就建立例項物件。餓漢式的寫法如下:

public class SingletonHungry {    private static SingletonHungry instance = new SingletonHungry();    private SingletonHungry() {    }    private static SingletonHungry getInstance() {        return instance;    }}
測試程式碼如下:
class A {    public static void main(String[] args) {        IntStream.rangeClosed(1, 5)                .forEach(i -> {                    new Thread(                            () -> {                                SingletonHungry instance = SingletonHungry.getInstance();                                System.out.println("instance = " + instance);                            }                    ).start();                });    }}

結果

優點:執行緒安全,不需要關心併發問題,寫法也是最簡單的。

缺點:在類被載入的時候物件就會被建立,也就是說不管你是不是用到該物件,此物件都會被建立,浪費記憶體空間

懶漢式

以下是最基本的餓漢式的寫法,在單執行緒情況下,這種方式是非常完美的,但是我們實際程式執行基本都不可能是單執行緒的,所以這種寫法必定會存線上程安全問題

public class SingletonLazy {    private SingletonLazy() {    }    private static SingletonLazy instance = null;    public static SingletonLazy getInstance() {        if (null == instance) {            return new SingletonLazy();        }        return instance;    }}

演示多執行緒執行

class B {    public static void main(String[] args) {        IntStream.rangeClosed(1, 5)                .forEach(i -> {                    new Thread(                            () -> {                                SingletonLazy instance = SingletonLazy.getInstance();                                System.out.println("instance = " + instance);                            }                    ).start();                });    }}

結果

結果很顯然,獲取的例項物件不是單例的。也就是說這種寫法不是執行緒安全的,也就不能在多執行緒情況下使用

DCL(雙重檢查鎖式)

DCL 即 Double Check Lock 就是在建立例項的時候進行雙重檢查,首先檢查例項物件是否為空,如果不為空將當前類上鎖,然後再判斷一次該例項是否為空,如果仍然為空就建立該是例項;程式碼如下:

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

測試程式碼如下:

class C {    public static void main(String[] args) {        IntStream.rangeClosed(1, 5)                .forEach(i -> {                    new Thread(                            () -> {                                SingleTonDcl instance = SingleTonDcl.getInstance();                                System.out.println("instance = " + instance);                            }                    ).start();                });    }}

結果

相信大多數初學者在接觸到這種寫法的時候已經感覺是「高大上」了,首先是判斷例項物件是否為空,如果為空那麼就將該物件的 Class 作為鎖,這樣保證同一時刻只能有一個執行緒進行訪問,然後再次判斷例項物件是否為空,最後才會真正的去初始化建立該例項物件。一切看起來似乎已經沒有破綻,但是當你學過JVM後你可能就會一眼看出貓膩了。沒錯,問題就在 instance = new SingleTonDcl(); 因為這不是一個原子的操作,這句話的執行是在 JVM 層面分以下三步:

1.給 SingleTonDcl 分配記憶體空間 2.初始化 SingleTonDcl 例項 3.將 instance 物件指向分配的記憶體空間( instance 為 null 了)

正常情況下上面三步是順序執行的,但是實際上JVM可能會「自作多情」得將我們的程式碼進行最佳化,可能執行的順序是1、3、2,如下程式碼所示

public static SingleTonDcl getInstance() {    if (null == instance) {        synchronized (SingleTonDcl.class) {            if (null == instance) {                1. 給 SingleTonDcl 分配記憶體空間                3.將 instance 物件指向分配的記憶體空間( instance 不為 null 了)                2. 初始化 SingleTonDcl 例項            }        }    }    return instance;}

假設現在有兩個執行緒 t1, t2

如果 t1 執行到以上步驟 3 被掛起然後 t2 進入了 getInstance 方法,由於 t1 執行了步驟 3,此時的 instance 已經不為空了,所以 if (null == instance) 這個條件不為空,直接返回 instance, 但由於 t1 還未執行步驟 2,導致此時的 instance 實際上是個半成品,會導致不可預知的風險!

該怎麼解決呢,既然問題出在指令有可能重排序上,不讓它重排序不就行了,volatile 不就是幹這事的嗎,我們可以在 instance 變數前面加上一個 volatile 修飾符

畫外音:volatile 的作用1.保證的物件記憶體可見性2.防止指令重排序

最佳化後的程式碼如下

public class SingleTonDcl {    private SingleTonDcl() {    }    //在物件前面新增 volatile 關鍵字即可    volatile private static SingleTonDcl instance = null;    public static SingleTonDcl getInstance() {        if (null == instance) {            synchronized (SingleTonDcl.class) {                if (null == instance) {                    instance = new SingleTonDcl();                }            }        }        return instance;    }}

到這裡似乎問題已經解決了,雙重鎖機制 + volatile 實際上確實基本上解決了執行緒安全問題,保證了“真正”的單例。但真的是這樣的嗎?繼續往下看

靜態內部類

先看程式碼

public class SingleTonStaticInnerClass {    private SingleTonStaticInnerClass() {    }    private static class HandlerInstance {        private static SingleTonStaticInnerClass instance = new SingleTonStaticInnerClass();    }    public static SingleTonStaticInnerClass getInstance() {        return HandlerInstance.instance;    }}
測試程式碼如下:
class D {    public static void main(String[] args) {        IntStream.rangeClosed(1, 5)                .forEach(i->{                    new Thread(()->{                        SingleTonStaticInnerClass instance = SingleTonStaticInnerClass.getInstance();                        System.out.println("instance = " + instance);                    }).start();                });    }}

靜態內部類的特點:

這種寫法使用 JVM 類載入機制保證了執行緒安全問題;由於 SingleTonStaticInnerClass 是私有的,除了 getInstance() 之外沒有辦法訪問它,因此它是懶漢式的;同時讀取例項的時候不會進行同步,沒有效能缺陷;也不依賴 JDK 版本;

但是,它依舊不是完美的。

不安全的單例

上面實現單例都不是完美的,主要有兩個原因

1. 反射攻擊

首先要提到 java 種讓人又愛又恨的反射機制, 閒言少敘,我們直接邊上程式碼邊說明,這裡就以 DCL 舉例(為什麼選擇 DCL 因為很多人覺得 DCL 寫法是最高大上的....這裡就開始去”打他們的臉“)

將上面的 DCl 的測試程式碼修改如下:

class C {    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {        Class<SingleTonDcl> singleTonDclClass = SingleTonDcl.class;        //獲取類的構造器        Constructor<SingleTonDcl> constructor = singleTonDclClass.getDeclaredConstructor();        //把構造器私有許可權放開        constructor.setAccessible(true);        //反射建立例項   注意反射建立要放在前面,才會攻擊成功,因為如果反射攻擊在後面,先使用正常的方式建立例項的話,在構造器中判斷是可以防止反射攻擊、丟擲異常的,        //因為先使用正常的方式已經建立了例項,會進入if        SingleTonDcl instance = constructor.newInstance();        //正常的獲取例項方式   正常的方式放在反射建立例項後面,這樣當反射建立成功後,單例物件中的引用其實還是空的,反射攻擊才能成功        SingleTonDcl instance1 = SingleTonDcl.getInstance();        System.out.println("instance1 = " + instance1);        System.out.println("instance = " + instance);    }}

居然是兩個物件!內心是不是異常平靜?果然和你想的不一樣?其他的方式基本類似,都可以透過反射破壞單例。

2. 序列化攻擊

我們以「餓漢式單例」為例來演示一下序列化和反序列化攻擊程式碼,首先給餓漢式單例對應的類新增實現 Serializable 介面的程式碼,

public class SingletonHungry implements Serializable {    private static SingletonHungry instance = new SingletonHungry();    private SingletonHungry() {    }    private static SingletonHungry getInstance() {        return instance;    }}

然後看看如何使用序列化和反序列化進行攻擊

SingletonHungry instance = SingletonHungry.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))// 反序列化【讀】操作SingletonHungry newInstance = (SingletonHungry) ois.readObject();System.out.println(instance);System.out.println(newInstance);System.out.println(instance == newInstance);

來看下結果

果然出現了兩個不同的物件!這種反序列化攻擊其實解決方式也簡單,重寫反序列化時要呼叫的 readObject 方法即可

private Object readResolve(){    return instance;}

這樣在反序列化時候永遠只讀取 instance 這一個例項,保證了單例的實現。

真正安全的單例: 列舉方式
public enum SingleTonEnum {    /**     * 例項物件     */    INSTANCE;    public void doSomething() {        System.out.println("doSomething");    }}

呼叫方法

public class Main {    public static void main(String[] args) {        SingleTonEnum.INSTANCE.doSomething();    }}

列舉模式實現的單例才是真正的單例模式,是完美的實現方式

有人可能會提出疑問:列舉是不是也能透過反射來破壞其單例實現呢?

試試唄,修改列舉的測試類

class E{    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {        Class<SingleTonEnum> singleTonEnumClass = SingleTonEnum.class;        Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor();        declaredConstructor.setAccessible(true);        SingleTonEnum singleTonEnum = declaredConstructor.newInstance();        SingleTonEnum instance = SingleTonEnum.INSTANCE;        System.out.println("instance = " + instance);        System.out.println("singleTonEnum = " + singleTonEnum);    }}

結果

沒有無參構造?我們使用 javap 工具來查下位元組碼看看有啥玄機

好傢伙,發現一個有參構造器 String Int ,那就試試唄

//獲取構造器的時候修改成這樣子Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor(String.class,int.class);

結果

好傢伙,丟擲了異常,異常資訊寫著: 「Cannot reflectively create enum objects」

原始碼之下無秘密,我們來看看 newInstance() 到底做了什麼?為啥用反射建立列舉會丟擲這麼個異常?

真相大白!如果是列舉,不允許透過反射來建立,這才是使用 enum 建立單例才可以說是真正安全的原因!

結束語

以上就是一些關於單例模式的知識點彙總,你還真不要小看這個小小的單例,面試的時候多數候選人寫不對這麼一個簡單的單例,寫對的多數也僅止於 DCL,但再問是否有啥不安全,如何用 enum 寫出安全的單例時,幾乎沒有人能答出來!有人說能寫出 DCL 就行了,何必這麼鑽牛角尖?但我想說的是正是這種鑽牛角尖的精神能讓你逐步積累技術深度,成為專家,對技術有一探究竟的執著,何愁成不了專家?

9
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 春節假期學習日記之Nodejs(09)