Java 高并發(fā)三:Java內(nèi)存模型和線程安全詳解
作者:Hosee
網(wǎng)上很多資料在描述Java內(nèi)存模型的時(shí)候,都會(huì)介紹有一個(gè)主存,然后每個(gè)工作線程有自己的工作內(nèi)存。數(shù)據(jù)在主存中會(huì)有一份,在工作內(nèi)存中也有一份。工作內(nèi)存和主存之間會(huì)有各種原子操作去進(jìn)行同步。
下圖來源于這篇Blog
但是由于Java版本的不斷演變,內(nèi)存模型也進(jìn)行了改變。本文只講述Java內(nèi)存模型的一些特性,無論是新的內(nèi)存模型還是舊的內(nèi)存模型,在明白了這些特性以后,看起來也會(huì)更加清晰。
1. 原子性
原子性是指一個(gè)操作是不可中斷的。即使是在多個(gè)線程一起執(zhí)行的時(shí)候,一個(gè)操作一旦開始,就不會(huì)被其它線程干擾。
一般認(rèn)為cpu的指令都是原子操作,但是我們寫的代碼就不一定是原子操作了。
比如說i++。這個(gè)操作不是原子操作,基本分為3個(gè)操作,讀取i,進(jìn)行+1,賦值給i。
假設(shè)有兩個(gè)線程,當(dāng)?shù)谝粋€(gè)線程讀取i=1時(shí),還沒進(jìn)行+1操作,切換到第二個(gè)線程,此時(shí)第二個(gè)線程也讀取的是i=1。隨后兩個(gè)線程進(jìn)行后續(xù)+1操作,再賦值回去以后,i不是3,而是2。顯然數(shù)據(jù)出現(xiàn)了不一致性。
再比如在32位的JVM上面去讀取64位的long型數(shù)值,也不是一個(gè)原子操作。當(dāng)然32位JVM讀取32位整數(shù)是一個(gè)原子操作。
2. 有序性
在并發(fā)時(shí),程序的執(zhí)行可能就會(huì)出現(xiàn)亂序。
計(jì)算機(jī)在執(zhí)行代碼時(shí),不一定會(huì)按照程序的順序來執(zhí)行。
class OrderExample { int a = 0; boolean flag = false; public void writer() { a = 1; flag = true; } public void reader() { if (flag) { int i = a +1; } } }
比如上述代碼,兩個(gè)方法分別被兩個(gè)線程調(diào)用。按照常理,寫線程應(yīng)該先執(zhí)行a=1,再執(zhí)行flag=true。當(dāng)讀線程進(jìn)行讀的時(shí)候,i=2;
但是因?yàn)閍=1和flag=true,并沒有邏輯上的關(guān)聯(lián)。所以有可能執(zhí)行的順序顛倒,有可能先執(zhí)行flag=true,再執(zhí)行a=1。這時(shí)當(dāng)flag=true時(shí),切換到讀線程,此時(shí)a=1還沒有執(zhí)行,那么讀線程將i=1。
當(dāng)然這個(gè)不是絕對(duì)的。是有可能會(huì)發(fā)生亂序,有可能不發(fā)生。
那么為什么會(huì)發(fā)生亂序呢?這個(gè)要從cpu指令說起,Java中的代碼被編譯以后,最后也是轉(zhuǎn)換成匯編碼的。
一條指令的執(zhí)行是可以分為很多步驟的,假設(shè)cpu指令分為以下幾步
- 取指 IF
- 譯碼和取寄存器操作數(shù) ID
- 執(zhí)行或者有效地址計(jì)算 EX
- 存儲(chǔ)器訪問 MEM
- 寫回 WB
假設(shè)這里有兩條指令
一般來說我們會(huì)認(rèn)為指令是串行執(zhí)行的,先執(zhí)行指令1,然后再執(zhí)行指令2。假設(shè)每個(gè)步驟需要消耗1個(gè)cpu時(shí)間周期,那么執(zhí)行這兩個(gè)指令需要消耗10個(gè)cpu時(shí)間周期,這樣做效率太低。事實(shí)上指令都是并行執(zhí)行的,當(dāng)然在第一條指令在執(zhí)行IF的時(shí)候,第二條指令是不能進(jìn)行IF的,因?yàn)橹噶罴拇嫫鞯炔荒鼙煌瑫r(shí)占用。所以就如上圖所示,兩條指令是一種相對(duì)錯(cuò)開的方式并行執(zhí)行。當(dāng)指令1執(zhí)行ID的時(shí)候,指令2執(zhí)行IF。這樣只用6個(gè)cpu時(shí)間周期就執(zhí)行了兩個(gè)指令,效率比較高。
按照這個(gè)思路我們來看下A=B+C的指令是如何執(zhí)行的。
如圖所示,ADD操作時(shí)有一個(gè)空閑(X)操作,因?yàn)楫?dāng)想讓B和C相加的時(shí)候,在圖中ADD的X操作時(shí),C還沒從內(nèi)存中讀?。ó?dāng)MEM操作完成時(shí),C才從內(nèi)存中讀取。這里會(huì)有一個(gè)疑問,此時(shí)還沒有回寫(WB)到R2中,怎么會(huì)將R1與R1相加。那是因?yàn)樵谟布娐樊?dāng)中,會(huì)使用一種叫“旁路”的技術(shù)直接把數(shù)據(jù)從硬件當(dāng)中讀取出來,所以不需要等待WB執(zhí)行完才進(jìn)行ADD)。所以ADD操作中會(huì)有一個(gè)空閑(X)時(shí)間。在SW操作中,因?yàn)镋X指令不能和ADD的EX指令同時(shí)進(jìn)行,所以也會(huì)有一個(gè)空閑(X)時(shí)間。
接下來舉個(gè)稍微復(fù)雜點(diǎn)的例子
a=b+c
d=e-f
對(duì)應(yīng)的指令如下圖
原因和上面的類似,這里就不分析了。我們發(fā)現(xiàn),這里的X很多,浪費(fèi)的時(shí)間周期很多,性能也被影響。有沒有辦法使X的數(shù)量減少呢?
我們希望用一些操作把X的空閑時(shí)間填充掉,因?yàn)锳DD與上面的指令有數(shù)據(jù)依賴,我們希望用一些沒有數(shù)據(jù)依賴的指令去填充掉這些因?yàn)閿?shù)據(jù)依賴而產(chǎn)生的空閑時(shí)間。
我們將指令的順序進(jìn)行了改變
改變了指令順序以后,X被消除了。總體的運(yùn)行時(shí)間周期也減少了。
指令重排可以使流水線更加順暢
當(dāng)然指令重排的原則是不能破壞串行程序的語義,例如a=1,b=a+1,這種指令就不會(huì)重排了,因?yàn)橹嘏诺拇薪Y(jié)果和原先的不同。
指令重排只是編譯器或者CPU的優(yōu)化一種方式,而這種優(yōu)化就造成了本章一開始程序的問題。
如何解決呢?用volatile關(guān)鍵字,這個(gè)后面的系列會(huì)介紹到。
3. 可見性
可見性是指當(dāng)一個(gè)線程修改了某一個(gè)共享變量的值,其他線程是否能夠立即知道這個(gè)修改。
可見性問題可能有各個(gè)環(huán)節(jié)產(chǎn)生。比如剛剛說的指令重排也會(huì)產(chǎn)生可見性問題,另外在編譯器的優(yōu)化或者某些硬件的優(yōu)化都會(huì)產(chǎn)生可見性問題。
比如某個(gè)線程將一個(gè)共享值優(yōu)化到了內(nèi)存中,而另一個(gè)線程將這個(gè)共享值優(yōu)化到了緩存中,當(dāng)修改內(nèi)存中值的時(shí)候,緩存中的值是不知道這個(gè)修改的。
比如有些硬件優(yōu)化,程序在對(duì)同一個(gè)地址進(jìn)行多次寫時(shí),它會(huì)認(rèn)為是沒有必要的,只保留最后一次寫,那么之前寫的數(shù)據(jù)在其他線程中就不可見了。
總之,可見性的問題大多都源于優(yōu)化。
接下來看一個(gè)Java虛擬機(jī)層面產(chǎn)生的可見性問題
問題來自于一個(gè)Blog
package edu.hushi.jvm; /** * * @author -10 * */ public class VisibilityTest extends Thread { private boolean stop; public void run() { int i = 0; while(!stop) { i++; } System.out.println("finish loop,i=" + i); } public void stopIt() { stop = true; } public boolean getStop(){ return stop; } public static void main(String[] args) throws Exception { VisibilityTest v = new VisibilityTest(); v.start(); Thread.sleep(1000); v.stopIt(); Thread.sleep(2000); System.out.println("finish main"); System.out.println(v.getStop()); } }
代碼很簡(jiǎn)單,v線程一直不斷的在while循環(huán)中i++,直到主線程調(diào)用stop方法,改變了v線程中的stop變量的值使循環(huán)停止。
看似簡(jiǎn)單的代碼運(yùn)行時(shí)就會(huì)出現(xiàn)問題。這個(gè)程序在 client 模式下是能停止線程做自增操作的,但是在 server 模式先將是無限循環(huán)。(server模式下JVM優(yōu)化更多)
64位的系統(tǒng)上面大多都是server模式,在server模式下運(yùn)行:
finish main
true
只會(huì)打印出這兩句話,而不會(huì)打印出finish loop??墒悄軌虬l(fā)現(xiàn)stop的值已經(jīng)是true了。
該Blog作者用工具將程序還原為匯編代碼
這里只截取了一部分匯編代碼,紅色部分為循環(huán)部分,可以清楚得看到只有在0x0193bf9d才進(jìn)行了stop的驗(yàn)證,而紅色部分并沒有取stop的值,所以才進(jìn)行了無限循環(huán)。
這是JVM優(yōu)化后的結(jié)果。如何避免呢?和指令重排一樣,用volatile關(guān)鍵字。
如果加入了volatile,再還原為匯編代碼就會(huì)發(fā)現(xiàn),每次循環(huán)都會(huì)get一下stop的值。
接下來看一些在“Java語言規(guī)范”中的示例
上圖說明了指令重排將會(huì)導(dǎo)致結(jié)果不同。
上圖使r5=r2的原因是,r2=r1.x,r5=r1.x,在編譯時(shí)直接將其優(yōu)化成r5=r2。最后導(dǎo)致結(jié)果不同。
4. Happen-Before
- 程序順序原則:一個(gè)線程內(nèi)保證語義的串行性
- volatile規(guī)則:volatile變量的寫,先發(fā)生于讀,這保證了volatile變量的可見性
- 鎖規(guī)則:解鎖(unlock)必然發(fā)生在隨后的加鎖(lock)前
- 傳遞性:A先于B,B先于C,那么A必然先于C
- 線程的start()方法先于它的每一個(gè)動(dòng)作
- 線程的所有操作先于線程的終結(jié)(Thread.join())
- 線程的中斷(interrupt())先于被中斷線程的代碼
- 對(duì)象的構(gòu)造函數(shù)執(zhí)行結(jié)束先于finalize()方法
- 這些原則保證了重排的語義是一致的。
5. 線程安全的概念
指某個(gè)函數(shù)、函數(shù)庫(kù)在多線程環(huán)境中被調(diào)用時(shí),能夠正確地處理各個(gè)線程的局部變量,使程序功能正確完成。
比如最開始所說的i++的例子
就會(huì)導(dǎo)致線程不安全。
關(guān)于線程安全的詳情使用,請(qǐng)參考以前寫的這篇Blog,或者關(guān)注后續(xù)系列,也會(huì)談到相關(guān)內(nèi)容