單例模式是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 |
可以看到懶漢式每次獲取實例都同步,所以效率較差,其他都差不多。