Java內(nèi)存模型
Java內(nèi)存模型即Java Memory Model界阁,簡稱JMM。JMM定義了Java 虛擬機(JVM)在計算機內(nèi)存(RAM)中的工作方式挂据。JVM是整個計算機虛擬模型以清,所以JMM是隸屬于JVM的。
如果我們要想深入了解Java并發(fā)編程崎逃,就要先理解好Java內(nèi)存模型掷倔。Java內(nèi)存模型定義了多線程之間共享變量的可見性以及如何在需要的時候?qū)蚕碜兞窟M行同步。原始的Java內(nèi)存模型效率并不是很理想个绍,因此Java1.5版本對其進行了重構(gòu)勒葱,現(xiàn)在的Java8仍沿用了Java1.5的版本。
JMM決定一個線程對共享變量的寫入何時對另一個線程可見巴柿。從抽象的角度來看凛虽,JMM定義了線程和主內(nèi)存之間的抽象關(guān)系:線程之間的共享變量存儲在主內(nèi)存(main memory)中,每個線程都有一個私有的本地內(nèi)存(local memory)广恢,本地內(nèi)存中存儲了該線程讀/寫共享變量的副本凯旋。本地內(nèi)存是JMM的一個抽象概念,并不真實存在钉迷。它涵蓋了緩存至非,寫緩沖區(qū),寄存器以及其他的硬件和編譯器優(yōu)化糠聪。
從上圖來看荒椭,線程A與線程B之間如要通信的話,必須要經(jīng)歷下面2個步驟:
- 首先舰蟆,線程A把本地內(nèi)存A中更新過的共享變量刷新到主內(nèi)存中去趣惠。
- 然后,線程B到主內(nèi)存中去讀取線程A之前已更新過的共享變量夭苗。
下面通過示意圖來說明這兩個步驟:
如上圖所示信卡,本地內(nèi)存A和B有主內(nèi)存中共享變量x的副本。假設(shè)初始時题造,這三個內(nèi)存中的x值都為0傍菇。線程A在執(zhí)行時,把更新后的x值(假設(shè)值為1)臨時存放在自己的本地內(nèi)存A中界赔。當(dāng)線程A和線程B需要通信時丢习,線程A首先會把自己本地內(nèi)存中修改后的x值刷新到主內(nèi)存中牵触,此時主內(nèi)存中的x值變?yōu)榱?。隨后咐低,線程B到主內(nèi)存中去讀取線程A更新后的x值揽思,此時線程B的本地內(nèi)存的x值也變?yōu)榱?。
從整體來看见擦,這兩個步驟實質(zhì)上是線程A在向線程B發(fā)送消息钉汗,而且這個通信過程必須要經(jīng)過主內(nèi)存。JMM通過控制主內(nèi)存與每個線程的本地內(nèi)存之間的交互鲤屡,來為java程序員提供內(nèi)存可見性保證损痰。
上面也說到了,Java內(nèi)存模型只是一個抽象概念酒来,那么它在Java中具體是怎么工作的呢卢未?為了更好的理解Java內(nèi)存模型工作方式,下面就JVM對Java內(nèi)存模型的實現(xiàn)堰汉、硬件內(nèi)存模型及它們之間的橋接做詳細介紹辽社。
JVM對Java內(nèi)存模型的實現(xiàn)
在JVM內(nèi)部,Java內(nèi)存模型把內(nèi)存分成了兩部分:線程棧區(qū)和堆區(qū)翘鸭,下圖展示了Java內(nèi)存模型在JVM中的邏輯視圖:
JVM中運行的每個線程都擁有自己的線程棧滴铅,線程棧包含了當(dāng)前線程執(zhí)行的方法調(diào)用相關(guān)信息,我們也把它稱作調(diào)用棧矮固。隨著代碼的不斷執(zhí)行失息,調(diào)用棧會不斷變化。
線程棧還包含了當(dāng)前方法的所有本地變量信息档址。一個線程只能讀取自己的線程棧盹兢,也就是說,線程中的本地變量對其它線程是不可見的守伸。即使兩個線程執(zhí)行的是同一段代碼绎秒,它們也會各自在自己的線程棧中創(chuàng)建本地變量,因此尼摹,每個線程中的本地變量都會有自己的版本见芹。
所有原始類型(boolean,byte,short,char,int,long,float,double)的本地變量都直接保存在線程棧當(dāng)中,對于它們的值各個線程之間都是獨立的蠢涝。對于原始類型的本地變量玄呛,一個線程可以傳遞一個副本給另一個線程,當(dāng)它們之間是無法共享的和二。
堆區(qū)包含了Java應(yīng)用創(chuàng)建的所有對象信息徘铝,不管對象是哪個線程創(chuàng)建的,其中的對象包括原始類型的封裝類(如Byte、Integer惕它、Long等等)怕午。不管對象是屬于一個成員變量還是方法中的本地變量,它都會被存儲在堆區(qū)淹魄。
下圖展示了調(diào)用棧和本地變量都存儲在棧區(qū)郁惜,對象都存儲在堆區(qū):
一個本地變量如果是原始類型,那么它會被完全存儲到棧區(qū)甲锡。
一個本地變量也有可能是一個對象的引用兆蕉,這種情況下,這個本地引用會被存儲到棧中缤沦,但是對象本身仍然存儲在堆區(qū)恨樟。
對于一個對象的成員方法,這些方法中包含本地變量疚俱,仍需要存儲在棧區(qū),即使它們所屬的對象在堆區(qū)缩多。
對于一個對象的成員變量呆奕,不管它是原始類型還是包裝類型,都會被存儲到堆區(qū)衬吆。
Static類型的變量以及類本身相關(guān)信息都會隨著類本身存儲在堆區(qū)梁钾。
堆中的對象可以被多線程共享。如果一個線程獲得一個對象的引用逊抡,它便可訪問這個對象的成員變量姆泻。如果兩個線程同時調(diào)用了同一個對象的同一個方法,那么這兩個線程便可同時訪問這個對象的成員變量冒嫡,但是對于本地變量拇勃,每個線程都會拷貝一份到自己的線程棧中。
下圖展示了上面描述的過程:
兩個線程擁有一些本地變量孝凌。其中一個本地變量(Local Variable 2)執(zhí)行堆上的一個共享對象(Object 3)方咆。這兩個線程分別擁有同一個對象的不同引用。這些引用都是本地變量蟀架,因此存放在各自線程的線程棧上瓣赂。這兩個不同的引用指向堆上同一個對象。
注意片拍,這個共享對象(Object 3)持有Object2和Object4一個引用作為其成員變量(如圖中Object3指向Object2和Object4的箭頭)煌集。通過Object3中這些成員變量引用,這兩個線程就可以訪問Object2和Object4捌省。
這張圖也展示了指向堆上兩個不同對象的一個本地變量苫纤。在這種情況下,指向兩個不同對象的引用不是同一個對象。理論上方面,兩個線程都可以訪問Object1和Object5放钦,如果兩個線程都擁有兩個對象的引用。但是在上圖中恭金,每一個線程僅有一個引用指向兩個對象其中之一操禀。
因此,什么類型的Java代碼會導(dǎo)致上面的內(nèi)存圖呢横腿?如下所示:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member1 = 67890;
}
如果兩個線程同時執(zhí)行run()方法颓屑,就會出現(xiàn)上圖所示的情景。run()方法調(diào)用methodOne()方法耿焊,methodOne()調(diào)用methodTwo()方法揪惦。
methodOne()聲明了一個原始類型的本地變量和一個引用類型的本地變量。
每個線程執(zhí)行methodOne()都會在它們對應(yīng)的線程棧上創(chuàng)建localVariable1和localVariable2的私有拷貝罗侯。localVariable1變量彼此完全獨立器腋,僅“生活”在每個線程的線程棧上。一個線程看不到另一個線程對它的localVariable1私有拷貝做出的修改钩杰。
每個線程執(zhí)行methodOne()時也將會創(chuàng)建它們各自的localVariable2拷貝纫塌。然而,兩個localVariable2的不同拷貝都指向堆上的同一個對象讲弄。代碼中通過一個靜態(tài)變量設(shè)置localVariable2指向一個對象引用措左。僅存在一個靜態(tài)變量的一份拷貝,這份拷貝存放在堆上避除。因此怎披,localVariable2的兩份拷貝都指向由MySharedObject指向的靜態(tài)變量的同一個實例。MySharedObject實例也存放在堆上瓶摆。它對應(yīng)于上圖中的Object3凉逛。
注意,MySharedObject類也包含兩個成員變量赏壹。這些成員變量隨著這個對象存放在堆上鱼炒。這兩個成員變量指向另外兩個Integer對象。這些Integer對象對應(yīng)于上圖中的Object2和Object4.
注意蝌借,methodTwo()創(chuàng)建一個名為localVariable的本地變量昔瞧。這個成員變量是一個指向一個Integer對象的對象引用。這個方法設(shè)置localVariable1引用指向一個新的Integer實例菩佑。在執(zhí)行methodTwo方法時自晰,localVariable1引用將會在每個線程中存放一份拷貝。這兩個Integer對象實例化將會被存儲堆上稍坯,但是每次執(zhí)行這個方法時酬荞,這個方法都會創(chuàng)建一個新的Integer對象搓劫,兩個線程執(zhí)行這個方法將會創(chuàng)建兩個不同的Integer實例。methodTwo方法創(chuàng)建的Integer對象對應(yīng)于上圖中的Object1和Object5混巧。
還有一點枪向,MySharedObject類中的兩個long類型的成員變量是原始類型的。因為咧党,這些變量是成員變量秘蛔,所以它們?nèi)匀浑S著該對象存放在堆上,僅有本地變量存放在線程棧上傍衡。
硬件內(nèi)存架構(gòu)
不管是什么內(nèi)存模型深员,最終還是運行在計算機硬件上的,所以我們有必要了解計算機硬件內(nèi)存架構(gòu)蛙埂,下圖就簡單描述了當(dāng)代計算機硬件內(nèi)存架構(gòu):
現(xiàn)代計算機一般都有2個以上CPU倦畅,而且每個CPU還有可能包含多個核心。因此绣的,如果我們的應(yīng)用是多線程的話叠赐,這些線程可能會在各個CPU核心中并行運行。
在CPU內(nèi)部有一組CPU寄存器屡江,也就是CPU的儲存器燎悍。CPU操作寄存器的速度要比操作計算機主存快的多。在主存和CPU寄存器之間還存在一個CPU緩存盼理,CPU操作CPU緩存的速度快于主存但慢于CPU寄存器。某些CPU可能有多個緩存層(一級緩存和二級緩存)俄删。計算機的主存也稱作RAM宏怔,所有的CPU都能夠訪問主存,而且主存比上面提到的緩存和寄存器大很多畴椰。
當(dāng)一個CPU需要訪問主存時臊诊,會先讀取一部分主存數(shù)據(jù)到CPU緩存,進而在讀取CPU緩存到寄存器斜脂。當(dāng)CPU需要寫數(shù)據(jù)到主存時抓艳,同樣會先flush寄存器到CPU緩存,然后再在某些節(jié)點把緩存數(shù)據(jù)flush到主存帚戳。
Java內(nèi)存模型和硬件架構(gòu)之間的橋接
正如上面講到的玷或,Java內(nèi)存模型和硬件內(nèi)存架構(gòu)并不一致。硬件內(nèi)存架構(gòu)中并沒有區(qū)分棧和堆片任,從硬件上看偏友,不管是棧還是堆,大部分數(shù)據(jù)都會存到主存中对供,當(dāng)然一部分棧和堆的數(shù)據(jù)也有可能會存到CPU寄存器中位他,如下圖所示,Java內(nèi)存模型和計算機硬件內(nèi)存架構(gòu)是一個交叉關(guān)系:
當(dāng)對象和變量存儲到計算機的各個內(nèi)存區(qū)域時,必然會面臨一些問題鹅髓,其中最主要的兩個問題是:
- 共享對象對各個線程的可見性
- 共享對象的競爭現(xiàn)象
共享對象的可見性
當(dāng)多個線程同時操作同一個共享對象時皆愉,如果沒有合理的使用volatile和synchronization關(guān)鍵字,一個線程對共享對象的更新有可能導(dǎo)致其它線程不可見其馏。
想象一下我們的共享對象存儲在主存剧蹂,一個CPU中的線程讀取主存數(shù)據(jù)到CPU緩存,然后對共享對象做了更改靡菇,但CPU緩存中的更改后的對象還沒有flush到主存重归,此時線程對共享對象的更改對其它CPU中的線程是不可見的。最終就是每個線程最終都會拷貝共享對象厦凤,而且拷貝的對象位于不同的CPU緩存中鼻吮。
下圖展示了上面描述的過程。左邊CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存较鼓,把對象obj的count變量改為2椎木。但這個變更對運行在右邊CPU中的線程不可見,因為這個更改還沒有flush到主存中:
要解決共享對象可見性這個問題博烂,我們可以使用java volatile關(guān)鍵字香椎。 Java’s volatile keyword. volatile 關(guān)鍵字可以保證變量會直接從主存讀取,而對變量的更新也會直接寫到主存禽篱。volatile原理是基于CPU內(nèi)存屏障指令實現(xiàn)的畜伐,后面會講到。
競爭現(xiàn)象
如果多個線程共享一個對象躺率,如果它們同時修改這個共享對象玛界,這就產(chǎn)生了競爭現(xiàn)象。
如下圖所示悼吱,線程A和線程B共享一個對象obj慎框。假設(shè)線程A從主存讀取Obj.count變量到自己的CPU緩存,同時后添,線程B也讀取了Obj.count變量到它的CPU緩存笨枯,并且這兩個線程都對Obj.count做了加1操作。此時遇西,Obj.count加1操作被執(zhí)行了兩次馅精,不過都在不同的CPU緩存中。
如果這兩個加1操作是串行執(zhí)行的粱檀,那么Obj.count變量便會在原始值上加2硫嘶,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是并行的梧税,不管是線程A還是線程B先flush計算結(jié)果到主存沦疾,最終主存中的Obj.count只會增加1次變成2称近,盡管一共有兩次加1操作。
要解決上面的問題我們可以使用java synchronized代碼塊哮塞。synchronized代碼塊可以保證同一個時刻只能有一個線程進入代碼競爭區(qū)刨秆,synchronized代碼塊也能保證代碼塊中所有變量都將會從主存中讀,當(dāng)線程退出代碼塊時忆畅,對所有變量的更新將會flush到主存衡未,不管這些變量是不是volatile類型的。
volatile和synchronized區(qū)別
首先需要理解線程安全的兩個方面:執(zhí)行控制和內(nèi)存可見家凯。
執(zhí)行控制的目的是控制代碼執(zhí)行(順序)及是否可以并發(fā)執(zhí)行缓醋。
內(nèi)存可見控制的是線程執(zhí)行結(jié)果在內(nèi)存中對其它線程的可見性。根據(jù)Java內(nèi)存模型的實現(xiàn)绊诲,線程在具體執(zhí)行時送粱,會先拷貝主存數(shù)據(jù)到線程本地(CPU緩存),操作完成后再把結(jié)果從線程本地刷到主存掂之。
synchronized關(guān)鍵字解決的是執(zhí)行控制的問題抗俄,它會阻止其它線程獲取當(dāng)前對象的監(jiān)控鎖,這樣就使得當(dāng)前對象中被synchronized關(guān)鍵字保護的代碼塊無法被其它線程訪問世舰,也就無法并發(fā)執(zhí)行动雹。更重要的是,synchronized還會創(chuàng)建一個內(nèi)存屏障跟压,內(nèi)存屏障指令保證了所有CPU操作結(jié)果都會直接刷到主存中胰蝠,從而保證了操作的內(nèi)存可見性,同時也使得先獲得這個鎖的線程的所有操作震蒋,都happens-before于隨后獲得這個鎖的線程的操作姊氓。
volatile關(guān)鍵字解決的是內(nèi)存可見性的問題,會使得所有對volatile變量的讀寫都會直接刷到主存喷好,即保證了變量的可見性。這樣就能滿足一些對變量可見性有要求而對讀取順序沒有要求的需求读跷。
使用volatile關(guān)鍵字僅能實現(xiàn)對原始變量(如boolen梗搅、 short 、int 效览、long等)操作的原子性无切,但需要特別注意, volatile不能保證復(fù)合操作的原子性丐枉,即使只是i++哆键,實際上也是由多個原子操作組成:read i; inc; write i,假如多個線程同時執(zhí)行i++瘦锹,volatile只能保證他們操作的i是同一塊內(nèi)存籍嘹,但依然可能出現(xiàn)寫入臟數(shù)據(jù)的情況闪盔。
在Java 5提供了原子數(shù)據(jù)類型atomic wrapper classes,對它們的increase之類的操作都是原子操作辱士,不需要使用sychronized關(guān)鍵字泪掀。
對于volatile關(guān)鍵字,當(dāng)且僅當(dāng)滿足以下所有條件時可使用:
- 對變量的寫入操作不依賴變量的當(dāng)前值颂碘,或者你能確保只有單個線程更新變量的值异赫。
- 該變量沒有包含在具有其他變量的不變式中。
volatile和synchronized的區(qū)別:
- volatile本質(zhì)是在告訴jvm當(dāng)前變量在寄存器(工作內(nèi)存)中的值是不確定的头岔,需要從主存中讀人;
- synchronized則是鎖定當(dāng)前變量峡竣,只有當(dāng)前線程可以訪問該變量靠抑,其他線程被阻塞住澎胡;
- volatile僅能使用在變量級別孕荠;synchronized則可以使用在變量、方法攻谁、和類級別的稚伍;
- volatile僅能實現(xiàn)變量的修改可見性,不能保證原子性戚宦;而synchronized則可以保證變量的修改可見性和原子性个曙;
- volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞受楼;
- volatile標(biāo)記的變量不會被編譯器優(yōu)化垦搬;synchronized標(biāo)記的變量可以被編譯器優(yōu)化。
支撐Java內(nèi)存模型的基礎(chǔ)原理
指令重排序
在執(zhí)行程序時艳汽,為了提高性能猴贰,編譯器和處理器會對指令做重排序。但是河狐,JMM確保在不同的編譯器和不同的處理器平臺之上米绕,通過插入特定類型的Memory Barrier來禁止特定類型的編譯器重排序和處理器重排序,為上層提供一致的內(nèi)存可見性保證馋艺。
- 編譯器優(yōu)化重排序:編譯器在不改變單線程程序語義的前提下栅干,可以重新安排語句的執(zhí)行順序。
- 指令級并行的重排序:如果不存在數(shù)據(jù)依賴性捐祠,處理器可以改變語句對應(yīng)機器指令的執(zhí)行順序碱鳞。
- 內(nèi)存系統(tǒng)的重排序:處理器使用緩存和讀寫緩沖區(qū),這使得加載和存儲操作看上去可能是在亂序執(zhí)行踱蛀。
數(shù)據(jù)依賴性
如果兩個操作訪問同一個變量窿给,其中一個為寫操作贵白,此時這兩個操作之間存在數(shù)據(jù)依賴性。
編譯器和處理器不會改變存在數(shù)據(jù)依賴性關(guān)系的兩個操作的執(zhí)行順序填大,即不會重排序戒洼。
as-if-serial
不管怎么重排序,單線程下的執(zhí)行結(jié)果不能被改變允华,編譯器圈浇、runtime和處理器都必須遵守as-if-serial語義。
內(nèi)存屏障(Memory Barrier )
上面講到了靴寂,通過內(nèi)存屏障可以禁止特定類型處理器的重排序磷蜀,從而讓程序按我們預(yù)想的流程去執(zhí)行。內(nèi)存屏障百炬,又稱內(nèi)存柵欄褐隆,是一個CPU指令,基本上它是一條這樣的指令:
- 保證特定操作的執(zhí)行順序剖踊。
- 影響某些數(shù)據(jù)(或則是某條指令的執(zhí)行結(jié)果)的內(nèi)存可見性庶弃。
編譯器和CPU能夠重排序指令,保證最終相同的結(jié)果德澈,嘗試優(yōu)化性能歇攻。插入一條Memory Barrier會告訴編譯器和CPU:不管什么指令都不能和這條Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是強制刷出各種CPU cache梆造,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的數(shù)據(jù)缴守,因此,任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本镇辉。
這和java有什么關(guān)系屡穗?上面java內(nèi)存模型中講到的volatile是基于Memory Barrier實現(xiàn)的。
如果一個變量是volatile修飾的忽肛,JMM會在寫入這個字段之后插進一個Write-Barrier指令村砂,并在讀這個字段之前插入一個Read-Barrier指令。這意味著屹逛,如果寫入一個volatile變量础废,就可以保證:
- 一個線程寫入變量a后,任何線程訪問該變量都會拿到最新值煎源。
- 在寫入變量a之前的寫入操作,其更新的數(shù)據(jù)對于其他線程也是可見的香缺。因為Memory Barrier會刷出cache中的所有先前的寫入手销。
happens-before
從jdk5開始,java使用新的JSR-133內(nèi)存模型图张,基于happens-before的概念來闡述操作之間的內(nèi)存可見性锋拖。
在JMM中诈悍,如果一個操作的執(zhí)行結(jié)果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關(guān)系兽埃,這兩個操作既可以在同一個線程侥钳,也可以在不同的兩個線程中。
與程序員密切相關(guān)的happens-before規(guī)則如下:
- 程序順序規(guī)則:一個線程中的每個操作柄错,happens-before于該線程中任意的后續(xù)操作舷夺。
- 監(jiān)視器鎖規(guī)則:對一個鎖的解鎖操作,happens-before于隨后對這個鎖的加鎖操作售貌。
- volatile域規(guī)則:對一個volatile域的寫操作给猾,happens-before于任意線程后續(xù)對這個volatile域的讀。
- 傳遞性規(guī)則:如果 A happens-before B颂跨,且 B happens-before C敢伸,那么A happens-before C。
注意:兩個操作之間具有happens-before關(guān)系恒削,并不意味前一個操作必須要在后一個操作之前執(zhí)行池颈!僅僅要求前一個操作的執(zhí)行結(jié)果,對于后一個操作是可見的钓丰,且前一個操作按順序排在后一個操作之前躯砰。