使用列舉來寫出更優雅的單例設計模式

goldenJet發表於2019-03-21

Java 中的單例設計模式,很多時候我們只會注意到執行緒引起的表象性問題,但是沒考慮過對反射機制的限制,此文旨在簡單介紹利用列舉來防止反射的漏洞。

一、最常見的單例

我們先展示一段最常見的懶漢式的單例:

public class Singleton {

    private Singleton(){} // 私有構造

    private static Singleton instance = null// 私有單例物件

    // 靜態工廠
    public static Singleton getInstance(){
        if (instance == null) { // 雙重檢測機制
            synchronized (Singleton.class) { // 同步鎖
                if (instance == null) { // 雙重檢測機制
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}
複製程式碼

上述單例的寫法採用的雙重檢查機制增加了一定的安全性,但是沒有考慮到 JVM 編譯器的指令重排

二、杜絕 JVM 的指令重排對單例造成的影響

1、什麼是指令重排

比如 java 中簡單的一句 instance = new Singleton,會被編譯器編譯成如下 JVM 指令:

memory =allocate();    //1:分配物件的記憶體空間 

ctorInstance(memory);  //2:初始化物件 

instance =memory;     //3:設定instance指向剛分配的記憶體地址
複製程式碼

但是這些指令順序並非一成不變,有可能會經過 JVM 和 CPU 的優化,指令重排成下面的順序:

memory =allocate();    //1:分配物件的記憶體空間 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

ctorInstance(memory);  //2:初始化物件
複製程式碼

2、影響

對應到上文的單例模式,會產生如下圖的問題:

  1. 當執行緒 A 執行完1,3,時,準備走2,即 instance 物件還未完成初始化,但已經不再指向 null 。

  2. 此時如果執行緒 B 搶佔到CPU資源,執行  if(instance == null)的結果會是 false,

  3. 從而返回一個沒有初始化完成的instance物件

使用列舉來寫出更優雅的單例設計模式

3、解決

如何去防止呢,很簡單,可以利用關鍵字 volatile 來修飾 instance 物件,如下圖進行優化:

使用列舉來寫出更優雅的單例設計模式

why?

很簡單,volatile 修飾符在此處的作用就是阻止變數訪問前後的指令重排,從而保證了指令的執行順序。

意思就是,指令的執行順序是嚴格按照上文的 1、2、3 來執行的,從而物件不會出現中間態。

其實,volatile 關鍵字在多執行緒的開發中應用很廣,暫不贅述。

雖然很贊,但是此處仍然沒有考慮過反射機制帶來的影響

三、進階篇,實現完美單例

1、小插曲

實現單例有很多種模式,在此介紹一種使用靜態內部類實現單例模式的方式:

public class Singleton {

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

    private Singleton (){}

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

}
複製程式碼

這是一種很巧妙的方式,原由是:

  1. 從外部無法訪問靜態內部類 LazyHolder,只有當呼叫 Singleton.getInstance() 方法的時候,才能得到單例物件 INSTANCE。

  2. INSTANCE 物件初始化的時機並不是在單例類 Singleton 被載入的時候,而是在呼叫 getInstance 方法,使得靜態內部類 LazyHolder 被載入的時候。

  3. 因此這種實現方式是利用classloader的載入機制來實現懶載入,並保證構建單例的執行緒安全。

2、漏洞展示

很多種單例的寫法都有一個通病,就是無法防止反射機制的漏洞,從而無法保證物件的唯一性,如下舉例:

利用如下的反正程式碼對上文構造的單例進行物件的建立。

public static void main(String[] args) {

    try {

        //獲得構造器
        Constructor con = Singleton.class.getDeclaredConstructor();

        //設定為可訪問
        con.setAccessible(true);

        //構造兩個不同的物件
        Singleton singleton1 = (Singleton)con.newInstance();
        Singleton singleton2 = (Singleton)con.newInstance();

        //驗證是否是不同物件
        System.out.println(singleton1);
        System.out.println(singleton2);
        System.out.println(singleton1.equals(singleton2));
    } catch (Exception e) {
        e.printStackTrace();
    }

}
複製程式碼

我們直接看結果:

使用列舉來寫出更優雅的單例設計模式

結果很明顯,這顯然是兩個物件。

3、解決

使用列舉來實現單例模式。

實現很簡單,就三行程式碼:

public enum Singleton {
    INSTANCE;
}
複製程式碼

上面所展示的就是一個單例,

why?

其實這就是 enum 的一塊語法糖,JVM 會阻止反射獲取列舉類的私有構造方法

仍然使用上文的反射程式碼來進行測試,發現,報錯。嘿嘿,完美解決反射的問題。

4、缺點

使用列舉的方法是起到了單例的作用,但是也有一個弊端,

那就是  無法進行懶載入

原文地址:www.jetchen.cn/java-single…

使用列舉來寫出更優雅的單例設計模式

相關文章