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

CodeBear發表於2019-01-03

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

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

我們直接進入正題:

餓漢式

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,結果顯而易見:

image.png

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

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方法,是不會出現這種事情的,所以直接丟擲異常:

image.png

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

上面我們是先正常的呼叫了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,就說明有問題了,因為正常的呼叫是不會第二次跑到私有構造方法的,所以丟擲異常:

image.png

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

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

列舉

public enum EnumSingleton {
    instance;
}

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

image.png
重點就是紅框中圈出來的部分,如果列舉去newInstance就直接丟擲異常了。

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

相關文章