一符糊、Java內(nèi)存模型 == JVM內(nèi)存模型扒袖?
??很多人都會(huì)認(rèn)為Java內(nèi)存模型就是JVM內(nèi)存模型馆类,但實(shí)際上是錯(cuò)的,Java內(nèi)存模型是一個(gè)抽象的概念嘴办,描述了Java語言的一組規(guī)則和規(guī)范瞬场,JVM實(shí)際上也不僅僅支持運(yùn)行Java代碼,還支持很多能在JVM上運(yùn)行的語言如JRuby涧郊、Scale等贯被,這是因?yàn)镴Ruby、Scale也有自己的語言規(guī)范妆艘,只要編譯出來的字節(jié)碼符合《Java虛擬機(jī)規(guī)范》彤灶,就可以在JVM上運(yùn)行。
??JVM不關(guān)心代碼是用哪種編程語言寫的双仍,只要編譯出來的指令碼符合JVM規(guī)范,那么就可以在JVM上運(yùn)行桌吃,所有語言在JVM上的內(nèi)存的結(jié)構(gòu)都是一樣的朱沃,JVM上的內(nèi)存模型圖如下。
??在JVM中茅诱,所有實(shí)例域逗物、靜態(tài)域和數(shù)組元素存儲(chǔ)在堆內(nèi)存中,堆內(nèi)存在線程之間共享瑟俭。局部變量翎卓、方法定義參數(shù)和異常處理器參數(shù)不會(huì)在線程之間共享,它們不會(huì)有內(nèi)存可見性問題摆寄,也不會(huì)收內(nèi)存模型的影響失暴。
??《Java語言規(guī)范》中對(duì)Java內(nèi)存模型的描述坯门,主要是針對(duì)多線程程序的語義,包括當(dāng)多個(gè)線程修改了共享內(nèi)存中的值時(shí)逗扒,應(yīng)該讀取到哪個(gè)值的規(guī)則古戴,這些語義沒有規(guī)定如何執(zhí)行多線程程序,相反它們描述了允許多線程程序的合法行為矩肩;所謂的“合法”现恼,其實(shí)就是保證多線程對(duì)共享數(shù)據(jù)訪問的可見性和修改的安全性。
二黍檩、Java內(nèi)存模型基礎(chǔ)
??在并發(fā)編程中叉袍,需要處理的兩個(gè)關(guān)鍵問題是:線程之前如何通信
和線程之間如何同步
。
1刽酱、通信
??通信
是指線程之間以何種機(jī)制來交換信息喳逛。在命令式編程中,線程之間的通信機(jī)制有兩種:共享內(nèi)存
和消息傳遞
肛跌。
??在共享內(nèi)存
的并發(fā)模型里艺配,線程之間共享程序的公共狀態(tài),線程之間通過寫-讀內(nèi)存中的公共狀態(tài)
來隱式
進(jìn)行通信衍慎。
??在消息傳遞
的并發(fā)模型里转唉,線程之間沒有公共狀態(tài),線程之間必須通過明確的發(fā)送消息
來顯式
進(jìn)行通信稳捆。
2赠法、同步
??同步
是指程序用于控制不同線程之間操作發(fā)生相對(duì)順序的機(jī)制。在共享內(nèi)存
的并發(fā)模型里乔夯,同步是顯式
進(jìn)行的砖织,程序員必須顯式指定某個(gè)方法或某段代碼需要在線程之間互斥執(zhí)行
;在消息傳遞
的并發(fā)模型里末荐,由于消息的發(fā)送必須在消息的接收之前,因此同步是隱式
進(jìn)行的。
??Java的并發(fā)采用的是共享內(nèi)存模型,Java線程之間的通信總是隱式進(jìn)行哲鸳,整個(gè)通信過程對(duì)程序員完全透明般渡。
3脸秽、Java內(nèi)存模型的抽象
??Java線程之間的通信由Java內(nèi)存模型(JMM)控制。JMM決定了一個(gè)線程對(duì)共享變量的寫入何時(shí)對(duì)另一個(gè)線程可見。從抽象的角度來看雕沿,JMM定義了線程與主內(nèi)存之間的抽象關(guān)系:線程之間的共享變量存儲(chǔ)在主內(nèi)存中辽俗,每一個(gè)線程都有一個(gè)自己私有的本地內(nèi)存朱浴,本地內(nèi)存中存儲(chǔ)了該變量以讀/寫共享變量的副本砰碴。本地內(nèi)存是JMM的一個(gè)抽象概念呈枉,并不真實(shí)存在埃碱,只是為了幫助理解酥泞。
從上圖來看,如果線程A和線程B通信的話芝囤,要如下兩個(gè)步驟:
(1)線程A需要將本地內(nèi)存A中的共享變量副本刷新到主內(nèi)存中
(2)線程B去主內(nèi)存讀取線程A之前已更新過的共享變量
【舉個(gè)例子】本地內(nèi)存A和B有主內(nèi)存共享變量X的副本羡藐。假設(shè)一開始時(shí)先壕,這三個(gè)內(nèi)存中X的值都是0集绰。線程A正執(zhí)行時(shí),把更新后的X值(假設(shè)為1)臨時(shí)存放在自己的本地內(nèi)存A中冈在。當(dāng)線程A和B需要通信時(shí)倒慧,線程A首先會(huì)把自己本地內(nèi)存A中修改后的X值刷新到主內(nèi)存去,此時(shí)主內(nèi)存中的X值變?yōu)榱?包券。隨后纫谅,線程B到主內(nèi)存中讀取線程A更新后的共享變量X的值,此時(shí)線程B的本地內(nèi)存的X值也變成了1溅固。
??整體看來付秕,這兩個(gè)步驟是指上是線程A在向線程B發(fā)送消息,而這個(gè)通信過程必須經(jīng)過主內(nèi)存侍郭。JMM通過控制主內(nèi)存與每個(gè)線程的本地內(nèi)存之間的交互询吴,來為Java程序員提供內(nèi)存可見性保證。
4亮元、重排序
??在執(zhí)行程序時(shí)為了提高性能猛计,編譯器和處理器常常會(huì)對(duì)指令做重排序,重排序分三類:
(1)編譯器優(yōu)化的重排序
??編譯器在不改變單線程程序語義的前提下爆捞,可以重新安排語句的執(zhí)行順序奉瘤。
(2)指令級(jí)并行的重排序
??現(xiàn)代處理器采用了指令級(jí)并行技術(shù)來將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性,處理器可以改變語句對(duì)應(yīng)及其指令的執(zhí)行順序盗温。
(3)內(nèi)存系統(tǒng)的重排序
??由于處理器使用緩存和讀/寫緩沖區(qū)藕赞,這使得加載和存儲(chǔ)操作看上去可能是在亂序執(zhí)行。
??從Java源代碼到最終實(shí)際執(zhí)行的指令序列卖局,會(huì)分別經(jīng)歷下面三種重排序:
??注意斧蜕,所有的重排都會(huì)遵循
as-if-serial語義
(詳見[Java多線程編程之四] CPU緩存和內(nèi)存屏障),即重排后指令的對(duì)單線程來說跟重排前的指令的執(zhí)行效果是一樣的砚偶,但是該語義不能保證程序指令在多線程環(huán)境下重排后的指令執(zhí)行效果跟重排前一致批销,所以就會(huì)導(dǎo)致可見性問題,所謂可見性
問題染坯,簡單地說就是某個(gè)線程修改了某個(gè)變量的值风钻,但是對(duì)另外一個(gè)線程來說,它感知不到這種變化酒请,當(dāng)程序運(yùn)行用到這個(gè)變量時(shí)骡技,用的還是舊值,就會(huì)導(dǎo)致程序運(yùn)行結(jié)果跟我們預(yù)料的大相庭徑羞反。??上面的這些重排序都可能導(dǎo)致多線程程序出現(xiàn)內(nèi)存可見性問題布朦。
??對(duì)于編譯器,JMM的編譯器重排序規(guī)則會(huì)禁止特定類型的編譯器重排序(即可能導(dǎo)致程序可見性問題的重排序要禁止昼窗,但是不會(huì)禁止能優(yōu)化程序執(zhí)行效率并不影響程序執(zhí)行結(jié)果正確性的重排序)是趴,如下所示:
??A和B的初始值都是0,對(duì)于線程1和線程2來說澄惊,重排序都不會(huì)影響單線程的執(zhí)行效果唆途,但是如果兩個(gè)線程并發(fā)操作A、B的值掸驱,則運(yùn)行結(jié)果可能不一致肛搬,比如重排序前線程2給r1賦值的時(shí)候值為0,但是重排序后毕贼,賦值這個(gè)操作可能在線程1給B賦值1這步之后執(zhí)行温赔,此時(shí)對(duì)線程2來說賦值時(shí)B的值就是1了,而這種重排序就是JMM規(guī)范里要禁止的鬼癣。
??對(duì)于處理器重排序陶贼,JMM的處理器重排序規(guī)則會(huì)要求Java編譯器在生成指令序列時(shí),插入特定類型的內(nèi)存屏障指令待秃,通過內(nèi)存屏障指令來禁止特定類型的處理器重排序(同樣不是所有的處理器重排序都要禁止)拜秧。
??JMM屬于語言級(jí)的內(nèi)存模型,它確保在不同的編譯器和不同的處理器平臺(tái)之上章郁,通過禁止特定類型的編譯器重排序和處理器重排序枉氮,為程序員提供一致的內(nèi)存可見性保證。
處理器重排序
??現(xiàn)代的處理器使用寫緩沖區(qū)來臨時(shí)保存向內(nèi)存寫入的數(shù)據(jù)。寫緩沖區(qū)可以保證指令流水線持續(xù)運(yùn)行嘲恍,它可以避免由于處理器停頓下來等待向內(nèi)存寫入數(shù)據(jù)而產(chǎn)生的延遲。同時(shí)雄驹,通過以批處理的方式刷新寫緩沖區(qū)佃牛,以及合并寫緩沖區(qū)中對(duì)同一內(nèi)存地址的多次寫,可以減少對(duì)內(nèi)存總線的占用医舆。雖然寫緩沖區(qū)有這么多好處俘侠,但每個(gè)處理器上的寫緩沖區(qū),僅僅對(duì)它所在的處理器可見蔬将。這個(gè)特定會(huì)對(duì)內(nèi)存操作的執(zhí)行順序產(chǎn)生重要的影響:處理器對(duì)內(nèi)存的讀/寫操作的執(zhí)行順序爷速,不一定與內(nèi)存實(shí)際發(fā)生的讀/寫操作順序一致。
??示例如下:
??假設(shè)處理器A和處理器B按程序的順序并行執(zhí)行內(nèi)存訪問霞怀,最終卻可能得到
x = y = 0
惫东。具體的原因如下圖所示:??處理器A和B同時(shí)把共享變量寫入在寫緩沖區(qū)中(A1、B1)毙石,然后再從內(nèi)存中讀取另一個(gè)共享變量(A2廉沮、B2),最后才把自己寫緩沖區(qū)中保存的臟數(shù)據(jù)刷新到內(nèi)存中(A3徐矩、B3)滞时。當(dāng)以這種時(shí)序執(zhí)行時(shí),程序就可以得到x = y = 0的效果滤灯。
??從內(nèi)存操作實(shí)際發(fā)生的順序來看坪稽,直到處理器A執(zhí)行A3來刷新自己的寫緩存去,寫操作A1才算真正執(zhí)行了鳞骤。雖然處理器A執(zhí)行內(nèi)存操作的順序?yàn)椋篈1 -> A2窒百,但內(nèi)存操作實(shí)際發(fā)生的順序卻是:A2 -> A1。此時(shí)豫尽,處理器A的內(nèi)存操作順序被重排序了贝咙。
??這里的關(guān)鍵是,由于寫緩沖區(qū)僅對(duì)自己的處理器可見拂募,它會(huì)導(dǎo)致處理器執(zhí)行內(nèi)存操作的順序可能會(huì)與內(nèi)存實(shí)際的操作執(zhí)行順序不一致庭猩。由于現(xiàn)代的處理器都會(huì)使用寫緩沖區(qū),因此現(xiàn)代處理器都會(huì)允許對(duì)寫-讀操作重排序陈症。
內(nèi)存屏障指令
??為了保證內(nèi)存可見性蔼水,Java編譯器在生成指令序列的適當(dāng)位置會(huì)插入內(nèi)存屏障指令來禁止特定類型的處理器重排序。
??處理器提供了兩個(gè)內(nèi)存屏障指令:
(1)讀內(nèi)存屏障:在指令前插入Load Barrier录肯,可以讓高速緩存中的數(shù)據(jù)失效趴腋,強(qiáng)制重新從主內(nèi)存加載數(shù)據(jù),讓CPU緩存與主內(nèi)存保持一致,避免緩存導(dǎo)致的一致性問題优炬。
(2)寫內(nèi)存屏障:在指令后插入Store Barrier颁井,能讓寫入緩存中的最新數(shù)據(jù)更新寫入主內(nèi)存,讓其他線程可見蠢护;當(dāng)發(fā)生這種強(qiáng)制寫入主內(nèi)存的顯式調(diào)用雅宾,CPU就不會(huì)處于性能優(yōu)化考慮進(jìn)行指令重排。
??程序進(jìn)行讀寫時(shí)葵硕,指令執(zhí)行順序可能有:讀寫眉抬,寫讀,讀讀懈凹,寫寫蜀变,所以JMM把內(nèi)存屏障指令分為下列四類:
5、多線程編程中常見的問題
??對(duì)于新手介评,多線程編程不是個(gè)容易上手的技術(shù)库北,對(duì)于經(jīng)驗(yàn)豐富的老手,還時(shí)不時(shí)馬失前蹄们陆,都會(huì)遇到很多問題贤惯,正因?yàn)檫@些問題,所以《Java語言規(guī)范》要提出一些規(guī)則范式來解決這些問題棒掠,常見的問題主要有:
- 所見非所得
- 無法用肉眼去檢測程序的準(zhǔn)確性
- 不同的運(yùn)行平臺(tái)有不同的表現(xiàn)
- 錯(cuò)誤很難重現(xiàn)
??下面通過一個(gè)實(shí)例程序體會(huì)下這些問題:
public class Demo1Visibility {
int i = 0;
boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
Demo1Visibility demo = new Demo1Visibility();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hrer i am...");
while (demo.isRunning) {
demo.i++;
}
System.out.println(demo.i);
}
}).start();
Thread.sleep(3000L);
demo.isRunning = false;
System.out.println(demo.i);
System.out.println("shutdown...");
}
}
??程序的邏輯很簡單孵构,有兩個(gè)線程:主線程和匿名線程,程序啟動(dòng)時(shí)烟很,主線程中會(huì)啟動(dòng)匿名線程颈墅,接著主線程休眠3秒,由于demo.isRunning
的初始值為true
雾袱,因此匿名線程中的while
循環(huán)會(huì)不斷循環(huán)恤筛,直到主線程休眠結(jié)束后,將demo.isRunning
的值更新為false
芹橡,此時(shí)匿名線程的while
循環(huán)會(huì)結(jié)束毒坛,從而打印出demo.i
的值。
??但是程序運(yùn)行后林说,發(fā)現(xiàn)運(yùn)行結(jié)果并不像預(yù)料的那樣煎殷,程序并沒有打印出i
的值,并且一直處于運(yùn)行狀態(tài)腿箩,很明顯這是因?yàn)槟涿€程沒有感知到demo.isRunning
的變化豪直,導(dǎo)致一直循環(huán)中,如下所示:
??如果運(yùn)行用的是32位的JDK珠移,并且設(shè)置程序的啟動(dòng)VM參數(shù)
-client
(默認(rèn)是-server
)弓乙,則運(yùn)行結(jié)果如下:??對(duì)于上面的程序末融,經(jīng)過試驗(yàn)可以知道,JDK暇韧、VM參數(shù)對(duì)程序運(yùn)行的影響如下:
參數(shù) | 32位JDK | 64位JDK |
---|---|---|
-server |
不打印i的值 | 不打印i的值 |
-client |
打印i的值 | 不打印i的值 |
??從上面的示例可以看出勾习,程序運(yùn)行的結(jié)果不一定如我們預(yù)料的(所見非所得),在不同版本的JDK下運(yùn)行有差異(不同的運(yùn)行平臺(tái)有不同的表現(xiàn))懈玻,使用不同的啟動(dòng)參數(shù)-client/-server
有不同的效果巧婶,無法肉眼檢測程序的準(zhǔn)確性,而這些問題就是《Java語言規(guī)范》提供的Java內(nèi)存模型所要解決的問題酪刀,但是要注意Java內(nèi)存模型并不會(huì)實(shí)際解決這些問題,更多的它是一種規(guī)范钮孵、規(guī)則骂倘,而JVM會(huì)去真正實(shí)現(xiàn)這些規(guī)則規(guī)范。
??對(duì)于上面代碼巴席,在非32位JDK非-client
下历涝,子線程都不能正常退出while
循環(huán)打印出i的值,我們結(jié)合上述講到的Java內(nèi)存模型的示意圖來分析漾唉。
??首先demo
是一個(gè)對(duì)象荧库,對(duì)象是存儲(chǔ)在堆內(nèi)存中的,從運(yùn)行時(shí)數(shù)據(jù)區(qū)的示意圖可知赵刑,堆內(nèi)存是所有線程共享的區(qū)域分衫;程序中有主線程和子線程兩個(gè)線程,每個(gè)線程在運(yùn)行時(shí)JVM都會(huì)分配一塊線程私有的內(nèi)存塊般此,我們稱之為線程工作區(qū)
蚪战,線程工作區(qū)就會(huì)存儲(chǔ)線程運(yùn)行中需要的局部變量表、操作數(shù)棧铐懊、動(dòng)態(tài)鏈接邀桑、返回地址等信息。主線程會(huì)修改demo.isRunning
的值為false
科乎,會(huì)將修改內(nèi)容寫入到共享堆內(nèi)存中壁畸,而子線程會(huì)去讀取堆內(nèi)存中的demo.isRunning
的值,來讓程序退出while
循環(huán)茅茂。
??當(dāng)執(zhí)行指令時(shí)捏萍,需要將其加載到CPU中,程序運(yùn)行中需要存儲(chǔ)一些變量空闲,這些變量會(huì)保存在RAM內(nèi)存中照弥,所以線程工作區(qū)既分布在CPU也分布在RAM內(nèi)存中。從[Java多線程編程之四] CPU緩存和內(nèi)存屏障可知进副,由于內(nèi)存讀取寫入操作的數(shù)據(jù)遠(yuǎn)遠(yuǎn)跟不上CPU運(yùn)行的速度这揣,所以在CPU和內(nèi)存中間悔常,有個(gè)高速緩存,內(nèi)存把數(shù)據(jù)加載到高速緩存中给赞,供CPU讀取机打,當(dāng)CPU要修改內(nèi)存時(shí),同樣是要先寫到高速緩存中片迅,再由高速緩存同步到內(nèi)存中残邀,高速緩存協(xié)議又保證了內(nèi)存中的數(shù)據(jù)被修改時(shí)同樣也會(huì)同步到其他線程的高速緩存中,如圖所示:
??從上一節(jié)對(duì)重排序的介紹可知柑蛇,主線程去寫
data
時(shí)芥挣,不會(huì)馬上寫入內(nèi)存中,而是先寫入緩存再寫入內(nèi)存中耻台,同步到內(nèi)存中后空免,高速緩存協(xié)議會(huì)將修改同步到子線程的緩存中,這中間有一定的時(shí)延盆耽,所以子線程得過一段時(shí)間(這個(gè)時(shí)間其實(shí)很短蹋砚,肉眼感受不到,但確實(shí)存在)摄杂,按理說子線程應(yīng)該在稍等一段時(shí)間后在while
循環(huán)里讀取到data
的最新值坝咐,然后退出循環(huán),打印demo.i
的值析恢,但是實(shí)際上卻沒有墨坚,這是怎么回事?
??這里又不得不再次提到上面說到的編譯優(yōu)化重排序映挂,在 [Java多線程編程之一] Java代碼是怎么運(yùn)行起來的框杜?看完這篇你就懂了!中提到Java的解釋執(zhí)行和編譯執(zhí)行袖肥,解釋執(zhí)行指JVM在讀取字節(jié)碼執(zhí)行時(shí)吟税,是由執(zhí)行引擎的解釋器逐條將字節(jié)碼翻譯成機(jī)器可識(shí)別的指令懊亡,編譯執(zhí)行則是直接將一段字節(jié)碼翻譯成機(jī)器可以識(shí)別的指令碼知牌。
??說起Java的編譯執(zhí)行就不得不提到JIT編譯器(Just In Time Compiler)
赶盔,當(dāng)Java程序中某個(gè)方法不斷被調(diào)用(比如遞歸)或者某段代碼不斷被循環(huán)(比如while(true)
)時(shí),調(diào)用執(zhí)行的頻率達(dá)到一定水平就會(huì)升級(jí)為熱點(diǎn)代碼寸癌,這時(shí)啟動(dòng)JIT編譯
专筷,直接將熱點(diǎn)代碼編譯成機(jī)器碼放到方法區(qū)中,當(dāng)程序再次執(zhí)行到這段熱點(diǎn)代碼時(shí)蒸苇,直接從方法區(qū)中取機(jī)器指令執(zhí)行磷蛹,從而提升程序執(zhí)行的效率,在JIT
編譯時(shí)溪烤,會(huì)進(jìn)行指令重排做性能優(yōu)化味咳,指令重排不僅僅是對(duì)執(zhí)行指令的重排庇勃,程序的邏輯可能也會(huì)發(fā)生改變,比如上面子線程執(zhí)行的循環(huán)體槽驶,可能被優(yōu)化成下面的形式:
boolean f = demo.isRunning;
if (f) {
while (true) {
i++;
}
}
??這相當(dāng)于demo.isRunning
一開始就被緩存起來了责嚷,并且不會(huì)再次去讀取它的值,這就導(dǎo)致了主線程修改了demo.isRunning
時(shí)掂铐,子線程感受不到罕拂,所以一直在循環(huán)執(zhí)行i++
,上面提到的運(yùn)行VM參數(shù)-client
全陨、-server
屬于JIT編譯
的參數(shù)爆班,影響指令優(yōu)化重排的行為,所以在32位JDK下辱姨,設(shè)置不同的參數(shù)會(huì)有不同的效果柿菩。
??問題的原因找到了,如何解決這個(gè)問題炮叶?很簡單碗旅,定義isRunning
時(shí)用volatile
修飾即可渡处,volatile
有禁止指令重排的效果镜悉,如下所示:
??將使用
javap
編譯字節(jié)碼,可以看到對(duì)應(yīng)的文本描述医瘫,從中可以看到對(duì)isRunning
的描述多了一個(gè)標(biāo)志ACC_VOLATILE
??從官方文檔可以看出侣肄,加了
volatile
描述的字段是不能被緩存的,因此加了volatile
描述的字段醇份,被多個(gè)不同線程訪問時(shí)稼锅,都是直接去內(nèi)存中查找,而不會(huì)加一層緩存僚纷,這保證了可見性矩距。6、Volatile關(guān)鍵字
??上面問題分析寫了一堆怖竭,最終卻出人意料地讓一個(gè)volatile
關(guān)鍵字輕松解決锥债,volatile
有什么魔力?
??volatile
可以解決多線程環(huán)境中共享數(shù)據(jù)的可見性問題痊臭,即一個(gè)線程修改了共享變量時(shí)哮肚,其他線程馬上能夠感知到這種變化。
舉個(gè)例子:
public class VolatileTest {
volatile long a = 1L; // 使用volatile聲明的64位long型
public void set(long l) { // 單個(gè)volatile變量的寫
a = l;
}
public long get() { // 單個(gè)volatile變量的讀
return a;
}
public void getAndIncreament() {
a++; // 復(fù)合多個(gè)volatile變量的讀/寫
}
}
假設(shè)有多個(gè)線程分別調(diào)用上面程序的三個(gè)方法广匙,則這個(gè)程序在語義上和下面的程序等價(jià):
public class VolatileTest {
long a = 1L; // 64位的long型普通變量
public synchronized void set(long l) {
a = l; // 對(duì)單個(gè)普通變量的寫用同一個(gè)鎖同步
}
public synchronized long get() {
return a;
}
public void getAndIncreament() {
long temp = get();
temp += 1L;
set(temp);
}
}
??如上面示例程序所示允趟,對(duì)一個(gè)volatile
變量的單個(gè)讀/寫操作,與對(duì)一個(gè)普通變量的讀/寫操作使用同一個(gè)鎖來同步鸦致,執(zhí)行效果是相同的潮剪。
??鎖的語義決定了臨界區(qū)代碼的執(zhí)行具有原子性涣楷,這意味著不管是什么類型的變量,只要它是volatile
變量鲁纠,對(duì)該變量的讀寫就將具有原子性总棵,但是這種原子性是對(duì)單個(gè)變量的操作而言的,如果是多個(gè)volatile
操作或類似于volatile++
這種復(fù)合操作改含,則其整體上不具有原子性情龄。
??再回到5、多線程編程中的程序案例捍壤,如果對(duì)demo.isRunning
修飾了volatile
骤视,禁止指令重排,JIT
不會(huì)優(yōu)化出下面這種形式的代碼鹃觉,程序讀寫時(shí)雖然也用到高速緩存专酗,但是保證了多個(gè)線程對(duì)同個(gè)共享數(shù)據(jù)的同步可見。
boolean f = demo.isRunning;
if (f) {
while (true) {
i++;
}
}
??總結(jié)起來:volatile
變量自身具有下列特性:
-
可見性:對(duì)一個(gè)
volatile
變量的讀盗扇,總是能看到(任意線程)對(duì)這個(gè)volatile
變量最后的寫入祷肯。 -
原子性:對(duì)任意單個(gè)
volatile
變量的讀/寫具有原子性,但類似于volatile++
這種復(fù)合操作不具有原子性疗隶。
(1)volatile寫-讀的內(nèi)存定義
- 當(dāng)寫一個(gè)volatile時(shí)佑笋,JMM會(huì)把該線程對(duì)應(yīng)的本地內(nèi)存中的共享變量刷新到內(nèi)存。
- 當(dāng)讀一個(gè)volatile時(shí)斑鼻,JMM會(huì)把該線程對(duì)應(yīng)的本地內(nèi)存中的共享變量置為無效蒋纬,線程接下來將從主內(nèi)存中讀取共享變量。
(2)volatile內(nèi)存語義的實(shí)現(xiàn)
下面是JMM針對(duì)編譯器制定的volatile重排序規(guī)則表:
為了實(shí)現(xiàn)volatile的內(nèi)存語義坚弱,編譯器在生成字節(jié)碼時(shí)蜀备,會(huì)在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。
下面是基于保守策略的JMM內(nèi)存屏障插入策略:
- 在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障荒叶。
- 在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障碾阁。
- 在每個(gè)volatile讀操作的前面插入一個(gè)LoadLoad屏障。
- 在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障些楣。
下面是保守策略下脂凶,volatile寫操作插入內(nèi)存屏障后生成的指令序列示意圖:
下面是保守策略下,volatile讀操作插入內(nèi)存屏障后生成的指令序列示意圖:
上述volatile寫操作和讀操作的內(nèi)存屏障插入策略非常保守戈毒。在實(shí)際執(zhí)行時(shí)艰猬,只要不改變volatile寫 - 讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障埋市。
三冠桃、Java內(nèi)存模型中的一些語義和規(guī)則
1、as-if-serial語義
??不管怎么重排序(編譯器和處理器為了提高并行度)道宅,單線程程序的執(zhí)行結(jié)果不能被改變食听,編譯器胸蛛、runtime和處理器都必須遵循as-if-serial語義,也就是說樱报,編譯器和處理器不會(huì)對(duì)存在數(shù)據(jù)依賴關(guān)系的操作做重排序葬项。
2、Shaerd Variables定義
??可以在線程之間共享的內(nèi)存稱為共享內(nèi)存或堆內(nèi)存迹蛤。所有實(shí)例字段民珍、靜態(tài)字段和數(shù)組元素都存儲(chǔ)在堆內(nèi)存中,這些字段和數(shù)組都是共享變量盗飒。
??多個(gè)線程對(duì)共享變量的訪問操作中嚷量,如果至少有一個(gè)訪問時(shí)寫操作,那么對(duì)同一個(gè)變量的兩次訪問時(shí)沖突的逆趣,訪問順序的不一樣可能導(dǎo)致出現(xiàn)不同的結(jié)果蝶溶。
3、線程間操作的定義
(1)線程間操作指:一個(gè)程序執(zhí)行的操作可被其他線程感知或被其他線程直接影響宣渗。
(2)Java內(nèi)存模型只描述線程間操作抖所,不描述線程內(nèi)操作,線程內(nèi)操作按照線程內(nèi)語義執(zhí)行痕囱。
線程間操作有:
- 普通讀
- 普通寫
- volatile讀
- volatile寫
- Lock田轧、Unlock:加鎖解鎖通常發(fā)生在多個(gè)線程對(duì)共享變量進(jìn)行操作的同步。
- 線程的第一個(gè)和最后一個(gè)操作:簡單說就是一個(gè)線程的啟動(dòng)和終止能被其他線程感知到咐蝇。
- 外部操作:比如多個(gè)線程去訪問DB涯鲁,DB是外部的資源巷查,所以叫外部操作有序,也是線程間操作。
4岛请、同步規(guī)則的定義
(1) 對(duì)volatile變量v的寫入旭寿,與所有其他線程后續(xù)對(duì)v的讀同步
(2) 對(duì)于監(jiān)視器m的解鎖與所有后續(xù)操作對(duì)m的加鎖同步
這里有兩層語義:一層是加鎖解鎖操作是不能被重排序的;
另一層含義線程1拿到鎖做了一些操作(比如修改了某個(gè)變量的值)崇败,接著解鎖盅称,接下來線程2拿到鎖,此時(shí)線程2是可以感知到線程1在持有鎖期間的操作后室。
(3)對(duì)于每個(gè)屬性寫入默認(rèn)值(0缩膝,false, null)與每個(gè)線程對(duì)其進(jìn)行的操作同步
??對(duì)象創(chuàng)建時(shí),JVM會(huì)根據(jù)對(duì)象屬性類型等信息為其分配一塊內(nèi)存岸霹,由于內(nèi)存中可能存在臟數(shù)據(jù)疾层,所以JVM在創(chuàng)建對(duì)象時(shí),會(huì)根據(jù)其屬性類型初始化默認(rèn)值贡避,比如數(shù)字類型的初始化為0痛黎,布爾類型初始化為false予弧,對(duì)象類型初始化為null,這個(gè)初始化的操作在線程訪問對(duì)象之前完成湖饱,確保訪問對(duì)象的線程不會(huì)看到“臟數(shù)據(jù)”(即沒初始化之前的內(nèi)存亂碼)掖蛤。
(4)啟動(dòng)線程的操作與線程中的第一個(gè)操作同步
??這里同步的意思同樣有兩層含義,一層指線程要先被啟動(dòng)才能執(zhí)行線程里的run方法井厌;一層指啟動(dòng)線程的線程調(diào)用start()方法啟動(dòng)了線程蚓庭,這個(gè)啟動(dòng)動(dòng)作修改了被啟動(dòng)的線程狀態(tài),并且被啟動(dòng)線程能感知到這種狀態(tài)的變化仅仆。
(5)線程T2的最后操作與線程T1發(fā)現(xiàn)線程T2已經(jīng)結(jié)束同步(isAlive,join可以判斷線程是否終結(jié))
??跟上面的規(guī)則差不多彪置,就是T2線程操作結(jié)束時(shí),線程狀態(tài)會(huì)修改成Terminated蝇恶,這個(gè)線程狀態(tài)對(duì)其他線程是可見的拳魁。
(6)如果線程T1中斷了T2,那么線程T1的中斷操作與其他所有線程發(fā)現(xiàn)T2被中斷了同步撮弧,通過拋出InterruptedException異常潘懊,或者調(diào)用Thread.interrupted或Thread.isInterrupted
??線程T1調(diào)用t1.interrupted()來中斷線程T1,本質(zhì)是修改T1線程對(duì)象的interrupted屬性的狀態(tài)值為true贿衍,這個(gè)狀態(tài)值對(duì)其他線程可見授舟,其他線程發(fā)現(xiàn)線程T2中斷肯定在線程T1中斷T2之后發(fā)生
5、Happens-before先行發(fā)生原則
??happens-before關(guān)系用于描述兩個(gè)有沖突的動(dòng)作之間的順序贸辈,如果一個(gè)action happends before另一個(gè)action释树,則第一個(gè)操作被第二個(gè)操作可見,JVM需要實(shí)現(xiàn)如下happens-before規(guī)則:
(1)某個(gè)線程中的每個(gè)動(dòng)作都happens-before該線程中該動(dòng)作后面的動(dòng)作
??簡單地說就是代碼指令的順序執(zhí)行
(2)某個(gè)管程上的unlock動(dòng)作happens-before同一個(gè)管程上后續(xù)的lock操作
??先加鎖后解鎖擎淤,順序不可調(diào)整
(3)對(duì)某個(gè)volatile字段的寫操作happens-before每個(gè)后續(xù)對(duì)該volatile字段的讀操作
(4)在某個(gè)線程對(duì)象上調(diào)用start()方法happens-before該啟動(dòng)線程中的任意動(dòng)作
(5)如果在線程t1中成功執(zhí)行了t2.join()奢啥,則t2中所有操作對(duì)t1可見
(6)如果某個(gè)動(dòng)作a happens-before動(dòng)作b,且b happens-before動(dòng)作c嘴拢,則有a happens-before c
6桩盲、final在JMM中的處理
Demo2Final
類的對(duì)象,線程1對(duì)對(duì)象屬性x席吴、y的修改赌结,只有x可以保證能被線程2讀取到正確的版本3,因?yàn)閤被final
所修飾孝冒;但是y不一定能被讀取到正確的構(gòu)造版本柬姚,線程2讀取y可能讀到的是0。
public class Demo2Final {
final int x;
int y;
static Demo2Final f;
public Demo2Final() {
x = 3;
y = 4;
}
static void writer() { f = new Demo2Final(); }
static void reader() {
if (f != null) {
int i = f.x; // 一定會(huì)讀到正確的構(gòu)造版本
int j = f.y; // 可能會(huì)讀到默認(rèn)值0
System.out.println("i = " + i + ", j = " + j);
}
}
public static void main(String[] args) throws InterruptedException {
// Thread1 writer
// Thread2 read
}
}
Demo3Final
對(duì)象庄涡,在構(gòu)造函數(shù)中量承,被聲明為final
的屬性x先初始化,再用x來給y賦值,則y被初始化后的值也可以被線程2看到宴合,而線程2可能依然看不到屬性c正確的構(gòu)造版本焕梅。
public class Demo3Final {
final int x;
int y;
int c;
static Demo3Final f;
public Demo3Final() {
x = 3;
// #### 重點(diǎn)語句 ####
y = x; // 因?yàn)閤被final修飾了,所以可讀到y(tǒng)的正確構(gòu)造版本
c = 4;
}
static void writer() { f = new Demo3Final(); }
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
int k = f.c;
System.out.println("i = " + i + ", j = " + j + ", k = " + k);
}
}
public static void main(String[] args) {
// Thread1 write
Demo3Final.writer();
// Thread2 read
}
}
7卦洽、Word Tearing字節(jié)處理
??有些處理器(尤其是早期的Alphas處理器)沒有提供寫單個(gè)字節(jié)的功能贞言。在這樣的處理器上更新byte數(shù)組,若只是簡單地讀取整個(gè)內(nèi)容阀蒂,更新對(duì)應(yīng)的字節(jié)该窗,然后將整個(gè)內(nèi)容再寫回內(nèi)存,將是不合法的蚤霞。
??這個(gè)問題有時(shí)候被稱為“字分裂(word tearing)”酗失,更新單個(gè)字節(jié)有難度的處理器,就需要尋求其他方式來解決問題昧绣。因此规肴,編程人員需要注意,盡量不要對(duì)byte[]中的元素進(jìn)行重新賦值夜畴,更不要在多線程程序中這樣做拖刃。
如下圖所示,展示了字分類的問題:
??內(nèi)存中存在數(shù)組[10, 2, 6, 9, 11, 23, 14]贪绘,現(xiàn)在線程1要修改數(shù)組下標(biāo)為4的元素值為10兑牡,線程2要修改數(shù)組下標(biāo)為3的元素值為6。
??但是由于處理器無法寫單個(gè)字節(jié)税灌,所以線程t1會(huì)拷貝整個(gè)數(shù)組的內(nèi)容到線程內(nèi)存中均函,對(duì)下標(biāo)為4的元素進(jìn)行賦值,再重新寫回內(nèi)存中去菱涤,線程t2也是類似操作苞也,但是這里存在一個(gè)問題,由于對(duì)數(shù)組進(jìn)行寫操作是整個(gè)數(shù)組進(jìn)行的狸窘,所以最后數(shù)組要么變成t1寫入的數(shù)組墩朦,要么變成t2寫入的數(shù)據(jù)坯认,這兩個(gè)都會(huì)到時(shí)另一個(gè)線程的修改被抹除掉了翻擒,如下所示:
8、double和long的特殊處理
??由于《Java語言規(guī)范》的原因牛哺,對(duì)非volatile的double陋气、long的單詞寫操作是分兩次來進(jìn)行的,每次操作其中32位引润,這可能導(dǎo)致第一次寫入后巩趁,讀取的值是臟數(shù)據(jù),第二次寫完成后,才能讀到正確值议慰。但是現(xiàn)在的JVM大都針對(duì)這點(diǎn)進(jìn)行了優(yōu)化蠢古,使得對(duì)double、long類型數(shù)據(jù)的操作都能以整體64位進(jìn)行别凹,保持原子性草讶,防止讀取的線程讀到臟數(shù)據(jù)。
??讀寫volatile修飾的long炉菲、double是原子性的堕战。
??商業(yè)JVM不會(huì)存在這個(gè)問題,雖然規(guī)范沒要求實(shí)現(xiàn)原子性拍霜,但是考慮到實(shí)際應(yīng)用嘱丢,大部分都實(shí)現(xiàn)了原子性。
??《Java語言規(guī)范》中說道:建議程序員將共享的64位值(long祠饺、double)用volatile修飾或正確同步其程序以避免可能的復(fù)雜的情況越驻。