最初的代碼
在最近的項目中,寫出了這樣的一段代碼
private static SomeClass instance;
public SomeClass getInstance() {
if (null == instance) {
instance = new SomeClass();
}
return instance;
}
然後在Code Review的時候被告知在多線程的情況下,這樣寫可能會導致instance
有多個實例。比如下面這種情況:
Time | Thread A | Thread B |
---|---|---|
t1 | A1 檢查到instance爲空 | |
t2 | B1 檢查到instance爲空 | |
t3 | B2 初始化對象 | |
t4 | A2 初始化對象 |
第一次的解決方案
於是就想到了爲這個方法加鎖,如下:
private static SomeClass instance;
public synchronized SomeClass getInstance() {
if (null == instance) {
instance = new SomeClass();
}
return instance;
}
但是又被提醒這樣雖然解決了問題,但是會導致很大的性能開銷,而加鎖只需要在第一次初始化的時候用到,之後的調用都沒必要再進行加鎖,於是就瞭解到了雙重檢查鎖(double checked locking)的辦法。
第二次的解決方案
private static SomeClass instance;
public SomeClass getInstance() {
if (null == instance) { // 第一重檢查
synchronized (this) {
if (null == instance) { // 第二重檢查
instance = new SomeClass(); // 這裏有問題
}
}
}
return instance;
}
這樣寫的話,運行順序就成了:
- 檢查變量是否被初始化(不去獲得鎖),如果已被初始化立即返回這個變量。
- 獲取鎖
- 第二次檢查變量是否已經被初始化:如果其他線程曾獲取過鎖,那麼變量已被初始化,返回初始化的變量。否則,初始化並返回變量。
這樣,除了初始化的時候會出現加鎖的情況,後續的所有調用都會避免加鎖而直接返回,從而避免了性能問題,而且看似也解決了同步的問題,然而這樣寫有個很大的隱患。詳細原因如下:
實例化對象的那行代碼(標記爲有問題的那行),實際上可以分解成以下三個步驟:
- 分配內存空間
- 初始化對象
- 將對象指向剛分配的內存空間
但是有些編譯器爲了性能的原因,可能會將第二步和第三步進行重排序,順序就成了:
- 分配內存空間
- 將對象指向剛分配的內存空間
- 初始化對象
現在考慮重排序後,發生了以下這種調用:
Time | Thread A | Thread B |
---|---|---|
t1 | A1 檢查到instance爲空 | |
t2 | A2 獲取鎖 | |
t3 | A3 再次檢查到instance爲空 | |
t4 | A4 爲instance分配內存空間 | |
t5 | A5 將instance指向內存空間 | |
t6 | B1 檢查到instance不爲空 | |
t7 | B2 訪問instance(對象還未初始化) | |
t8 | A6 初始化instance |
注意,在這種情況下,t7時刻線程B對instance的訪問,訪問的是一個初始化未完成的對象!
最終的解決方案
爲了解決上述問題,可以在instance
前加入關鍵字volatile
。使用了volatile變量後,就能保證先行發生關。對於volatile變量,所有的寫(write)都將先行發生於讀(read),但在Java5之前不是這樣,所以這樣的方法只針對Java5及以上的版本。
private volatile static SomeClass instance;
public SomeClass getInstance() {
if (null == instance) {
synchronized (Test.class) {
if (null == instance) {
instance = new SomeClass();
}
}
}
return instance;
}
至此,雙重檢查鎖就可以完美工作了。現在實現單例模式也有了別的更好的辦法,但個人覺得這樣的坑很有教育意義,故做此記錄。
參考資料: