2014年1月6日

Singleton的雙重檢查鎖與volatile

前言:

幾年前從踏進java開始,熟悉了物件導向之後,偶然從朋友那邊聽到了一個詞,Design Pattern,因此買了本歐萊禮的Headfirst Design Pattern來看,其中對於Singleton這Pattern一直有特別印象,因為這Pattern在系統設計時,放置系統設定檔案資料時很常用,也是一個好入門的Pattern,只是對於書上的雙重檢查鎖範例,一直沒有去理解它,趁最近比較有空時,花點時間去搞懂它。

Singleton雙重檢查鎖技巧:

先看一下書上的範例:

public class Singleton {
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

    public static Singleton getInstance() {
        if(uniqueInstance == null) {
            synchronized(Singleton.class) {
                if(uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

這毫無疑問的是個Singleton的Pattern,建構子設為private,因此不能用new Singleton()的方式來建立instance,而其他物件要來取此物件的instance,也只能透過它對外開放的getInstance()來取得;為了講求效能,因此只有在uniqueInstance尚未初始化,也就是為null時,才會用synchronized來避免多執行緒造成new過多物件的問題發生,而如果它已經初始化過了,則直接回傳物件回去。

看起來是個很簡單很好懂的範例,但這範例裡面有個讓我困惑很久的地方就是,為什麼要在uniqueInstance這個field上加個volatile的關鍵字?

關鍵字volatile:

一開始我對valotitle的理解大概是這樣。

如果我今天宣告了一個宣告了一個long變數為volatile,如下

public volatile long value = 0l;

則其語意會相等於(注意是語意,不是底層真正的實作方式):

public long value = 0l;
public synchronized void set(long v) {
    value = v;
}

public synchronized long get() {
    return value;
}

也就是說,把一個有可能會被中斷的操作,變為不可中斷的操作,看起來似乎不加關鍵字volatile是對Singleton沒啥影響,那為何還要加呢?於是我花了一些時間尋找了幾篇相關討論的文章。

問題1,物件可能尚未初始化完成:

在這個網站The "Double-Checked Locking is Broken" Declaration提到,在不同系統下的compiles,可能會對於指令的處理有所不同,以下是網站裡提到的測試程式片段:

singletons[i].reference = new Singleton();

網站裡提到,Paul Jakubik發現把這程式碼片段程式在Symantec JIT執行過後,它會先對物件做memory allocate,然後才去執行建構子

這會造成什麼問題呢?讓我們在回過頭來看Singleton的程式碼:

public static Singleton getInstance() {
    if(uniqueInstance == null) {
        synchronized(Singleton.class) {
            if(uniqueInstance == null) {
                uniqueInstance = new Singleton();
            }
        }
    }
    return uniqueInstance;
}

上面問題在於,一開始我們判斷物件是不是為null,null代表物件尚未初始化過,接著我們而在Symantec JIT的情況下,會先做memory allocate,在去執行建構子,因此可能有某個Thread正在對物件做初始化,做到一半被中斷了,而第二個物件呼叫了getInstance(),然後發現該物件不為null,因此馬上就回傳回去,因此它就得到了一個尚未初始化完成的物件,這有可能會造成系統錯誤。

問題2,編譯器優化:

接著另一本書提到的Effective Java (2nd Edition)裡面的 Item66 Synchronize access to shared mutable data 提到的範例,編譯器可能會對你的程式碼進行優化,如以下的程式碼:

public class HoistingTestClass {
    private static boolean stopRequested;

    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while(!stopRequested)
                    i++;
            }
        });

        t.start();
        try {
            TimeUnit.SECONDS.sleep(1);
            stopRequested = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

程式裡面,宣告一個Thread,並且先讓它執行,Thread裡面只做一件事,就是判斷stopRequested這個flag,如果為false,就一直執行下去,而主thread會在停止1秒之後,把stopRequested這個flag設為true。預期中,這程式應該是會跑一秒之後就停止了,不過實際卻是無窮迴圈。原因在於jvm對它做了優化處理。所以原本code是:

while(!stopRequested)
    i++;

在jvm執行時會被處理成:

if(!stopRequested)
    while(true)
        i++;

因此會和預期中的不一樣。而這問題要怎麼處理,只要將變數加上volatile則一切就正常了。

private static volatile boolean stopRequested;

為何用volatile可以解決呢?因為用了volatile,編譯器會已確保這個值的正確做為主要策略,而不會已效能為主來優化程式,因此它可以讓被設為volatile的field在任何Thread去讀取時,都會看到最新的狀態。

問題3,指令的重新排序:

在這篇文章 JSR 133 (Java Memory Model) FAQ 提到,執行程式時為了提高效率,因此compiler和processor可以對指令做重新排序的動作,比如說這段code:

class VolatileExample {
    int x = 0;
    boolean v = false;

    public void writer() {
        x = 42;
        v = true;
      }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

其中compile會認為在writer()內的這段code

public void writer() {
    x = 42;
    v = true;
}

和以下這段code,就語意上來說執行起來是一樣的,而為了效能考量,它有可能會對這段code進行重新排序然後執行,就會變成以下這段code:

public void writer() {
    v = true;
    x = 42;
}

而這如果發生在multithread的情況下,當有一條thread執行writer(),而另一條thread執行reader(),就會發生問題了,問題在於reader()內的程式:

public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
}

這邊會先判斷v是否為true,是true的話,根據原始碼的流程來看,如果這邊的v為true了,則代表writer()已經將x設為42了,但是由於編譯器對writer()做了重新排序,因此當reader()判斷v== ture之後繼續往下執行時,x的值有可能還是為0,這會造成問題。

所以這邊的code只要boolean v設為volatile,就能避免編譯器對writer ()這段code重新排序,如下:

volatile boolean v = false;

結論:

所以回到一開始的疑問,為何要在這Pattern的instance加上volatile關鍵字。

  • 為了讓Singleton instance初始化正常,不會初始化到一半就被別的thread取走了。

  • 確保編譯器不會幫你對Singleton的code進行優化,讓一切判斷如你預期般的執行。

  • 確保指令在執行時不會被重新排序,造成Singleton的值有異常。

值得注意的是,在JAVA 5以上才能用這種雙重鎖的技巧,原因volatile在JAVA 5之後變得比較完善(JSR-133實現)。。

參考:

  1. 深入理解Java内存模型(一)——基础
  2. 深入理解Java内存模型(四)——volatile
  3. The "Double-Checked Locking is Broken" Declaration
  4. Double-checked locking
  5. JSR 133 (Java Memory Model) FAQ