Java單例模式的最佳實踐?

2022-12-08 18:00:27

「讀過書,……我便考你一考。茴香豆的茴字,怎樣寫的?」——魯迅《孔乙己》

0x00 大綱

0x01 前言

最近在重溫設計模式(in Java)的相關知識,然後在單例模式的實現上面進行了一些較深入的探究,有了一些以前不曾注意到的發現,遂將其整理成文,以作後用。

單例模式最初的定義出現於《設計模式》(艾迪生維斯理, 1994):「保證一個類僅有一個範例,並提供一個存取它的全域性存取點。」

其應用場景可以說是十分廣泛,尤其是在涉及到資源管理方面的程式碼,像應用設定(範例)、部分工具類或工廠類、JDK裡的Runtime等,都有出現單例模式的身影。

0x02 單例的正確性

探討單例模式有多少種實現方式的意義不是很大,因為單例模式的實現方式比茴字的寫法還多,但是正確的實現卻不多,我們不妨將重點放在如何保證單例的正確性上,從而尋求最佳實踐方案。

單例模式的關鍵在於如何保證「一個類僅有一個範例」。首先思考一下建立範例的方式有哪些?在Java語言裡面,有這幾種方式:new關鍵字、clone方法克隆、反序列化、反射。

new關鍵字

public class Main {
    public static void main(String[] args) {
        Singleton instance = new Singleton();
    }
}

如果要保證一個類是單例,則必須阻止使用者通過new關鍵字來隨意建立物件,最簡單粗暴的方法就是將構造方法私有化,然後提供一個靜態方法來進行範例的外部存取:

public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() { }

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

此時就不能在類的外部通過new來建立物件了。

clone方法克隆

clone方法是原型模式中建立複雜物件的方法,在Java中,clone方法是Object基礎類別的方法,因此所有的類都會繼承該方法,但只有實現了Cloneable介面的類才能正常呼叫clone方法克隆物件範例,否則會丟擲型別為CloneNotSupportedException的異常,單例的類要防止使用者通過clone方法克隆就不能實現Cloneable介面。

反序列化

在Java裡面,實現了Serializable介面的類可以通過ObjectOutputStream將其範例序列化,然後再通過ObjectInputStream進行反序列化,而在預設情況下,反序列之後得到的是一個新的範例,這就違背了單例的法則了。幸好JDK的開發人員也想到了這點,再Serializable介面的檔案中有這樣一段描述:

Classes that need to designate a replacement when an instance of it is read from the stream should implement this special method with the exact signature.

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

意思就是在反序列化時可以通過在類裡面定義readResolve方法來指定反序列化時返回的物件,例如:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();

    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

反射

聰明的你也許注意到了,上面的readResolve方法是private的。那麼它是怎麼被呼叫的呢?答案就是通過反射,想了解更詳細的呼叫過程可以去看看ObjectInputStream類原始碼中的readOrdinaryObject方法。

通過反射可以無視private修飾符的限制呼叫類裡面的各種方法,也就是說使用者可以利用反射來呼叫我們的私有構造方法,像這樣:

public class Main {
    public static void main(String[] args) throws Exception {
        // 這句程式碼無法執行,因為我們的構造方法是private的
        // Singleton singleton = new Singleton();
        // 通過反射來建立範例
        java.lang.reflect.Constructor<Singleton> constructor;
        constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton = constructor.newInstance();
        // 兩個範例不一樣,單例完蛋
        if(singleton != Singleton.getInstance()) {
            System.out.println("哦嚯,完蛋");
        }
    }
}

解決方法是在構造方法裡面判斷類的範例是否已經被建立過,如果已經建立過的,丟擲異常從而阻止反射呼叫。把單例類的程式碼修改如下:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();
    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    /**
     * 顯式指定反序列化時返回的單例物件
     * @return
     * @throws java.io.ObjectStreamException
     */
    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

再次通過反射進行物件建立時,就會丟擲型別為RuntimeException的異常,從而阻止新範例的建立。

0x03 最佳實踐方案

可以看到,我們為了實現單例模式,加入了一大堆膠水程式碼,用於保證其正確性,這一點都不簡潔。那麼有沒有更簡單更有效的方式呢?有,而且已經有人幫我們驗證過了。

Joshua Bloch在《Effective Java》一書中寫道:

使用列舉實現單例的方法雖然還沒有廣泛採用,但是單元素的列舉型別已經成為實現Singleton的最佳方法。

我們直接上程式碼看看:

public enum EnumSingleton {
    INSTANCE;
    public void doSomething() {
        System.out.println("do something.");
    }
}

就是這麼簡單,再看看呼叫它的程式碼:

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

