我們一起來討論討論java內(nèi)存模型。理解內(nèi)存模型對多線程編程無疑是有好處的。
java代碼是如何跑起來的
java代碼如何運行
我們寫的java代碼,自己看得懂,然而虛擬機是看不懂的,更不用說直接在機器上跑起來了。要讓java代碼按照我們的意圖跑起來的話,需要以下幾個過程。
java代碼會經(jīng)過javac編譯器編譯,轉(zhuǎn)化成class文件,也就是常說的字節(jié)碼。然后再經(jīng)過jvm把字節(jié)碼轉(zhuǎn)化成機器可以識別的機器碼,才能跑起來。
為什么要轉(zhuǎn)化為字節(jié)碼,而不是直接轉(zhuǎn)化為機器碼呢?這是為了實現(xiàn)跨平臺,同一份機器碼,在不同cpu架構(gòu)的機器上跑,出來的結(jié)果可能大相徑庭。如果直接轉(zhuǎn)化成機器碼,那么可能程序在x86的機器上跑是正常的,但是在x64的機器上確出現(xiàn)了很詭異的結(jié)果。
而字節(jié)碼,是可以被java虛擬機所識別的。眾所周知,java跨平臺的原因,就在于java虛擬機在這其中起到的作用。虛擬機作為一個中間橋梁,把字節(jié)碼轉(zhuǎn)化為可以在特定機器上執(zhí)行的二進(jìn)制機器碼。
虛擬機的解釋器和編譯器
那么,虛擬機是怎么把字節(jié)碼轉(zhuǎn)化成機器碼的呢?主流的虛擬機,一般都有兩種方法來做。
- 解釋器
- 編譯器
需要注意的是,這里的編譯器和前面提到的javac編譯器不同,稍候會進(jìn)一步說明。
第一種是解釋器,解釋執(zhí)行。字節(jié)碼被一行一行的翻譯成機器碼,然后直接在機器上執(zhí)行。
第二種是編譯器,以方法為單位,把方法編譯成機器可以識別的機器碼,然后執(zhí)行。
二者有什么不同呢?
1.解釋器以行為單位,解釋完就直接執(zhí)行,速度更快。編譯器以方法為單位進(jìn)行編譯,速度相對解釋器要慢。
2.解釋器解釋完字節(jié)碼,轉(zhuǎn)化成的機器碼,并沒有保留下來。而編譯器把方法轉(zhuǎn)化成機器碼后,會緩存下來。后面如果再次需要使用到,直接拿之前編譯好的機器碼即可。
這里的編譯器是JIT(Just-In-Time)即時編譯器,和前面的javac編譯器有以下幾點不同。
1.javac的輸入是java源代碼,輸出是class文件,即字節(jié)碼。JIT的輸入是字節(jié)碼,輸出是機器可以執(zhí)行的二進(jìn)制機器碼。
2.javac是靜態(tài)編譯器,而JIT是動態(tài)編譯器,JIT在程序運行的時候動態(tài)編譯
3.編譯范圍不一樣。javac把所有的java代碼直接編譯成字節(jié)碼,而JIT只編譯一部分的字節(jié)碼,并非把所有的字節(jié)碼都轉(zhuǎn)化成機器碼。
為什么需要JIT
可能有同學(xué)會有疑問,既然有了解釋器,為什么還要整一個JIT編譯器呢。直接像js引擎一樣,解釋執(zhí)行js不可以嗎?是可以,但是有了JIT會更好。
解釋器有1個不足:解釋器解釋字節(jié)碼得到的機器碼沒有緩存,如果一個同樣的方法被調(diào)用了很多次,那么意味著同樣的字節(jié)碼要被重復(fù)解釋很多次,這一點無疑會降低運行的效率。
既然如此,那直接把解釋器解釋的所有機器碼都緩存下來,不就得了嗎。但是如果一個程序特別龐大,這樣做無疑非常浪費資源。所以,我們需要JIT來配合解釋器。JIT可以把常用的字節(jié)碼,編譯成機器碼,并緩存下來,以后再調(diào)用時,直接使用緩存的機器碼,提高運行效率。
當(dāng)然,JIT也有缺點。因為JIT編譯字節(jié)碼以方法為單位,同時還會做一些優(yōu)化,速度比解釋器的解釋要慢,所以,如果所有的字節(jié)碼都先經(jīng)過JIT編譯以后,再執(zhí)行,那么程序啟動會變得很慢。
所以,解釋器和JIT一般都是并存的。啟動的字節(jié)碼一般都由解釋器來解釋執(zhí)行,確保啟動速度。JIT對一些常用的代碼編譯優(yōu)化并緩存,提供程序運行效率。
JIT編譯什么
上面說JIT把常用的字節(jié)碼編譯成機器碼,如何來判斷常用字節(jié)碼。
基于計數(shù)器的熱點探測
JIT編譯是以方法為單位的。每個方法都會關(guān)聯(lián)一個計數(shù)器,當(dāng)方法被調(diào)用時,計數(shù)器會加1。當(dāng)一個方法的調(diào)用次數(shù)達(dá)到一定的閾值時,我們認(rèn)為這個方法是一個常用的方法,我們需要去編譯它,以免多次重復(fù)解釋執(zhí)行降低效率。
這種方法的優(yōu)點是統(tǒng)計精確,能夠動態(tài)且精準(zhǔn)知道,哪些方法是常用的。缺點是,每個方法都需要關(guān)聯(lián)計數(shù)器,開銷較大。
基于采樣的熱點探測
這種方法會確定一個采樣周期,每個周期都會檢測在調(diào)用棧的棧頂是哪個方法在被調(diào)用,并記錄下來。然后統(tǒng)計出常用的方法。
這種方法,相較于基于計數(shù)器的熱點探測,優(yōu)點在于開銷小,不需要給每個方法都關(guān)聯(lián)一個計數(shù)器。但是缺點在于統(tǒng)計并不精確,有時候還可能誤統(tǒng)計,比如線程被阻塞了,幾個周期內(nèi)檢測到的棧頂方法都是同一個方法,但很顯然,這個方法只是因為被阻塞了,而不一定是常用的方法。
為什么多線程的代碼可能出現(xiàn)詭異的結(jié)果
如果我們的多線程代碼,沒有正確使用各種同步或者并行的語義,很有可能會出現(xiàn)各種意想不到的結(jié)果。更有甚者,即使當(dāng)前的代碼在這臺機器上跑出了預(yù)期的結(jié)果,當(dāng)放在另一臺機器上運行時,卻發(fā)現(xiàn)結(jié)果完全不是自己想要的了。這樣的事情時有發(fā)生,讓人懷疑人生有木有!
多線程產(chǎn)生不可預(yù)期的結(jié)果,其實基本都是因為編譯器和處理器對代碼進(jìn)行優(yōu)化而產(chǎn)生的副作用。優(yōu)化自然是為了提高性能,但有可能優(yōu)化過了頭。我們一起來看看幾種導(dǎo)致多線程意外結(jié)果的原因。先說明下,既然本文探討的是java內(nèi)存模型,自然這里的多線程也是指的java版的多線程。
呀,還有一點很重要,需要先給個大前提。不論是編譯器,還是處理器,優(yōu)化代碼邏輯,都有一個原則:
必須不能改變單線程環(huán)境下的語義
這是優(yōu)化的底線,先了解這一點有助于我們理解下面的幾種優(yōu)化,為什么是允許的。
指令重排
jvm可能會重排我們的代碼邏輯,以jvm認(rèn)為這不會影響正確的結(jié)果為前提,并且以提高性能為目標(biāo)。
然而這恰恰可能導(dǎo)致多線程環(huán)境下,產(chǎn)生超出預(yù)期的結(jié)果。
一起來看看下面這個例子。
class Reordering {
int foo = 0;
int bar = 0;
void method() {
foo += 1;
bar += 1;
foo += 2;
}
}
假如現(xiàn)在有兩個線程和一個Reordering對象,線程1調(diào)用了這個Reordering對象的method方法,而線程2在觀察這個Reordering對象的foo變量和bar變量的值。兩個線程同時跑了起來。
直觀上,線程2看到的變量情況,應(yīng)該會有4種可能。
- 當(dāng)線程2在程序點1的地方觀察,應(yīng)該得到
foo=0
,bar=0
- 當(dāng)線程2在程序點2的地方觀察,應(yīng)該得到
foo=1
,bar=0
- 當(dāng)線程2在程序點3的地方觀察,應(yīng)該得到
foo=1
,bar=1
- 當(dāng)線程2在程序點4的地方觀察,應(yīng)該得到
foo=3
,bar=1
我們預(yù)期會有上面4種情況發(fā)生。但實際跑的時候,卻發(fā)現(xiàn)出現(xiàn)了foo=3
, bar=0
的情況。
這就尷尬了,理論上,bar+=1
是先于foo+=2
的,而foo=3
只能是經(jīng)過foo += 2
之后才會得到,也就是說, 當(dāng)foo=3
的時候,bar += 1
已經(jīng)執(zhí)行過了,那么為什么當(dāng) foo=3
的時候, bar=0
呢?
問題就處在了jvm和處理器對指令的優(yōu)化上,這里的優(yōu)化具體來說,是指令重排。
方法method()里面的3條語句都是形如 變量 += 常量
,我們以 foo += 1
來看下cpu是如何處理這條語句的。
在此之前,我們需要先了解下cpu的cache。
學(xué)過計算機組成原理的同學(xué)應(yīng)該知道,cpu訪問內(nèi)存需要經(jīng)過總線傳遞數(shù)據(jù),速度較慢,cpu如果每次存取數(shù)據(jù)都要和內(nèi)存交互,無疑會降低cpu的運轉(zhuǎn)效率。cpu訪問寄存器的速度是非??斓模怯植豢赡苤灰蕾囉诩拇嫫鳎驗榧拇嫫鞯目臻g太小了。
因此,每塊cpu芯片上都設(shè)置了緩存cache,訪問cache的速度要高于訪問內(nèi)存的速度,因此,如果我們需要取內(nèi)存中的數(shù)據(jù),可以先加載到cache中,如果做了相應(yīng)的修改,再把新的數(shù)據(jù)同步回內(nèi)存。具體的緩存命中(cache hit)和緩存未命中(cache miss)不是本文的重點,有興趣的同學(xué)自己了解下~
言歸正傳。我們來看下foo += 1
是如何被cpu執(zhí)行的(這里不涉及具體的cpu指令,只是一種容易理解的方式來說明)。
分三步走。
首先從內(nèi)存中取foo變量加載到cache。
然后,cpu取cache中的foo,并執(zhí)行加1的操作。此時cache中的foo的值是1,而內(nèi)存中的foo的值還是0。
最后,cache把foo=1
同步回到內(nèi)存中。至此,foo += 1
就算完成了。
所以,方法method()執(zhí)行,理論上是需要9步操作的。
foo += 1
- cpu加載foo到cache
- cache的foo加1
- cache的foo同步到內(nèi)存
bar += 1
- cpu加載bar到cache
- cache的bar加1
- cache的bar同步到內(nèi)存
foo += 2
- cpu加載foo到cache
- cache的foo加2
- cache的foo同步到內(nèi)存
小伙伴可能會發(fā)現(xiàn),foo被加載到cache中發(fā)生了2次,被同步到內(nèi)存也發(fā)生了2次,如果把foo += 1
和foo += 2
放在一起處理,可以減少加載和同步的次數(shù)。
- cpu加載foo到cache
- cache的foo加1
- cache的foo加2
- cache的foo同步到內(nèi)存
此時,代碼邏輯就變成了
void method {
foo += 1;
foo += 2;
bar += 1;
}
事實上,jvm和cpu就是這么干的,甚至?xí)?code>foo += 1 和foo +=2
合并到一起,
void method {
foo += 3;
bar += 1;
}
乍一看,這似乎是對的,因為不論是原來的代碼順序,還是指令重排后,二者的結(jié)果都是 foo=3 bar=1
。單線程環(huán)境下的確如此。但是多線程就不一樣了。
當(dāng)另一個線程在上圖的程序點試圖去讀取foo和bar的值時,發(fā)現(xiàn)bar=0
的時候,出現(xiàn)了foo=3
的情況,如我們前面提到的,這個結(jié)果不在我們的預(yù)期之內(nèi)。
這說明,單線程下正確的語義,不代表多線程環(huán)境下也是正確的
去掉無效的語句
看下面這段程序
class Caching {
boolean flag = true;
int count = 0;
void thread1() {
while (flag) {
count++;
}
}
void thread2() {
flag = false;
}
}
假設(shè)現(xiàn)在有2個線程,線程1執(zhí)行thread1方法,線程2執(zhí)行thread2方法。
我們來紙上談?wù)劚?br>
線程1會不斷輪詢flag的值,flag初始化為true,所以count會不斷自增。
線程2把flag改成false。
線程1發(fā)現(xiàn)flag變成了false,退出循環(huán)。
實際情況呢?
jvm在thread1執(zhí)行過程中,發(fā)現(xiàn)在該執(zhí)行路徑中(thread1()),并沒有任何修改flag的地方。所以,JIT把這段代碼優(yōu)化了一下
void thread1() {
while(true) {
count++;
}
}
jvm在thread2執(zhí)行過程中,發(fā)現(xiàn)在該執(zhí)行路徑中(thread2()),并沒有任何讀取flag的地方,所以,JIT把這段代碼也優(yōu)化了下
void thread2() {
// flag = false;
}
你沒看錯,flag=false
被忽略了,因為在thread2方法里面,沒有任何去讀取flag的地方,所以flag=false
在jvm看來是沒有必要同步到內(nèi)存的。
這就會導(dǎo)致thread1()不斷的執(zhí)行,從而和我們想要的效果不一樣了。
不同的平臺
在不同架構(gòu)的機器上跑,也可能會出現(xiàn)難以解釋的現(xiàn)象。
看下面一段程序
class Atomic {
long foo = 0L;
void thread1() {
foo = 0xFFFF0000;
}
void thread2() {
foo = 0x0000FFFF;
}
}
同樣,我們假設(shè)線程1調(diào)用thread1方法,線程2調(diào)用thread2方法。
理論上講,如果線程1先執(zhí)行,線程2后執(zhí)行,那么foo = 0x0000FFFF
, 如果線程2先執(zhí)行,線程1后執(zhí)行,那么foo = 0xFFFF0000
。
如果是在64位的機器上,這個結(jié)論是正確的。
但如果是32位的機器,就不一定了。有可能出現(xiàn)0x00000000,也可能出現(xiàn)0xFFFFFFFF。
我們以出現(xiàn)0xFFFFFFFF的結(jié)果為例子討論。
在32位的機器上,cpu存取一個數(shù)據(jù)是以32位為單位取的。foo是一個64位的變量,會被分成兩次存取。
如前面所說,cpu會先從內(nèi)存中取數(shù)據(jù)到cache,然后cache設(shè)置變量的值,最后再把值同步回內(nèi)存。
當(dāng)同步值到內(nèi)存中時,因為是分兩次同步,所以線程1和線程2可能下面的情形。
thread1把foo的低32位同步回內(nèi)存,thread2把foo的高32位同步回內(nèi)存,此時內(nèi)存的值是0x00000000
然后,thread1把foo的高32位同步回內(nèi)存,thread2把foo的低32位同步回內(nèi)存,此時就出現(xiàn)了內(nèi)存中foo=0xffffffff
的情況了。
下面來總結(jié)下不同平臺對于指令亂序的容忍度。如果大家以后出現(xiàn)一個程序在一種cpu架構(gòu)的機器上跑是正常的,但是在另外一種cpu架構(gòu)的機器上跑出現(xiàn)了奇怪的結(jié)果,可以考慮是否是因為這方面引起的原因。
http://dreamrunner.org/blog/2014/06/28/qian-tan-memory-reordering/#memory-ordering-at-processor-time
. | ARM | POWERPC | SPARC TSO | X86 | AMD64 |
---|---|---|---|---|---|
load-load | yes | yes | no | no | no |
load-store | yes | yes | no | no | no |
store-store | yes | yes | no | no | no |
store-load | yes | yes | yes | yes | yes |
和內(nèi)存交互的指令可以分為兩大類,load指令(也就是從內(nèi)存讀取),store指令(也就是寫入內(nèi)存),所以,兩條指令的順序一共有4種可能:
- load-load,前一條是讀取指令,后一條也是讀取指令
- load-store,前一條是讀取指令,后一條是寫入指令
- store-store,前一條是寫入指令,后一條也是寫入指令
- store-load,前一條是寫入指令,后一條是讀取指令
需要注意,這里的指令順序,指的是在同一個線程中的指令順序,而不是兩個或者多個不同線程的指令順序。怎么理解呢?想一下,處理器重排指令順序,就是為了讓cpu可以更高效地處理,而cpu不可能同時處理兩個或多個線程,每個線程同時運行,其實是由不同的cpu來處理。
上表中的load-load,load-store,store-store,store-load,指的是指令原來的順序,yes表示在對應(yīng)的cpu架構(gòu)上,允許重排指令,no表示。
以load-load為例,假如有兩個指令如下
load a
load b
那么,在ARM和POWERPC處理器上,有可能出現(xiàn)指令重排,變成
load b
load a
而SPARC TSO,X86,AMD64的處理器都不允許load-load重排。
從嚴(yán)格度來講,ARM和POWERPC是最寬松的,四種指令順序,都允許重排指令來優(yōu)化性能。而SPARC TSO,X86,AMD64都比較嚴(yán)格,僅僅在store-load的指令順序下,才允許處理器重排指令以優(yōu)化性能。
所以,有時候會出現(xiàn)這樣一種情況,我們的程序在x86的機器上跑的好好的,拿到ARM上面跑就莫名其妙了,很有可能就是ARM做了過度的優(yōu)化導(dǎo)致的。
為什么ARM和POWERPC,相比于其他架構(gòu),做了更多可能的優(yōu)化呢?其實這和市場定位有很大的關(guān)系,比如ARM,主要應(yīng)用在手機和PDA等設(shè)備,而X86則更多應(yīng)用在PC上,這使得二者的定位截然不同,其中非常重要的一點考量就是耗電量。手機之于PC,前者使用的時候一般是不插電,而PC則一般是插電使用,這使得ARM必須更多考慮如何省電,而對程序做更多的優(yōu)化,很重要的原因就是提高性能,降低功耗。
java內(nèi)存模型
了解了多線程出現(xiàn)data race的各種可能原因后,下面開始引入java內(nèi)存模型
一句話概括
什么是java內(nèi)存模型,通俗的說,就是(我的個人理解,如果有誤,請大家一定指出)
java內(nèi)存模型是一個規(guī)范,如果jvm是一個遵循了java內(nèi)存模型的實現(xiàn),并且我們的程序也正確使用了各種同步機制,那么多線程環(huán)境下,我們從內(nèi)存中讀取一個變量,其值是確定的。
每個處理器上的寫緩沖區(qū),僅僅對它的處理器可見。對其他的處理器不可見。
模型簡介
在java內(nèi)存模型中,內(nèi)存被分成了2種類型。
- 本地內(nèi)存:每個線程都有一個本地內(nèi)存,存儲線程需要操作的一些數(shù)據(jù)。
- 共享內(nèi)存:不同的線程之間共享主內(nèi)存(也可以叫共享內(nèi)存,更直觀一點)。
線程之間不能互相訪問各自的本地內(nèi)存,即線程1不能訪問線程2的本地內(nèi)存,線程2也不能訪問線程1的本地內(nèi)存。
線程之間要通信,只能通過共享內(nèi)存來傳遞消息。比如線程1要發(fā)消息給線程2,必須先把消息寫入到共享內(nèi)存,然后線程2到共享內(nèi)存取出消息。
這里的內(nèi)存模型只是一個抽象的概念,并非對應(yīng)了真正的物理內(nèi)存。但是真正內(nèi)存上的存取是符合這個模型的。比如處理器的cache就可以比作是本地內(nèi)存,而物理內(nèi)存就可以看做多個cpu執(zhí)行多線程時的共享內(nèi)存。
內(nèi)存屏障
之前提到因為編譯器和處理器可能做的各種優(yōu)化,而導(dǎo)致多線程語義出現(xiàn)不可預(yù)期,為了解決這個問題,java內(nèi)存模型提出了4種內(nèi)存屏障。
load-load barrier
load-store barrier
store-store barrier
store-load barrier
**load-load barrier **:
sequence: Load 1 load-load-barrier Load2
ensures that Load1's data are loaded before data accessed by Load2 and all subsequent load instructions are loaded.
load-load可以確保程序語義不被重排序,比如下面的例子
System.out.print(a);
/** load-load barrier **/
System.out.print(b);
那么,一定能保證,編譯器不會對這兩條語句進(jìn)行重排。
load-store barrier:
sequence: Load 1 load-store-barrier Load 2
ensures that Load1's data are loaded before all data associated with Store2 and subsequent store instructions are flushed.
load-store可以確保程序語義不被重排序,比如下面的例子
System.out.print(a);
/** load-store barrier **/
b = 1;
那么,一定能保證,編譯器不會對這兩條語句進(jìn)行重排。
store-store barrier
sequence: Load1 store-store-barrier Load2
ensures that Store1's data are visible to other processors (i.e., flushed to memory) before the data associated with Store2 and all subsequent store instructions.
store-store稍有不同。它有兩個方面的作用。
第一,store-store一樣可以確保程序語義不被重排,比如下面的例子
a = 1;
/** store-store barrier **/
b = 2;
那么,一定能保證,編譯器不會對這兩條語句進(jìn)行重排。
store-store的另一個功能是,前者的store一定能對其他線程可見。
換句話將,當(dāng)執(zhí)行完a = 1,正常情況下,并不一定會馬上同步到共享內(nèi)存,因此,其他線程此時如果獲取a的值,有可能還是舊的。但是store-store barrier可以確保,a=1執(zhí)行完后會馬上同步到共享內(nèi)存,其他線程從共享內(nèi)存獲取的一定是新的值。
store-load barrier
sequence: Store1 store-store-barrier Store2
ensures that Store1's data are made visible to other processors (i.e., flushed to main memory) before data accessed by Load2 and all subsequent load instructions are loaded. StoreLoad barriers protect against a subsequent load incorrectly using Store1's data value rather than that from a more recent store to the same location performed by a different processor.
輪到store-load barrier,突然字就變多了,沒錯,store-load的確與眾不同。
store-load比store-store還要強大。
首先,store-load一樣可以確保程序的語義不被重排,比如下面的例子
a = 1;
/** store-load barrier **/
System.out.print(a);
那么,一定能保證,編譯器一定不會重排這兩條語句。
store-load的第二個功能是,前者的寫入操作會同步到共享內(nèi)存,對其他線程馬上可見。
也就是說,a = 1會馬上同步到共享內(nèi)存,其他的線程此時獲取到的a一定是最新的值。
store-load的第三個功能是,在store-load屏障后面的讀入,一定會從共享內(nèi)存中去讀取,而不是直接取本地內(nèi)存的緩存。
這意味著,一旦插入了store-load屏障,那么屏障執(zhí)行之后,線程會讓自己的本地緩存失效,讀取操作一定會到共享內(nèi)存去取最新值。
對四種內(nèi)存屏障做個小總結(jié):
四種內(nèi)存屏障都能夠保證程序語義的正確,編譯器不會重排邏輯
load-load和load-store只能保證程序語義正確,無法確保內(nèi)存可見性。也就是說,load操作雖然執(zhí)行了,但實際上可能取的是緩存的值,而不是重新到共享內(nèi)存取。store操作雖然執(zhí)行了 ,但實際上,可能還沒有馬上同步到共享內(nèi)存中。
store-store和store-load都可以確保程序語義正確,也都可以確保屏障前的store操作能夠馬上同步到共享內(nèi)存。但是store-store屏障后面的load操作,不能確保一定會都共享內(nèi)存取最新值,可能還是讀的緩存。而store-load屏障后面的load操作,一定能夠確保是去共享內(nèi)存取的最新值。
從性能的角度講,store-load是最耗性能的,但是在確保正確性方面,store-load做的最好。
同步機制
這里我們介紹幾種同步機制,這些是上層開發(fā)實際會用到的幾種同步。
從修飾類型來看,可以分為3種:
修飾字段的同步機制
修飾方法的同步機制
修飾代碼塊
關(guān)于修飾字段的同步機制,我們一起來討論下面二者
volatile
final
關(guān)于修飾方法和代碼塊的同步機制,我們來討論
- synchronized
下面就開始吧~
volatile
看如下一個例子
class DataRace {
bool ready = false;
int foo = 0;
void thread1() {
while (!ready);
assert foo == 42;
}
void thread2() {
foo = 42;
ready = true;
}
}
線程1調(diào)用thread1方法,線程2調(diào)用thread2方法。我們想要的結(jié)果是,當(dāng)線程2把執(zhí)行完ready = true后,線程1檢測到ready為true了,然后檢測foo == 42成功。
按道理應(yīng)該是這樣。因為foo = 42 在 ready = true前面,所以,當(dāng)ready = true,foo == 42肯定成立。
在x86的機器上,這個說法說得通。但是在ARM上就有問題了。
還記得前面提到的不同平臺的指令重排嗎,ARM上允許store-store重排,所以,完全有可能ready = true,在foo = 42前面執(zhí)行了。
然后當(dāng)thread1檢測到ready = true的時候,foo可能還沒設(shè)置成42
| thread2 | thread1
| |
| ready = true |
| | while(!ready);
| | assert foo == 42; // fail
| foo = 42; |
如上圖所示,assert foo == 42
的時候,foo = 42
還沒執(zhí)行導(dǎo)致assert fail。
如果ready變量用volatile修飾,就截然不同了。
class DataRace {
volatile boolean ready = false;
int foo = 0;
void thread1() {
while(!ready);
assert foo == 42;
}
void thread2() {
foo = 42;
ready = true;
}
}
volatile修飾在成員變量上,能具有如下作用:
在volatile變量的寫操作后面會插入一個store屏障,保證volatile的寫操作會立即同步到共享內(nèi)存,使其他線程對該寫操作可見
在volatile變量的讀操作前面會插入一個load屏障,保證volatile的讀操作會從共享內(nèi)存取最新的數(shù)據(jù),而不是直接讀取的緩存。
在volatile變量的寫操作前面的程序語句不允許和volatile變量的寫操作重排指令。
在volatile變量的讀操作后面的程序語句不允許和volatile變量的讀操作重排指令。
volatile變量寫操作前面的寫操作一定會在執(zhí)行完store屏障前,同步到共享內(nèi)存。
volatile變量讀操作后面的讀操作一定會從共享內(nèi)存取最新數(shù)據(jù)。
從例子看,對volatile變量的寫操作是ready = true
,對volatile變量的讀操作是while(!ready)
,所以得出:
foo = 42
會馬上刷新到共享內(nèi)存,對應(yīng)第1條。在thread2執(zhí)行了
foo=42
后,thread1的while(!ready)
會從共享內(nèi)存取出最新的ready,對應(yīng)第2條。foo = 42
不允許和ready = true
重排,對應(yīng)第3條。while(!ready)
不允許和assert foo == 42
重排,對應(yīng)第4條。foo = 42
會在執(zhí)行完store屏障前,更新最新值到共享內(nèi)存,對應(yīng)第5條。assert foo == 42
會重新到共享內(nèi)存取最新值,對應(yīng)第6條。
所以,新的執(zhí)行流程是
| thread2 | thread1
| |
| foo = 42; |
| ready = 1; |
|--------------------------------------------
| | while(!ready);
| | assert foo == 42; // success
對了,還記得前面我們聊過,如果一個變量是64位的,在32位的機器上跑,有可能出現(xiàn)錯誤結(jié)果,原因是分成2個32位的分開存儲導(dǎo)致的。如果用volatile來修飾,那么這種情況是不存在的。
但是這并不意味著,volatile具有原子性。比如
volatile int i =0;
i++;
i++
其實是分三步走的,先讀取,再自增,最后寫入。volatile不能保證這三步操作具有原子性,依然可能出現(xiàn)data race。volatile只保證讀取是取最新值,寫入能立即同步到共享內(nèi)存。
synchronized
前面的例子,除了用volatile來解決,還可以synchronized修飾方法或者代碼塊。
class DataRace {
boolean ready = false;
int foo = 0;
synchronized void thread1() {
while(!ready) ;
assert foo == 42;
}
synchronized void thread2() {
foo = 42;
ready = true;
}
}
synchronized是如何保證結(jié)果的正確性呢?
synchronized會被拆分成兩個指令
<enter lock>
<exit lock>
<enter lock>
就是獲取了鎖,同一時刻,只能有一個線程能夠獲得lock。
<exit lock>
就是釋放鎖,擁有l(wèi)ock的線程只有在釋放鎖以后,其他線程才有機會獲得鎖。
也就是說,synchronized對應(yīng)的鎖是互斥鎖。
當(dāng)synchronzed修飾在非靜態(tài)方法時,lock就是實例本身,即this
,所以可以寫成這樣
void thread1() {
<enter this>
while(!ready);
assert foo == 42;
<exit this>
}
void thread2() {
<enter this>
foo = 42;
ready = true;
<exit this>
}
synchronzed可以保證以下幾點:
同一時刻,只能有一個線程獲取lock。
線程在獲得lock后,所有的寫入操作,會在釋放lock前,全部同步到共享內(nèi)存。
線程在獲得lock后,所有的讀取操作,都會從共享內(nèi)存讀取最新值,而不是從緩存直接獲取。
第1條保證了線程1和線程2的代碼不可能同時執(zhí)行;
第2條保證了foo=42
和ready = true
都會在線程2釋放lock之前,全部同步到共享內(nèi)存,使得更新對線程1可見。
第3條保證了while(!ready)
和assert foo == 42
都會從共享內(nèi)存獲取最新值。
假設(shè)線程2先獲得lock,如圖
| thread2 | thread1
| |
| <enter this> |
| foo = 42; |
| ready = true; |
| <exit this> |
|---------------------------------------------
| | <enter this>
| | while(!ready);
| | assert foo == 42; // success
| | <exit this>
注意,synchronized并不能保證在塊內(nèi)的語句不被重排。也就是說,thread2的foo = 42
和ready = true
可能重排,thread1的while(!ready)
和foo == 42
可能重排。
線程啟動同步
來看下面的例子
class ThreadLife {
int foo = 0;
void thread1() {
foo = 42;
new Thread(new Runnable() {
public void run() {
assert foo == 42;
}
}).start();
}
}
線程1會調(diào)用thread1方法,在里面會啟動一個新的線程(假設(shè)為線程2),那么,線程2的foo == 42
是否一定能保證成功呢?
答案是肯定的。
線程1調(diào)用new Thread(runnable).start()
啟動線程2,線程2啟動時會在一開始放入一個<start>
指令。并且有以下保證:
線程1的thread.start()方法一定在線程2的
<start>
之前執(zhí)行(否則怎么啟動呢)。線程1的thread.start()方法前面的寫入操作一定會同步到共享內(nèi)存
線程2在啟動時,會從共享內(nèi)存中獲取線程1的變量,拷貝到線程2的本地內(nèi)存。
以上保證了線程2中的foo是共享內(nèi)存中的最新值,如下
| thread1 | thread2
| |
| foo = 42; |
| thread.start() |
|-----------------------------------------
| | <start>
| | assert foo == 42; // success
final關(guān)鍵字
其實,final也有自己的多線程語義的,這一點倒是讓我感到有點意外??聪旅娴睦印?/p>
class UnsafePublication {
int foo;
UnsafePublication() {
foo = 42;
}
static UnsafePublication instance;
static void thread1() {
instance = new UnsafePublication();
}
static void thread2() {
if (instance != null) {
assert instance.foo == 42;
}
}
}
這一段程序并沒有用到final呀?這是一段有問題的程序。假設(shè)線程1調(diào)用thread1生成一個instance對象,線程2調(diào)用thread2(),判斷如果instance非空,就assert instance.foo == 42
。
我們看構(gòu)造函數(shù)里面,初始化foo = 42
,而如果instance != null
,說明已經(jīng)調(diào)用構(gòu)造函數(shù)了,那assert foo == 42
肯定成功的。真是這樣嗎?其實,有可能出現(xiàn)assert fail的情況。
構(gòu)造一個對象,其實是拆分成了2步:
首先,分配內(nèi)存空間給對象(allocate)
然后,才調(diào)用構(gòu)造函數(shù)(init)
所以,線程1 new一個instance可以拆分成
static void thread1() {
instance = <allocate UnsafePublication>;
instance.<init>();
}
問題就出在這里。分配內(nèi)存空間給對象,和調(diào)用構(gòu)造函數(shù)不是原子性的。這意味著,線程2可能已經(jīng)看見了線程1為instance分配了內(nèi)存空間,即instance != null
,但是線程1其實還沒有調(diào)用構(gòu)造函數(shù)。那么,此時assert foo == 42
就失敗了。
解決方式?final出馬。
把final成員修飾成final:final int foo = 0;
一個final成員,如果是在構(gòu)造函數(shù)里面初始化的,那么會在構(gòu)造函數(shù)的末尾插入一個<freeze>
指令
UnsafePublication() {
foo = 42;
<freeze>
}
freeze指令能夠保證,對象分配和final成員的初始化,同時對其他線程可見。
這并不是說,對象的內(nèi)存分配和final成員的初始化,會馬上對其他線程可見。而是指即使我們已經(jīng)為對象創(chuàng)建了內(nèi)存空間了,即allocate
已經(jīng)完成,但是只要final成員還沒有初始化,那么,對象的allocate就對其他線程不可見,即instance == null
,只有當(dāng)final成員初始化了,allocate
才對其他線程可見。
所以,如果instance不為null,一定能保證foo = 42。
|thread1 | thread2
| instance = <allocate>; |
| instance.foo = 42; |
| <freeze instance.foo> |
|-----------------------------|---------------------
| | if (instance != null){
| | assert instance.foo == 42; // success
| | }
jni
編譯器會重排java調(diào)用和jni調(diào)用嗎?不會。
看下面例子。
class Externalization {
int foo = 0;
void method() {
foo = 42;
jni();
}
native void jni();
// jni里面判斷foo: assert == 42;
}
JIT是無法讀取native代碼的,所以無法去優(yōu)化它,jni里面的內(nèi)存管理也無法用gc來代勞。在JIT不了解JNI代碼都做了什么事情的情況下,如果重排java調(diào)用和jni調(diào)用,是非常危險的,違背了單線程語義正確性的原則。
所以,有人說jni調(diào)用效率并不高,有一部分原因,應(yīng)該就是因為JIT無法優(yōu)化jni調(diào)用的原因吧。
所以,這里的assert == 42是成功的。
Thread Divergence Action
看如下例子
class ThreadDivergence {
int foo = 42;
void thread1() {
while(true);
foo = 0;
}
void thread2() {
assert foo == 42;
}
}
線程1調(diào)用thread1方法,線程2調(diào)用thread2方法,線程1有foo = 0
,那么線程2可能出現(xiàn)assert fail嗎?不會的。
看看thread1()。
while(true);
是一個Thread Divergence Action。至于什么是Thread Divergence Action,看了定義其實我不太理解意思(sorry),這里就不瞎BB誤導(dǎo)大家了。
但是可以肯定的2點:
while(true);
這個無線循環(huán)是一個Thread Divergence Action。JIT不會重排Thread Divergence Action語句。
也就是說,while(true);
和foo = 0
不會發(fā)生重排。這個很好理解,因為while(true);
是一個無限循環(huán),永遠(yuǎn)也不會執(zhí)行foo = 0
,如果把foo = 0
放到while(true)
前面,程序語義就改變了,這是不允許的。
本篇文章到這里就結(jié)束了。如果您看到了這,非常感謝您的時間和耐心~~
如果本文有理解偏頗或者錯誤的地方,請留言指正,謝謝~~