【設計模式】你的單例模式真的是生產可用的嗎?

東陸之滇發表於2019-02-07

本文重要關注點:

  • 執行緒安全的單例模式
  • 防止物件克隆破壞單例模式Singleton
  • 防止序列化破壞單例模式

單例模式

什麼是單例模式

單例模式屬於管理例項的創造型型別模式。單例模式保證在你的應用種最多隻有一個指定類的例項。

單例模式應用場景

  • 專案配置類

讀取專案的配置資訊的類可以做成單例的,因為只需要讀取一次,且配置資訊欄位一般比較多節省資源。通過這個單例的類,可以對應用程式中的類進行全域性訪問。無需多次對配置檔案進行多次讀取。

  • 應用日誌類

日誌器Logger在你的應用中是無處不在的。也應該只初始化一次,但是可以到處使用。

  • 分析和報告類

如果你在使用一些資料分析工具例如Google Analytics。你就可以注意到它們被設計成單例的,僅僅初始化一次,然後在使用者的每一個行為中都可以使用。

單例模式簡圖

實現單例模式的類

  • 將預設的構造器設定為private。阻止其他類從應用中直接初始化該類。

  • 建立一個public static 的靜態方法。該方法用於返回一個單例類例項。

  • 還可以選擇懶載入初始化更友好。

示例程式碼

示例程式碼參見以下類

  • org.byron4j.cookbook.designpattern.singleton.Singleton
public class Singleton {

    private static Singleton instance;

    // 構造器私有化
    private Singleton(){

    }

    // 提供靜態方法
    public static Singleton getInstance(){

        // 懶載入初始化,在第一次使用時才建立例項
        if(instance == null){
            instance = new Singleton();
        }
        return  instance;
    }


    public void display(){
        System.out.println("Hurray! I am create as a Singleton!");
    }


}
複製程式碼

單元測試類:

package org.byron4j.cookbook.designpattern;

import org.byron4j.cookbook.designpattern.singleton.Singleton;
import org.junit.Test;

import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SingletonTest {

    @Test
    public void test(){
        final Set<Singleton> sets = new HashSet<>();

        ExecutorService es = Executors.newFixedThreadPool(10000);

        for(int i = 1; i <= 100000; i++){
            es.execute(new Runnable() {
                public  void run(){
                    Singleton s = Singleton.getInstance();
                    sets.add(s);
                }
            });
        }

        System.out.println(sets);

    }
}

複製程式碼

執行輸出如下,結果生成了多個Singleton例項:

[org.byron4j.cookbook.designpattern.singleton.Singleton@46b91344, org.byron4j.cookbook.designpattern.singleton.Singleton@1f397b96]

執行緒安全的單例模式

執行緒安全對於單例類來說是非常重要的。上述Singleton類是非執行緒安全的,因為線上程併發的場景下,可能會建立多個Singleton例項。

為了規避這個問題,我們可以將 getInstance 方法用同步字 synchronized 修飾,這樣迫使執行緒等待直到前面一個執行緒執行完畢,如此就避免了同時存在多個執行緒訪問該方法的場景。

public static synchronized Singleton getInstance() {
		
		// Lazy initialization, creating object on first use
		if (instance == null) {
			instance = new Singleton();
		}
		return instance;
}
複製程式碼

這樣確實解決了執行緒安全的問題。但是,synchronized 關鍵字存在嚴重的效能問題。我們還可以進一步優化 getInstance 方法,將例項同步,將方法範圍縮小:

public static Singleton getInstance() {

		// Lazy initialization, creating object on first use
		if (instance == null) {
			synchronized (Singleton.class) {
				if (instance == null) {
					instance = new Singleton();
				}
			}
		}

	return instance;

}
複製程式碼

單元測試三種方式耗時比較:

package org.byron4j.cookbook.designpattern;

