本文主要內容出自周志明老師《深入理解Java虛擬機》一書,是筆者結合自己的理解,做了一些補充,重新組織排版后,總結的讀書筆記。
并發編程知識樹概覽
計算機性能
摩爾定律
當價格不變時,集成電路上可容納的元器件的數目,約每隔18-24個月便會增加一倍,性能也將提升一倍。換言之,每一美元所能買到的電腦性能,將每隔18-24個月翻一倍以上。
Amdahl定律
系統中對某一部件采用更快執行方式所能獲得的系統性能改進程度,取決于這種執行方式被使用的頻率,或所占總執行時間的比例。阿姆達爾定律實際上定義了采取增強(加速)某部分功能處理的措施后可獲得的性能改進或執行時間的加速比。
摩爾定律,描述處理器晶體管數量與運行效率之間的發展關系。而Amdahl定律,則是通過系統中并行化與串行化的比重來描述多處理器系統能獲得的運算加速能力。
并發處理的廣泛應用,使得Amdahl代替摩爾定律成為計算機性能發展的源動力,而這種更替也代表了近年來硬件發展從追求處理器頻率到追求多核心并行處理的發展過程。
物理計算機的并發問題
在講解Java虛擬機的并發知識前,我們先來談談物理計算機發展過程中遇到的并發問題,因為它與虛擬機中的情況有不少相似之處,而物理機對并發的處理方案對于虛擬機的實現也有相當大的參考意義。
雖然說一個運算任務,主要是由處理器“計算”完成的,但絕大多數情況下,處理器還要與內存交互,如讀取運算數據、儲存運算結果等,這個I/O操作是很難消除的。而存儲設備與處理器的運算速度又有幾個數量級的差距,這就是物理機不同硬件的效率矛盾。為了“壓榨”處理器的運算能力,現代計算機通常采取以下方案:
- 加入高速緩存:解決處理器與內存的運算效率矛盾
- 多處理器(或多核心)并發處理
- 對代碼進行亂序執行(Out-Of-Order Execution)優化:充分利用處理器內部的運算單元
高速緩存(Cache)
直接問題:不同硬件的效率矛盾
CPU處理速度與內存讀寫速度相差幾個數量級,導致CPU要等待緩慢的內存讀寫。
解決方案:高速緩存
加入一層讀寫速度盡可能接近處理器的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中。
引入的新問題:緩存一致性問題
在單處理器系統中,高速緩存并沒有什么問題,然而在多處理器系統中,每個處理器都有獨自的高速緩存,而它們又共享同一主內存。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,這正是物理計算機遇到的并發問題。
為了解決該問題,就需要各個處理器在訪問緩存時遵循 緩存一致性協議
。這類協議有MSI、MESI、MOSI、Dragon Protocol等。這里我們不再針對“緩存一致性協議”做展開,只要明白該協議是為了防止緩存數據不一致,而做的一些訪問約定即可。
物理硬件和操作系統的內存模型
處理器的亂序執行(Out-Of-Order Execution)優化
為了使得處理器內部的運算單元能盡量被充分利用,處理器可能會對輸入的代碼進行亂序執行優化,處理器會在計算之后將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但并不保證程序中各個語句計算的先后順序與輸入代碼中的順序一致。
CPU總是順序的去內存中取指令,然后將其順序的放入指令流水線。但是指令執行時的各種條件、指令與指令之間的相互影響,可能導致順序放入流水線的指令,最終亂序執行完成。這就是所謂的順序流入,亂序流出。
與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有類似的指令重排序優化。
編譯器的“指令重排序”優化
既然提到了處理器的亂序執行優化,這里就再簡單說一下編譯器的指令重排序優化,因為這兩個概念比較容易混淆。
從硬件結構上講,指令重排序是指CPU采用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理。
并不是說指令任意重排,CPU需要能正確處理指令依賴情況以保證程序能得出正確的執行結果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排—— (A+10)*2
與 A*2+10
顯然不相等,但指令3可以重排到指令1和2之前或者中間,只要保證CPU執行后面依賴到A、B值的操作時能獲取到正確的A和B值即可。
“亂序執行”和“指令重排序”對比
概念 | 執行者 | 發生時期 | 內存中指令順序是否真的變化 |
---|---|---|---|
亂序執行 | 處理器(CPU) | 運行期 | 否 |
指令重排序 | 虛擬機編譯器 | 編譯期 | 是 |
Java內存模型
Java為什么要定義內存模型
這里提到的“內存模型”一詞,可以理解為在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的內存模型。為了屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果,Java虛擬機規范中試圖定義一種Java內存模型。而在此之前,主流程序語言(如C/C++等)直接使用物理硬件和操作系統的內存模型,因此,會由于不同平臺上內存模型的差異,有可能導致程序在一套平臺上并發完全正常,而在另外一套平臺上,并發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程序。
Java內存模型
Java內存模型有以下規定:
- 所有的變量都存儲在主內存(Main Memory)中
- 每條線程還有自己的工作內存(Working Memory),其中保存了被該線程使用到的變量的主內存副本拷貝
- 線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量
- 不同線程間無法直接訪問對方工作內存中的變量, 線程間變量值的傳遞均需要通過主內存來完成
從更低層次上說,主內存就直接對應于物理硬件的內存,而為了獲得更好的運行速度,虛擬機(甚至是操作系統本身的優化措施)會讓工作內存優先存儲于寄存器和高速緩存中,因為程序運行時主要訪問讀寫的是工作內存。
所有變量都有副本拷貝嗎?
假設訪問一個10MB的對象,也要把它復制一份拷貝出來嗎?顯然不行,這個對象的引用、對象中某個在線程訪問到的字段是有可能存在拷貝的,但不會有虛擬機實現成把整個對象拷貝一次。
volatile 變量也有工作內存的拷貝嗎?
雖然volatile保證了多線程間變量的可見性,但它依然有工作內存的拷貝,只是由于它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般。volatile 的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。
內存間交互操作
關于主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節,Java內存模型中定義了8種操作(lock、unlock、read、load、use、assign、store、wtite)及相關的規則限定,并要求這8種操作都具有原子性(但是對于64位的long和double型數據,特別定義了一條相對寬松的非原子性協定,后續再介紹)。
這8種內存訪問操作及規則限定,再加上稍后要介紹的對volatile的一些特殊規定,就已經完全確定了Java程序中哪些內存訪問操作在并發下是安全的。鑒于這些定義十分繁瑣,這里不再做深入展開,下面介紹一個等效的判斷原則——先行發生原則,用來確定一個訪問在并發環境下是否安全。
先行發生原則
先行發生是Java內存模型中定義的兩項操作之間偏序關系,如果說操作A先行發生于操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。這句話意味著什么呢?我們看如下偽代碼:
// 以下操作在線程A中執行
i = 1;
// 以下操作在線程B中執行
j = i;
// 以下操作在線程C中執行
i = 2;
如果不通過某種手段明確3個操作之間的先行發生關系,那么最終 j 的值就很難確定,不具備多線程安全性。
“天然的”先行發生關系
以下是Java內存模型中“天然的”先行發生關系。如果兩個操作之間的關系不在此列,并且無法從下列規則推導出來的話 ,它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。
- 程序次序規則:在一個線程內,按照程序代碼順序,寫在前面的操作先行發生于寫在后面的操作。準確地說,應該是控制流順序,而非程序代碼順序,因為要考慮分支、循環等結構。
- 管程鎖定規則:一個unlock操作先行發生于后面對同一個鎖的lock操作。要注意是“同一個鎖”,而“后面”是指時間上的先后順序。
- volatile變量規則:對一個volatile變量的寫操作先行發生于后面對這個變量的讀操作,這里的“后面”同樣指時間上的先后順序。
- 線程啟動規則:Thread對象的start()方法先行發生于此線程的每一個動作。
- 線程終止規則:線程中的所有操作都先行發生于對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
- 線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷發生。
- 對象終結規則:一個對象的初始化完成(構造函數的執行結束)先行發生于它的finalize()方法的開始。
- 傳遞性:如果操作A先行發生于操作B,操作B先行發生于操作C,那么操作A必定先行發生于操作C。
下面演示一下如何通過以上規則去判定操作間是否具備順序性,對于讀寫共享變量的操作來說,就是線程是否安全,同時還可以感受一下“時間上的先后順序”和“先行發生”之間有什么不同。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
這是一組再普通不過的getter/setter方法,假設存在線程A和B,線程A先(時間上的先后)調用了“setValue(1)”,然后線程B調用了同一個對象的“getValue()”,那么線程B收到的返回值是什么?
我們依次分析一下先行發生原則中的各項規則:
- 兩個方法的調用不在同一個線程 → 程序次序規則不適用;
- 沒有同步塊,自然就沒有lock和unlock操作 → 管程鎖定規則不適用;
- value變量沒有被volatile修飾 → volatile變量規則不適用;
- 線程啟動、終止、中斷規則和對象終結規則也和這里完全沒有關系;
- 沒有任何一個適用的先行發生規則,所以傳遞性也無從談起;
可見,盡管線程A在操作時間上先于線程B,但是無法確定線程B中“getValue()”方法的返回值,換句話說,這里的操作不是線程安全的。
那怎么修復這個問題呢?至少有兩種比較簡單的方案:
- 把getter/setter方法都定義為
synchronized
方法,這樣就可以套用管程鎖定規則; - 把value定義為volatile變量,由于setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,因此可以套用volatile變量規則;
從上述例子,可以得出結論:一個操作“時間上的先發生”不代表這個操作會是“先行發生”,那如果一個操作“先行發生”是否就能推導出這個操作必定是“時間上的先發生”呢?很遺憾,也不成立,一個典型的例子就是“指令重排序”,請看下面的例子:
// 以下操作在同一個線程中執行
int i = 1;
int j = 2;
由于在同一個線程中,根據程序次序規則,“int i = 1”的操作先行發生于“int j = 2”,但是“int j = 2”的代碼完全可能先被處理器執行,這并不影響先行發生原則的正確性,因為我們在這條線程之中沒有辦法感知到這點。
上面兩個例子綜合起來證明了一個結論:時間先后順序與先行發生原則之間基本沒有太大關系,所以我們衡量并發安全問題時不能受時間順序干擾,一切必須以先行發生原則為準。
并發中的三大特性
其實Java內存模型就是圍繞著在并發過程中如何處理原子性、可見性和有序性這3個特征來建立的,我們來看看哪些操作實現了這3個特性。
原子性
原子性是指一個操作或多個操作要么全部執行,且執行的過程不會被任何因素打斷,要么就都不執行。線程切換會導致原子性問題。
基本數據類型的訪問讀寫具備原子性
例外就是long和double的非原子性協定,不過目前各商用虛擬機幾乎都選擇把它們的讀寫作為原子操作來對待,開發者無須太在意
long和double的非原子性協定:
允許虛擬機將沒有被volatile修飾的64位數據(long和double)的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性。
基于上述非原子性協定,如果有多個線程共享一個并未聲明為volatile的long或double型變量,并且同時對其進行讀取和修改操作,那么某些線程可能會讀取到一個既非原值,也不是其他線程修改值的代表了“半個變量”的數值。但是這種現象非常罕見,Java內存模型雖然允許虛擬機不把64位數據類型的讀寫實現成原子操作,但還是“強烈建議”虛擬機這樣實現。目前的商用java虛擬機也都選擇將64位數據的讀寫操作實現為原子操作。所以在實際開發中,一般不需要把long和double型變量專門聲明為volatile。
synchronized塊之間的操作也具備原子性
可見性
可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。工作內存導致可見性問題。
以下關鍵字能實現可見性:
volatile關鍵字
volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。
synchronized關鍵字
同步塊的可見性是由”對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)“這條規則獲得的(Java內存模型中定義的規則之一,本文沒有展開)。
final關鍵字
被final修飾的字段在構造器中一旦初始化完成,并且構造器沒有把”this“的引用傳出去(this引用逃逸是一件很危險的事情,其他線程可能通過這個引用訪問到”初始化了一半“的對象),那在其他線程中就能看見final字段的值。
如下面的代碼所示,i 和 j 都具備可見性,他們無須同步就能被其他線程正確訪問。
public static final int i;
public final int j;
static {
i = 0;
// do something
}
{
// 也可以選擇在構造函數中初始化
j = 0;
// do something
}
有序性
Java中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內表現為串行的語義”(之所以用”表現為串行的語義“來描述,就是要告訴大家,它只是我們感知到的結果,但實際上cpu執行指令時很有可能是亂序的),后半句是指“指令重排序”(cpu只保證在一個線程內指令重排序不會影響最終結果,并不保證該線程內的指令重排序不會影響其他線程的運算結果)現象和“工作內存和主內存同步延遲”現象。編譯器優化可能導致有序性問題。
Java提供了 volatile
和 synchronized
兩個關鍵字來保證線程之間操作的有序性。
volatile
volatile本身就包含了禁止指令重排序的語義。
synchronized
它是由”一個變量在同一時刻只允許一條線程對其進行lock操作“這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行地進入。
synchronized關鍵字在需要這3種特性時都可以作為一種解決方案,看起來很“萬能”,但也間接的造就了它被濫用的局面,越“萬能”的并發控制,通常會伴隨著越大的性能影響。
volatile型變量的特殊規則
關鍵字volatile可以說是java提供的最輕量級的同步機制。當一個變量定義為volatile之后,它將具備以下兩種特性:
- 保證此變量對所有線程的“可見性”,即一個線程修改了該變量的值,新值對于其他線程來說是可以立即得知的。
- 禁止指令重排序
關于volatile變量的可見性,經常會被誤解,認為以下描述成立:volatile變量對所有線程是立即可見的,對volatile變量所有的寫操作都能立刻反應到其他線程中,換句話說,volatile變量在各個線程中是一致的,所以基于volatile變量的運算在并發下是安全的。
這句話的論據部分并沒有錯,但是其論據并不能得出“基于volatile變量的運算在并發下是安全的”這個結論。volatile變量在各個線程的工作內存中不存在一致性問題(在各個線程的工作內存中,volatile變量也可以不一致,但由于每次使用之前都要先從主內存中刷新一次,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題,你也可以理解為volatile會使得線程的工作內存“失效”),但是Java里的運算并非原子操作,導致volatile變量的運算在并發下一樣是不安全的,我們可以通過下面的演示來說明:
/**
* Volatile變量自增運算測試
*/
public class VolatileTest {
private static volatile int race = 0;
private static final int THREADS_COUNT = 20;
public static void increase() {
race++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加線程都結束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println("race = " + race);
}
}
這段代碼創建了20個線程,每個線程對 race 循環執行10000次自增操作,如果說”基于volatile變量的運算在并發下是安全的“,那么最終的結果 race 應該為200000。但實際運行后,每次得到的都會是一個小于200000的數字。
問題就出在 "race++ " 并非原子操作,我們用 javac 和 javap 反編譯后可以得到其匯編指令,可以看到,race++ 實際上是由4條匯編指令構成的,事實上 volatile 只能保證 ”getstatic“執行時 race 的值是最新的,但執行后續的 ”加1“ 指令時,很可能其他線程已經修改了 race 的值,所以 ”putstatic“ 執行后就可能把一個較小的race值同步回主內存之中。
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I
8: return
LineNumberTable:
line 14: 0
line 15: 8
這里使用匯編指令來解釋指令重排序,仍然是不嚴謹的,因為指令重排序是機器級的優化操作,即使編譯出來只有一條字匯編指令,也并不意味著執行這條指令就是一個原子操作。一條指令在解釋執行時,解釋器可能將要運行許多行代碼才能實現它的語義,在編譯執行時,一條匯編指令可能被轉化成若干條本地機器碼指令,此處使用 -XX:+PrintAssembly 參數輸出反匯編來分析會更加嚴謹一些,只不過從匯編指令已經能看出問題了,考慮到閱讀方便,直接用了匯編指令來分析。
由于 volatile 只能保證可見性,所以它只能在以下兩種場景保證并發安全:
- 運算結果并不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
- 變量不需要與其他的狀態變量共同參與不變約束。
自增運算符的運算結果依賴變量的當前值,因此不能使用volatile確保并發安全。而類似 i < value
這樣的表達式,即使 i 聲明為 volatile ,也不能保證并發安全,因為 value 也可能在執行判斷時發生變化。
而像下面這樣的場景中,就很適合使用 volatile來控制并發:
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested) {
// do stuff
}
}
當 shutdown()
方法被調用時,能保證所有線程中執行的 doWork()
方法都立即停下來。
使用 volatile 的第二個語義是禁止指令重排序優化。前面講Java中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。可能你還不是很理解,下面我們再通過一個例子來看看指令重排序如何干擾并發:
Map configOptions;
char[] configText;
// 此變量必須定義為 volatile
volatile boolean initialized = false;
// 假設以下代碼在線程A中執行
// 模擬讀取配置信息,當讀取完成后將 initialized 設置為 true 以通知其他線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假設以下代碼在線程B中執行
// 等待 initialized 為 true ,代表線程A已經把配置信息初始化完成
while(!initialized) {
sheep();
}
// 使用線程A中初始化好的配置信息
doSthWithConfig();
上述代碼描述的場景很常見,只是我們在處理配置文件時一般不會出現并發而已。如果 initialized 沒有使用 volatile 修飾,就可能會由于指令重排序優化,導致位于線程A最后的 ”initialized = true“ 被提前執行,這樣在線程B中使用配置信息的代碼就可能出現錯誤。
除了上述偽代碼,我們再來看一個更加常見的實際場景。以下代碼是一段標準的DCL(雙鎖檢測)單例代碼:
public class Singleton {
// 必須使用volatile修飾
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
// 如果有任意一個線程執行到這里時發現instance已經指向了一個內存空間(此時可能由于指令重排序,導致Singleton對象還未初始化),
// 就會錯誤地拿到一個instance,它指向一個內存空間,但是該空間內還沒有初始化完成的Singleton對象
if (instance == null) {
// 如果有其他線程正在這里等待鎖,那么指令重排序也不會造成問題,因為在鎖釋放前這些線程只能等待,而synchronize塊相當于一個原子操作
synchronized (Singleton.class) {
if (instance == null) {
// 關鍵就是這句賦值操作,它并非原子操作,可以拆分為:分配內存空間、初始化對象、將instance指向剛分配的內存空間
instance = new Singleton();
}
}
}
return instance;
}
}
有興趣的話可以使用 javac 和 javap 命名查看這段代碼的匯編指令,只要關注對 instance 賦值的這一行匯編指令即可,這里我們直接分析結果。
"instance = new Singleton();" 這一行代碼,實際上可以拆分成以下指令:
memory = allocate(); // 1.分配對象的內存空間
ctorInstance(memory); // 2.初始化對象
instance = memory; // 3.令instance指向剛分配的內存空間
但由于指令重排序優化,cpu很可能是按照下面的順序執行的:
memory = allocate(); // 1.分配對象的內存空間
instance = memory; // 3.令instance指向剛分配的內存空間(注意,此時對象還沒有初始化)
ctorInstance(memory); // 2.初始化對象
在線程內觀察,無論上述3條指令如何被重排序,最終 ”return instance“ 這條代碼都會是最后執行的,此時1、2、3都執行結束了,在本線程內肯定能得到正確的 instance 對象。然而在其他線程中觀察時,當賦值線程剛好執行了3,還沒來得及執行2時(指令重排序后的case),其他線程就能得到一個還沒初始化完成的instance(實際上就是一個空的內存地址)。
為什么使用volatile
解決了 volatile 的語義問題,再來思考一下在眾多保障并發安全的工具中選用 volatile 的意義——它能讓我們的代碼比使用其他同步工具更快嗎?在某些情況下,volatile 的同步機制的性能確實要優于鎖(synchronized 關鍵字或 java.util.concurrent 包里的鎖),但是由于虛擬機對鎖實行的許多消除和優化,使得我們很難量化地認為 volatile 就會比 synchronized 快多少。如果讓 volatile 與自己比較,那可以確定一個原則:volatile 變量讀操作的性能消耗與普通變量幾乎沒有什么差別,但是寫操作則可能會慢一些,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下 volatile 的總開銷仍然要比鎖低,我們在 volatile 與鎖之間做選擇的唯一依據僅僅是 volatile 的語義能否滿足當前使用場景的需求。