1. 程式人生 > >JMM和底層實現原理

JMM和底層實現原理

現代計算機物理上的記憶體模型

在這裡插入圖片描述
物理機遇到的併發問題與虛擬機器中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機器的實現也有相當大的參考意義。
在這裡插入圖片描述
在這裡插入圖片描述
其中一個重要的複雜性來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個I/O操作是很難消除的(無法僅靠暫存器來完成所有運算任務)。早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,cpu的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。

在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(MainMemory)。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,舉例說明變數在多個CPU之間的共享。如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
現代的處理器使用寫緩衝區臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對記憶體操作的執行順序產生重要的影響:處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致!
處理器A和處理器B按程式的順序並行執行記憶體訪問,最終可能得到x=y=0的結果。
處理器A和處理器B可以同時把共享變數寫入自己的寫緩衝區(A1,B1),然後從記憶體中讀取另一個共享變數(A2,B2),最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(A3,B3)。當以這種時序執行時,程式就可以得到x=y=0的結果。
從記憶體操作實際發生的順序來看,直到處理器A執行A3來重新整理自己的寫快取區,寫操作A1才算真正執行了。雖然處理器A執行記憶體操作的順序為:A1→A2,但記憶體操作實際發生的順序卻是A2→A1。

Java記憶體模型(JMM)

在這裡插入圖片描述
在這裡插入圖片描述
即Java Memory Model,簡稱JMM。JMM定義了Java 虛擬機器(JVM)在計算機記憶體(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬於JVM的。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。

JVM對Java記憶體模型的實現

在這裡插入圖片描述
所有原始型別(boolean,byte,short,char,int,long,float,double)的區域性變數都直接儲存線上程棧當中,對於它們的值各個執行緒之間都是獨立的。對於原始型別的區域性變數,一個執行緒可以傳遞一個副本給另一個執行緒,當它們之間是無法共享的。
堆區包含了Java應用建立的所有物件資訊,不管物件是哪個執行緒建立的,其中的物件包括原始型別的封裝類(如Byte、Integer、Long等等)。不管物件是屬於一個成員變數還是方法中的區域性變數,它都會被儲存在堆區。
一個區域性變數如果是原始型別,那麼它會被完全儲存到棧區。 一個區域性變數也有可能是一個物件的引用,這種情況下,這個本地引用會被儲存到棧中,但是物件本身仍然儲存在堆區。
對於一個物件的成員方法,這些方法中包含區域性變數,仍需要儲存在棧區,即使它們所屬的物件在堆區。 對於一個物件的成員變數,不管它是原始型別還是包裝型別,都會被儲存到堆區。Static型別的變數以及類本身相關資訊都會隨著類本身儲存在堆區。
在這裡插入圖片描述

Java記憶體模型帶來的問題

可見性問題

左邊CPU中執行的執行緒從主存中拷貝共享物件obj到它的CPU快取,把物件obj的count變數改為2。但這個變更對執行在右邊CPU中的執行緒不可見,因為這個更改還沒有flush到主存中:要解決共享物件可見性這個問題,我們可以使用java volatile關鍵字或者是加鎖
競爭問題:執行緒A和執行緒B共享一個物件obj。假設執行緒A從主存讀取Obj.count變數到自己的CPU快取,同時,執行緒B也讀取了Obj.count變數到它的CPU快取,並且這兩個執行緒都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU快取中。如果這兩個加1操作是序列執行的,那麼Obj.count變數便會在原始值上加2,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是並行的,不管是執行緒A還是執行緒B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,儘管一共有兩次加1操作。 要解決上面的問題我們可以使用java synchronized程式碼塊。

重排序

除了共享記憶體和工作記憶體帶來的問題,還存在重排序的問題:在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。
Java記憶體模型中的重排序
重排序型別
重排序分3種類型。
1)編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
3)記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

重排序與依賴性

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分為下列3種類型
在這裡插入圖片描述
上面3種情況,只要重排序兩個操作的執行順序,程式的執行結果就會被改變。

控制依賴性

在這裡插入圖片描述
flag變數是個標記,用來標識變數a是否已被寫入,在use方法中比變數i依賴if (flag)的判斷,這裡就叫控制依賴,如果發生了重排序,結果就不對了。

as-if-serial

不管如何重排序,都必須保證程式碼在單執行緒下的執行正確,連單執行緒下都無法正確,更不用討論多執行緒併發的情況,所以就提出了一個as-if-serial的概念,
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。(強調一下,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮。)但是,如果操作之間不存在資料依賴關係,這些操作依然可能被編譯器和處理器重排序。
在這裡插入圖片描述
1和3之間存在資料依賴關係,同時2和3之間也存在資料依賴關係。因此在最終執行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程式的結果將會被改變)。但1和2之間沒有資料依賴關係,編譯器和處理器可以重排序1和2之間的執行順序。
asif-serial語義使單執行緒下無需擔心重排序的干擾,也無需擔心記憶體可見性問題。

併發下重排序帶來的問題

在這裡插入圖片描述
這裡假設有兩個執行緒A和B,A首先執行init ()方法,隨後B執行緒接著執行use ()方法。執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入呢?答案是:不一定能看到。
由於操作1和操作2沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什麼效果?操作1和操作2做了重排序。程式執行時,執行緒A首先寫標記變數flag,隨後執行緒B讀這個變數。由於條件判斷為真,執行緒B將讀取變數a。此時,變數a還沒有被執行緒A寫入,這時就會發生錯誤!

當操作3和操作4重排序時會產生什麼效果?
在程式中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴性時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取並計算a*a,然後把計算結果臨時儲存到一個名為重排序緩衝(Reorder Buffer,ROB)的硬體快取中。當操作3的條件判斷為真時,就把該計算結果寫入變數i中。猜測執行實質上對操作3和4做了重排序,問題在於這時候,a的值還沒被執行緒A賦值。在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

解決在併發下的問題

記憶體屏障

Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序,從而讓程式按我們預想的流程去執行。
1、保證特定操作的執行順序。
2、影響某些資料(或則是某條指令的執行結果)的記憶體可見性。
編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化效能。插入一條Memory Barrier會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的資料,因此,任何CPU上的執行緒都能讀取到這些資料的最新版本。
JMM把記憶體屏障指令分為4類,解釋表格,StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支援該屏障(其他型別的屏障不一定被所有處理器支援)。

臨界區

臨界區內的程式碼可以重排序(但JMM不允許臨界區內的程式碼“逸出”到臨界區之外,那樣會破壞監視器的語義)。JMM會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,雖然執行緒A在臨界區內做了重排序,但由於監視器互斥執行的特性,這裡的執行緒B根本無法“觀察”到執行緒A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

Happens-Before

定義

在Java 規範提案中為讓大家理解記憶體可見性的這個概念,提出了happens-before的概念來闡述操作之間的記憶體可見性。對應Java程式設計師來說,理解happens-before是理解JMM的關鍵。JMM這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是程式執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關係本質上和as-if-serial語義是一回事。•as-if-serial語義保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結果不被改變。
在這裡插入圖片描述

應用

在這裡插入圖片描述