劍指offer-面試題2 實現單例模式
我也不知道面試題1去哪兒了。。
面試題2 實現單例模式
1. 單例模式的定義
單例模式最初的定義出現於《設計模式》(艾迪生維斯理,1994):“保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。”
另一個常見的定義是:一個類只有一個例項,並且自行例項化向整個系統提供。
這兩句話的意思就是,當我們需要用到某個例項的時候,我們無需進行其它多餘操作,而是直接通過某個介面獲取到它的例項,並且這個例項在整個系統中保證唯一。
舉個簡單的例子:我們在平時使用電腦時,我們希望點選“設定”按鈕,就可以直接訪問設定,而且要求設定在整個系統中是唯一的(這是廢話),電腦的設定在這裡就是一個單例。
我們通過定義,得出完成單例模式需要滿足下面兩個條件:
1. 生成類的例項要唯一。也就是生成程式碼只能執行一次,“阻止”所有想要生成新物件的操作;
2. 生成例項的方法必須是全域性方法(也就是靜態)。原因是非靜態方法必須通過例項進行呼叫,如果已經有了例項,我們還需要生成例項的方法幹什麼呢?
那麼如何具體實現單例模式呢?
2. 一個小例子
我們有個小需求:要獲取電腦的現在時間,試著寫一個MyTime
類。
實現1:
import java.util.Date;
public class MyTime{
...
private static Date time = new Date();
public static Date getTime() {
return time;
}
...
}
實現2:
...
private static Date time;
public static Date getTime() {
time = new Date();
return time;
}
...
程式碼簡單呼叫Date()
介面實現了MyTime
類,其中兩個實現都滿足了要求。
它們有什麼區別呢?第一個例子直接獲取了當前時間,而第二個例子中,當我們需要time
時,呼叫getTime()
再進行建立,降低了初始化時間,但是每次呼叫都會新獲取新的Date()
,事實上與單例模式的定義相悖。
這裡介紹一個概念,延遲載入。延遲載入(lazy loading),就是Java虛擬機器在進行類載入的時候不建立物件,當我們需要時再進行建立。這樣做可以減少執行時間,提高系統的效能。
為了提高系統性能,單例模式中應該儘量實現延遲載入(lazy loading)。
第二個例子實現了延遲載入。但是,它不是一個單例模式,而且它是執行緒不安全的。後面我們會對它進行改良,實現執行緒安全的單例模式。
3. 餓漢模式(執行緒安全)
在實現1的基礎上改進一下:
public class Ex02Singleton {
private Ex02Singleton(){}
private static Ex02Singleton singleton = new Ex02Singleton();
public static Ex02Singleton getInstance(){
return singleton;
}
}
我們先建立一個Ex02Singleton
的例項,之後在呼叫getInstance()
方法中返回這個例項就可以了。這樣例項的唯一性就得到了保證 ,這是一種可行的方法。
這種辦法為什麼是執行緒安全的呢?這涉及到JVM在類的初始化階段給出的執行緒安全性保證。因為JVM在類初始化階段,會獲取一個鎖,並且每個執行緒都會至少獲取一次這個鎖以確保這個類已經載入。
在靜態初始化期間,記憶體的寫入操作自動對所有執行緒可見,而singleton
的初始化就是屬於靜態初始化。因此,在構造期間或者被引用時,靜態初始化的物件都不需要顯式的同步。
但是這個規則只適用於在構造時的狀態,如果物件可變,那麼在其它地方對該物件的訪問還是需要使用同步來確保對物件的修改操作是可見的。
優點:執行緒安全,程式碼簡單;
缺點:不能延遲載入,系統性能會有所降低。
4. 懶漢模式( 執行緒不安全)
根據實現2:
public class Ex02Singleton {
private Ex02Singleton(){}
public static Ex02Singleton getInstance(){
return new Ex02Singleton();
}
}
這個例子的建構函式Ex02Singleton()
是私有的,因為一旦公有,任何人都能通過建構函式建立新的例項,這樣就不能保證例項的唯一性。
但現在的問題是:每當我們呼叫getInstace()
方法時,它都會返回一個新的Ex02Singleton
例項。多次呼叫就會產生多個Ex02Singleton
例項,這和單例模式中例項的唯一性相悖。
如何改進呢?
事實上我們應該先對singleton
先進行判斷,如果不為null
,就直接返回;為null
時再去建立,之後返回,這樣就不會有實現1的問題了。
改進後:
public class Ex02Singleton {
private Ex02Singleton(){}
private static Ex02Singleton singleton;
public static Ex02Singleton getInstance(){
if(singleton == null)
singleton = new Ex02Singleton();
return singleton;
}
}
程式碼很簡單,先宣告靜態的Ex02Singleton
型變數singleton
,不進行例項化,在呼叫getInstance()
方法時進行判斷,如果singleton
還沒有被例項化就進行例項化,這樣做實現了延遲載入。這就是與餓漢模式相對應的懶漢模式。
懶漢模式的問題
懶漢模式個致命的問題,這是由多執行緒訪問時出現的執行緒不安全問題。看下圖:
有兩個執行緒A和B,當A執行緒往下執行,執行完命令
if(singleton == null)
判定結果為true
。此時,執行緒被中止(這個過程是系統隨機的,也有可能不中止一直執行下去)。然後執行緒B開始執行,它也執行到這句:
if(singleton == null)
因為執行緒A判斷完就中止了,還沒來得及建立例項,B執行這句的結果也會是true
,接著它建立了一個例項,到A繼續執行時還會建立新的Ex02Singleton
例項。這樣就有兩個例項存在。
為了避免類似的情況發生,Java中出現了同步關鍵字synchronized
,來保證被其修飾的程式碼塊會被加同步鎖,同一時間段內只能有一個執行緒訪問它,直到程式碼執行完畢,才會釋放這部分程式碼。
修改一下程式碼,如下:
public static synchronized Ex02Singleton getInstance(){
if(singleton == null)
singleton = new Ex02Singleton();
return singleton;
}
這樣看似不錯,多執行緒問題得到了解決。但是同步加鎖是一種耗費時間的操作,getInstance()
方法不是什麼敏感操作,我們只需要在第一次例項化時需要加鎖,之後呼叫getInstance()
方法都沒有必要加鎖。
所以這種方法雖然能保證執行緒安全和延遲載入,但實際應用中由於效率太低,不會有人去用它。
我們得想個方法,既能保證在例項化時加同步鎖,又在每次呼叫getInstance()
方法時正常執行。
優點:能延遲載入,對單執行緒程式無影響;
缺點:執行緒不安全。
5. 雙重檢查鎖定+volatile關鍵字(執行緒安全)
(1)DCL(雙重檢查加鎖)
基於上面懶漢式 + synchronized
關鍵字加鎖的思想,我們對程式碼進行改進:
public static Ex02Singleton getInstance(){
if(singleton == null)
synchronized (Ex02Singleton.class){
if(singleton == null)
singleton = new Ex02Singleton();
}
return singleton;
}
這種雙重判斷被稱為雙重檢查加鎖(DCL,double check lock)。
其中用了兩個if()
判斷,第一個if
先判斷singleton
是否為null
:如果不為null
,說明singleton
已經被初始化了,直接返回singleton
;
如果singleton
為null
,說明singleton
還沒有被初始化,這樣才會去執行synchronized
修飾的程式碼塊內容,只在其初始化的時候呼叫一次。這樣的設計既能保證只產生一個例項,並且只在初始化的時候加同步鎖,也實現了延遲載入。
這個就是我們需要的操作了,可在實際操作中還是會發生問題,這又是怎麼回事呢?
(2)指令重排序
指令重排序的作用是為了優化指令,提高程式執行效率。指令重排序包括編譯器重排序和執行時重排序。
JVM規範規定,指令重排序可以在不影響單執行緒程式執行結果前提下進行。例如
instance = new Singleton();
可分解為如下虛擬碼:
memory = allocate(); //1:分配物件的記憶體空間
ctorInstance(memory); //2:初始化物件
instance = memory; //3:設定instance指向剛分配的記憶體地址
經過重新排序後:
memory = allocate(); //1:分配物件的記憶體空間
instance = memory; //3:設定instance指向剛分配的記憶體地址
//注意:此時物件還沒有被初始化!
ctorInstance(memory); //2:初始化物件
將第2步和第3步調換順序,在單執行緒情況下不會影響程式執行的結果,但是在多執行緒情況下就不一樣了。
這裡需要明確的一點是:對於synchronized
關鍵字,當一個執行緒訪問物件的一個synchronized(xx.class)
同步程式碼塊時,另一個執行緒仍然可以訪問該物件中的非synchronized(xx.class)
同步程式碼塊。
執行緒A執行了
instance = memory; //這對另一個執行緒B來說是可見的
此時執行緒B執行外層
if (instance == null) {
...
}
發現singleton
不為空,隨即返回,但是得到的卻是未被完全初始化的例項,在使用的時候必定會有風險,這正是雙重檢查鎖定的問題所在。
在JDK1.5之後,新增了volatile
關鍵字禁止指令重排序的功能:
public class Ex02Singleton {
private Ex02Singleton(){}
private static volatile Ex02Singleton singleton;
public static Ex02Singleton getInstance(){
if(singleton == null)
synchronized (Ex02Singleton.class){
if(singleton == null)
singleton = new Ex02Singleton();
}
return singleton;
}
}
volatile
關鍵字禁止指令重排序的做法是在對被其修飾的變數進行操作時,增加一個記憶體屏障(Memory Barrier或Memory Fence,指重排序時不能把後面的指令重排序到記憶體屏障之前的的位置)用以保證一致性。這樣我們就解決了指令重排序的問題。
(關於volatile
關鍵字的用法在此不詳述,參見《深入理解Java虛擬機器》第十二章即可。)
優點:能延遲載入,也能保證執行緒安全;
缺點:程式碼較複雜。
6. 延遲初始化佔位(Holder)類模式(推薦)
單例模式還有以下實現:
public class Ex02Singleton {
private Ex02Singleton(){}
private static class InstanceHolder{
public static final Ex02Singleton singleton = new Ex02Singleton();
}
public static Ex02Singleton getInstance(){
return InstanceHolder.singleton;
}
}
這種方式成為延遲初始化佔位(Holder)類模式。該模式引入了一個內部靜態類(佔位類),內部靜態類只有在呼叫時才會載入,既保證了Ex02Singleton
例項的延遲初始化,又保證了例項的唯一性。是一種提前初始化(餓漢式)和延遲初始化(飽漢式)的綜合模式,推薦使用這種操作。
這種方法基於在懶漢模式中提出的,JVM在類的初始化階段給出的執行緒安全性保證,將singleton
的例項化操作放置到一個靜態內部類中,在第一次呼叫getInstance()
方法時,JVM才會去載入InstanceHolder
類,同時初始化singleton
例項,因此,即使我們不採取任何同步策略,getInstance()
方法也是執行緒安全的。
優點:能延遲載入,也能保證執行緒安全。
7. 列舉
列舉(enum
,全稱為 enumeration), 是 JDK 1.5 中引入的新特性,存放在java.lang
包中。列舉的詳細用法見列舉的詳細用法。
public enum ExSingleton {
INSTANCE;
public void someMethod() {
}
}
需要時使用Singleton.INSTANCE
即可實現呼叫。
這種方式是Effective Java作者Josh Bloch 提倡的方式,它不僅能避免多執行緒同步問題,而且還能防止反序列化重新建立新的物件,可謂是很堅強的壁壘。
優點:程式碼十分簡潔,而且便於操作;
缺點:較為不常見。
總結
單例模式執行緒安全的寫法有以下幾種:
1. 餓漢式(不能延遲載入);
2. 雙重檢查鎖(DLC)+volatile
關鍵字;
3. 延遲初始化佔位類模式(Holder);
4. 列舉。
補充
在看Android原始碼時發現一個方法
package android.os;
public abstract class AsyncTask<Params, Progress, Result> {
…
private static Handler getHandler() {
synchronized (AsyncTask.class) {
if (sHandler == null) {
sHandler = new InternalHandler();
}
return sHandler;
}
}
…
}
在getHandler()
方法中,直接對if操作進行了同步鎖定。這引出了一個問題:synchronized
關鍵字的不同操作方式:synchronized(xx.class)
和方法中帶有synchronized
關鍵字的異同,這篇文章會抽空補上,參見 初步探究synchronized的用法 。