一個單例模式中volatile關鍵字引起的思考

關於單例模式

單例模式相信你們都不陌生,學習設計模式的時候,每每第一個要學習的就是單例模式。單例模式在Java中有許多實現,最多見的是「雙重鎖檢測」、「靜態內部類」以及「枚舉」的實現方式。《Effective Java》推薦使用枚舉的方式。html

但今天要討論是使用「雙重鎖檢測」實現單例的時候,關於volatile關鍵字引起的一些探索和思考。限於篇幅緣由,本文假設你已經瞭解如下知識:java

  • Java內存模型
  • volatile關鍵字的內存語義
  • synchronized同步鎖的內存語義
  • volatile和synchronized同步鎖的happens-before規則

不使用volatile會有什麼問題?

一個不使用volatile的雙重鎖檢驗單例模式大概長這樣:設計模式

public class Singleton {

    private static Singleton instance; // 不使用volatile關鍵字
    
    // 雙重鎖檢驗
    public static Singleton getInstance() {
        if (instance == null) { // 第7行
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 第10行
                }
            }
        }
        return instance;
    }
}
複製代碼

這個代碼會有什麼問題?咱們知道,對一個鎖的解鎖happens-before隨後對這個鎖的加鎖。粗略一看,上述代碼是沒有太大問題的。加鎖操做並不能保證同步區內的代碼不會發生重排序。對於第10行,是可能會被JVM分解和重排序的,也就是說:bash

instance = new Singleton(); // 第10行

// 能夠分解爲如下三個步驟
1 memory=allocate();// 分配內存 至關於c的malloc
2 ctorInstanc(memory) //初始化對象
3 s=memory //設置s指向剛分配的地址

// 上述三個步驟可能會被重排序爲 1-3-2,也就是:
1 memory=allocate();// 分配內存 至關於c的malloc
3 s=memory //設置s指向剛分配的地址
2 ctorInstanc(memory) //初始化對象
複製代碼

而一旦假設發生了這樣的重排序,好比線程A在第10行執行了步驟1和步驟3,可是步驟2尚未執行完。這個時候線程A執行到了第7行,它會斷定instance不爲空,而後直接返回了一個未初始化完成的instance!app

volatile如何解決這個問題?

針對上述問題,在Java 5 之後,JMM模型容許咱們使用volatile關鍵字禁止這樣的重排序。對於JMM的happens-before規則,即對一個volatile修飾的變量的寫操做,happens-before隨後對這個變量的讀操做。因此咱們能夠在聲明instance的時候,給它加上volatile關鍵字。ide

public class Singleton {

    private static volatile Singleton instance; // 使用volatile關鍵字
    
    // 雙重鎖檢驗
    public static Singleton getInstance() {
        if (instance == null) { // 第7行
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 第10行
                }
            }
        }
        return instance;
    }
}
複製代碼

OK,問題彷佛解決了。可是筆者心底仍然有一個疑問:假設沒有使用volatile,真的會返回一個未初始化完成的實例嗎?實例未初始化完成會怎樣?函數

若是不加volatile,到底會發生什麼?

先來看看一個Java對象實例化的過程:工具

1.先爲對象分配空間,並按屬性類型默認初始化
ps:八種基本數據類型,按照默認方式初始化,其餘數據類型默認爲null
2.父類屬性的初始化(包括代碼塊,和屬性按照代碼順序進行初始化)
3.父類構造函數初始化
4.子類屬性的初始化(同父類同樣)
5.子類構造函數的初始化學習

在好奇心的驅使下,我寫了一個Demo代碼作了一個實驗:this

// 單例代碼
public class Singleton {

    private static Singleton instance; // 不加volatile

    private volatile boolean flag = false; // 一個flag來標識初始化是否完成

    private Singleton() {
        try {
            Thread.sleep(1000);
            flag = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 給客戶端調用的,若是初始化未完成,應該返回false,若是完成,返回true
    public boolean isFlag() {
        return flag;
    }

    // 雙重鎖檢查實現單例模式
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
複製代碼
// 客戶端代碼
public class SingletonDemo {

    private final static int THREAD_NUMBER = 1000; // 線程數量

    private static class MyThread implements Runnable {

        @Override
        public void run() {
            Singleton singleton = Singleton.getInstance();
            if (!singleton.isFlag()) {
                System.out.println("I am false!!!");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(new MyThread()).start();
        }

    }
}
複製代碼

若是按照上述推斷,有可能返回一個未初始化完成的實例的話,客戶端調用isFlag()方法是有可能返回false的。

神奇的事情發生了,我反覆調整了各類參數(線程數量和睡眠時間)並運行了屢次,發現並無打印出「I am false!!!」這句話!也就是說,那個地方沒有發生咱們理論上說的重排序

到底是什麼緣由呢?爲何沒有發生重排序呢?

在網上找到這篇文章:The "Double-Checked Locking is Broken" Declaration,其中說到:若是使用Symantec JIT(一個基於句柄方式訪問對象的編譯器),它編譯出來的代碼就會發生上述的重排序。

筆者沒有可以找到Symantec JIT或一個其它的基於句柄方式訪問對象的編譯器來實驗。不過看了一下HotSpot的反編譯結果。

咱們用HotSpot的javap工具來反編譯一下:

javac Singleton.java
javap -l -v Singleton.class
複製代碼
public static communication.Singleton getInstance();
    descriptor: ()Lcommunication/Singleton;
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: getstatic     #8                  // Field instance:Lcommunication/Singleton;
         3: ifnonnull     37
         6: ldc           #9                  // class communication/Singleton
         8: dup
         9: astore_0
        10: monitorenter
        11: getstatic     #8                  // Field instance:Lcommunication/Singleton;
        14: ifnonnull     27
        17: new           #9                  // class communication/Singleton
        20: dup
        21: invokespecial #10                 // Method "<init>":()V
        24: putstatic     #8                  // Field instance:Lcommunication/Singleton;
        27: aload_0
        28: monitorexit
        // 省略
複製代碼

從序號17到序號24應該就是new一個對象的過程。逐一解釋一下:

  • new: 在java堆上爲對象分配內存空間,並將地址壓入操做數棧頂;
  • dup:複製操做數棧頂值,並將其壓入棧頂,也就是說此時操做數棧上有連續相同的兩個對象地址
  • invokespecial:用於調用一些須要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。
  • putstatic:從棧頂取值,存入靜態變量中
  • aload_0:把this引用推入操做數棧
  • monitorexit:釋放鎖

能夠看到,它是先進行實例化,再存入到靜態變量instance中。也就是說,這個地方沒有發生以前說的重排序。

結論

再來看看Java訪問對象的兩種方式:使用句柄訪問和直接訪問。

句柄訪問

直接訪問

再聯想到以前說的可能出現的重排序結果,咱們可能有這樣一個猜測:只有句柄訪問方式纔有可能發生那種重排序。

若是咱們使用一個基於直接訪問對象的編譯器(如HotSpot默認編譯器),這個地方不加volatile關鍵字也不會出現問題。

而若是咱們使用一個基於句柄方式訪問對象的編譯器(如Symantec JIT),不加volatile關鍵字可能會致使重排序,返回一個未初始化完成的實例。

此結論並不保證必定正確,只是基於目前現有的信息進行的猜測,若是要證明,可能還須要進一步實驗。若是您有嚴瑾的理論或更詳盡的實驗數據,歡迎聯繫筆者。

相關文章
相關標籤/搜索