面試官:請你寫個單例模式
你:(太簡單了吧,我給他來個“餓漢式”,再來個“懶漢式”)
(2分鍾后,你的代碼新鮮出爐了)
餓漢式單例模式代碼
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
懶漢式單例模式代碼
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {
}
public static LazySingleton getInstance() {
if (instance == null) { // 1
instance = new LazySingleton(); // 2
}
return instance;
}
}
(很棒~但是他們真的時單例嗎)
代碼分析
第一段代碼
instance 是一個類變量,類變量再類初始化時創建,類初始化時相當於會加個鎖,保證原子性。因此他確實能保證單例,除非時多次加載這個類。
第二段代碼
單線程環境下沒有問題,確實是單例。
多線程下則需要考慮下了.
假設線程A走到了2,同時線程B走到了1. 線程A走完了,實例化了LazySingleton
,由於B在A還沒有給instance
賦值時走到了1,所以判斷為instance==null
, 所以他也會創建一個LazySingleton
實例。
因此此段代碼存在線程安全問題,也就是不能保證LazySingleton
是單例的。
解決方案
方案一:直接給獲取實例的方法加鎖
我們可以通過將getInstance
變為同步方法來保證同一時刻只能有一個線程進入到方法。
如下:
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {
}
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
這種方式簡單粗暴,但是當高並發去獲取單例時,只有一個線程能競爭到鎖,其他的線程都將阻塞,效率低下。
方案二:雙重鎖定
public class DoubleCheckLocking {
private static DoubleCheckLocking instance;
public DoubleCheckLocking() {
}
public DoubleCheckLocking getInstance() {
if (instance == null) { // 1
synchronized (DoubleCheckLocking.class) {
if (instance == null) {
instance = new DoubleCheckLocking(); // 問題根源
}
}
}
return instance;
}
}
這段代碼很巧妙,前一種方法直接將getInstance
方法變為同步方法會帶來效率低下問題,那么我們就只在創建對象的時候加鎖,這樣既能保證效率,也能保證單例。
然而,這種方式也有問題,方騰飛老師在《Java並發編程藝術》中無情地嘲諷了這種做法,原文如下:
因此,人們想出了一個“聰明”的技巧:雙重檢查鎖定(Double-Checked Locking)
問題的根源就在於new DoubleCheckLocking()
這句話,創建一個對象大體分為三步, 偽碼表示如下:
memory=allocate() 1分配對象內存
ctorInstance(memory) 2初始化對象
instance=memory 3引用指向創建的對象
其中2和3是可能重排序的,因為他們不存在數據依賴性。也就是3可能在2之前執行。
假設A線程獲取單例按1、3、2走到了3,那么此時instance
不為null
, 此時B線程走到1處,直接將instance
返回了,所以調用方拿到了一個未被初始化的對象。
所以,這個方法嚴格來講是不可取的。
方案二:改良雙重鎖定
方案很簡單,直接在instance變量前加volatile關鍵字,如下
private static volatile DoubleCheckLocking instance;
加上volatile可以阻止上述2、3兩條指令的重排序。
方案三:基於類初始化
public class MySingleInstance {
private MySingleInstance() {
}
private static class InstanceHolder {
public static MySingleInstance instance = new MySingleInstance();
}
public static MySingleInstance getInstance() {
return InstanceHolder.instance;
}
}
JVM在執行類的初始化期間會去獲取一個鎖, 這個鎖可以同步多個線程對同一個類的初始化。
一些知識點
這里簡單總結下以上解決方案中涉及的一些知識點, 只是知識點的簡單羅列,后面會繼續寫一些文章來介紹。
線程
線程是輕量級進程,是系統調度的基本單元,線程之間可以共享內存變量,每個線程都有自己獨立的計數器、堆棧和局部變量。
syncronized
方案一中我們通過syncronized將獲取實例的方法同步化了。
三種形式
- 普通同步方法,鎖為當前實例對象
- 靜態同步方法,鎖為當前類的Class對象
- 同步代碼塊,鎖為()里邊的那個對象
基本原理
在對象頭存儲鎖信息,基於進入和退出Monitor對象來實現同步方法和代碼塊同步。
volatile
方案三中,我們通過volatile解決了重排序和內存可見性問題。
volatile的特點:
- 輕量級的synchronized,不會引起線程上下文切換
- 保證共享變量可見性,即一個線程修改共享變量時,其他線程能讀到修改后的值
- 加了volatile后,寫操作會立即從本地內存刷新到主內存,讀操作會直接標記本地內存無效,從主內存中讀取
這里的本地內存只是一個抽象概念,並非真實存在
重排序
方案二中,我們分析是重排序導致這個方案存在問題。
重排序是編譯器或處理器為了優化程序性能對指令序列進行重新排列的過程。
分類:
- 編譯器的指令重排序
- 處理器的指令重排序
處理器的指令重排序規則較為寬松,java編譯器為了防止處理器對某些指令重排序會使用內存屏障。
例如上面的volatile, 編譯器生成字節碼時會通過加入內存屏障來阻止cpu對volatile變量讀寫操作的重排序。
內部類
在方案三中,我們使用到了內部類。內部類就是類里邊的類。
外部類無法訪問內部類成員,只能通過內部類的實例訪問。
內部類可以直接訪問外部類的信息,靜態內部類不能訪問實例成員。
按照其所處的不同位置可以分為:
- 成員內部類
- 靜態內部類
- 方法內部類
- 匿名內部類
總結
本文介紹常見寫單例的方式存在的問題及解決方案,並將解決方案中涉及的重要知識點做了簡單羅列。