經典的面試題:DCL需不需要被volatile關鍵字修飾?為什麼?
歡迎大家搜尋“小猴子的技術筆記”關注我的公眾號,有問題可以及時和我交流。
DCL(Double-Checked Locking)雙重檢查鎖。在Java的多執行緒中,有時候需要採用延遲初始化來降低初始化類和建立物件的開銷,使用雙重檢查所是常見的延遲初始化的技術。但是,要正確使用執行緒安全的延遲初始化需要一些技巧,否則很容易出現問題。
首先來看看下面這段程式碼。下面的程式碼是一個典型的懶載入單例模式的實現,使用了延遲載入來降低同步的開銷。請你猜一猜它會不會有執行緒安全的問題:
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { // 第一次檢查instance是否為空 if (instance == null) { // 加鎖進行instance的初始化 synchronized (Singleton.class) { // 再次判斷instance是否為空 if (instance == null) { instance = new Singleton(); } } } return instance; } }
也許從程式碼來看似乎很完美。因為如果第一次檢查instance不為null,就不會繼續執行加鎖的方法,而是直接返回例項化好的instance。這樣來看確實大幅降低了synchronized帶來的效能開銷。如果為null,就會繼續執行加鎖的方法。如果一個執行緒拿到鎖了,就會再次檢查instance是否為null,如果不為null就進行例項化。
也許它就是你看到的表面完美,因為它是存在錯誤的。如果執行到第一次檢查instance不為null的時候,instance引用的物件可能還沒有完成初始化。
上述程式碼“instance = new Singleton()”的目的是建立一個物件,這一行程式碼的從無到有可以經歷如下的步驟:
注意:在一些JIT編譯器上,步驟2和步驟3是有可能發生重排序的。重排序之後的執行順序如下:
因此我們回到上面的程式碼示例中:假設這個單例還沒有被初始化,它也允許了JIT編譯器對步驟2和步驟3進行了重排序。這個時候有兩個執行緒同時來訪問這個單例,那麼就有可能存在其中某一個執行緒訪問到的物件是沒有初始化的物件。
下面來做一個解析:再假設執行緒A先訪問這個單例,然後走到重排序之後的步驟2的時候,執行緒B也來訪問這個單例了。這個時候看到了這個單例已經在記憶體中存在了(因為判斷物件是否為空,判斷的是有沒有在記憶體開闢空間),就會認為之前已經有人已經初始化過這個單例了,就會直接返回。但是它拿到的其實是一個還沒有經過初始化的單例物件。
因此上面這個示例並不是安全的單例懶載入。
如果你看過我之前寫的文章介紹禁止重排序的關鍵字的話,你就應該能夠想到volatile。因此上面程式碼只需要做一點點小小的修改就可以實現執行緒安全的延遲初始化:
public class Singleton {
// 這裡增加了volatile關鍵字:禁止指令重排序
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
在“private static volatile Singleton instance;”新增一個volatile關鍵字,可以有效禁止指令的重排序來解決DCL引發的多執行緒同一時間進行的第一次訪問帶來的問題。
請注意:我這裡說的是“第一次”,因為instance被static關鍵字修飾了,所以它一旦初始化了就是全域性的,因此這裡多執行緒問題,也是第一次併發訪問的時候出現的問題。