詳解單例模式及性能比較

單例模式是java中廣泛運用的一種設計模式。單例模式的基本原則是一個類對外只提供一個實例,單例對象只會被初始化一次。

實現單例的基本思想是構造函數私有化,自己構造一個實例,對外暴露實例的get方法。它的寫法多種多樣,下面就介紹單例模式的餓漢式、懶漢式、雙重檢測鎖、靜態內部類、枚舉這5種寫法。並從線程安全、反射漏洞、反序列化漏洞三個方面進行分析優化。最後測試各寫法的性能。

一、單例的5種寫法

餓漢式

public class HungrySingleton {
	private static HungrySingleton instance =new HungrySingleton();
	private HungrySingleton(){}
	public static HungrySingleton getInstance(){
		return instance;
	}
}

餓漢式寫法很簡單,類加載時就初始化一個實例,私有化構造器,然後對外提供getInstance方法獲取實例。所謂餓漢式,就是說很飢餓,剛剛初始化就生成實例,不管你訪不訪問,實例都已經生成。沒有實現延時加載。

因爲同一個類加載器加載同一個類只會加載一次,所以餓漢式單例是線程安全的。因爲沒有同步,所以調用效率也比較高;缺點是沒有實現延時加載,也就是沒有實現需要的時候才創建實例。一般需要實現單例的對象都是比較佔用資源的對象,餓漢式寫法就比較消耗資源。

爲了實現延時加載,於是就有了懶漢式寫法。

懶漢式

public class LazySingleton {
	private static LazySingleton instance;
	private LazySingleton(){}
	public static synchronized LazySingleton getInstance(){
		if(null==instance){
			instance=new LazySingleton();
		}
		return instance;
	}
}

所謂懶漢式,就是懶得創建實例,等需要的時候再去創建。私有化造器的基本思想是一樣的,懶漢式單例在構建實例是在調用getInstance方法時,實現了延時加載。通過synchronized關鍵字實現線程安全。

懶漢式同步了整個getInstance方法,不管唯一實例有沒有被創建都同步,調用效率自然就比較低。爲了優化這一問題,就有了雙重檢測鎖(double check lock)寫法。

雙重檢測鎖

public class DCLSingleton {
	private static DCLSingleton instance;
	private DCLSingleton(){}
	public static DCLSingleton getInstance(){
		if(null==instance){
			synchronized (DCLSingleton.class) {
				if(null==instance){
						instance=new DCLSingleton();
					}
				}
			}
		return instance;
	}
}

雙重檢測鎖寫法有兩處對實例是否已經被創建的檢測。取消懶漢式的對整個方法同步。如果實例被創建,直接返回實例,不會進入同步代碼,否則加鎖創建實例。

雙重檢測鎖通過鎖細化,保證線程安全的同時又提升了效率。但是代碼較爲複雜。

靜態內部類

public class StaticSingleton {
	private static class SingletonClassInstance{
		private static final StaticSingleton instance=new StaticSingleton();
	}
    private StaticSingleton(){}
	public static StaticSingleton getInstance(){
		return SingletonClassInstance.instance;
	}
}

靜態內部類寫法是我個人比較喜歡的寫法,代碼比較簡單,類加載時不會初始化靜態內部類,所以實現延時加載,並且始終只有一個實例,線程安全。調用效率也比較高。

枚舉

public enum EnumSingleton {
	//這個枚舉元素本身就是單例對象
	INSTANCE;
}

枚舉裏的元素天然就是單例,線程安全,效率高,不能延時加載。jdk 1.5纔出現枚舉。

二、單例模式的漏洞

單例模式的語義是用戶獲取的實例永遠是同一個。正常情況下,只要是線程安全的寫法,這一點都能得到保證。

但是java中創建對象有多種方式,通過反射和反序列化獲取的對象還是同一個對象嗎?

通過以下代碼測試一下(以餓漢式爲例):

反射