使用列舉實現單例模式,不僅程式碼簡潔,而且可以輕鬆阻止使用者通過new關鍵字、clone方法克隆、反序列化、反射等方式建立重複範例,還保證執行緒安全,這一切由JVM替你操辦,不需要新增額外程式碼。

0x04 驗證測試

列舉實現單例模式能不能保證上面的提到的各種屬性呢?我們用程式碼逐一驗證一下:

public class Main {
    public static void main(String[] args) throws Exception {
        // TEST-1: 驗證是否單一範例
        EnumSingleton s1 = EnumSingleton.INSTANCE;
        EnumSingleton s2 = EnumSingleton.INSTANCE;
        if (s1.hashCode() != s2.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-1 PASSED.");
        }
        // TEST-2: 驗證反射建立
        java.lang.reflect.Constructor<EnumSingleton> constructor;
        // 注意這裡用的是列舉的父構造器,因為我們沒有定義構造方法
        constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        boolean passed = false;
        try {
            EnumSingleton s3 = constructor.newInstance("NEW_INSTANCE", 2);
        } catch (Exception ex) {
            // 報錯說明反射不能建立
            passed = true;
        }
        if (!passed) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-2 PASSED.");
        }
        // TEST-3: 驗證反序列化
        EnumSingleton s4 = EnumSingleton.INSTANCE;
        EnumSingleton s5;
        try (java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(new java.io.FileOutputStream("EnumObject"))) {
            oos.writeObject(s4);
        }
        try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream(new java.io.FileInputStream("EnumObject"))) {
            s5 = (EnumSingleton) ois.readObject();
        }
        if (s4.hashCode() != s5.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-3 PASSED.");
        }
        // TEST-4: 多執行緒測試
        java.util.concurrent.CountDownLatch begin = new java.util.concurrent.CountDownLatch(10);
        java.util.concurrent.CountDownLatch end = new java.util.concurrent.CountDownLatch(10);
        java.util.Set<EnumSingleton> set = new java.util.HashSet<>(1024);
        java.util.stream.IntStream.range(0, 20).forEach(
                i -> {
                    new Thread(() -> {
                        try {
                            begin.await();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        set.add(EnumSingleton.INSTANCE);
                        System.out.println(System.currentTimeMillis() + ":" + Thread.currentThread().getName() + "->" + EnumSingleton.INSTANCE.hashCode());
                        end.countDown();
                    }).start();
                    begin.countDown();
                }
        );
        end.await();
        if(set.size() != 1) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-4 PASSED.");
        }
    }
}

測試結果:

TEST-1 PASSED.
TEST-2 PASSED.
TEST-3 PASSED.
...
TEST-4 PASSED.

0x05 真的是最佳實踐嗎

在 Java Language Specification 列舉型別這一章節中,具體闡述了若干點對於列舉型別的強制和隱性約束:

An enum declaration specifies a new enum type, a special kind of class type.

It is a compile-time error if an enum declaration has the modifier abstract or final.

An enum declaration is implicitly final unless it contains at least one enum constant that has a class body (§8.9.1).

A nested enum type is implicitly static. It is permitted for the declaration of a nested enum type to redundantly specify the static modifier.

This implies that it is impossible to declare an enum type in the body of an inner class (§8.1.3), because an inner class cannot have static members except for constant variables.

It is a compile-time error if the same keyword appears more than once as a modifier for an enum declaration.

The direct superclass of an enum type E is Enum (§8.1.4).

An enum type has no instances other than those defined by its enum constants. It is a compile-time error to attempt to explicitly instantiate an enum type (§15.9.1).

其中最為突出和有影響是以下兩點:

不能顯式繼承

和常規類一樣,列舉可以實現介面,並提供公共實現或每個列舉值的單獨實現,但不能繼承,因為所有的列舉預設隱式繼承了Enum<E>型別,不能繼承也就意味著喪失了一部分的抽象能力(不能定義abstract方法),雖然可以通過組合的方式變通實現,但這無疑犧牲了擴充套件性和靈活性。

無法延遲載入

因為列舉範例化的特殊性,所有的構造器屬性都必須在列舉建立時指定,無法在執行時通過程式碼動態傳遞和構造。

0x06 小結

非列舉的單例實現除開少數極端場景,在大多數時候下也都夠用了,且保留了OOP的靈活特性,方便日後業務擴充套件,基於列舉的單例實現有序列化和執行緒安全的保證,而且只要幾行程式碼就能實現,不失為一種有效的方案,但並不無敵。具體的實現方案還是要根據業務背景和實際情況來進行選擇,畢竟,軟體工程沒有銀彈。