import org.byron4j.cookbook.designpattern.singleton.Singleton;
import org.byron4j.cookbook.designpattern.singleton.SingletonSynchronized;
import org.byron4j.cookbook.designpattern.singleton.SingletonSynchronizedOptimized;
import org.junit.Test;

import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SingletonTest {

    @Test
    public void test(){
        final Set<Singleton> sets = new HashSet<>();
        long startTime = System.currentTimeMillis();
        ExecutorService es = Executors.newFixedThreadPool(10000);

        for(int i = 1; i <= 100000; i++){
            es.execute(new Runnable() {
                public  void run(){
                    Singleton s = Singleton.getInstance();
                    sets.add(s);
                }
            });
        }
        System.out.println("test用時:" + (System.currentTimeMillis() - startTime));
        System.out.println(sets);

    }

    @Test
    public void testSynchronized(){
        final Set<SingletonSynchronized> sets = new HashSet<>();
        long startTime = System.currentTimeMillis();
        ExecutorService es = Executors.newFixedThreadPool(10000);

        for(int i = 1; i <= 100000; i++){
            es.execute(new Runnable() {
                public  void run(){
                    SingletonSynchronized s = SingletonSynchronized.getInstance();
                    sets.add(s);
                }
            });
        }
        System.out.println("testSynchronized用時:" + (System.currentTimeMillis() - startTime));
        System.out.println(sets);

    }

    @Test
    public void testOptimised(){
        final Set<SingletonSynchronizedOptimized> sets = new HashSet<>();
        long startTime = System.currentTimeMillis();
        ExecutorService es = Executors.newFixedThreadPool(10000);

        for(int i = 1; i <= 100000; i++){
            es.execute(new Runnable() {
                public  void run(){
                    SingletonSynchronizedOptimized s = SingletonSynchronizedOptimized.getInstance();
                    sets.add(s);
                }
            });
        }

        System.out.println("testOptimised用時:" + (System.currentTimeMillis() - startTime));
        System.out.println(sets);

    }
}

複製程式碼

執行測試用例,輸出如下:

test用時:1564
[org.byron4j.cookbook.designpattern.singleton.Singleton@68eae58e]

testSynchronized用時:3658
[org.byron4j.cookbook.designpattern.singleton.SingletonSynchronized@36429a46]

testOptimised用時:2254
[org.byron4j.cookbook.designpattern.singleton.SingletonSynchronizedOptimized@21571826]


複製程式碼

可以看到,最開始的實現方式效能是最好的,但是是非執行緒安全的; Synchronized 鎖住整個getInstance方法,可以做到執行緒安全,但是效能是最差的; 縮小Synchronized範圍,可以提高效能。

單例Singleton和物件克隆

涉及單例類時還要注意clone方法的正確使用:

package org.byron4j.cookbook.designpattern.singleton;

/**
 * 單例模式例項
 * 1. 構造器私有化
 * 2. 提供靜態方法供外部獲取單例例項
 * 3. 延遲初始化例項
 */
public class SingletonZClone implements  Cloneable{

    private static SingletonZClone instance;

    // 構造器私有化
    private SingletonZClone(){

    }

    // 提供靜態方法
    public static SingletonZClone getInstance(){

        // 將同步鎖範圍縮小,降低效能損耗
        if(instance == null){
            synchronized (SingletonZClone.class){
                if(instance == null){
                    instance = new SingletonZClone();
                }
            }
        }
        return  instance;
    }

