前言:
幾年前從踏進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實現)。。
沒有留言:
張貼留言