1.寂寞的Singleton
如果你是一名OO程序員,Singleton的名字對你來說就不會陌生,它是GoF設計模式的一種,江湖人稱“單例”的便是;即便你不是OO程序員,中國人你總該是吧?那麼下面一段你應該也會背:“世界上只有一個敏感詞,敏感詞是敏感詞的一部分,敏感詞是代表敏感詞的唯一合法敏感詞,任何企圖製造兩個敏感詞的企圖都是註定要失敗的。”說的多麼好!一語道破Singleton的真諦。但是,爲了讓帖子存活下去,爲了更好地娛樂大衆,下面我們從“敏感詞系統”轉到“世界盃系統”,我們來看:
public class WorldCup { private static WorldCup instance = new WorldCup(); public static WorldCup getInstance(){ return instance; } }
這就是一個極爲簡易的Singleton範例,但是雷米特同學看到這個類估計哭得心思都有了:這裏的instance是eager initialization,也就是說作爲世界盃的發起人,雷米特小朋友必須在提出“世界盃”這個概念的時候,就自己掏錢鑄一座金盃撂那,這賽事成不成還兩說。擱誰誰也不樂意。那這事咋整?雷米特老婆溫柔地說,“你個完蛋敗家玩意,那就等破世界盃板上釘釘,第一屆舉辦的時候再造唄!”真是一語驚醒夢中人,雷米特立刻打開IDE,敲出下面的代碼:
public class WorldCup { private static WorldCup instance; public static WorldCup getInstance(){ if(instance == null) //A instance = new WorldCup(); //B return instance; } }
雷米特長出一口氣,這回lazy initialization,總高枕無憂了吧~
2. 當Singleton遇見多線程
這時,溫柔賢惠的老婆又發話了:“你傻啊?倒黴玩意你想造多少個破杯啊?杯具啊,我~不~活~了~~我錯了,我從一開始就錯了,如果我不嫁過來……”雷米特表示理解不能,這麼NB的代碼錯在哪了?
觀衆朋友們來分析一下,是什麼讓雷米特的老婆如此傷心欲絕呢?絕大部分朋友應該已經知道了,那就是多線程的問題。在A和B之間存在一個時間差,可能有t1,t2兩個線程,t1檢測instance爲null,沒有繼續執行B而是被切走,t2又檢測到instance爲null,這下,兩個世界盃就被造出來了。Singleton名不副實。那應該如何?你可能已經對我的傻逼描述煩不勝煩了,加鎖唄:
public class WorldCup { private static WorldCup instance; public static WorldCup getInstance() { synchronized (WorldCup.class) { if (instance == null) // A instance = new WorldCup();// B return instance; } } }
問題解決,不是嗎?對,但不那麼完美。我們知道,加鎖/放鎖是很費的操作,這裏完全沒有必要每次調用getInstance都加鎖,事實上我們只想保證一次初始化成功而已,其餘的快速返回就好了。
3. 又見DCL
那也不難,用傳說中的“雙檢鎖”(Double-Checked Lock)即可:
public class WorldCup { private static WorldCup instance; public static WorldCup getInstance() { if (instance == null) { //C synchronized (WorldCup.class) { if (instance == null) // A instance = new WorldCup();// B } } return instance; } }
新加的C操作過濾掉了大量的“快速返回”,讓程序只有在真正需要加鎖時纔去加鎖,效率大漲。雷米特大喜過望,終於可以和老婆交差了。但是,結束了麼?
4.安全發佈
“結束了麼?”一段時間之前的一次電話面試中,面對同樣的問題,面試官不懷好意地問。我立刻深深地覺得我被這兩個臭不要臉的傢伙徹底調戲了,萬分糾結地敗下陣來。相信雷米特再次面對他老婆時,也會有相同的感受。那麼,看似精巧的DCL,會有什麼問題呢?我們要從“安全發佈”談起。
所謂對象的“發佈”(publish),是指使他能夠讓當前範圍以外的代碼使用。爲了方便理解,列舉幾種發佈對象的常用方法:
- 把對象的引用存到公共靜態域裏。
- 把一個對象傳遞給一個“外部方法”,所謂外部方法是指其他類的方法或者自身可以被子類重寫的方法(因爲你不知道這些方法會有些什麼動作)
- 發佈一個Inner Class的實例。這是因爲每個Inner Class實例都保存了外部對象的引用。
另外需要記住的規則是“當發佈一個對象時,實際上隱式地發佈了他的所有非私有域對象”。
發佈對象並不可怕,可怕的是錯誤地發佈對象。當一個對象還沒有做好準備時就將他發佈,我們稱作“逃逸”(escape)。舉一個簡單的例子:
public class Argentina { private boolean isMessiReady; public Argentina() { new Thread(new Runnable() { @Override public void run() { tell(); //Argentina.this } }).start(); isMessiReady = true; } void tell() { System.out.println("Is Messi Here?:" + isMessiReady); } }
阿根廷隊隊伍還沒組建好就開新聞發佈會,這時Messi到底在不在呢?老馬可能在放煙霧彈。這裏的對象發佈屬於我們上面提到的第三種,即發佈內部類,因爲這裏的Thread其實是用一個匿名內部類Runnable實現的,新線程可以訪問到外部類,在我們加註釋的那一行其實隱含的訪問了外部類Argentina.this。這屬於臭名昭著的“this逃逸”,常見情景包括了“在構造函數中添加listener,啓動新線程或者調用可重寫方法”。
其實說白了,所謂逃逸,無非是對象“發佈”和另一個線程訪問該對象之間沒有正確的Happens-before關係。
回過頭來看我們上面的DCL,他雖然不是“this逃逸”,但也屬於肇事逃逸的一種。一個線程t1的B操作和另一線程t2的C操作之間沒有HB關係,也就是對instance的讀寫沒有同步,可能會造成的現象是t1-B的new WorldCup()還沒有完全構造成功,但t2-C已經看到instance非空,這樣t2就直接返回了未完全構造的instance的引用,t2想當然地對instance進行操作,結果是微妙的。
看到這裏,結合上一次的討論,可能你已經明白了,“說了這麼久,原來還不是勞什子的可見性問題,翠花,上volatile~”
public class WorldCup { private volatile static WorldCup instance; public static WorldCup getInstance() { if (instance == null) { //C synchronized (WorldCup.class) { if (instance == null) // A instance = new WorldCup();// B } } return instance; } }
“這下,你,們,該,滿,足,了,吧?”
5. Yet another 解決方法
恭喜你,成功了。但是,其實我還想說,恭喜你,你out了。隨着時代的發展JVM的進步,DCL這樣的技巧已經逐步被淘汰了,而lazy initialization holder這樣的新秀在效率上和DCL已經沒什麼差別:
public class WorldCup { private static class WorldCupHolder{ public static WorldCup instance = new WorldCup(); } public static WorldCup getInstance() { return WorldCupHolder.instance; } }
同樣是“惰性初始化”,這個是不是更好看?
在這裏我們回過頭來看看我們最初的eager initialization,你這時可能會反過來思考,他不是volatile,會不會有escape問題?不會。因爲Java保證了域初始化段落對其餘操作的HB關係。好了,這下,雷米特家的河東獅估計可以休矣。
6. 討論的延續
關於上面阿根廷的例子,寫的時候我發現一點疑問,把自己的理解拿出來和大家討論。那就是如果我把isMessiReady = true(記做A)放在新線程start(記做B)的前面,在這裏新線程不就可以保證HB關係了麼?因爲有IntraThread原則,A hb B,而我們又有start操作hb於任何該線程的動作,比如tell(記做C),那麼不就有A hb B hb C了麼?可以保證新聞發佈會上梅西肯定在場。那麼,爲什麼幾乎所有看到的資料裏都警告說“即使是構造函數的最後一個操作,也不要啓新線程、加listener、調可重寫函數”云云?我的理解是,是爲了防止這種情況:
class SuperArgentina extends Argentina{ private boolean isDiegoReady; public SuperArgentina() { super(); isDiegoReady =true; } @Override protected void tell() { super.tell(); System.out.println("Is Diego Here?:"+isDiegoReady); } }
我們拓展阿根廷爲“超級阿根廷”,加入老馬的狀態,這樣一來,老馬自己在不在場就成問題了。關於上面的這段分析,我的把握並不是特別大,希望大牛們能夠提點一下。
主要參考資料:
1. JavaWorld文章:Double-checked locking: Clever, but broken
2. Java Concurrency in Practice
3. GoF設計模式