    /**
     * 克隆方法--改為public
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    public void display(){
        System.out.println("Hurray! I am create as a SingletonZClone!");
    }


}

複製程式碼

預設情況下clone時protected修飾的,這裡改為了public修飾,測試用例如下:

@Test
    public void testClone() throws CloneNotSupportedException {
        SingletonZClone singletonZClone1 = SingletonZClone.getInstance();
        SingletonZClone singletonZClone2 = SingletonZClone.getInstance();
        SingletonZClone singletonZClone3 = (SingletonZClone)SingletonZClone.getInstance().clone();

        System.out.println(singletonZClone1 == singletonZClone2);
        System.out.println(singletonZClone1 == singletonZClone3);
        System.out.println(singletonZClone2 == singletonZClone3);

    }
複製程式碼

輸出如下:

true

false

false

我們瞭解一下clone方法的API解釋, clone 後的物件雖然屬性值可能是一樣的,但是已經不是同一個物件例項了:

x.clone() != x

x.clone().getClass() == x.getClass()

x.clone().equals(x)

clone方法返回一個被克隆物件的例項的副本,除了記憶體地址其他屬性值都是一樣的,所以副本和被克隆物件不是同一個例項。 可以看出clone方法破壞了單例類,為防止該問題出現,我們需要禁用clone方法,直接改為:

 /**
     * 克隆方法--改為public
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
    }
複製程式碼

單例和序列化問題

Java序列化機制允許將一個物件的狀態轉換為位元組流,就可以很容易地儲存和轉移。 一旦物件被序列化,你就可以對其進行反序列化--將位元組流轉為物件。 如果一個Singleton類被序列化,則可能建立重複的物件。 我們可以使用鉤子hook,來解釋這個問題。

readResolve()方法

在Java規範中有關於readResolve()方法的介紹:

對於可序列化的和外部化的類,readResolve() 方法允許一個類可以替換/解析從流中讀取到的物件。 通過實現 readResolve 方法,一個類就可以直接控制反序列化後的例項以及型別。 定義如下:

  ANY-ACCESS-MODIFIER Object readResolve()
       		throws ObjectStreamException;
複製程式碼

readResolve 方法會在ObjectInputStream 從流中讀取一個物件時呼叫。ObjectInputStream 會檢測類是否定義了 readResolve 方法。 如果 readResolve 方法定義了,會呼叫該方法用於指定從流中反序列化後作為返回的結果物件。 返回的型別要與原物件的型別一致,不然會出現 ClassCastException。

@Test
    public void testSeria() throws Exception {
        SingletonZCloneSerializable singletonZClone1 = SingletonZCloneSerializable.getInstance();


        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.ser"));
        oos.writeObject(singletonZClone1);
        oos.close();
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.ser"));
        SingletonZCloneSerializable test = (SingletonZCloneSerializable) ois.readObject();
        ois.close();
        System.out.println(singletonZClone1 == test);

    }
複製程式碼

測試輸出: false; 說明反序列化的時候已經不是原來的例項了,如此會破壞單例模式。

所以我們可以覆蓋 readResolve 方法來解決序列化破壞單例的問題:

類 SingletonZCloneSerializableReadResolve 增加 readResolve 方法:

/**
     * 反序列化時返回instance例項,防止破壞單例模式
     * @return
     */
    protected Object readResolve(){
        return getInstance();
    }
複製程式碼

執行測試用例:

@Test
    public void testSReadResolve() throws Exception {
        
         s = SingletonZCloneSerializableReadResolve.getInstance();


        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.ser"));
        oos.writeObject(s);
        oos.close();
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.ser"));
        SingletonZCloneSerializableReadResolve test = (SingletonZCloneSerializableReadResolve) ois.readObject();
        ois.close();
        System.out.println(s == test);

    }
複製程式碼

輸出true,有效防止了反序列化對單例的破壞。

你知道嗎?

  • 單例類是很少使用的,如果你要使用這個設計模式,你必須清楚的知道你在做什麼。因為全域性範圍內僅僅建立一個例項,所以在資源受約束的平臺是存在風險的。

  • 注意物件克隆。 單例模式需要仔細檢查並阻止clone方法。

  • 多執行緒訪問下,需要注意執行緒安全問題。

  • 小心多重類載入器,也許會破壞你的單例類。

  • 如果單例類是可序列化的,需要實現嚴格型別

相關文章