本文將簡要介紹java內存模型(JMM)的底層細節以及所提供的保障,并從JMM的角度再談如何在并發環境下正確初始化對象,這將有助于理解更高層面的并發同步機制背后的原理。
1. 何為內存模型
如大家所知,Java代碼在編譯和運行的過程中會對代碼有很多意想不到且不受開發人員控制的操作:
- 在生成指令順序可能和源代碼中順序不相同;
- 編譯器可能會把變量保存到寄存器中而非內存中;
- 處理器可以采用亂序或者并行的方式執行指令;
- 緩存可能會改變將寫入變量提交到主內存的次序;
- 保存在處理器本地緩存中的值,對于其他處理器是不可見的;
- .....
以上所有的這些情況都可能會導致多線程同步的問題。
其實,在單線程的環境下,這些底層的技術都是為了提高執行效率而存在,不會影響運行結果:JVM只會在運行結果和嚴格串行執行結果相同的情況下進行如上的優化操作。我們需要知道近些年以來計算性能的提高很大程度上要感謝這些重新排序的操作。
為了進一步提高效率,多核處理器已經廣泛被使用,程序在多數時間內都是并發執行,只有在需要的時候才回去協調各個線程之間的操作。那什么是需要的時候呢,JVM將這個問題拋給了程序,要求在代碼中使用同步機制來保證多線程安全。
1.1 多處理器架構中的內存模型
在多核理器架構中,每個處理器都擁有自己的緩存,并且會定期地與主內存進行協調。這樣的架構就需要解決緩存一致性(Cache Coherence)的問題。很可惜,一些框架中只提供了最小保證,即允許不同處理器在任意時刻從同一存儲位置上看到不同的值。
正因此存在上面所述的硬件能力和線程安全需求的差異,才導致需要在代碼中使用同步機制來保證多線程安全。
這樣“不靠譜”的設計還是為了追求性能,因為要保證每個處理器都能在任意時刻知道其他處理器在做什么需要很大的開銷,而且大部分情況下處理器也沒有這樣的需求,放寬對于存儲一致性的保障,以換取性能的提升。
架構中定義了一些特殊的指令,也就是內存柵欄,當需要多線程間數據共享的時,這些指令將會提供額外的存儲協調。
值得慶幸的是JMM為我們屏蔽了各個框架在內存模型上的差異,讓開發人員不用再去關系這些底層問題。
1.2 重排序
JVM不光會改變命令執行的順序,甚至還會讓不同線程看到的代碼執行的順序也是不同的,這就會讓在沒有同步操作的情況下預測代碼執行結果邊變的困難。
下面的代碼是《Java Concurrency in Practice》給出的一個例子
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
//對于每個線程內部而言,語句的執行順序和結果無關
//但是對于線程之間,語句的執行順序卻和結果密切相關
//而不同線程之間的見到的代碼執行順序可能都是不同的
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
other.start();
one.join();
other.join();
System.out.println("( " + x + "," + y + ")");
}
}
以上代碼的輸出結果可能是(1,0)、(0,1)、(1,1)甚至是(0,0),這是由于兩個線程的執行先后順序可能不同,線程內部的賦值操作的順序也有可能相互顛倒。
上面這樣簡單的代碼,如果缺少合理的同步機制都很難預測其結果,復雜的程序將更為困難,這正是通過同步機制限制編譯器和運行時對于內存操作重排序限制的意義所在。
1.3 Java內存模型與Happens-Before規則
Java內存模型是通過各種操作來定義的,包括對于變量的對寫操作,監視器的加鎖和釋放鎖操作,以及線程的啟動和合并,而這些操作都要滿足一種偏序關系——Happen-Before規則:想要保證執行操作B的線程看到執行操作A的結果,而無論兩個操作是否在同一線程,則操作A和操作B之間必須滿足Happens-Before關系,否者JVM將可以對他們的執行順序任意安排。
Happens-Before規則:
- 程序順序規則:一個線程中的每個操作,先于隨后該線程中的任意后續操作執行(針對可見性而言);
- 監視器鎖規則:對一個鎖的解鎖操作,先于隨后對這個鎖的獲取操作執行;
- volatile變量規則:對一個volatile變量的寫操作,先于對這個變量的讀操作執行;
- 傳遞性:如果A happens-before B,B happens-before C,那么A happens-before C;
- start規則:如果線程A執行線程B的start方法,那么線程A的ThreadB.start()先于線程B的任意操作執行;
- join規則:如果線程A執行線程B的join方法,那么線程B的任意操作先于線程A從TreadB.join()方法成功返回之前執行;
- 中斷規則:當線程A調用另一個線程B的interrupt方法時,必須在線程A檢測到線程B被中斷(拋出InterruptException,或者調用ThreadB.isInterrupted())之前執行。
- 終結器規則:一個對象的構造函數先于該對象的finalizer方法執行前完成;
2. 安全發布與內存模型
對象共享:Java并發環境中的煩心事中曾介紹過安全發布和數據共享的問題,而造成不正確的發布的根源就在于發布對象的操作和訪問對象的操作之間缺少Happens-Before關系。
請看下面這個例子,這是一個不安全的懶加載,只有在第一次用到Resource對象時才會去初始化該對象。
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getInstance() {
if (resource == null)
resource = new Resource(); // unsafe publication
return resource;
}
static class Resource {
}
}
getInstance() 方法是一個靜態方法,可以被多個線程同時調用,就有可能出現數據競爭的問題,在Java內存模型的角度來說就是讀取resource對象判斷是都為空和對resource賦值的寫操作并不存在Happens-Before關系,彼此在多線程環境中不一定是可見的。此外,new Resource()來創建一個類對象,要先分配內存空間,對象各個域都是被賦予默認值,然后再調用構造函數對寫入各個域。由于這個過程和讀取Resource對象的操作并不滿足Happens-Before關系,所以可能一個線程中正在創建對象但是沒有執行完畢,而這時另一個線程看到的Resource對象的確不是為空,但卻是個失效的狀態。
真正線程安全的懶加載應該是這樣的,通過同步機制上鎖,讓讀操作和寫操作滿足Happens-Before規則。
public class SafeLazyInitialization {
private static Resource resource;
//一線程獲得內置鎖之后,在釋放鎖之前的操作都會先于另外一個線程得到鎖的操作執行
public synchronized static Resource getInstance() {
if (resource == null)
resource = new Resource();
return resource;
}
static class Resource {
}
}
2.1 正確的延遲初始化
為了避免懶加載每次調用getInstance方法的同步開銷,可以使用提前初始化的方法,如下:
public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() {
return resource;
}
static class Resource {
}
}
提前初始化方法利用靜態初始化提前加載并有同步機制保護的特性實現了安全發布。更進一步,該方法和JVM的延遲加載機制結合,形成了一種完備的延遲初始化技術-延遲初始化占位類模式,實例如下:
public class ResourceFactory {
//靜態初始化不需要額外的同步機制
private static class ResourceHolder {
public static Resource resource = new Resource();
}
//延遲加載
public static Resource getResource() {
return ResourceHolder.resource;
}
static class Resource {
}
}
上述代碼中專門使用了一個類ResourceHolder來初始化Resource對象,ResourceHolder會被JVM推遲初始化直到被真正的調用,并且因為利用了靜態初始化而不需要額外的同步機制。
靜態初始化或靜態代碼塊因為由JVM的機制保護,不需要額外的同步機制;
2.2 雙重檢查加鎖
下面讓我們從Java內存模型的角度談談臭名昭著的雙重檢查加鎖(DCL),示例代碼如下:
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
//沒有在同步的情況下讀取共享變量,破壞了Happens_Before規則
if (resource == null) {
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
static class Resource {
}
}
由于在早期的JVM中,同步操作很是效率低,所以延遲初始化常被用來避免不必要的同步開銷,但是對于DCL,其雖然很好的解決了“獨占性”,但是沒有正確理解"可見性"。
對象共享:Java并發環境中的煩心事中曾經介紹過:對于共享變量,讀寫操作都需要在同一個鎖的保護之下,從而使得讀/寫操作都滿足Happens-Before規則,在多線程環境中可見。但是在DCL中,第一次對于resource的空判斷沒有在同步機制下進行,和寫操作之間沒有Happens-Before關系,即使寫操作是同步的,也不能保證寫操作的結果是多線程可見的,此時讀出的resource的值就可能是初始化到一半的失效狀態。
其實只要把resource設置為Volatile就能保證DCL的正常工作,而且性能的影響很小,但是現在JVM已經不斷成熟和完善, 沒有必要再使用DCL技術,延遲初始化占位模式更為簡單和易于理解。