JAVA併發編程-10-JMM和底層實現原理

上一篇看這裏:JAVA併發編程-9-併發安全

一、JAVA內存模型JMM

1、併發編程領域的關鍵問題

線程之間的通信
線程的通信是指線程之間以何種機制來交換信息。在編程中,線程之間的通信機制有兩種,共享內存和消息傳遞。
在共享內存的併發模型裏,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信,典型的共享內存通信方式就是通過共享對象進行通信。
在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信,在java中典型的消息傳遞方式就是wait()和notify()。

線程之間的同步
同步是指程序用於控制不同線程之間操作發生相對順序的機制。
在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。
在消息傳遞的併發模型裏,由於消息的發送必須在消息的接收之前,因此同步是隱式進行的。

Java的併發採用的是共享內存模型

2、現代計算機物理上的內存模型

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。

3、Java內存模型

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

在這裏插入圖片描述

4、JVM對Java內存模型的實現

在JVM內部,Java內存模型把內存分成了兩部分:線程棧區和堆區

JVM中運行的每個線程都擁有自己的線程棧,線程棧包含了當前線程執行的方法調用相關信息,我們也把它稱作調用棧。隨着代碼的不斷執行,調用棧會不斷變化。
在這裏插入圖片描述

線程棧還包含了當前方法的所有局部變量信息。一個線程只能讀取自己的線程棧,也就是說,線程中的本地變量對其它線程是不可見的。即使兩個線程執行的是同一段代碼,它們也會各自在自己的線程棧中創建局部變量,因此,每個線程中的局部變量都會有自己的版本。

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

堆中的對象可以被多線程共享。如果一個線程獲得一個對象的應用,它便可訪問這個對象的成員變量。如果兩個線程同時調用了同一個對象的同一個方法,那麼這兩個線程便可同時訪問這個對象的成員變量,但是對於局部變量,每個線程都會拷貝一份到自己的線程棧中。
在這裏插入圖片描述

5、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內存模型中的重排序

1、重排序的類型

重排序分3種類型。
1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
3)內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

2、重排序與依賴性

數據依賴性

如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分爲下列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語義使單線程下無需擔心重排序的干擾,也無需擔心內存可見性問題。

3、併發下重排序帶來的問題

在這裏插入圖片描述
這裏假設有兩個線程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語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。

4、解決在併發下的問題

內存屏障
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關係保證正確同步的多線程程序的執行結果不被改變。

1、Happens-Before規則

在這裏插入圖片描述
無需任何同步手段就可以保證的。

  1. 程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。
  2. 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  3. volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  4. 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  5. start()規則:如果線程A執行操作ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。
  6. join()規則:如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。
  7. 線程中斷規則:對線程interrupt方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生。

2、volatile的內存語義

可以把對volatile變量的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步。
在這裏插入圖片描述
在這裏插入圖片描述
volatile變量自身具有下列特性:

  • 可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

volatile寫的內存語義如下:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。
在這裏插入圖片描述

volatile讀的內存語義如下:當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

在這裏插入圖片描述

3、volatile內存語義的實現

volatile重排序規則表:

在這裏插入圖片描述
JMM對volatile的內存屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的後面插入一個StoreLoad屏障。

在這裏插入圖片描述
在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個LoadStore屏障。
在這裏插入圖片描述

4、final的內存語義

編譯器和處理器要遵守兩個重排序規則:

  • 在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
  • 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

final域爲引用類型:

  • 增加了如下規則:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序

final語義在處理器中的實現:

  • 會要求編譯器在final域的寫之後,構造函數return之前插入一個StoreStore障屏。
  • 讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障。

5、鎖的內存語義

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
在這裏插入圖片描述

當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。
在這裏插入圖片描述

6、volatile的實現原理

有volatile變量修飾的共享變量進行寫操作的時候會使用CPU提供的Lock前綴指令。

  • 將當前處理器緩存行的數據寫回到系統內存
  • 這個寫回內存的操作會使在其他CPU裏緩存了該內存地址的數據無效

7、synchronized的實現原理

使用monitorenter和monitorexit指令實現的

  • monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處
  • 每個monitorenter必須有對應的monitorexit與之配對
  • 任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態

8、各種鎖

鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。

偏向鎖:大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。無競爭時不需要進行CAS操作來加鎖和解鎖

輕量級鎖:無競爭時通過CAS操作來加鎖和解鎖

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章