//通過反射的方式直接調用私有構造器
	@Test
	public void testReject() throws Exception {
		Class<HungrySingleton> clazz=(Class<HungrySingleton>) Class.forName("com.youzi.singleton.HungrySingleton");
		Constructor<HungrySingleton> c=clazz.getDeclaredConstructor(null);
		c.setAccessible(true);
		HungrySingleton instance1=c.newInstance();
		HungrySingleton instance2=c.newInstance();

		System.out.println("原對象的hashcode:"+instance1.hashCode());
		System.out.println("反射對象的hashcode:"+instance2.hashCode());
	}

結果:

原對象的hashcode:580024961
反射對象的hashcode:2027961269

反序列化:

//通過反序列化的方式構造多個對象
	@Test
	public void testSerialize() throws Exception {
		HungrySingleton instance1= HungrySingleton.getInstance();
		ObjectOutputStream oos= new ObjectOutputStream(new FileOutputStream("D:/temp/ab.txt"));
		oos.writeObject(instance1);
		oos.close();
		ObjectInputStream ois=new ObjectInputStream(new FileInputStream("D:/temp/ab.txt"));
		HungrySingleton instance2=(HungrySingleton) ois.readObject();
		ois.close();
		System.out.println("原對象的hashcode:"+instance1.hashCode());
		System.out.println("反序列化對象的hashcode:"+instance2.hashCode());
	}

注意:測試反序列化必須讓被序列化的對象的類實現Serializable接口。

測試結果:

原對象的hashcode:1642360923
反序列化對象的hashcode:1451270520

經過測試發現,除了枚舉寫法,其他四種單例寫法均存在反射漏洞和反序列化漏洞。即通過這兩種方式可以生成多個實例。枚舉寫法天然不存在反射漏洞和反序列化漏洞。

針對這兩個漏洞我們再做一些優化,基於DCL寫法解決這兩個漏洞的寫法:

public class SafeSingleton implements Serializable {
	private static SafeSingleton instance;
	private SafeSingleton(){
		if(instance!=null){
			throw new RuntimeException("不允許反射調用構造方法");
		}
	}
	public static SafeSingleton getInstance(){
		if(null==instance){
			synchronized (SafeSingleton.class) {
				if(null==instance){
					instance=new SafeSingleton();
				}
			}
		}
		return instance;
	}
	
	//反序列化時直接調用此方法返回instance
	private Object readResolve(){
		return instance;
	}
}

存在反射漏洞是因爲通過反射可以調用類的私有方法,在調用私有構造器時我們再判斷一下實例是否已經存在,如果存在就拋出異常,不讓創建新的實例。

反序列化時會調用readResolve()方法,我們直接在該方法返回實例,就可以防止反序列化生成新的實例。

三、測試各寫法的效率

其實根據各寫法是否有同步,以及同步粒度就可以判斷他們的效率優劣。這裏通過以下代碼測試一下,開10個線程同時訪問單例,每個線程訪問100萬次,所花的時間。

public class TestEfficiency {
	public static void main(String[] args) throws Exception {
		long start = System.currentTimeMillis();
		int threadNum=10;
		final CountDownLatch countDownLatch=new CountDownLatch(threadNum);
		
		for(int i=0;i<threadNum;i++){
			new Thread(() -> {
				for (int j = 0; j < 1000000; j++) {
					Object o= HungarySingleton.getInstance();
				}
				countDownLatch.countDown();
			}).start();
		}
		countDownLatch.await();//main方法阻塞,直到計數器變爲0纔會繼續執行
		long end=System.currentTimeMillis();
		System.out.println("總耗時:"+(end-start)+"ms");
	}
}

測試結果:

單例類型 平均耗時(ms)
HungrySingleton 98
LazySingleton 484
DCLSingleton 94
StaticSingleton 108
EnumSingleton 97
SafeSingleton 107

可以看到懶漢式每次獲取實例都同步,所以效率較差,其他都差不多。


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