您好,登錄后才能下訂單哦!
這篇文章主要介紹“Java內存區(qū)域與內存模型詳解”,在日常操作中,相信很多人在Java內存區(qū)域與內存模型詳解問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”Java內存區(qū)域與內存模型詳解”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
首先介紹兩個名詞:1)可見性:一個線程對共享變量值的修改,能夠及時地被其他線程看到。2)共享變量:如果一個變量在多個線程的工作內存中都存在副本,那么這個變量就是這幾個線程的共享變量
Java線程之間的通信對程序員完全透明,在并發(fā)編程中,需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步。
通信:通信是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。在共享內存的并發(fā)模型里,線程之間共享程序的公共狀態(tài),通過寫-讀內存中的公共狀態(tài)來進行隱式通信。在消息傳遞的并發(fā)模型里,線程之間沒有公共狀態(tài),線程之間必須通過發(fā)送消息來進行顯示通信。
同步:同步是指程序中用于控制不同線程間操作發(fā)生相對順序的機制。在共享內存并發(fā)模型里,同步是顯示進行的,程序員必須顯示指定某個方法或某段代碼需要在線程之間互斥執(zhí)行。在消息傳遞的并發(fā)模型里,由于消息的發(fā)送必須在消息的接收之前,因此同步是隱式進行的。
Java并發(fā)采用的是共享內存模型。
Java虛擬機在運行程序時會把其自動管理的內存劃分為以上幾個區(qū)域,每個區(qū)域都有的用途以及創(chuàng)建銷毀的時機,其中藍色部分代表的是所有線程共享的數(shù)據(jù)區(qū)域,而綠色部分代表的是每個線程的私有數(shù)據(jù)區(qū)域。
方法區(qū)(Method Area):
方法區(qū)屬于線程共享的內存區(qū)域,又稱Non-Heap(非堆),主要用于存儲已被虛擬機加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù),根據(jù)Java 虛擬機規(guī)范的規(guī)定,當方法區(qū)無法滿足內存分配需求時,將拋出OutOfMemoryError 異常。值得注意的是在方法區(qū)中存在一個叫運行時常量池(Runtime Constant Pool)的區(qū)域,它主要用于存放編譯器生成的各種字面量和符號引用,這些內容將在類加載后存放到運行時常量池中,以便后續(xù)使用。
JVM堆(Java Heap):
Java 堆也是屬于線程共享的內存區(qū)域,它在虛擬機啟動時創(chuàng)建,是Java 虛擬機所管理的內存中最大的一塊,主要用于存放對象實例,幾乎所有的對象實例都在這里分配內存,注意Java 堆是垃圾收集器管理的主要區(qū)域,因此很多時候也被稱做GC 堆,如果在堆中沒有內存完成實例分配,并且堆也無法再擴展時,將會拋出OutOfMemoryError 異常。
程序計數(shù)器(Program Counter Register):
屬于線程私有的數(shù)據(jù)區(qū)域,是一小塊內存空間,主要代表當前線程所執(zhí)行的字節(jié)碼行號指示器。字節(jié)碼解釋器工作時,通過改變這個計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數(shù)器來完成。
虛擬機棧(Java Virtual Machine Stacks):
屬于線程私有的數(shù)據(jù)區(qū)域,與線程同時創(chuàng)建,總數(shù)與線程關聯(lián),代表Java方法執(zhí)行的內存模型。棧中只保存基礎數(shù)據(jù)類型和自定義對象的引用(不是對象),對象都存放在堆區(qū)中。每個方法執(zhí)行時都會創(chuàng)建一個棧楨來存儲方法的的變量表、操作數(shù)棧、動態(tài)鏈接方法、返回值、返回地址等信息。每個方法從調用直結束就對于一個棧楨在虛擬機棧中的入棧和出棧過程,如下(圖有誤,應該為棧楨):
本地方法棧(Native Method Stacks):
本地方法棧屬于線程私有的數(shù)據(jù)區(qū)域,這部分主要與虛擬機用到的 Native 方法相關,一般情況下,我們無需關心此區(qū)域。
Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在。Java線程之間的通信由JMM控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系。
由于JVM運行程序的實體是線程,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內存(有些地方稱為??臻g),用于存儲線程私有的數(shù)據(jù),而Java內存模型中規(guī)定所有變量都存儲在主內存,主內存是共享內存區(qū)域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行。
首先要將變量從主內存拷貝的自己的工作內存空間,然后對變量進行操作,操作完成后再將變量寫回主內存,不能直接操作主內存中的變量,工作內存中存儲著主內存中的變量副本拷貝,前面說過,工作內存是每個線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,其簡要訪問過程如下圖
圖3
需要注意的是,JMM與Java內存區(qū)域的劃分是不同的概念層次,更恰當說JMM描述的是一組規(guī)則,通過這組規(guī)則控制程序中各個變量在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的(稍后會分析)。
JMM與Java內存區(qū)域唯一相似點,都存在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域,在JMM中主內存屬于共享數(shù)據(jù)區(qū)域,從某個程度上講應該包括了堆和方法區(qū),而工作內存數(shù)據(jù)線程私有數(shù)據(jù)區(qū)域,從某個程度上講則應該包括程序計數(shù)器、虛擬機棧以及本地方法棧。或許在某些地方,我們可能會看見主內存被描述為堆內存,工作內存被稱為線程棧,實際上他們表達的都是同一個含義。關于JMM中的主內存和工作內存說明如下
主內存
主要存儲的是Java實例對象以及線程之間的共享變量,所有線程創(chuàng)建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態(tài)變量。由于是共享數(shù)據(jù)區(qū)域,多條線程對同一個變量進行訪問可能會發(fā)現(xiàn)線程安全問題。
工作內存
有的書籍中也稱為本地內存,主要存儲當前方法的所有本地變量信息(工作內存中存儲著主內存中的變量副本拷貝),每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執(zhí)行的是同一段代碼,它們也會各自在自己的工作內存中創(chuàng)建屬于當前線程的本地變量,當然也包括了字節(jié)碼行號指示器、相關Native方法的信息。
注意由于工作內存是每個線程的私有數(shù)據(jù),線程間無法相互訪問工作內存,因此存儲在工作內存的數(shù)據(jù)不存在線程安全問題。注意,工作內存是JMM的一個抽象概念,并不真實存在。
弄清楚主內存和工作內存后,接了解一下主內存與工作內存的數(shù)據(jù)存儲類型以及操作方式,根據(jù)虛擬機規(guī)范,對于一個實例對象中的成員方法而言,如果方法中包含本地變量是基本數(shù)據(jù)類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內存的幀棧結構中,但倘若本地變量是引用類型,那么該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數(shù)據(jù)區(qū)域,堆)中。
但對于實例對象的成員變量,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區(qū)。至于static變量以及類本身相關信息將會存儲在主內存中。需要注意的是,在主內存中的實例對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那么兩條線程會將要操作的數(shù)據(jù)拷貝一份到自己的工作內存中,執(zhí)行完成操作后才刷新到主內存,簡單示意圖如下所示:
圖4
從圖3來看,如果線程A與線程B之間要通信的話,必須經歷下面兩個步驟:
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去
2)線程B到主內存中去讀取線程A之前已更新過的共享變量
從以上兩個步驟來看,共享內存模型完成了“隱式通信”的過程。
JMM也主要是通過控制主內存與每個線程的工作內存之間的交互,來為Java程序員提供內存可見性的保證。
重排序是指編譯器和處理器為了優(yōu)化程序性能而對指令序列進行重新排序的一種手段。as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關系的操作做重排序,因為這種重排序會改變執(zhí)行結果。
但是,如果操作之間不存在數(shù)據(jù)依賴關系,這些操作就可能被編譯器和處理器重排序。
happens-before是JMM最核心的概念。對應Java程序來說,理解happens-before是理解JMM的關鍵。
設計JMM時,需要考慮兩個關鍵因素:
程序員對內存模型的使用。程序員希望內存模型易于理解、易于編程。程序員希望基于一個強內存模型來編寫代碼。
編譯器和處理器對內存模型的實現(xiàn)。編譯器和處理器希望內存模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優(yōu)化來提高性能。編譯器和處理器希望實現(xiàn)弱內存模型。
但以上兩點相互矛盾,所以JSR-133專家組在設計JMM時的核心膜表就是找到一個好的平衡點:一方面,為程序員提高足夠強的內存可見性保證;另一方面,對編譯器和處理器的限制盡可能地放松。
另外還要一個特別有意思的事情就是關于重排序問題,更簡單的說,重排序可以分為兩類:1)會改變程序執(zhí)行結果的重排序。 2) 不會改變程序執(zhí)行結果的重排序。
JMM對這兩種不同性質的重排序,采取了不同的策略,如下:
對于會改變程序執(zhí)行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
對于不會改變程序執(zhí)行結果的重排序,JMM對編譯器和處理器不做要求(JMM允許這種 重排序)
JMM的設計圖為:
JMM設計示意圖 從圖可以看出:
JMM向程序員提供的happens-before規(guī)則能滿足程序員的需求。JMM的happens-before規(guī)則不但簡單易懂,而且也向程序員提供了足夠強的內存可見性保證(有些內存可見性保證其實并不一定真實存在,比如上面的A happens-before B)。
JMM對編譯器和處理器的束縛已經盡可能少。從上面的分析可以看出,JMM其實是在遵循一個基本原則:只要不改變程序的執(zhí)行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優(yōu)化都行。例如,如果編譯器經過細致的分析后,認定一個鎖只會被單個線程訪問,那么這個鎖可以被消除。再如,如果編譯器經過細致的分析后,認定一個volatile變量只會被單個線程訪問,那么編譯器可以把這個volatile變量當作一個普通變量來對待。這些優(yōu)化既不會改變程序的執(zhí)行結果,又能提高程序的執(zhí)行效率。
happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。JSR-133使用happens-before的概念來指定兩個操作之間的執(zhí)行順序。由于這兩個操作可以在一個線程之內,也可以是在不同線程之間。因此,JMM可以通過happens-before關系向程序員提供跨線程的內存可見性保證(如果A線程的寫操作a與B線程的讀操作b之間存在happens-before關系,盡管a操作和b操作在不同的線程中執(zhí)行,但JMM向程序員保證a操作將對b操作可見)。具體的定義為:
1)如果一個操作happens-before另一個操作,那么第一個操作的執(zhí)行結果將對第二個操作可見,而且第一個操作的執(zhí)行順序排在第二個操作之前。
2)兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現(xiàn)必須要按照happens-before關系指定的順序來執(zhí)行。如果重排序之后的執(zhí)行結果,與按happens-before關系來執(zhí)行的結果一致,那么這種重排序并不非法(也就是說,JMM允許這種重排序)。
上面的1)是JMM對程序員的承諾。從程序員的角度來說,可以這樣理解happens-before關系:如果A happens-before B,那么Java內存模型將向程序員保證——A操作的結果將對B可見,且A的執(zhí)行順序排在B之前。注意,這只是Java內存模型向程序員做出的保證!
上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM其實是在遵循一個基本原則:只要不改變程序的執(zhí)行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優(yōu)化都行。JMM這么做的原因是:程序員對于這兩個操作是否真的被重排序并不關心,程序員關心的是程序執(zhí)行時的語義不能被改變(即執(zhí)行結果不能被改變)。因此,happens-before關系本質上和as-if-serial語義是一回事。
as-if-serial語義保證單線程內程序的執(zhí)行結果不被改變,happens-before關系保證正確同步的多線程程序的執(zhí)行結果不被改變。
as-if-serial語義給編寫單線程程序的程序員創(chuàng)造了一個幻境:單線程程序是按程序的順序來執(zhí)行的。happens-before關系給編寫正確同步的多線程程序的程序員創(chuàng)造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執(zhí)行的。
as-if-serial語義和happens-before這么做的目的,都是為了在不改變程序執(zhí)行結果的前提下,盡可能地提高程序執(zhí)行的并行度。
程序順序規(guī)則:一個線程中的每個操作,happens-before于該線程中的任意后續(xù)操作。
監(jiān)視器鎖規(guī)則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
volatile變量規(guī)則:對一個volatile域的寫,happens-before于任意后續(xù)對這個volatile域的讀。
傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()規(guī)則:如果線程A執(zhí)行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
join()規(guī)則:如果線程A執(zhí)行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
一個happens-before規(guī)則對應于一個或多個編譯器和處理器重排序規(guī)則。對于Java程序員來說,happens-before規(guī)則簡單易懂,它避免Java程序員為了理解JMM提供的內存可見性保證而去學習復雜的重排序規(guī)則以及這些規(guī)則的具體實現(xiàn)方法
當聲明共享變量為volatile后,對這個變量的讀/寫會很特別。一個volatile變量的單個讀/寫操作,與一個普通變量的讀/寫操作都是使用同一個鎖來同步,它們之間的執(zhí)行效果相同。
鎖的happens-before規(guī)則保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這意味著對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。
鎖的語義決定了臨界區(qū)代碼的執(zhí)行具有原子性。這意味著,即使是64位的long型和double型變量,只要是volatile變量,對該變量的讀/寫就具有原子性。如果是多個volatile操作或類似于volatile++這種復合操作,這些操作整體上不具有原子性。
簡而言之,一旦一個共享變量(類的成員變量、類的靜態(tài)成員變量)被volatile修飾之后,那么就具備了兩層語義:
1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
2)禁止進行指令重排序。
可見性。對一個volatiole變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。
有序性。volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。
volatile關鍵字禁止指令重排序有兩層意思:
1)當程序執(zhí)行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見;在其后面的操作肯定還沒有進行;
2)在進行指令優(yōu)化時,不能將在對volatile變量訪問的語句放在其后面執(zhí)行,也不能把volatile變量后面的語句放到其前面執(zhí)行。
可能上面說的比較繞,舉個簡單的例子:
//x、y為非volatile變量 //flag為volatile變量 x = 2; //語句1 y = 0; //語句2 flag = true; //語句3 x = 4; //語句4 y = -1; //語句5
由于flag變量為volatile變量,那么在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5后面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。
原子性。對任意單個volatile變量的讀、寫具有原子性,但類似于volatile++這種復合操作不具有原子性。
volatile寫的內存語義:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。
volatile讀的內存語義:當讀一個volatile變量時,JMM會把該線程對應的本地內存置位無效。線程接下來將從主內存中讀取共享變量。(強制從主內存讀取共享變量,把本地內存與主內存的共享變量的值變成一致)。
volatile寫和讀的內存語義總結總結:
線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發(fā)出了(其對變量所做修改的)消息。
線程B讀一個volatile變量,實質上是線程B接收了之前某個線程發(fā)出的消息。
線程A寫一個volatile變量,隨后線程B讀這個volatile變量,這個過程實質上是線程A通過主內存向線程B發(fā)送消息。(隱式通信)
前面提到過編譯器重排序和處理器重排序。為了實現(xiàn)volatile內存語義,JMM分別限制了這兩種類型的重排序類型。
當?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
當?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
當?shù)谝粋€操作是volatile寫時,第二個操作是volatile讀時,不能重排序。
為了實現(xiàn)volatile的內存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內存屏障插入策略:
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。
在每個volatile讀操作的后面插入一個LoadStore屏障。
上述內存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內存語義。
下面是保守策略下,volatile寫插入內存屏障后生成的指令序列示意圖:
上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內存。
這里比較有意思的是volatile寫后面的StoreLoad屏障。這個屏障的作用是避免volatile寫與后面可能有的volatile讀/寫操作重排序。因為編譯器常常無法準確判斷在一個volatile寫的后面,是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即return)。為了保證能正確實現(xiàn)volatile的內存語義,JMM在這里采取了保守策略:在每個volatile寫的后面或在每個volatile讀的前面插入一個StoreLoad屏障。從整體執(zhí)行效率的角度考慮,JMM選擇了在每個volatile寫的后面插入一個StoreLoad屏障。因為volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數(shù)量大大超過寫線程時,選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執(zhí)行效率的提升。從這里我們可以看到JMM在實現(xiàn)上的一個特點:首先確保正確性,然后再去追求執(zhí)行效率。下面是在保守策略下,volatile讀插入內存屏障后生成的指令序列示意圖:
上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內存屏障插入策略非常保守。在實際執(zhí)行時,只要不改變volatile寫-讀的內存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。下面我們通過具體的示例代碼來說明:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; //第一個volatile讀 int j = v2; // 第二個volatile讀 a = i + j; //普通寫 v1 = i + 1; // 第一個volatile寫 v2 = j * 2; //第二個 volatile寫 } … //其他方法 }
針對readAndWrite()方法,編譯器在生成字節(jié)碼時可以做如下的優(yōu)化:
注意,最后的StoreLoad屏障不能省略。因為第二個volatile寫之后,方法立即return。此時編譯器可能無法準確斷定后面是否會有volatile讀或寫,為了安全起見,編譯器常常會在這里插入一個StoreLoad屏障。
上面的優(yōu)化是針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內存模型,內存屏障的插入還可以根據(jù)具體的處理器內存模型繼續(xù)優(yōu)化。以x86處理器為例,上圖中除最后的StoreLoad屏障外,其它的屏障都會被省略。
為了提供一種比鎖更輕量級的線程之間通信的機制,JSR-133專家組決定增強volatile的內存語義:嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和鎖的釋放-獲取具有相同的內存語義。
由于volatile僅僅保證對單個volatile變量的讀/寫具有原子性,而鎖的互斥執(zhí)行的特性可以確保對整個臨界區(qū)代碼的執(zhí)行具有原子性。在功能上,鎖比volatile更強大;在可伸縮性和執(zhí)行性能上,volatile更有優(yōu)勢。
當一個變量被定義為volatile之后,就可以保證此變量對所有線程的可見性,即當一個線程修改了此變量的值的時候,變量新的值對于其他線程來說是可以立即得知的??梢岳斫獬桑簩olatile變量所有的寫操作都能立刻被其他線程得知。但是這并不代表基于volatile變量的運算在并發(fā)下是安全的,因為volatile只能保證內存可見性,卻沒有保證對變量操作的原子性。比如下面的代碼:
/ * * 發(fā)起20個線程,每個線程對race變量進行10000次自增操作,如果代碼能夠正確并發(fā), * 則最終race的結果應為200000,但實際的運行結果卻小于200000。 * * @author Colin Wang */ public class Test { public static volatile int race = 0; public static void increase() { race++; } private static final int THREADS_COUNT = 20; public static void main(String[] args) { Thread[] threads = new Thread[THREADS_COUNT]; for (int i = 0; i < THREADS_COUNT; i++) { threads[i] = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) { increase(); } } }); threads[i].start(); } while (Thread.activeCount() > 1) Thread.yield(); System.out.println(race); } }
按道理來說結果是10000,但是運行下很可能是個小于10000的值。有人可能會說volatile不是保證了可見性啊,一個線程對race的修改,另外一個線程應該立刻看到啊!可是這里的操作race++是個復合操作啊,包括讀取race的值,對其自增,然后再寫回主存。
假設線程A,讀取了race的值為10,這時候被阻塞了,因為沒有對變量進行修改,觸發(fā)不了volatile規(guī)則。
線程B此時也讀讀race的值,主存里race的值依舊為10,做自增,然后立刻就被寫回主存了,為11。
此時又輪到線程A執(zhí)行,由于工作內存里保存的是10,所以繼續(xù)做自增,再寫回主存,11又被寫了一遍。所以雖然兩個線程執(zhí)行了兩次increase(),結果卻只加了一次。
有人說,volatile不是會使緩存行無效的嗎?但是這里線程A讀取到線程B也進行操作之前,并沒有修改inc值,所以線程B讀取的時候,還是讀的10。
又有人說,線程B將11寫回主存,不會把線程A的緩存行設為無效嗎?但是線程A的讀取操作已經做過了啊,只有在做讀取操作時,發(fā)現(xiàn)自己緩存行無效,才會去讀主存的值,所以這里線程A只能繼續(xù)做自增了。
綜上所述,在這種復合操作的情景下,原子性的功能是維持不了了。但是volatile在上面那種設置flag值的例子里,由于對flag的讀/寫操作都是單步的,所以還是能保證原子性的。
要想保證原子性,只能借助于synchronized,Lock以及并發(fā)包下的atomic的原子操作類了,即對基本數(shù)據(jù)類型的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數(shù)),減法操作(減一個數(shù))進行了封裝,保證這些操作是原子性操作。
Java 理論與實踐: 正確使用 Volatile 變量 總結了volatile關鍵的使用場景,
只能在有限的一些情形下使用 volatile 變量替代鎖。要使 volatile 變量提供理想的線程安全,必須同時滿足下面兩個條件:
對變量的寫操作不依賴于當前值。
該變量沒有包含在具有其他變量的不變式中。
實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立于任何程序的狀態(tài),包括變量的當前狀態(tài)。
第一個條件的限制使 volatile 變量不能用作線程安全計數(shù)器。雖然增量操作(x++
)看上去類似一個單獨操作,實際上它是一個由讀?。薷模瓕懭氩僮餍蛄薪M成的組合操作,必須以原子方式執(zhí)行,而 volatile 不能提供必須的原子特性。實現(xiàn)正確的操作需要使x
的值在操作期間保持不變,而 volatile 變量無法實現(xiàn)這點。(然而,如果將值調整為只從單個線程寫入,那么可以忽略第一個條件。)
volatile一個使用場景是狀態(tài)位;還有只有一個線程寫,其余線程讀的場景
鎖可以讓臨界區(qū)互斥執(zhí)行。鎖的釋放-獲取的內存語義與volatile變量寫-讀的內存語義很像。
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
當線程獲取鎖時,JMM會把該線程對應的本地內存置位無效,從而使得被監(jiān)視器保護的臨界區(qū)代碼必須從主內存中讀取共享變量。
不難發(fā)現(xiàn):鎖釋放與volatile寫有相同的內存語音;鎖獲取與volatile讀有相同的內存語義。
下面對鎖釋放和鎖獲取的內存語義做個總結。
線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發(fā)出了(線程A對共享變量所做修改的)消息。
線程B獲取一個鎖,實質上是線程B接收了之前某個線程發(fā)出的(在釋放這個鎖之前對共享變量所做修改)的消息。
線程A釋放鎖,隨后線程B獲取這個鎖,這個過程實質上是線程A通過主內存向線程B發(fā)送消息。
與前面介紹的鎖和volatile想比,對final域的讀和寫更像是普通的變量訪問。
對于final域,編譯器和處理器要遵循兩個重排序規(guī)則:
1.在構造函數(shù)內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
2.初次讀一個包含final域的對象的應用,與隨后初次讀這個final域,這兩個操作之間不能重排序
下面通過一個示例來分別說明這兩個規(guī)則:
public class FinalTest { int i;//普通變量 final int j; static FinalExample obj; public FinalExample(){ i = 1; j = 2; } public static void writer(){ obj = new FinalExample(); } public static void reader(){ FinalExample object = obj;//讀對象引用 int a = object.i; int b = object.j; } }
這里假設一個線程A執(zhí)行writer()方法,隨后另一個線程B執(zhí)行reader()方法。下面我們通過這兩個線程的交互來說明這兩個規(guī)則。
寫final域的重排序規(guī)則禁止把final域的寫重排序到構造函數(shù)之外。這個規(guī)則的實現(xiàn)包含下面兩個方面。
1)JMM禁止編譯器把final域的寫重排序到構造函數(shù)之外。
2)編譯器會在final域的寫之后,構造函數(shù)return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數(shù)之外。
現(xiàn)在讓我們分析writer方法,writer方法只包含一行代碼obj = new FinalTest();這行代碼包含兩個步驟:
1)構造一個FinalTest類型的對象
2)把這個對象的引用賦值給obj
假設線程B的讀對象引用與讀對象的成員域之間沒有重排序,下圖是一種可能的執(zhí)行時序
在上圖中,寫普通域的操作被編譯器重排序到了構造函數(shù)之外,讀線程B錯誤的讀取到了普通變量i初始化之前的值。而寫final域的操作被寫final域重排序的規(guī)則限定在了構造函數(shù)之內,讀線程B正確的讀取到了final變量初始化之后的值。
寫final域的重排序規(guī)則可以確保:在對象引用為任意線程可見之前,對象的final域已經被初始化了,而普通變量不具有這個保證。以上圖為例,讀線程B看到對象obj的時候,很可能obj對象還沒有構造完成(對普通域i的寫操作被重排序到構造函數(shù)外,此時初始值1還沒有寫入普通域i)
讀final域的重排序規(guī)則是:在一個線程中,初次讀對象的引用與初次讀這個對象包含的final域,JMM禁止重排序這兩個操作(該規(guī)則僅僅針對處理器)。編譯器會在讀final域的操作前面加一個LoadLoad屏障。
初次讀對象引用與初次讀該對象包含的final域,這兩個操作之間存在間接依賴關系。由于編譯器遵守間接依賴關系,因此編譯器不會重排序這兩個操作。大多數(shù)處理器也會遵守間接依賴,也不會重排序這兩個操作。但有少數(shù)處理器允許對存在間接依賴關系的操作做重排序(比如alpha處理器),這個規(guī)則就是專門用來針對這種處理器的。
上面的例子中,reader方法包含三個操作
1)初次讀引用變量obj
2)初次讀引用變量指向對象的普通域
3)初次讀引用變量指向對象的final域
現(xiàn)在假設寫線程A沒有發(fā)生任何重排序,同時程序在不遵守間接依賴的處理器上執(zhí)行,下圖是一種可能的執(zhí)行時序:
在上圖中,讀對象的普通域操作被處理器重排序到讀對象引用之前。在讀普通域時,該域還沒有被寫線程寫入,這是一個錯誤的讀取操作,而讀final域的重排序規(guī)則會把讀對象final域的操作“限定”在讀對象引用之后,此時該final域已經被A線程初始化過了,這是一個正確的讀取操作。
讀final域的重排序規(guī)則可以確保:在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用。在這個示例程序中,如果該引用不為null,那么引用對象的final域一定已經被A線程初始化過了。
final域為引用類型,上面我們看到的final域是基礎的數(shù)據(jù)類型,如果final域是引用類型呢?
public class FinalReferenceTest { final int[] arrs;//final引用 static FinalReferenceTest obj; public FinalReferenceTest(){ arrs = new int[1];//1 arrs[0] = 1;//2 } public static void write0(){//A線程 obj = new FinalReferenceTest();//3 } public static void write1(){//線程B obj.arrs[0] = 2;//4 } public static void reader(){//C線程 if(obj!=null){//5 int temp =obj.arrs[0];//6 } } }
JMM可以確保讀線程C至少能看到寫線程A在構造函數(shù)中對final引用對象的成員域的寫入。即C至少能看到數(shù)組下標0的值為1。而寫線程B對數(shù)組元素的寫入,讀線程C可能看得到,也可能看不到。JMM不保證線程B的寫入對讀線程C可見,因為寫線程B和讀線程C之間存在數(shù)據(jù)競爭,此時的執(zhí)行結果不可預知。
如果想要確保讀線程C看到寫線程B對數(shù)組元素的寫入,寫線程B和讀線程C之間需要使用同步原語(lock或volatile)來確保內存可見性。
前面我們提到過,寫final域的重排序規(guī)則可以確保:在引用變量為任意線程可見之前,該引用變量指向的對象的final域已經在構造函數(shù)中被正確初始化過了。其實,要得到這個效果,還需要一個保證:在構造函數(shù)內部,不能讓這個被構造對象的引用為其他線程所見,也就是對象引用不能在構造函數(shù)中“逸出”。
public class FinalReferenceEscapeExample {final int i;static FinalReferenceEscapeExample obj;public FinalReferenceEscapeExample () { i = 1; // 1寫final域 obj = this; // 2 this引用在此"逸出" } public static void writer() {new FinalReferenceEscapeExample (); }public static void reader() {if (obj != null) { // 3 int temp = obj.i; // 4 } } }
假設一個線程A執(zhí)行writer()方法,另一個線程B執(zhí)行reader()方法。這里的操作2使得對象還未完成構造前就為線程B可見。即使這里的操作2是構造函數(shù)的最后一步,且在程序中操作2排在操作1后面,執(zhí)行read()方法的線程仍然可能無法看到final域被初始化后的值,因為這里的操作1和操作2之間可能被重排序。
JSR-133為什么要增強final的語義:
通過為final域增加寫和讀重排序規(guī)則,可以為Java程序員提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造函數(shù)中沒有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保證任意線程都能看到這個final域在構造函數(shù)中被初始化之后的值。
JMM是圍繞這在并發(fā)過程中如何處理原子性、可見性和有序性這3個特性來建立的。
原子性:
Java中,對基本數(shù)據(jù)類型的讀取和賦值操作是原子性操作,所謂原子性操作就是指這些操作是不可中斷的,要做一定做完,要么就沒有執(zhí)行。比如:
i = 2;j = i;i++;i = i + 1;
上面4個操作中,i=2是讀取操作,必定是原子性操作,j=i你以為是原子性操作,其實吧,分為兩步,一是讀取i的值,然后再賦值給j,這就是2步操作了,稱不上原子操作,i++和i = i + 1其實是等效的,讀取i的值,加1,再寫回主存,那就是3步操作了。所以上面的舉例中,最后的值可能出現(xiàn)多種情況,就是因為滿足不了原子性。
JMM只能保證對單個volatile變量的讀/寫具有原子性,但類似于volatile++這種符合操作不具有原子性,這時候就必須借助于synchronized和Lock來保證整塊代碼的原子性了。線程在釋放鎖之前,必然會把i的值刷回到主存的。
可見性:可見性指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現(xiàn)可見性。
無論是普通變量還是volatile變量,它們的區(qū)別是:volatile的特殊規(guī)則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因為,可以說volatile保證了多線程操作時變量的可見性,而普通變量不能保證這一點。
除了volatile之外,java中還有2個關鍵字能實現(xiàn)可見性,即synchronized和final(final修飾的變量,線程安全級別最高)。同步塊的可見性是由“對一個變量執(zhí)行unlock操作之前,必須先把此變量同步回主內存中(執(zhí)行store,write操作)”這條規(guī)則獲得;而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦初始化完成,并且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事,其他線程有可能通過這個引用訪問到“初始化了一半”的對象),那么在其他線程中就能看到final字段的值。
有序性:JMM的有序性在講解volatile時詳細的討論過,java程序中天然的有序性可以總結為一句話:如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有操作都是無序的。前半句是指“線程內表現(xiàn)為串行的語義”,后半句指的是“指令重排”現(xiàn)象和“工作內存與主內存同步延遲”現(xiàn)象。
前半句可以用JMM規(guī)定的as-if-serial語義來解決,后半句可以用JMM規(guī)定的happens-before原則來解決。Java語義提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行l(wèi)ock操作”這條規(guī)則獲取的。這個規(guī)則決定了持有同一個鎖的兩個同步塊只能串行的進入。
到此,關于“Java內存區(qū)域與內存模型詳解”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續(xù)學習更多相關知識,請繼續(xù)關注億速云網站,小編會繼續(xù)努力為大家?guī)砀鄬嵱玫奈恼拢?/p>
免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據(jù),一經查實,將立刻刪除涉嫌侵權內容。