設計模式【1】-- 單例模式到底幾種寫法?
單例模式,是一種比較簡單的設計模式,也是屬於建立型模式(提供一種建立物件的模式或者方式)。
要點:
- 1.涉及一個單一的類,這個類來建立自己的物件(不能在其他地方重寫建立方法,初始化類的時候建立或者提供私有的方法進行訪問或者建立,必須確保只有單個的物件被建立)。
- 2.單例模式不一定是執行緒不安全的。
- 3.單例模式可以分為兩種:懶漢模式(在第一次使用類的時候才建立,可以理解為類載入的時候特別懶,要用的時候才去獲取,要是沒有就建立,由於是單例,所以只有第一次使用的時候沒有,建立後就可以一直用同一個物件),餓漢模式(在類載入的時候就已經建立,可以理解為餓漢已經餓得飢渴難耐,肯定先把資源緊緊拽在自己手中,所以在類載入的時候就會先建立例項)
關鍵字:
- 單例:
singleton
- 例項:
instance
- 同步:
synchronized
餓漢模式
1.私有屬性
第一種single
是public
,可以直接通過Singleton
類名來訪問。
public class Singleton {
// 私有化構造方法,以防止外界使用該構造方法建立新的例項
private Singleton(){
}
// 預設是public,訪問可以直接通過Singleton.instance來訪問
static Singleton instance = new Singleton();
}
2.公有屬性
第二種是用private
singleton
,那麼就需要提供static
方法來訪問。
public class Singleton {
private Singleton(){
}
// 使用private修飾,那麼就需要提供get方法供外界訪問
private static Singleton instance = new Singleton();
// static將方法歸類所有,直接通過類名來訪問
public static Singleton getInstance(){
return instance;.
}
}
3. 懶載入
餓漢模式,這樣的寫法是沒有問題的,不會有執行緒安全問題(類的static
instance
的初始化是在類載入的時候就在進行的,所以類載入是由ClassLoader
來實現的,那麼初始化得比較早好處是後來直接可以用,壞處也就是浪費了資源,要是隻是個別類使用這樣的方法,依賴的資料量比較少,那麼這樣的方法也是一種比較好的單例方法。在單例模式中一般是呼叫
getInstance()
方法來觸發類裝載,以上的兩種餓漢模式顯然沒有實現lazyload
(個人理解是用的時候才觸發類載入)所以下面有一種餓漢模式的改進版,利用內部類實現懶載入。
這種方式
Singleton類
被載入了,但是instance
也不一定被初始化,要等到SingletonHolder
被主動使用的時候,也就是顯式呼叫getInstance()
方法的時候,才會顯式的裝載SingletonHolder
類,從而例項化instance
。這種方法使用類裝載器保證了只有一個執行緒能夠初始化instance
,那麼也就保證了單例,並且實現了懶載入。
值得注意的是:靜態內部類雖然保證了單例在多執行緒併發下的執行緒安全性,但是在遇到序列化物件時,預設的方式執行得到的結果就是多例的。
public class Singleton {
private Singleton(){
}
//內部類
private static class SingletonHolder{
private static final Singleton instance = new Singleton();
}
//對外提供的不允許重寫的獲取方法
public static final Singleton getInstance(){
return SingletonHolder.instance;
}
}
懶漢模式
最基礎的程式碼(執行緒不安全):
public class Singleton {
private static Singleton instance = null;
private Singleton(){
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
這種寫法,是在每次獲取例項instance
的時候進行判斷,如果沒有那麼就會new
一個出來,否則就直接返回之前已經存在的instance
。但是這樣的寫法不是執行緒安全的,當有多個執行緒都執行getInstance()
方法的時候,都判斷是否等於null的時候,就會各自建立新的例項,這樣就不能保證單例了。所以我們就會想到同步鎖,使用synchronized關鍵字:
加同步鎖的程式碼(執行緒安全,效率不高)
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
synchronized(Singleton.class){
if (instance == null)
instance = new Singleton();
}
return instance;
}
}
這樣的話,getInstance()
方法就會被鎖上,當有兩個執行緒同時訪問這個方法的時候,總會有一個執行緒先獲得了同步鎖,那麼這個執行緒就可以執行下去,而另一個執行緒就必須等待,等待第一個執行緒執行完getInstance()
方法之後,才可以執行。這段程式碼是執行緒安全的,但是效率不高,因為假如有很多執行緒,那麼就必須讓所有的都等待正在訪問的執行緒,這樣就會大大降低了效率。那麼我們有一種思路就是,將鎖出現等待的概率再降低,也就是我們所說的雙重校驗鎖(雙檢鎖)。
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null){
synchronized(Singleton.class){
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
1.第一個if判斷,是為了降低鎖的出現概率,前一段程式碼,只要執行到同一個方法都會觸發鎖,而這裡只有singleton
為空的時候才會觸發,第一個進入的執行緒會建立物件,等其他執行緒再進入時物件已建立就不會繼續建立,如果對整個方法同步,所有獲取單例的執行緒都要排隊,效率就會降低。
2.第二個if判斷是和之前的程式碼起一樣的作用。
上面的程式碼看起來已經像是沒有問題了,事實上,還有有很小的概率出現問題,那麼我們先來了解:原子操作,指令重排。
1.原子操作
- 原子操作,可以理解為不可分割的操作,就是它已經小到不可以再切分為多個操作進行,那麼在計算機中要麼它完全執行了,要麼它完全沒有執行,它不會存在執行到中間狀態,可以理解為沒有中間狀態。比如:賦值語句就是一個原子操作:
n = 1; //這是一個原子操作
假設n的值以前是0,那麼這個操作的背後就是要麼執行成功n等於1,要麼沒有執行成功n等於0,不會存在中間狀態,就算是併發的過程中也是一樣的。
下面看一句不是原子操作的程式碼:
int n =1; //不是原子操作
原因:這個語句中可以拆分為兩個操作,1.宣告變數n,2.給變數賦值為1,從中我們可以看出有一種狀態是n被聲明後但是沒有來得及賦值的狀態,這樣的情況,在併發中,如果多個執行緒同時使用n,那麼就會可能導致不穩定的結果。
2.指令重排
所謂指令重排,就是計算機會對我們程式碼進行優化,優化的過程中會在不影響最後結果的前提下,調整原子操作的順序。比如下面的程式碼:
int a ; // 語句1
a = 1 ; // 語句2
int b = 2 ; // 語句3
int c = a + b ; // 語句4
正常的情況,執行順序應該是1234,但是實際有可能是3124,或者1324,這是因為語句3和4都沒有原子性問題,那麼就有可能被拆分成原子操作,然後重排.
原子操作以及指令重排的基本瞭解到這裡結束,看回我們的程式碼:
主要是
instance = new Singleton()
,根據我們所說的,這個語句不是原子操作,那麼就會被拆分,事實上JVM(java虛擬機器)對這個語句做的操作:
- 1.給instance分配了記憶體
- 2.呼叫Singleton的建構函式初始化了一個成員變數,產生了例項,放在另一處記憶體空間中
- 3.將instance物件指向分配的記憶體空間,執行完這一步才算真的完成了,instance才不是null。
在一個執行緒裡面是沒有問題的,那麼在多個執行緒中,JVM做了指令重排的優化就有可能導致問題,因為第二步和第三步的順序是不能夠保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance
已經是非 null
了(但卻沒有初始化),所以執行緒二會直接返回instance
,然後使用,就會報空指標。
從更上一層來說,有一個執行緒是instance已經不為null但是仍沒有完成初始化中間狀態,這個時候有一個執行緒剛剛好執行到第一個if(instance==null
),這裡得到的instance
已經不是null
,然後他直接拿來用了,就會出現錯誤。
對於這個問題,我們使用的方案是加上volatile
關鍵字。
public class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null){
synchronized(Singleton.class){
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
volatile
的作用:禁止指令重排,把instance
宣告為volatile
之後,這樣,在它的賦值完成之前,就不會呼叫讀操作。也就是在一個執行緒沒有徹底完成instance = new Singleton()
;之前,其他執行緒不能夠去呼叫讀操作。
- 上面的方法實現單例都是基於沒有複雜序列化和反射的時候,否則還是有可能有問題的,還有最後一種方法是使用列舉來實現單例,這個可以說的比較理想化的單例模式,自動支援序列化機制,絕對防止多次例項化。
public enum Singleton {
INSTANCE;
public void doSomething() {
}
}
以上最推薦列舉方式,當然現在計算機的資源還是比較足夠的,餓漢方式也是不錯的,其中懶漢模式下,如果涉及多執行緒的問題,也需要注意寫法。
最後提醒一下,volatile
關鍵字,只禁止指令重排序,保證可見性(一個執行緒修改了變數,對任何其他執行緒來說都是立即可見的,因為會立即同步到主記憶體),但是不保證原子性。
【作者簡介】:
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。這個世界希望一切都很快,更快,但是我希望自己能走好每一步,寫好每一篇文章,期待和你們一起交流。
此文章僅代表自己(本菜鳥)學習積累記錄,或者學習筆記,如有侵權,請聯絡作者核實刪除。人無完人,文章也一樣,文筆稚嫩,在下不才,勿噴,如果有錯誤之處,還望指出,感激不盡~