在Java多線程程序中,有時候須要採用延遲初始化來下降初始化類和建立對象的開銷。雙重檢查鎖定是常見的延遲初始化技術,但它是一個錯誤的用法。編程
package 雙重檢查鎖定與延遲初始化; //非線程安全的延遲初始化對象 public class UnsafeLazyInitialization { private static UnsafeLazyInitialization instance; public static UnsafeLazyInitialization getInstance(){ if(instance == null)//代碼1 instance = new UnsafeLazyInitialization();//代碼2 return instance; } }
在上面的類中,假設線程A執行代碼1的同時,B線程執行代碼2。此時,線程A可能會看到instance引用的對象尚未完成初始化。安全
package 雙重檢查鎖定與延遲初始化; //線程安全的延遲初始化,因爲對getInstance方法作了同步處理,sychronized將致使性能開銷 public class SafeLazyInitialization { private static SafeLazyInitialization instance; public synchronized static SafeLazyInitialization getInstance(){ if(instance == null) instance = new SafeLazyInitialization(); return instance; } }
因爲對getInstance方法作了同步處理,synchronized將致使性能開銷。getInstance方法若是被多個線程頻繁調用,將致使程序執行性能的降低。多線程
public class DoubleCheckedLocking { private static DoubleCheckedLocking instance; public static DoubleCheckedLocking getInstance(){ if(instance == null){//1處 synchronized(DoubleCheckedLocking.class){ if(instance == null) instance = new DoubleCheckedLocking();//2處 } } return instance; } }
在A線程位於同步代碼塊2處,初始化對象(但還未初始化完成的時候),有可能有另外的線程B運行到1處。此時instance檢查不爲null,但instance返回的卻不是一個完整的對象。併發
爲何instance沒有被A真正初始化的時候,其指向不爲null呢?緣由在於編譯器的指令重排序。app
建立一個對象的過程可由以下三步僞代碼表示:性能
1.memory = allocate() //分配對象的內存空間spa
2.ctorInstance(memory) //初始化對象線程
3.instance = memory //設置instance指向分配的內存地址3d
上面的建立對象的過程是在一個單線程中完成的, JMM保證了重排序不會改變單線程內的程序執行結果。換句話說,JAVA容許那些在單線程內,不會改變單線程程序執行結果對的指令重排序。上述三個步驟在一些JIT編譯器上被單線程執行的時候,爲了提升程序的執行性能,有可能會被重排序且不影響單線程下的執行結果:code
1.memory = allocate() //分配對象的內存空間
2.instance = memory //設置instance指向分配的內存地址
3.ctorInstance(memory) //初始化對象
這樣,instance 在單線程內會先被指向內存分配地址(此時檢查instance不爲null),然後這個地址內才真正存放初始化完成的對象數據。這樣線程A執行到步驟2,將instance指向內存地址以致於對instance做檢查不爲null的時候(但實際上該地址內沒有初始化完成的對象數據),線程B運行到了程序的1處,對instance做了不爲null的檢查後,直接返回了這個「虛有其表」的instance。後續程序若是用到了這個對象,必然致使程序的錯誤。
在知曉了問題的根源後,咱們能夠採用兩個辦法來實現線程安全的延遲初始化:
1. 不容許2和3重排序
2. 容許2和3重排序,但不容許其餘線程「看到」這個重排序
基於前面的雙重檢查鎖定來實現延遲初始化的方案,只須要作一點小的修改(將instance聲明爲volatile類型),就能夠實現線程安全的延遲初始化
package 雙重檢查鎖定與延遲初始化; public class SafeDoubleCheckedLocking { private volatile static SafeDoubleCheckedLocking instance; public static SafeDoubleCheckedLocking getInstance(){ if(instance==null){ synchronized (SafeDoubleCheckedLocking.class){ if(instance == null) instance = new SafeDoubleCheckedLocking();//instance爲volatile,禁止了volatile變量的指令重排序,如今沒有問題了。 } } return instance; } }
當聲明對象引用爲volatile後,前面僞代碼的2和3之間的重排序在多線程環境中將被禁止,從而解決了這個問題。
JVM在類的初始化階段(即在class被加載後,且被線程使用以前),會執行類的初始化。在執行類的初始化期間,JVM會取獲取一個鎖。這個鎖能夠同步多個線程對同一個類的初始化。。這個方案的實質是,容許僞代碼中2和3的重排序,但不容許非構造線程(指線程B)「看到」這個重排序。
package 雙重檢查鎖定與延遲初始化; /*JVM在類的初始化階段(即在class被加載後,且被線程使用以前),會執行類的初始化。 * 在執行類的初始化期間,JVM會取獲取一個鎖。這個鎖能夠同步多個線程對同一個類的初始化。*/ public class InstanceFactory { private static class InstanceHolder{ public static InstanceFactory instance = new InstanceFactory(); } public static InstanceFactory getInstance(){ return InstanceHolder.instance; } }
JAVA語言規範規定,對於每個類或者接口C,都有惟一一個的初始化鎖LC與之對應。JVM在初始化期間會獲取這個初始化鎖,而且每一個線程至少獲取了一次鎖來確保這個類已經被初始化過了。
爲了更好的說明類初始化過程當中的同步處理機制,本書做者人爲的把類初始化的處理過程分紅了5個階段:
假設Class對象當前尚未被初始化(初始化狀態state被標記爲noInitialization),且有兩個線程A和B試圖同時初始化這個Class對象。
線程A在第2階段的A1執行類的初始化,並在第3階段的A4釋放初始化鎖;線程B在第4階段的B1獲取同一個初始化鎖,並在第4階段的B4以後纔開始訪問這個類。根據Java內存模型規範的鎖規則,這裏將存在以下的happens-before關係。這個happens-before關係將保證:線程A執行類的初始化時的寫入操做(執行類的靜態初始化和初始化類中聲明的靜態字段),線程B必定能看到
在第3階段以後,類已經完成了初始化。所以線程C在第5階段的類初始化處理過程相對簡單一些(前面的線程A和B的類初始化處理過程都經歷了兩次鎖獲取-鎖釋放,而線程C的類初始化處理只須要經歷一次鎖獲取-鎖釋放)。線程A在第2階段的A1執行類的初始化,並在第3階段的A4釋放鎖;線程C在第5階段的C1獲取同一個鎖,並在在第5階段的C4以後纔開始訪問這個類。根據Java內存模型規範的鎖規則,將存在以下的happens-before關係。這個happens-before關係將保證:線程A執行類的初始化時的寫入操做,線程C必定能看到 。
這個講起來有點複雜,見《JAVA併發編程的藝術》p72-p78