我們一起來討論討論java內(nèi)存模型。理解內(nèi)存模型對(duì)多線程編程無疑是有好處的俘枫。
java代碼是如何跑起來的
java代碼如何運(yùn)行
我們寫的java代碼满着,自己看得懂,然而虛擬機(jī)是看不懂的积暖,更不用說直接在機(jī)器上跑起來了。要讓java代碼按照我們的意圖跑起來的話怪与,需要以下幾個(gè)過程夺刑。
java代碼會(huì)經(jīng)過javac編譯器編譯,轉(zhuǎn)化成class文件分别,也就是常說的字節(jié)碼遍愿。然后再經(jīng)過jvm把字節(jié)碼轉(zhuǎn)化成機(jī)器可以識(shí)別的機(jī)器碼,才能跑起來耘斩。
為什么要轉(zhuǎn)化為字節(jié)碼沼填,而不是直接轉(zhuǎn)化為機(jī)器碼呢?這是為了實(shí)現(xiàn)跨平臺(tái)括授,同一份機(jī)器碼坞笙,在不同cpu架構(gòu)的機(jī)器上跑岩饼,出來的結(jié)果可能大相徑庭。如果直接轉(zhuǎn)化成機(jī)器碼薛夜,那么可能程序在x86的機(jī)器上跑是正常的籍茧,但是在x64的機(jī)器上確出現(xiàn)了很詭異的結(jié)果。
而字節(jié)碼却邓,是可以被java虛擬機(jī)所識(shí)別的硕糊。眾所周知院水,java跨平臺(tái)的原因腊徙,就在于java虛擬機(jī)在這其中起到的作用。虛擬機(jī)作為一個(gè)中間橋梁檬某,把字節(jié)碼轉(zhuǎn)化為可以在特定機(jī)器上執(zhí)行的二進(jìn)制機(jī)器碼撬腾。
虛擬機(jī)的解釋器和編譯器
那么,虛擬機(jī)是怎么把字節(jié)碼轉(zhuǎn)化成機(jī)器碼的呢恢恼?主流的虛擬機(jī)民傻,一般都有兩種方法來做。
- 解釋器
- 編譯器
需要注意的是场斑,這里的編譯器和前面提到的javac編譯器不同漓踢,稍候會(huì)進(jìn)一步說明。
第一種是解釋器漏隐,解釋執(zhí)行喧半。字節(jié)碼被一行一行的翻譯成機(jī)器碼,然后直接在機(jī)器上執(zhí)行青责。
第二種是編譯器挺据,以方法為單位,把方法編譯成機(jī)器可以識(shí)別的機(jī)器碼脖隶,然后執(zhí)行扁耐。
二者有什么不同呢?
1.解釋器以行為單位产阱,解釋完就直接執(zhí)行婉称,速度更快。編譯器以方法為單位進(jìn)行編譯构蹬,速度相對(duì)解釋器要慢王暗。
2.解釋器解釋完字節(jié)碼,轉(zhuǎn)化成的機(jī)器碼怎燥,并沒有保留下來瘫筐。而編譯器把方法轉(zhuǎn)化成機(jī)器碼后,會(huì)緩存下來铐姚。后面如果再次需要使用到策肝,直接拿之前編譯好的機(jī)器碼即可肛捍。
這里的編譯器是JIT(Just-In-Time)即時(shí)編譯器,和前面的javac編譯器有以下幾點(diǎn)不同之众。
1.javac的輸入是java源代碼拙毫,輸出是class文件,即字節(jié)碼棺禾。JIT的輸入是字節(jié)碼缀蹄,輸出是機(jī)器可以執(zhí)行的二進(jìn)制機(jī)器碼。
2.javac是靜態(tài)編譯器膘婶,而JIT是動(dòng)態(tài)編譯器缺前,JIT在程序運(yùn)行的時(shí)候動(dòng)態(tài)編譯
3.編譯范圍不一樣。javac把所有的java代碼直接編譯成字節(jié)碼悬襟,而JIT只編譯一部分的字節(jié)碼衅码,并非把所有的字節(jié)碼都轉(zhuǎn)化成機(jī)器碼。
為什么需要JIT
可能有同學(xué)會(huì)有疑問脊岳,既然有了解釋器逝段,為什么還要整一個(gè)JIT編譯器呢。直接像js引擎一樣割捅,解釋執(zhí)行js不可以嗎奶躯?是可以,但是有了JIT會(huì)更好亿驾。
解釋器有1個(gè)不足:解釋器解釋字節(jié)碼得到的機(jī)器碼沒有緩存,如果一個(gè)同樣的方法被調(diào)用了很多次颊乘,那么意味著同樣的字節(jié)碼要被重復(fù)解釋很多次乏悄,這一點(diǎn)無疑會(huì)降低運(yùn)行的效率浙值。
既然如此檩小,那直接把解釋器解釋的所有機(jī)器碼都緩存下來,不就得了嗎规求。但是如果一個(gè)程序特別龐大,這樣做無疑非常浪費(fèi)資源瓦戚。所以较解,我們需要JIT來配合解釋器。JIT可以把常用的字節(jié)碼,編譯成機(jī)器碼瞎暑,并緩存下來了赌,以后再調(diào)用時(shí)揍拆,直接使用緩存的機(jī)器碼,提高運(yùn)行效率贮喧。
當(dāng)然猪狈,JIT也有缺點(diǎn)雇庙。因?yàn)镴IT編譯字節(jié)碼以方法為單位疆前,同時(shí)還會(huì)做一些優(yōu)化竹椒,速度比解釋器的解釋要慢胸完,所以赊窥,如果所有的字節(jié)碼都先經(jīng)過JIT編譯以后扯再,再執(zhí)行叔收,那么程序啟動(dòng)會(huì)變得很慢饺律。
所以复濒,解釋器和JIT一般都是并存的巧颈。啟動(dòng)的字節(jié)碼一般都由解釋器來解釋執(zhí)行十籍,確保啟動(dòng)速度勾栗。JIT對(duì)一些常用的代碼編譯優(yōu)化并緩存围俘,提供程序運(yùn)行效率界牡。
JIT編譯什么
上面說JIT把常用的字節(jié)碼編譯成機(jī)器碼宿亡,如何來判斷常用字節(jié)碼。
基于計(jì)數(shù)器的熱點(diǎn)探測(cè)
JIT編譯是以方法為單位的坤按。每個(gè)方法都會(huì)關(guān)聯(lián)一個(gè)計(jì)數(shù)器臭脓,當(dāng)方法被調(diào)用時(shí)来累,計(jì)數(shù)器會(huì)加1嘹锁。當(dāng)一個(gè)方法的調(diào)用次數(shù)達(dá)到一定的閾值時(shí)米同,我們認(rèn)為這個(gè)方法是一個(gè)常用的方法面粮,我們需要去編譯它熬苍,以免多次重復(fù)解釋執(zhí)行降低效率。
這種方法的優(yōu)點(diǎn)是統(tǒng)計(jì)精確似枕,能夠動(dòng)態(tài)且精準(zhǔn)知道,哪些方法是常用的冗恨。缺點(diǎn)是,每個(gè)方法都需要關(guān)聯(lián)計(jì)數(shù)器傲武,開銷較大揪利。
基于采樣的熱點(diǎn)探測(cè)
這種方法會(huì)確定一個(gè)采樣周期疟位,每個(gè)周期都會(huì)檢測(cè)在調(diào)用棧的棧頂是哪個(gè)方法在被調(diào)用绍撞,并記錄下來傻铣。然后統(tǒng)計(jì)出常用的方法矾柜。
這種方法,相較于基于計(jì)數(shù)器的熱點(diǎn)探測(cè)缆瓣,優(yōu)點(diǎn)在于開銷小弓坞,不需要給每個(gè)方法都關(guān)聯(lián)一個(gè)計(jì)數(shù)器。但是缺點(diǎn)在于統(tǒng)計(jì)并不精確族吻,有時(shí)候還可能誤統(tǒng)計(jì)超歌,比如線程被阻塞了,幾個(gè)周期內(nèi)檢測(cè)到的棧頂方法都是同一個(gè)方法懊悯,但很顯然炭分,這個(gè)方法只是因?yàn)楸蛔枞耍灰欢ㄊ浅S玫姆椒ā?/p>
為什么多線程的代碼可能出現(xiàn)詭異的結(jié)果
如果我們的多線程代碼岖妄,沒有正確使用各種同步或者并行的語義荐虐,很有可能會(huì)出現(xiàn)各種意想不到的結(jié)果腕铸。更有甚者狠裹,即使當(dāng)前的代碼在這臺(tái)機(jī)器上跑出了預(yù)期的結(jié)果,當(dāng)放在另一臺(tái)機(jī)器上運(yùn)行時(shí)俗冻,卻發(fā)現(xiàn)結(jié)果完全不是自己想要的了迄薄。這樣的事情時(shí)有發(fā)生,讓人懷疑人生有木有勤篮!
多線程產(chǎn)生不可預(yù)期的結(jié)果账劲,其實(shí)基本都是因?yàn)榫幾g器和處理器對(duì)代碼進(jìn)行優(yōu)化而產(chǎn)生的副作用瀑焦。優(yōu)化自然是為了提高性能榛瓮,但有可能優(yōu)化過了頭精续。我們一起來看看幾種導(dǎo)致多線程意外結(jié)果的原因重付。先說明下弓颈,既然本文探討的是java內(nèi)存模型翔冀,自然這里的多線程也是指的java版的多線程。
呀计福,還有一點(diǎn)很重要,需要先給個(gè)大前提说订。不論是編譯器,還是處理器埂伦,優(yōu)化代碼邏輯沾谜,都有一個(gè)原則:
必須不能改變單線程環(huán)境下的語義
這是優(yōu)化的底線,先了解這一點(diǎn)有助于我們理解下面的幾種優(yōu)化媳否,為什么是允許的力图。
指令重排
jvm可能會(huì)重排我們的代碼邏輯,以jvm認(rèn)為這不會(huì)影響正確的結(jié)果為前提晓折,并且以提高性能為目標(biāo)。
然而這恰恰可能導(dǎo)致多線程環(huán)境下胃珍,產(chǎn)生超出預(yù)期的結(jié)果。
一起來看看下面這個(gè)例子填抬。
class Reordering {
int foo = 0;
int bar = 0;
void method() {
foo += 1;
bar += 1;
foo += 2;
}
}
假如現(xiàn)在有兩個(gè)線程和一個(gè)Reordering對(duì)象,線程1調(diào)用了這個(gè)Reordering對(duì)象的method方法宏蛉,而線程2在觀察這個(gè)Reordering對(duì)象的foo變量和bar變量的值。兩個(gè)線程同時(shí)跑了起來辟灰。
直觀上西采,線程2看到的變量情況胖眷,應(yīng)該會(huì)有4種可能珊搀。
- 當(dāng)線程2在程序點(diǎn)1的地方觀察,應(yīng)該得到
foo=0
,bar=0
- 當(dāng)線程2在程序點(diǎn)2的地方觀察链沼,應(yīng)該得到
foo=1
,bar=0
- 當(dāng)線程2在程序點(diǎn)3的地方觀察,應(yīng)該得到
foo=1
,bar=1
- 當(dāng)線程2在程序點(diǎn)4的地方觀察疾捍,應(yīng)該得到
foo=3
,bar=1
我們預(yù)期會(huì)有上面4種情況發(fā)生。但實(shí)際跑的時(shí)候咙鞍,卻發(fā)現(xiàn)出現(xiàn)了foo=3
, bar=0
的情況。
這就尷尬了,理論上朗恳,bar+=1
是先于foo+=2
的,而foo=3
只能是經(jīng)過foo += 2
之后才會(huì)得到怀浆,也就是說镰踏, 當(dāng)foo=3
的時(shí)候,bar += 1
已經(jīng)執(zhí)行過了绊率,那么為什么當(dāng) foo=3
的時(shí)候, bar=0
呢顽聂?
問題就處在了jvm和處理器對(duì)指令的優(yōu)化上,這里的優(yōu)化具體來說耀石,是指令重排。
方法method()里面的3條語句都是形如 變量 += 常量
爸黄,我們以 foo += 1
來看下cpu是如何處理這條語句的滞伟。
在此之前,我們需要先了解下cpu的cache炕贵。
學(xué)過計(jì)算機(jī)組成原理的同學(xué)應(yīng)該知道,cpu訪問內(nèi)存需要經(jīng)過總線傳遞數(shù)據(jù)称开,速度較慢亩钟,cpu如果每次存取數(shù)據(jù)都要和內(nèi)存交互,無疑會(huì)降低cpu的運(yùn)轉(zhuǎn)效率鳖轰。cpu訪問寄存器的速度是非城逅郑快的,但是又不可能只依賴于寄存器蕴侣,因?yàn)榧拇嫫鞯目臻g太小了焰轻。
因此,每塊cpu芯片上都設(shè)置了緩存cache昆雀,訪問cache的速度要高于訪問內(nèi)存的速度鹦马,因此胧谈,如果我們需要取內(nèi)存中的數(shù)據(jù),可以先加載到cache中荸频,如果做了相應(yīng)的修改,再把新的數(shù)據(jù)同步回內(nèi)存客冈。具體的緩存命中(cache hit)和緩存未命中(cache miss)不是本文的重點(diǎn)旭从,有興趣的同學(xué)自己了解下~
言歸正傳。我們來看下foo += 1
是如何被cpu執(zhí)行的(這里不涉及具體的cpu指令场仲,只是一種容易理解的方式來說明)和悦。
分三步走。
首先從內(nèi)存中取foo變量加載到cache渠缕。
然后鸽素,cpu取cache中的foo,并執(zhí)行加1的操作亦鳞。此時(shí)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)存
小伙伴可能會(huì)發(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)存
此時(shí)挫掏,代碼邏輯就變成了
void method {
foo += 1;
foo += 2;
bar += 1;
}
事實(shí)上侦另,jvm和cpu就是這么干的,甚至?xí)?code>foo += 1 和foo +=2
合并到一起尉共,
void method {
foo += 3;
bar += 1;
}
乍一看褒傅,這似乎是對(duì)的,因?yàn)椴徽撌窃瓉淼拇a順序袄友,還是指令重排后殿托,二者的結(jié)果都是 foo=3 bar=1
。單線程環(huán)境下的確如此剧蚣。但是多線程就不一樣了支竹。
當(dāng)另一個(gè)線程在上圖的程序點(diǎn)試圖去讀取foo和bar的值時(shí)旋廷,發(fā)現(xiàn)bar=0
的時(shí)候,出現(xiàn)了foo=3
的情況礼搁,如我們前面提到的饶碘,這個(gè)結(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個(gè)線程,線程1執(zhí)行thread1方法饮戳,線程2執(zhí)行thread2方法豪治。
我們來紙上談?wù)劚?br>
線程1會(huì)不斷輪詢flag的值,flag初始化為true扯罐,所以count會(huì)不斷自增负拟。
線程2把flag改成false。
線程1發(fā)現(xiàn)flag變成了false歹河,退出循環(huán)掩浙。
實(shí)際情況呢?
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;
}
你沒看錯(cuò),flag=false
被忽略了弄喘,因?yàn)樵趖hread2方法里面玖喘,沒有任何去讀取flag的地方,所以flag=false
在jvm看來是沒有必要同步到內(nèi)存的蘑志。
這就會(huì)導(dǎo)致thread1()不斷的執(zhí)行累奈,從而和我們想要的效果不一樣了。
不同的平臺(tái)
在不同架構(gòu)的機(jī)器上跑急但,也可能會(huì)出現(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位的機(jī)器上匣椰,這個(gè)結(jié)論是正確的。
但如果是32位的機(jī)器桥滨,就不一定了窝爪。有可能出現(xiàn)0x00000000,也可能出現(xiàn)0xFFFFFFFF齐媒。
我們以出現(xiàn)0xFFFFFFFF的結(jié)果為例子討論。
在32位的機(jī)器上纷跛,cpu存取一個(gè)數(shù)據(jù)是以32位為單位取的喻括。foo是一個(gè)64位的變量,會(huì)被分成兩次存取贫奠。
如前面所說唬血,cpu會(huì)先從內(nèi)存中取數(shù)據(jù)到cache,然后cache設(shè)置變量的值唤崭,最后再把值同步回內(nèi)存。
當(dāng)同步值到內(nèi)存中時(shí),因?yàn)槭欠謨纱瓮嚼瑁跃€程1和線程2可能下面的情形钦铺。
thread1把foo的低32位同步回內(nèi)存,thread2把foo的高32位同步回內(nèi)存芦疏,此時(shí)內(nèi)存的值是0x00000000
然后冕杠,thread1把foo的高32位同步回內(nèi)存,thread2把foo的低32位同步回內(nèi)存酸茴,此時(shí)就出現(xiàn)了內(nèi)存中foo=0xffffffff
的情況了分预。
下面來總結(jié)下不同平臺(tái)對(duì)于指令亂序的容忍度。如果大家以后出現(xiàn)一個(gè)程序在一種cpu架構(gòu)的機(jī)器上跑是正常的薪捍,但是在另外一種cpu架構(gòu)的機(jī)器上跑出現(xiàn)了奇怪的結(jié)果笼痹,可以考慮是否是因?yàn)檫@方面引起的原因。
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馋贤,前一條是寫入指令赞别,后一條是讀取指令
需要注意,這里的指令順序配乓,指的是在同一個(gè)線程中的指令順序仿滔,而不是兩個(gè)或者多個(gè)不同線程的指令順序。怎么理解呢犹芹?想一下崎页,處理器重排指令順序,就是為了讓cpu可以更高效地處理腰埂,而cpu不可能同時(shí)處理兩個(gè)或多個(gè)線程飒焦,每個(gè)線程同時(shí)運(yùn)行,其實(shí)是由不同的cpu來處理屿笼。
上表中的load-load牺荠,load-store,store-store驴一,store-load休雌,指的是指令原來的順序,yes表示在對(duì)應(yīng)的cpu架構(gòu)上肝断,允許重排指令杈曲,no表示。
以load-load為例孝情,假如有兩個(gè)指令如下
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)化性能。
所以轮蜕,有時(shí)候會(huì)出現(xiàn)這樣一種情況昨悼,我們的程序在x86的機(jī)器上跑的好好的,拿到ARM上面跑就莫名其妙了跃洛,很有可能就是ARM做了過度的優(yōu)化導(dǎo)致的率触。
為什么ARM和POWERPC,相比于其他架構(gòu)汇竭,做了更多可能的優(yōu)化呢葱蝗?其實(shí)這和市場(chǎng)定位有很大的關(guān)系,比如ARM细燎,主要應(yīng)用在手機(jī)和PDA等設(shè)備两曼,而X86則更多應(yīng)用在PC上,這使得二者的定位截然不同玻驻,其中非常重要的一點(diǎn)考量就是耗電量合愈。手機(jī)之于PC,前者使用的時(shí)候一般是不插電击狮,而PC則一般是插電使用,這使得ARM必須更多考慮如何省電益老,而對(duì)程序做更多的優(yōu)化彪蓬,很重要的原因就是提高性能,降低功耗捺萌。
java內(nèi)存模型
了解了多線程出現(xiàn)data race的各種可能原因后档冬,下面開始引入java內(nèi)存模型
一句話概括
什么是java內(nèi)存模型,通俗的說桃纯,就是(我的個(gè)人理解酷誓,如果有誤,請(qǐng)大家一定指出)
java內(nèi)存模型是一個(gè)規(guī)范态坦,如果jvm是一個(gè)遵循了java內(nèi)存模型的實(shí)現(xiàn)盐数,并且我們的程序也正確使用了各種同步機(jī)制,那么多線程環(huán)境下伞梯,我們從內(nèi)存中讀取一個(gè)變量玫氢,其值是確定的。
每個(gè)處理器上的寫緩沖區(qū)谜诫,僅僅對(duì)它的處理器可見漾峡。對(duì)其他的處理器不可見。
模型簡(jiǎn)介
在java內(nèi)存模型中喻旷,內(nèi)存被分成了2種類型生逸。
- 本地內(nèi)存:每個(gè)線程都有一個(gè)本地內(nèi)存,存儲(chǔ)線程需要操作的一些數(shù)據(jù)。
- 共享內(nèi)存:不同的線程之間共享主內(nèi)存(也可以叫共享內(nèi)存槽袄,更直觀一點(diǎn))烙无。
線程之間不能互相訪問各自的本地內(nèi)存,即線程1不能訪問線程2的本地內(nèi)存掰伸,線程2也不能訪問線程1的本地內(nèi)存皱炉。
線程之間要通信,只能通過共享內(nèi)存來傳遞消息狮鸭。比如線程1要發(fā)消息給線程2合搅,必須先把消息寫入到共享內(nèi)存,然后線程2到共享內(nèi)存取出消息歧蕉。
這里的內(nèi)存模型只是一個(gè)抽象的概念灾部,并非對(duì)應(yīng)了真正的物理內(nèi)存。但是真正內(nèi)存上的存取是符合這個(gè)模型的惯退。比如處理器的cache就可以比作是本地內(nèi)存赌髓,而物理內(nèi)存就可以看做多個(gè)cpu執(zhí)行多線程時(shí)的共享內(nèi)存。
內(nèi)存屏障
之前提到因?yàn)榫幾g器和處理器可能做的各種優(yōu)化催跪,而導(dǎo)致多線程語義出現(xiàn)不可預(yù)期锁蠕,為了解決這個(gè)問題,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);
那么,一定能保證骑丸,編譯器不會(huì)對(duì)這兩條語句進(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;
那么通危,一定能保證铸豁,編譯器不會(huì)對(duì)這兩條語句進(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稍有不同菊碟。它有兩個(gè)方面的作用节芥。
第一,store-store一樣可以確保程序語義不被重排框沟,比如下面的例子
a = 1;
/** store-store barrier **/
b = 2;
那么藏古,一定能保證,編譯器不會(huì)對(duì)這兩條語句進(jìn)行重排忍燥。
store-store的另一個(gè)功能是拧晕,前者的store一定能對(duì)其他線程可見。
換句話將梅垄,當(dāng)執(zhí)行完a = 1厂捞,正常情況下输玷,并不一定會(huì)馬上同步到共享內(nèi)存,因此靡馁,其他線程此時(shí)如果獲取a的值欲鹏,有可能還是舊的。但是store-store barrier可以確保臭墨,a=1執(zhí)行完后會(huì)馬上同步到共享內(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胧弛,突然字就變多了尤误,沒錯(cuò),store-load的確與眾不同结缚。
store-load比store-store還要強(qiáng)大损晤。
首先,store-load一樣可以確保程序的語義不被重排红竭,比如下面的例子
a = 1;
/** store-load barrier **/
System.out.print(a);
那么尤勋,一定能保證,編譯器一定不會(huì)重排這兩條語句茵宪。
store-load的第二個(gè)功能是最冰,前者的寫入操作會(huì)同步到共享內(nèi)存,對(duì)其他線程馬上可見稀火。
也就是說锌奴,a = 1會(huì)馬上同步到共享內(nèi)存,其他的線程此時(shí)獲取到的a一定是最新的值憾股。
store-load的第三個(gè)功能是,在store-load屏障后面的讀入箕慧,一定會(huì)從共享內(nèi)存中去讀取服球,而不是直接取本地內(nèi)存的緩存。
這意味著颠焦,一旦插入了store-load屏障斩熊,那么屏障執(zhí)行之后,線程會(huì)讓自己的本地緩存失效伐庭,讀取操作一定會(huì)到共享內(nèi)存去取最新值粉渠。
對(duì)四種內(nèi)存屏障做個(gè)小總結(jié):
四種內(nèi)存屏障都能夠保證程序語義的正確,編譯器不會(huì)重排邏輯
load-load和load-store只能保證程序語義正確圾另,無法確保內(nèi)存可見性霸株。也就是說,load操作雖然執(zhí)行了集乔,但實(shí)際上可能取的是緩存的值去件,而不是重新到共享內(nèi)存取。store操作雖然執(zhí)行了 ,但實(shí)際上尤溜,可能還沒有馬上同步到共享內(nèi)存中倔叼。
store-store和store-load都可以確保程序語義正確,也都可以確保屏障前的store操作能夠馬上同步到共享內(nèi)存宫莱。但是store-store屏障后面的load操作丈攒,不能確保一定會(huì)都共享內(nèi)存取最新值,可能還是讀的緩存授霸。而store-load屏障后面的load操作巡验,一定能夠確保是去共享內(nèi)存取的最新值。
從性能的角度講绝葡,store-load是最耗性能的深碱,但是在確保正確性方面,store-load做的最好藏畅。
同步機(jī)制
這里我們介紹幾種同步機(jī)制敷硅,這些是上層開發(fā)實(shí)際會(huì)用到的幾種同步。
從修飾類型來看愉阎,可以分為3種:
修飾字段的同步機(jī)制
修飾方法的同步機(jī)制
修飾代碼塊
關(guān)于修飾字段的同步機(jī)制绞蹦,我們一起來討論下面二者
volatile
final
關(guān)于修飾方法和代碼塊的同步機(jī)制,我們來討論
- synchronized
下面就開始吧~
volatile
看如下一個(gè)例子
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檢測(cè)到ready為true了澡屡,然后檢測(cè)foo == 42成功。
按道理應(yīng)該是這樣咐旧。因?yàn)閒oo = 42 在 ready = true前面驶鹉,所以,當(dāng)ready = true铣墨,foo == 42肯定成立室埋。
在x86的機(jī)器上,這個(gè)說法說得通伊约。但是在ARM上就有問題了姚淆。
還記得前面提到的不同平臺(tái)的指令重排嗎,ARM上允許store-store重排屡律,所以腌逢,完全有可能ready = true,在foo = 42前面執(zhí)行了超埋。
然后當(dāng)thread1檢測(cè)到ready = true的時(shí)候上忍,foo可能還沒設(shè)置成42
| thread2 | thread1
| |
| ready = true |
| | while(!ready);
| | assert foo == 42; // fail
| foo = 42; |
如上圖所示骤肛,assert foo == 42
的時(shí)候,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變量的寫操作后面會(huì)插入一個(gè)store屏障淑玫,保證volatile的寫操作會(huì)立即同步到共享內(nèi)存,使其他線程對(duì)該寫操作可見
在volatile變量的讀操作前面會(huì)插入一個(gè)load屏障面睛,保證volatile的讀操作會(huì)從共享內(nèi)存取最新的數(shù)據(jù)絮蒿,而不是直接讀取的緩存。
在volatile變量的寫操作前面的程序語句不允許和volatile變量的寫操作重排指令叁鉴。
在volatile變量的讀操作后面的程序語句不允許和volatile變量的讀操作重排指令土涝。
volatile變量寫操作前面的寫操作一定會(huì)在執(zhí)行完store屏障前,同步到共享內(nèi)存幌墓。
volatile變量讀操作后面的讀操作一定會(huì)從共享內(nèi)存取最新數(shù)據(jù)但壮。
從例子看,對(duì)volatile變量的寫操作是ready = true
常侣,對(duì)volatile變量的讀操作是while(!ready)
蜡饵,所以得出:
foo = 42
會(huì)馬上刷新到共享內(nèi)存,對(duì)應(yīng)第1條胳施。在thread2執(zhí)行了
foo=42
后溯祸,thread1的while(!ready)
會(huì)從共享內(nèi)存取出最新的ready,對(duì)應(yīng)第2條。foo = 42
不允許和ready = true
重排舞肆,對(duì)應(yīng)第3條焦辅。while(!ready)
不允許和assert foo == 42
重排,對(duì)應(yīng)第4條椿胯。foo = 42
會(huì)在執(zhí)行完store屏障前氨鹏,更新最新值到共享內(nèi)存,對(duì)應(yīng)第5條压状。assert foo == 42
會(huì)重新到共享內(nèi)存取最新值,對(duì)應(yīng)第6條跟继。
所以种冬,新的執(zhí)行流程是
| thread2 | thread1
| |
| foo = 42; |
| ready = 1; |
|--------------------------------------------
| | while(!ready);
| | assert foo == 42; // success
對(duì)了,還記得前面我們聊過舔糖,如果一個(gè)變量是64位的娱两,在32位的機(jī)器上跑,有可能出現(xiàn)錯(cuò)誤結(jié)果金吗,原因是分成2個(gè)32位的分開存儲(chǔ)導(dǎo)致的十兢。如果用volatile來修飾趣竣,那么這種情況是不存在的。
但是這并不意味著旱物,volatile具有原子性遥缕。比如
volatile int i =0;
i++;
i++
其實(shí)是分三步走的,先讀取宵呛,再自增单匣,最后寫入。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會(huì)被拆分成兩個(gè)指令
<enter lock>
<exit lock>
<enter lock>
就是獲取了鎖,同一時(shí)刻莉兰,只能有一個(gè)線程能夠獲得lock挑围。
<exit lock>
就是釋放鎖,擁有l(wèi)ock的線程只有在釋放鎖以后糖荒,其他線程才有機(jī)會(huì)獲得鎖杉辙。
也就是說,synchronized對(duì)應(yīng)的鎖是互斥鎖捶朵。
當(dāng)synchronzed修飾在非靜態(tài)方法時(shí)蜘矢,lock就是實(shí)例本身,即this
综看,所以可以寫成這樣
void thread1() {
<enter this>
while(!ready);
assert foo == 42;
<exit this>
}
void thread2() {
<enter this>
foo = 42;
ready = true;
<exit this>
}
synchronzed可以保證以下幾點(diǎn):
同一時(shí)刻品腹,只能有一個(gè)線程獲取lock。
線程在獲得lock后红碑,所有的寫入操作舞吭,會(huì)在釋放lock前,全部同步到共享內(nèi)存析珊。
線程在獲得lock后羡鸥,所有的讀取操作,都會(huì)從共享內(nèi)存讀取最新值忠寻,而不是從緩存直接獲取惧浴。
第1條保證了線程1和線程2的代碼不可能同時(shí)執(zhí)行;
第2條保證了foo=42
和ready = true
都會(huì)在線程2釋放lock之前奕剃,全部同步到共享內(nèi)存衷旅,使得更新對(duì)線程1可見捐腿。
第3條保證了while(!ready)
和assert foo == 42
都會(huì)從共享內(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
可能重排。
線程啟動(dòng)同步
來看下面的例子
class ThreadLife {
int foo = 0;
void thread1() {
foo = 42;
new Thread(new Runnable() {
public void run() {
assert foo == 42;
}
}).start();
}
}
線程1會(huì)調(diào)用thread1方法猪钮,在里面會(huì)啟動(dòng)一個(gè)新的線程(假設(shè)為線程2)品山,那么,線程2的foo == 42
是否一定能保證成功呢烤低?
答案是肯定的肘交。
線程1調(diào)用new Thread(runnable).start()
啟動(dòng)線程2,線程2啟動(dòng)時(shí)會(huì)在一開始放入一個(gè)<start>
指令扑馁。并且有以下保證:
線程1的thread.start()方法一定在線程2的
<start>
之前執(zhí)行(否則怎么啟動(dòng)呢)涯呻。線程1的thread.start()方法前面的寫入操作一定會(huì)同步到共享內(nèi)存
線程2在啟動(dòng)時(shí),會(huì)從共享內(nèi)存中獲取線程1的變量腻要,拷貝到線程2的本地內(nèi)存复罐。
以上保證了線程2中的foo是共享內(nèi)存中的最新值,如下
| thread1 | thread2
| |
| foo = 42; |
| thread.start() |
|-----------------------------------------
| | <start>
| | assert foo == 42; // success
final關(guān)鍵字
其實(shí)雄家,final也有自己的多線程語義的效诅,這一點(diǎn)倒是讓我感到有點(diǎn)意外√思茫看下面的例子乱投。
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生成一個(gè)instance對(duì)象戚炫,線程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
肯定成功的萌腿。真是這樣嗎?其實(shí)抖苦,有可能出現(xiàn)assert fail的情況毁菱。
構(gòu)造一個(gè)對(duì)象米死,其實(shí)是拆分成了2步:
首先,分配內(nèi)存空間給對(duì)象(allocate)
然后贮庞,才調(diào)用構(gòu)造函數(shù)(init)
所以峦筒,線程1 new一個(gè)instance可以拆分成
static void thread1() {
instance = <allocate UnsafePublication>;
instance.<init>();
}
問題就出在這里。分配內(nèi)存空間給對(duì)象窗慎,和調(diào)用構(gòu)造函數(shù)不是原子性的物喷。這意味著,線程2可能已經(jīng)看見了線程1為instance分配了內(nèi)存空間遮斥,即instance != null
峦失,但是線程1其實(shí)還沒有調(diào)用構(gòu)造函數(shù)。那么术吗,此時(shí)assert foo == 42
就失敗了尉辑。
解決方式?final出馬较屿。
把final成員修飾成final:final int foo = 0;
一個(gè)final成員隧魄,如果是在構(gòu)造函數(shù)里面初始化的,那么會(huì)在構(gòu)造函數(shù)的末尾插入一個(gè)<freeze>
指令
UnsafePublication() {
foo = 42;
<freeze>
}
freeze指令能夠保證隘蝎,對(duì)象分配和final成員的初始化购啄,同時(shí)對(duì)其他線程可見。
這并不是說嘱么,對(duì)象的內(nèi)存分配和final成員的初始化狮含,會(huì)馬上對(duì)其他線程可見。而是指即使我們已經(jīng)為對(duì)象創(chuàng)建了內(nèi)存空間了拱撵,即allocate
已經(jīng)完成辉川,但是只要final成員還沒有初始化,那么拴测,對(duì)象的allocate就對(duì)其他線程不可見乓旗,即instance == null
,只有當(dāng)final成員初始化了集索,allocate
才對(duì)其他線程可見屿愚。
所以,如果instance不為null务荆,一定能保證foo = 42妆距。
|thread1 | thread2
| instance = <allocate>; |
| instance.foo = 42; |
| <freeze instance.foo> |
|-----------------------------|---------------------
| | if (instance != null){
| | assert instance.foo == 42; // success
| | }
jni
編譯器會(huì)重排java調(diào)用和jni調(diào)用嗎?不會(huì)函匕。
看下面例子娱据。
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)用,是非常危險(xiǎn)的结啼,違背了單線程語義正確性的原則掠剑。
所以,有人說jni調(diào)用效率并不高郊愧,有一部分原因朴译,應(yīng)該就是因?yàn)镴IT無法優(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嗎?不會(huì)的喇肋。
看看thread1()坟乾。
while(true);
是一個(gè)Thread Divergence Action。至于什么是Thread Divergence Action蝶防,看了定義其實(shí)我不太理解意思(sorry)甚侣,這里就不瞎BB誤導(dǎo)大家了。
但是可以肯定的2點(diǎn):
while(true);
這個(gè)無線循環(huán)是一個(gè)Thread Divergence Action间学。JIT不會(huì)重排Thread Divergence Action語句殷费。
也就是說,while(true);
和foo = 0
不會(huì)發(fā)生重排低葫。這個(gè)很好理解详羡,因?yàn)?code>while(true); 是一個(gè)無限循環(huán),永遠(yuǎn)也不會(huì)執(zhí)行foo = 0
嘿悬,如果把foo = 0
放到while(true)
前面实柠,程序語義就改變了,這是不允許的善涨。
本篇文章到這里就結(jié)束了窒盐。如果您看到了這,非常感謝您的時(shí)間和耐心~~
如果本文有理解偏頗或者錯(cuò)誤的地方钢拧,請(qǐng)留言指正蟹漓,謝謝~~