雙重檢查鎖定的問題以及解決方案

2020-09-30 14:00:21

來源:Java並行程式設計的藝術

1 典型例子

雙重檢查鎖定的典型例子,如下:

public class DoubleCheckedLocking {

    private static Instance instance;

    /**
     * 雙重檢查鎖定
     */
    public static Instance getInstance() {
        if (instance == null) {                           // 1、第一次檢查
            synchronized (DoubleCheckedLocking.class) {   // 2、加鎖
                if (instance == null) {                   // 3、第二次檢查
                    instance = new Instance();            // 4、建立物件 (問題的根源在這裡)
                }
            }
        }
        return instance;
    }
}

這樣的雙重檢查鎖定是一個錯誤的優化!!!

當執行緒讀取到instance不為null的時候,instance參照的物件有可能還沒有完成初始化。

2 問題的根源

問題的根源在於 4、建立物件 的流程可能會被重排序。

  • 建立物件的一般流程

建立物件的流程可以簡化為三步:分配記憶體空間、初始化物件、設定參照指向分配的記憶體地址。

memory = allocate();    // 1、分配物件的記憶體空間
initObject(memory);     // 2、初始化物件
instance = memory;      // 3、設定instance指向分配的記憶體地址

其中,2 和 3 之間可能被重排序。

  • 重排序之後的流程
memory = allocate();    // 1、分配物件的記憶體空間
instance = memory;      // 2、設定instance指向分配的記憶體地址
initObject(memory);     // 3、初始化物件

那麼,在發生重排序的情況下,AB兩個執行緒執行這段雙重檢查鎖定的程式碼時的執行順序可能是這樣的:

時間A 執行緒B 執行緒
t1A1:分配物件的記憶體空間
t2A2:設定instance指向記憶體空間的地址
t3B1:判斷instance是否為null
t4B2:由於instance不是null,B執行緒將直接存取instance參照的物件
t5A3:初始化物件
t6A4:存取instance參照的物件

按照以上的順序,B執行緒將會在instance物件還未初始化完成之前就存取它,導致NPE

3 解決方案

3.1 基於volatile的解決方案

對於雙重檢查鎖定來實現的延遲初始化方案,只需要將instance宣告為volatile型別,就能夠實現執行緒安全的延遲初始化:

public class SafeDoubleCheckedLocking {

    private volatile static Instance instance;            // 宣告為volatile型別

    /**
     * 雙重檢查鎖定
     */
    public static Instance getInstance() {
        if (instance == null) {                           // 1、第一次檢查
            synchronized (DoubleCheckedLocking.class) {   // 2、加鎖
                if (instance == null) {                   // 3、第二次檢查
                    instance = new Instance();            // 4、建立物件
                }
            }
        }
        return instance;
    }
}

volatile通過禁止重排序(初始化物件和設定instance指向分配的記憶體地址)來保證執行緒安全的延遲初始化。

3.2 基於類初始化的解決方案

Java語言規範規定,對於每一個類或者介面,都有唯一的一個初始化鎖與之對應。在多執行緒環境下,只有一個執行緒能夠獲取這個鎖並執行類的初始化,其他執行緒需要等待獲取這個鎖,這樣就能保證執行緒安全的類的初始化。

初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中宣告的靜態欄位。

public class InstanceFactory {

    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }

    public Instance getInstance() {
        return InstanceHolder.instance;                   // 觸發InstanceHolder類的初始化
    }
}

如上,當執行緒執行getInstance()方法時,將會觸發InstanceHolder類的初始化,此時只有一個執行緒能夠獲取InstanceHolder類的初始化鎖,並完成類的初始化過程,類中的靜態欄位instance將被初始化,Instance物件將被建立。雖然Instance物件建立過程中存在重排序,但是對於其他執行緒來說都是不可見的。

4 總結

  • 大多數情況下,正常的初始化優於延遲初始化。
  • 基於volatile的延遲初始化方案有一個優勢,就是除了可以對靜態欄位實現延遲初始化之外,還可以對範例欄位實現延遲初始化。
  • 如果確實需要對範例欄位實現執行緒安全的延遲初始化,請使用基於volatile的延遲初始化方案。
  • 如果確實需要對靜態欄位實現執行緒安全的延遲初始化,請優先使用基於類初始化的方案。