簡單的單例模式其實也不簡單

單例模式可以說只要是一個合格的開發都會寫,但是如果要深究,小小的單例模式可以牽扯到很多東西,比如 多線程是否安全,是否懶加載,性能等等。還有你知道幾種單例模式的寫法呢?如何防止反射破壞單例模式?今天,我就花一章內容來說說單例模式。

關於單例模式的概念,在這裏就不在闡述了,相信每個小夥伴都瞭如指掌。

我們直接進入正題:

餓漢式

public class Hungry {
    private Hungry() {
    }

    private final static Hungry hungry = new Hungry();

    public static Hungry getInstance() {
        return hungry;
    }
}

餓漢式是最簡單的單例模式的寫法,保證了線程的安全,在很長的時間裏,我都是餓漢模式來完成單例的,因爲夠簡單,後來才知道餓漢式會有一點小問題,看下面的代碼:

public class Hungry {
    private byte[] data1 = new byte[1024];
    private byte[] data2 = new byte[1024];
    private byte[] data3 = new byte[1024];
    private byte[] data4 = new byte[1024];
    
    private Hungry() {
    }

    private final static Hungry hungry = new Hungry();

    public static Hungry getInstance() {
        return hungry;
    }
}

在Hungry類中,我定義了四個byte數組,當代碼一運行,這四個數組就被初始化,並且放入內存了,如果長時間沒有用到getInstance方法,不需要Hungry類的對象,這不是一種浪費嗎?我希望的是 只有用到了 getInstance方法,纔會去初始化單例類,纔會加載單例類中的數據。所以就有了 第二種單例模式:懶漢式。

懶漢式(DCL)

public class LazyMan {
    private LazyMan() {
    }

    private static LazyMan lazyMan;

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

DCL懶漢式的單例,保證了線程的安全性,又符合了懶加載,只有在用到的時候,纔會去初始化,調用效率也比較高,但是這種寫法在極端情況還是可能會有一定的問題。因爲

 lazyMan = new LazyMan();

不是原子性操作,至少會經過三個步驟:

  1. 分配內存
  2. 執行構造方法
  3. 指向地址

由於指令重排,導致A線程執行 lazyMan = new LazyMan();的時候,可能先執行了第三步(還沒執行第二步),此時線程B又進來了,發現lazyMan已經不爲空了,直接返回了lazyMan,並且後面使用了返回的lazyMan,由於線程A還沒有執行第二步,導致此時lazyMan還不完整,可能會有一些意想不到的錯誤,所以就有了下面一種單例模式。

懶漢式(Volatile)

這種單例模式只是在上面DCL單例模式增加一個volatile關鍵字來避免指令重排:

public class LazyMan {
    private LazyMan() {
    }

    private volatile static LazyMan lazyMan;

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

持有者

public class Holder {
    private Holder() {
    }

    public static Holder getInstance() {
        return InnerClass.holder;
    }

    private static class InnerClass {
        private static final Holder holder = new Holder();
    }
}

這種方式是第一種餓漢式的改進版本,同樣也是在類中定義static變量的對象,並且直接初始化,不過是移到了靜態內部類中,十分巧妙。既保證了線程的安全性,同時又滿足了懶加載。

萬惡的反射

萬惡的反射登場了,反射是一個比較霸道的東西,無視private修飾的構造方法,可以直接在外面newInstance,破壞我們辛辛苦苦寫的單例模式。

 public static void main(String[] args) {
        try {
            LazyMan lazyMan1 = LazyMan.getInstance();
            Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);
            declaredConstructor.setAccessible(true);
            LazyMan lazyMan2 = declaredConstructor.newInstance();
            System.out.println(lazyMan1.hashCode());
            System.out.println(lazyMan2.hashCode());
            System.out.println(lazyMan1 == lazyMan2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

我們分別打印出lazyMan1,lazyMan2的hashcode,lazyMan1是否相等lazyMan2,結果顯而易見:

那麼,怎麼解決這種問題呢?

public class LazyMan {
    private LazyMan() {
        synchronized (LazyMan.class) {
            if (lazyMan != null) {
                throw new RuntimeException("不要試圖用反射破壞單例模式");
            }
        }
    }

    private volatile static LazyMan lazyMan;

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

在私有的構造函數中做一個判斷,如果lazyMan不爲空,說明lazyMan已經被創建過了,如果正常調用getInstance方法,是不會出現這種事情的,所以直接拋出異常:

但是這種寫法還是有問題:

上面我們是先正常的調用了getInstance方法,創建了LazyMan對象,所以第二次用反射創建對象,私有構造函數裏面的判斷起作用了,反射破壞單例模式失敗。但是如果破壞者乾脆不先調用getInstance方法,一上來就直接用反射創建對象,我們的判斷就不生效了:

 public static void main(String[] args) {
        try {
            Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);
            declaredConstructor.setAccessible(true);
            LazyMan lazyMan1 = declaredConstructor.newInstance();
            LazyMan lazyMan2 = declaredConstructor.newInstance();
            System.out.println(lazyMan1.hashCode());
            System.out.println(lazyMan2.hashCode());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

那麼如何防止這種反射破壞呢?

public class LazyMan {
    private static boolean flag = false;
    private LazyMan() {
        synchronized (LazyMan.class) {
            if (flag == false) {
                flag = true;
            } else {
                throw new RuntimeException("不要試圖用反射破壞單例模式");
            }
        }
    }
    private volatile static LazyMan lazyMan;
    public static LazyMan getInstance() {
        if (lazyMan == null) {
            synchronized (LazyMan.class) {
                if (lazyMan == null) {
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
}

在這裏,我定義了一個boolean變量flag,初始值是false,私有構造函數裏面做了一個判斷,如果flag=false,就把flag改爲true,但是如果flag等於true,就說明有問題了,因爲正常的調用是不會第二次跑到私有構造方法的,所以拋出異常:

看起來很美好,但是還是不能完全防止反射破壞單例模式,因爲可以利用反射修改flag的值。

看起來並沒有一個很好的方案去避免反射破壞單例模式,所以輪到我們的枚舉登場了。

枚舉

public enum EnumSingleton {
    instance;
    public EnumSingleton getInstance(){
        return instance;
    }
}

枚舉是目前最推薦的單例模式的寫法,因爲足夠簡單,不需要開發自己保證線程的安全,同時又可以有效的防止反射破壞我們的單例模式,我們可以看下newInstance的源碼:


重點就是紅框中圈出來的部分,如果枚舉去newInstance就直接拋出異常了。

好了,這章的內容就結束了,下次再有人問你單例模式,再也不用害怕了。

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