設計模式之單列模式
6.單例模式(Singleton Pattern)
前面說提到的五種建立模式,主要解決的問題是如何建立物件,獲得產品。而單例模式最要關心的則是物件建立的次數以及何時被建立。
Singleton模式可以是很簡單的,它的全部只需要一個類就可以完成(看看這章可憐的UML圖)。但是如果在“物件建立的次數以及何時被建立”這兩點上較真起來,Singleton模式可以相當的複雜,比頭五種模式加起來還複雜,譬如涉及到DCL雙鎖檢測(double checked locking)的討論、涉及到多個類載入器(ClassLoader)協同時、涉及到跨JVM(叢集、遠端EJB等)時、涉及到單例物件被銷燬後重建等。對於複雜的情況,本章中會涉及到其中一些
目的:
希望物件只建立一個例項,並且提供一個全域性的訪問點。
場景:
Kerrigan對於Zerg來說是個至關重要的靈魂人物,無數的Drone、Zergling、Hydralisk……可以被創造、被犧牲,但是Kerrigan得存在關係到Zerg在這局遊戲中的生存,而且Kerrigan是不允許被多次創造的,必須有且只有一個蟲族刀鋒女王的例項存在,這不是遊戲規則,但這是個政治問題。
分析:
如前面一樣,我們還是嘗試使用程式碼來描述訪問Kerrigan的過程,看看下面的UML圖,簡單得我都不怎麼好意思放上來佔版面。
圖6.1 單例模式的UML圖
結構是簡單的,只是我們還有一些小小的要求如下:
1.最基本要求:每次從getInstance()都能返回一個且唯一的一個Kerrigan物件。
2.稍微高一點的要求:Kerrigan很忙,很多人找,所以希望這個方法能適應多執行緒併發訪問。
3.再提高一點的要求:Zerg是講究公務員效率的社會,希望找Kerrigan的方法效能儘可能高。
4.最後一點要求是Kerrigan自己提出的:體諒到Kerrigan太累,希望多些睡覺時間,因此Kerrigan希望實現懶載入(Lazy Load),在需要的時候才被構造。
5.原本打算說還提要處理多ClassLoader、多JVM等情況,不過還是不要把情況考慮的太複雜了,暫且先放過作者吧(-_-#)。
我們第一次寫的單例模式是下面這個樣子的:
這個寫法我們把四點需求從上往下檢測,發現第二點的時候就出了問題,假設這樣的場景:兩個執行緒併發呼叫SingletonKerriganA.getInstance(),假設執行緒一先判斷完instance是否為null,既程式碼中的line A進入到line B的位置。剛剛判斷完畢後,JVM將CPU資源切換給執行緒二,由於執行緒一還沒執行line B,所以instance仍然是空的,因此執行緒二執行了new SignletonKerriganA()操作。片刻之後,執行緒一被重新喚醒,它執行的仍然是new SignletonKerriganA()操作,好了,問題來了,兩個Kerrigan誰是李逵誰是李鬼?
緊接著,我們做單例模式的第二次嘗試:
比起第一段程式碼僅僅在方法中多了一個synchronized修飾符,現在可以保證不會出執行緒問題了。但是這裡有個很大(至少耗時比例上很大)的效能問題。除了第一次呼叫時是執行了SingletonKerriganB的建構函式之外,以後的每一次呼叫都是直接返回instance物件。返回物件這個操作耗時是很小的,絕大部分的耗時都用在synchronized修飾符的同步準備上,因此從效能上說很不划算。
那繼續把程式碼改成下面的樣子:
基本上,把synchronized移動到程式碼內部是沒有什麼意義的,每次呼叫getInstance()還是要進行同步。同步本身沒有問題,但是我們只希望在第一次建立Kerrigan例項的時候進行同步,因此我們有了下面的寫法——雙重鎖定檢查(DCL)。
看起來這樣已經達到了我們的要求,除了第一次建立物件之外,其他的訪問在第一個if中就返回了,因此不會走到同步塊中。已經完美了嗎?
我們來看看這個場景:假設執行緒一執行到instance = new SingletonKerriganD()這句,這裡看起來是一句話,但實際上它並不是一個原子操作(原子操作的意思就是這條語句要麼就被執行完,要麼就沒有被執行過,不能出現執行了一半這種情形)。事實上高階語言裡面非原子操作有很多,我們只要看看這句話被編譯後在JVM執行的對應彙編程式碼就發現,這句話被編譯成8條彙編指令,大致做了3件事情:
1.給Kerrigan的例項分配記憶體。
2.初始化Kerrigan的構造器
3.將instance物件指向分配的記憶體空間(注意到這步instance就非null了)。
但是,由於Java編譯器允許處理器亂序執行(out-of-order),以及JDK1.5之前JMM(Java Memory Medel)中Cache、暫存器到主記憶體回寫順序的規定,上面的第二點和第三點的順序是無法保證的,也就是說,執行順序可能是1-2-3也可能是1-3-2,如果是後者,並且在3執行完畢、2未執行之前,被切換到執行緒二上,這時候instance因為已經線上程一內執行過了第三點,instance已經是非空了,所以執行緒二直接拿走instance,然後使用,然後順理成章地報錯,而且這種難以跟蹤難以重現的錯誤估計除錯上一星期都未必能找得出來,真是一茶几的杯具啊。
DCL的寫法來實現單例是很多技術書、教科書(包括基於JDK1.4以前版本的書籍)上推薦的寫法,實際上是不完全正確的。的確在一些語言(譬如C語言)上DCL是可行的,取決於是否能保證2、3步的順序。在JDK1.5之後,官方已經注意到這種問題,因此調整了JMM、具體化了volatile關鍵字,因此如果JDK是1.5或之後的版本,只需要將instance的定義改成“private volatile static SingletonKerriganD instance = null;”就可以保證每次都去instance都從主記憶體讀取,就可以使用DCL的寫法來完成單例模式。當然volatile或多或少也會影響到效能,最重要的是我們還要考慮JDK1.42以及之前的版本,所以本文中單例模式寫法的改進還在繼續。
程式碼倒越來越複雜了,現在先來個返璞歸真,根據JLS(Java Language Specification)中的規定,一個類在一個ClassLoader中只會被初始化一次,這點是JVM本身保證的,那就把初始化例項的事情扔給JVM好了,程式碼被改成這樣:
好吧,如果這種寫法是完美的話,那前面那麼幾大段話就是作者在消遣各位讀者。這種寫法不會出現併發問題,但是它是餓漢式的,在ClassLoader載入類後Kerrigan的例項就會第一時間被建立,餓漢式的建立方式在一些場景中將無法使用:譬如Kerrigan例項的建立是依賴引數或者配置檔案的,在getInstance()之前必須呼叫某個方法設定引數給它,那樣這種單例寫法就無法使用了。
再來看看下面這種我覺得能應對較多場景的單例寫法:
這種寫法仍然使用JVM本身機制保證了執行緒安全問題;由於SingletonHolder是私有的,除了getInstance()之外沒有辦法訪問它,因此它是懶漢式的;同時讀取例項的時候不會進行同步,沒有效能缺陷;也不依賴JDK版本。
其他單例模式的寫法還有很多,如使用本地執行緒(ThreadLocal)來處理併發以及保證一個執行緒內一個單例的實現、GoF原始例子中使用註冊方式應對單例類需要需要繼承時的實現、使用指定類載入器去應對多ClassLoader環境下的實現等等。我們做開發設計工作的時,應當既要考慮到需求可能出現的擴充套件與變化,也應當避免“幻影需求”導致無謂的提升設計、實現複雜度,最終反而帶來工期、效能和穩定性的損失。設計不足與設計過度都是危害,所以說沒有最好的單例模式,只有最合適的單例模式。
到這裡為止,單例模式本身就先告一段落了,最後在介紹從其他途徑遮蔽構造單例物件的方法:
1.直接new單例物件
2.通過反射構造單例物件
3.通過序列化構造單例物件。
對於第一種情況,一般我們會加入一個private或者protected的建構函式,這樣系統就不會自動新增那個public的構造函數了,因此只能呼叫裡面的static方法,無法通過new建立物件。
對於第二種情況,反射時可以使用setAccessible方法來突破private的限制,我們需要做到第一點工作的同時,還需要在在ReflectPermission("suppressAccessChecks") 許可權下使用安全管理器(SecurityManager)的checkPermission方法來限制這種突破。一般來說,不會真的去做這些事情,都是通過應用伺服器進行後臺配置實現。
對於第三種情況,如果單例物件有必要實現Serializable介面(很少出現),則應當同時實現readResolve()方法來保證反序列化的時候得到原來的物件。
基於上述情況,將單例模式增加兩個方法:
總結:
本章通過一次次的的嘗試,去了解單例模式各種實現方案的優缺點。對雙鎖鎖定檢測進行了簡單的討論,相信大家能從各種嘗試的演化過程中,明白為何單例模式是最簡單而又最複雜的一種構造模式。
各種構造模式之間可以互相比較,但是沒有優劣好壞之分,只有確定了上下文環境,才能談應用什麼模式。學習設計模式我覺得也沒有必要去強背一些程式碼模版,應當去理解每種模式的出現的原因和解決的問題,當你發現你的設計需要更大靈活性時,設計便會向著合適的模式演化,這時候你就真正的掌握了設計模式。