摘要:單例在遊戲中會經常用到,它減少了對象分配的次數,也就是new的次數,每次使用對象時,對象已經提前執行個體化完成了。但是遊戲伺服器往往是一個多線程的環境,所以單例也必須是安全執行緒的。看一下這篇文章,你會明白很多 在GoF的23種設計模式中,單例模式是比較簡單的一種。然而,有時候越是簡單的東西越容易出現問題。下面就單例設計模式詳細的探討一下。 所謂單例模式,簡單來說,就是在整個應用中保證只有一個類的執行個體存在。就像是Java Web中的application,也就是提供了一個全域變數,用處相當廣泛,比如儲存全域資料,實現全域性的操作等。 1. 最簡單的實現 首先,能夠想到的最簡單的實現是,把類的建構函式寫成private的,從而保證別的類不能執行個體化此類,然後在類中提供一個靜態執行個體並能夠返回給使用者。這樣,使用者就可以通過這個引用使用到這個類的執行個體了。
public class SingletonClass { private static final SingletonClass instance = new SingletonClass(); public static SingletonClass getInstance() { return instance; } private SingletonClass() { } }
如上例,外部使用者如果需要使用SingletonClass的執行個體,只能通過getInstance()方法,並且它的構造方法是private的,這樣就保證了只能有一個對象存在。 2. 效能最佳化——lazy loaded 上面的代碼雖然簡單,但是有一個問題——無論這個類是否被使用,都會建立一個instance對象。如果這個建立過程很耗時,比如需要串連10000次資料庫(誇張了…:-)),並且這個類還並不一定會被使用,那麼這個建立過程就是無用的。怎麼辦呢。 為瞭解決這個問題,我們想到了新的解決方案:
public class SingletonClass { private static SingletonClass instance = null; public static SingletonClass getInstance() { if(instance == null) { instance = new SingletonClass(); } return instance; } private SingletonClass() { } }
代碼的變化有兩處——首先,把instance初始化為null,直到第一次使用的時候通過判斷是否為null來建立對象。因為建立過程不在聲明處,所以那個final的修飾必須去掉。 我們來想象一下這個過程。要使用SingletonClass,調用getInstance()方法。第一次的時候發現instance是null,然後就建立一個對象,返回出去;第二次再使用的時候,因為這個instance是static的,所以已經不是null了,因此不會再建立對象,直接將其返回。 這個過程就成為lazy loaded,也就是遲載入——直到使用的時候才進行載入。 3. 同步 上面的代碼很清楚,也很簡單。然而就像那句名言:“80%的錯誤都是由20%代碼最佳化引起的”。單線程下,這段代碼沒有什麼問題,可是如果是多線程,麻煩就來了。我們來分析一下: 線程A希望使用SingletonClass,調用getInstance()方法。因為是第一次調用,A就發現instance是null的,於是它開始建立執行個體,就在這個時候,CPU發生時間片切換,線程B開始執行,它要使用SingletonClass,調用getInstance()方法,同樣檢測到instance是null——注意,這是在A檢測完之後切換的,也就是說A並沒有來得及建立對象——因此B開始建立。B建立完成後,切換到A繼續執行,因為它已經檢測完了,所以A不會再檢測一遍,它會直接建立對象。這樣,線程A和B各自擁有一個SingletonClass的對象——單例失敗。 解決的方法也很簡單,那就是加鎖:
public class SingletonClass { private static SingletonClass instance = null; public synchronized static SingletonClass getInstance() { if(instance == null) { instance = new SingletonClass(); } return instance; } private SingletonClass() { } }
是要getInstance()加上同步鎖,一個線程必須等待另外一個線程建立完成後才能使用這個方法,這就保證了單例的唯一性。 4. 又是效能 上面的代碼又是很清楚很簡單的,然而,簡單的東西往往不夠理想。這段代碼毫無疑問存在效能的問題——synchronized修飾的同步塊可是要比一般的程式碼片段慢上幾倍的。如果存在很多次getInstance()的調用,那效能問題就不得不考慮了。 讓我們來分析一下,究竟是整個方法都必須加鎖,還是僅僅其中某一句加鎖就足夠了。我們為什麼要加鎖呢。分析一下出現lazy loaded的那種情形的原因。原因就是檢測null的操作和建立對象的操作分離了。如果這兩個操作能夠原子地進行,那麼單例就已經保證了。於是,我們開始修改代碼:
public class SingletonClass { private static SingletonClass instance = null; public static SingletonClass getInstance() { synchronized (SingletonClass.class) { if(instance == null) { instance = new SingletonClass(); } } return instance; } private SingletonClass() { } }
首先去掉getInstance()的同步操作,然後把同步鎖載入if語句上。但是這樣的修改起不到任何作用:因為每次調用getInstance()的時候必然要同步,效能問題還是存在。如果……如果我們事先判斷一下是不是為null再去同步呢。
public class SingletonClass { private static SingletonClass instance = null; public static SingletonClass getInstance() { if (instance == null) { synchronized (SingletonClass.class) { if (instance == null) { instance = new SingletonClass(); } } } return instance; } private SingletonClass() { } }
還有問題嗎。首先判斷instance是不是為null,如果為null,加鎖初始化;如果不為null,直接返回instance。 這就是double-checked locking設計實現單例模式。到此為止,一切都很完美。我們用一種很聰明的方式實現了單例模式。 5. 從源頭檢查 下面我們開始說編譯原理。所謂編譯,就是把原始碼“翻譯”成目標代碼——大多數是指機器代碼——的過程。針對Java,它的目標代碼不是本地機器代碼,而是虛擬機器代碼。編譯原理裡面有一個很重要的內容是編譯器最佳化。所謂編譯器最佳化是指,在不改變原來語義的情況下,通過調整語句順序,來讓程式啟動並執行更快。這個過程成為reorder。 要知道,JVM只是一個標準,並不是實現。JVM中並沒有規定有關編譯器最佳化的內容,也就是說,JVM實現可以自由的進行編譯器最佳化。 下面來想一下,建立一個變數需要哪些步驟呢。一個是申請一塊記憶體,調用構造方法進行初始化操作,另一個是分配一個指標指向這塊記憶體。這兩個操作誰在前誰在後呢。JVM規範並沒有規定。那麼就存在這麼一種情況,JVM是先開闢出一塊記憶體,然後把指標指向這塊記憶體,最後調用構造方法進行初始化。 下面我們來考慮這麼一種情況:線程A開始建立SingletonClass的執行個體,此時線程B調用了getInstance()方法,首先判斷instance是否為null。按照我們上面所說的記憶體模型,A已經把instance指向了那塊記憶體,只是還沒有調用構造方法,因此B檢測到instance不為null,於是直接把instance返回了——問題出現了,儘管instance不為null,但它並沒有構造完成,就像一套房子已經給了你鑰匙,但你並不能住進去,因為裡面還沒有收拾。此時,如果B在A將instance構造完成之前就是用了這個執行個體,程式就會出現錯誤了。 於是,我們想到了下面的代碼:
public class SingletonClass { private static SingletonClass instance = null; public static SingletonClass getInstance() { if (instance == null) { SingletonClass sc; synchronized (SingletonClass.class) { sc = instance; if (sc == null) { synchronized (SingletonClass.class) { if(sc == null) { sc = new SingletonClass(); } } instance = sc; } } } return instance; } private SingletonClass() { } }
我們在第一個同步塊裡面建立一個臨時變數,然後使用這個臨時變數進行對象的建立,並且在最後把instance指標臨時變數的記憶體空間。寫出這種代碼基於以下思想,即synchronized會起到一個代碼屏蔽的作用,同步塊裡面的代碼和外部的代碼沒有聯絡。因此,在外部的同步塊裡面對臨時變數sc進行操作並不影響instance,所以外部類在instance=sc;之前檢測instance的時候,結果instance依然是null。 不過,這種想法完全是錯誤的。同步塊的釋放保證在此之前——也就是同步塊裡面——的操作必須完成,但是並不保證同步塊之後的操作不能因編譯器最佳化而調換到同步塊結束之前進行。因此,編譯器完全可以把instance=sc;這句移到內部同步塊裡面執行。這樣,程式又是錯誤的了。 6. 解決方案 說了這麼多,難道單例沒有辦法在Java中實現嗎。其實不然。 在JDK 5之後,Java使用了新的記憶體模型。volatile關鍵字有了明確的語義——在JDK1.5之前,volatile是個關鍵字,但是並沒有明確的規定其用途——被volatile修飾的寫變數不能和之前的讀寫代碼調整,讀變數不能和之後的讀寫代碼調整。因此,只要我們簡單的把instance加上volatile關鍵字就可以了。
public class SingletonClass { private volatile static SingletonClass instance = null; public static SingletonClass getInstance() { if (instance == null) { synchronized (SingletonClass.class) { if(instance == null) { instance = new SingletonClass(); } } } return instance; } private SingletonClass() { } }
然而,這隻是JDK1.5之後的Java的解決方案,那之前版本呢。其實,還有另外的一種解決方案,並不會受到Java版本的影響:
public class SingletonClass { private static class SingletonClassInstance { private static final SingletonClass instance = new SingletonClass(); } public static SingletonClass getInstance() { return SingletonClassInstance.instance; } private SingletonClass() { } }
在這一版本的單例模式實現代碼中,我們使用了Java的靜態內部類。這一技術是被JVM明確說明了的,因此不存在任何二義性。在這段代碼中,因為SingletonClass沒有static的屬性,因此並不會被初始化。直到調用getInstance()的時候,會首先載入SingletonClassInstance類,這個類有一個static的SingletonClass執行個體,因此需要調用SingletonClass的構造方法,然後getInstance()將把這個內部類的instance返回給使用者。由於這個instance是static的,因此並不會構造多次。 由於SingletonClassInstance是私人靜態內部類,所以不會被其他類知道,同樣,static語義也要求不會有多個執行個體存在。並且,JSL規範定義,類的構造必須是原子性的,非並發的,因此不需要加同步塊。同樣,由於這個構造是並發的,所以getInstance()也並不需要加同步。 至此,我們完整的瞭解了單例模式在Java語言中的時候,提出了兩種解決方案。個人偏向於第二種,並且Effiective Java也推薦的這種方式。