學習java編程藝術文章的儲存與整理第三章

摘要: 文字的搬運工,如下內容來源於網上,這裏只是儲存與方便本身學習程序員

《Java併發編程的藝術》第三章——Java內存模型

這篇文章內容有不少是jvm的知識範圍,而不是單純的講併發 編程

Java內存模型  
知識點:數組

Java內存模型的基礎。
重排序規則。
Java內存模型的設計。
同步原語(synchronized、volatile、final)的內存語義。
1.Java內存模型的基礎。
在命令式編程中,線程之間的通訊方式有兩種:
- 共享內存:即線程把數據寫到內存,其餘線程從內存讀取,進行隱式的通訊,但共享內存的同步必須依靠程序顯式的指定,Java就採起這種方式。
- 消息傳遞:線程之間發送及接收消息,進行顯式的通訊。因爲消息發送和接收存在順序關係,因此消息傳遞的同步的隱式進行的。緩存

 


如上圖,線程A與線程B進行通訊流程圖。線程A把本地內存A中更新過的共享變量刷新到住內存中,線程B到主內存中去讀取線程A以前已更新過的共享變量,從而達到線程間通訊的目的。安全

2.重排序
在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。重排序分3中類型:
- 編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。
- 指令級並行的重排序:現代處理器能夠將多條指令重疊執行。若是不存在數據依賴性(若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性),處理器能夠改變機器指令的執行順序。
- 內存系統的重排序:因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去可能存在亂序執行。多線程

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序:
併發

上圖1屬於編譯器重排序,二、3屬於處理器重排序。重排序會致使多線程程序出現內存可見性問題。對於編譯器,JMM(Java Memory Model)會禁止特定類型的編譯器重排序。而處理器重排序,JMM會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障,來保證可見性。
JMM把內存屏障指令分爲4類:
app

爲了保證重排序不會影響到程序的可見性,編譯器、runtime和處理器都必須遵照as-if-serial語義。as-if-serial語義:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序。
【備註】:這裏說的數據依賴性僅針對單個處理器中的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮。
demo:
框架

 


如上圖,A和C之間存在數據依賴性,B和C之間也存在數據依賴關係。所以在執行的指令序列中,C不能被重排序到A和B的前面,但A和B無數據依賴關係,能夠被重排序。
as-if-serial語義把單線程程序保護起來,所以會產生單線程是按照程序的順序來執行的錯覺。但一樣由於有了as-if-serial語義,咱們在單線程裏無需擔憂內存可見性問題。
雖然在單線程中依靠as-if-serial能夠保證可見性,但在多線程中,卻仍然存在由重排序引發的可見性問題:
jvm

假設有兩個線程A和B,A先執行writer()方法,隨後B執行reader()方法,在線程B執行操做4時,卻不必定能夠看到線程A在操做1對共享變量a的寫入。
由於操做1和操做2沒有數據依賴關係,因此編譯器和處理器能夠對這兩個操做重排序,同理,操做3和操做4一樣能夠被重排序。若操做1和操做2發生重排序:

很明顯,此時多線程程序語義被重排序破壞了!
若操做3和操做4發生重排序:

 


如上圖,操做3和操做4存在控制依賴關係。當代碼中存在控制依賴時,會影響指令序列執行的並行度。因此,編譯器和處理器會採用猜想執行來克服對並行度的影響:以處理器的猜想執行爲例,執行線程B的處理器能夠提早讀取並計算a*a,而後把計算結果臨時保存到一個名爲重排序緩衝(Recoder Buffer,ROB)的硬件緩存中,當操做3的條件判斷爲真時,就把該計算結果寫入變量i中。
3.JMM的設計
3.1 順序一致性內存模型
當程序未正確同步時,就可能存在數據競爭。JMM規範對數據競爭的定義爲:在一個線程中寫一個變量,在另外一個線程讀同一個變量,並且寫和讀沒有經過同步來排序。
當代碼中存在數據競爭時,程序的執行每每產生違反直覺的結果,若是一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。
JMM對正確同步的多線程程序的內存一致性作了以下保證:若是程序是正確同步的,程序的執行將具備順序一致性——即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。
【備註】:這裏的同步是指廣義上的同步,包括對經常使用同步原語(synchronized,volatile和final)的正確使用。
【備註】:順序一致性內存模型是一個理論參考模型,在設計時,處理器的內存模塊和編程語言的內存模型都會以順序一致性內存模型做爲參照。
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,他爲程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:
- 一個線程中的全部操做必須按照程序的順序來執行。
- (無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性內存模型中,每一個操做都必須原子執行且馬上對全部線程可見。
順序一致性內存模型的視圖以下:

 


在概念上,順序一致性模型有一個單一的全局內存,這個內存經過一個左右擺動的開關能夠鏈接到任意一個線程,同時每個線程必須按照程序的順序來執行內存讀/寫操做。如上圖所示,在任意時間點最多隻能有一個線程能夠鏈接到內存。當多個線程併發執行時,圖中的開關裝置能把全部線程的全部內存讀/寫操做串行化。
實例:假設有兩個線程A和B併發執行。A操做爲:A1->A2->A3。B操做爲:B1->B2->B3。若這兩個線程使用監視器鎖來正確同步,A線程執行後釋放監視器鎖,隨後B獲取同一個監視器鎖。那麼程序在順序一致性模型中的執行效果爲:

 


若這兩個線程沒有作同步,那麼程序在順序一致性模型中的執行效果爲:

 


未同步程序在順序一致性模型中雖然總體執行順序是無序的,但全部線程都只能看到一個一致的總體執行順序。由於順序一致性內存模型保證每一個操做必須當即對任意線程可見。但在JMM中就沒有這個保證。未同步程序在JMM中不但總體的執行順序是無序的,並且全部線程看到的操做執行順序也可能不一致。好比,在當前線程把寫過的數據緩存在本地內存中,在沒有刷新到主內存以前,這個寫操做僅對當前線程可見;從其餘線程的角度來觀察,會認爲這個寫操做根本沒有被當前線程執行。只有當前線程把本地內存中寫過的數據刷新到主內存以後,這個寫操做才能對其餘線程可見。
實例:

 


如上圖所示,程序已經正確的同步。假設A線程執行writer()方法後,B線程執行reader()方法。根據JMM規範,該程序的執行結果將與該程序在順序一致性模型中的執行結果相同。該程序在JMM與順序一致性內存模型的執行時序圖以下:

 


在JMM中,writer()與reader()方法內部是能夠重排序的,即便發生重排序,在監視器互斥特性的保證下,程序執行結果仍然不會改變。
綜上,咱們能夠得出:JMM在具體實現上的基本方針爲:在不改變(正確同步的)程序執行結果的前提下,儘量的爲編譯器和處理器的優化打開方便之門。
【備註】:JMM容許臨界區內的代碼能夠重排序,但不容許臨界區內的代碼「逸出」到臨界區以外,那樣會破壞監視器的語義。
而對於未同步或未正確同步的多線程程序,JMM只提供最小安全性:線程執行時讀取到的值,要麼是以前某個線程寫入的值,要麼是默認值,JMM保證線程讀操做讀取到的值不會無中生有。
未同步程序在JMM與順序一致性內存模型執行的差別:
- 順序一致性模型保證單線程內的操做按照程序的順序執行,但JMM不保證,可能發生重排序。
- 順序一致性模型保證全部線程只能看到一致的操做執行順序,而JMM不保證。
- JMM不保證對64位long/double型變量的寫操做具備原子性,而順序一致性模型保證對全部的內存讀/寫操做都具備原子性。
3.2 happens-before
在設計JMM時須要考慮一下兩個關鍵因素:
- 程序員對內存模型的使用。程序員但願內存模型易於理解、易於編程。
- 編譯器和處理器對內存模型的實現。但願內存模型對他們的舒束縛越少越好,這樣就能夠儘量多的優化來提升性能。
這就須要JMM找到一個平衡點:要爲程序員提供足夠強的內存可見性保證的同時,也要對編譯器和處理器的限制儘量的放鬆。
JMM的設計示意圖以下:

 


JMM對不一樣性質的重排序,採起不一樣的策略:
- 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
- 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不作要求,容許重排序。
【備註】:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。例如:若編譯器在細緻的分析後,認定一個鎖只會被單個線程訪問,則可消除這個鎖,這種策略對於volatile變量一樣適用。
happens-before是JMM最核心的概念。用來指定兩個操做之間的執行順序。這兩個操做能夠處於不一樣的線程。也就是說,能夠經過happens-before規則來保證跨線程的內存可見性。JMM向程序員提供的happens-before規則向程序員提供了足夠強的內存可見性保證。
《JSR-133:Java Memory Model and Thread Specification》對happens-before關係的定義以下:
- 若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。(JMM對程序員的承諾)
- 兩個操做之間存在happens-before關係,並不意味着Java平臺的具體實現必需要按照happens-before關係指定的順序來執行。若是重排序以後的執行結果,與按happens-before關係來執行的結果一致,那麼JMM也會容許。(JMM對編譯器和處理器重排序的約束原則)
【備註】:as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變。
《JSR-133:Java Memory Model and Thread Specification》定義了以下happens-before規則:
- 程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
- 監視器鎖原則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
- volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
- 傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
- start()規則:若是線程A執行操做ThreadB.start(),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做。
- join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。

4. 同步原語(synchronized、volatile、final)的內存語義。
4.1 volatile
volatile變量具備如下特性:
- 可見性:對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
- 原子性:對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操做不具備原子性。
經過volatile變量的寫-讀能夠實現線程之間的通訊。從內存語義的角度說,volatile的寫/讀與鎖的釋放/獲取有相同的內存效果。volatile寫和鎖的釋放有相同的內存語義,volatile讀和鎖的獲取有相同的內存語義。
實例:

假設線程A執行writer()方法以後,線程B執行reader()方法。能夠創建以下happens-before關係:
- 根據程序次序規則,1 happens-before 2;3 happens-before 4;
- 根據volatile規則,2 happens-befored 3;
- 根據happens-before的傳遞性規則,1 happens-befored 4;

 


如上圖,每個箭頭連接的兩個節點,表明一個happens-before 關係,黑色箭頭表示程序順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens-before 保證。
若A線程寫一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量以前全部可見的共享變量,在B線程讀同一個volatile變量後,將當即變得對B線程可見。
volatile寫的內存語義:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。
volatile讀的內存語義:當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。
volatile內存語義是怎麼實現的?
爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。JMM針對編譯器制定的volatile重排序規則表以下:

 


根據上圖咱們能夠總結出:
- 當第一個操做是volatile讀時,第二個操做不管是什麼都不容許重排序。
- 當第一個操做是volatile寫時,第二個操做爲volatile讀時,不能重排序。
- 當第二個操做是volatile寫時,第一個操做不管是什麼都不容許重排序。
爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來講,發現一個最優佈置來最小化屏障的總數幾乎不可能,因此JMM採起保守策略。
volatile寫時插入內存屏障以下:

 


volatile讀時插入內存屏障以下:

 


【備註】在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器能夠根據具體狀況省略沒必要要的屏障。
4.2 synchronized 鎖
鎖是Java併發編程中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可讓釋放鎖的線程向獲取同一個鎖的線程發送消息。
實例:

假設線程A執行writer()方法,隨後線程B執行reader()方法。則咱們能夠創建以下happens-before規則:
- 根據程序次序規則:1 happens-before 2,2 happens-before 3;4 happens-before 5;5 happens-before 6;
- 根據監視器鎖規則:3 happens-before 4;
- 根據happens-before的傳遞性,2 happens-before 5;
關係圖以下:

黑色箭頭表示程序順序規則;橙色箭頭表示監視器鎖規則;藍色箭頭表示組合這些規則後提供的保證。
在線程A釋放鎖以後,隨後線程B獲取同一個鎖。經過2 happens-before 5的保證,線程A在釋放鎖以前全部可見的共享變量,在線程B獲取同一個鎖以後,將馬上變得對B線程可見。
獲取鎖的內存語義:當線程獲取鎖時,JMM會把該線程的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須從住內存中讀取共享變量。
釋放鎖的內存語義:當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到住內存中。
鎖內存語義是怎麼實現的?
下面經過分析ReentrantLock源代碼來講明鎖內存語義的具體實現機制。

如上圖所示,調用ReentrantLock lock()方法獲取鎖,調用unlock()方法釋放鎖。
ReentrantLock的實現依賴於Java同步器框架AbstractQueuedSynchronizer(簡稱 AQS)。AQS使用一個整形的volatile變量state來維護同步狀態。
ReentrantLock分爲公平所和非公平鎖,以公平鎖爲例,其加鎖過程爲:
1>ReentrantLock:lock();
2>FairSync:lock();
3>AbstractQueuedSynchronizer:acquire(int arg)。
4>ReentrantLock:tryAcquire(int acquires);
在第4步真正開始加鎖,源代碼爲:

而其解鎖過程爲:
1>ReentrantLock:unlock();
2>AbstractQueuedSynchronizer:release(int arg);
3>Sync:tryRelease(int releases);
在第3步真正開始釋放鎖,源代碼爲:

經過源代碼能夠看到,公平鎖在獲取鎖時先讀volatile變量,而釋放鎖時最後寫volatile變量。根據happens-before規則,釋放鎖的線程在寫volatile變量以前可見的共享變量,在獲取鎖的線程讀取同一個volatile變量後將當即變得對獲取鎖的線程可見。(第一個操做爲讀volatile變量時,不管第二個操做是什麼,都不容許重排序。當第二個操做是寫volatile變量時,不管第一個操做是什麼,都不容許重排序。)
而非公平鎖的獲取過程爲:
1>RenntrantLock:lock();
2>NofairSync:lock();
3>AbstractQueuedSynchronizer:compareAndSetState(int expect,int update);
在第3部開始加鎖,源代碼爲:

該方法以原子操做的方式更新state變量。此操做具備volatile讀和volatile寫的內存語義。
CAS是如何同時具備volatile讀和volatile寫的內存語義的?
以sun.misc.Unsafe類下compareAndSwapInt(Object o,long offset)方法爲例,底層是一個本地方法的調用,程序會根據當前處理器的類型來決定是否爲其添加lock前綴。若是程序在多處理器上運行,則添加,不然,省略。而對於lock前綴來說,有如下做用:
- 確保對內存的讀-改-寫操做原子執行。
- 禁止該指令,與以前和以後的讀和寫指令重排序。
- 把寫緩衝區中的全部數據刷新到內存中。
因此CAS同時具備volatile讀和volatile寫的內存語義。
*【備註】:咱們能夠總結出,鎖釋放-獲取的內存語義的實現至少有如下兩種方式:
- 利用volatile變量的寫-讀所具備的內存語義。
- 利用CAS所附帶的volatile讀和volatile寫的內存語義。*
【備註】:AQS是很重要的一個東西,concurrent中不少基礎類都是以此來實現,後續會開一篇博客,進一步講解AQS。
附一張concurrent包實現示意圖:

4.3 final域的內存語義
對於final域,編譯器和處理器要遵照兩個重排序規則:
- 在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
- 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操做之間不能重排序。
實例:

 


假設線程A執行writer()方法,隨後另外一個線程B執行reader()方法。執行writer()方法包含兩個步驟:
- 構造一個FinalExample類型的對象。
- 把這個對象的引用賦值給引用變量obj。
假設線程B讀對象引用和讀對象的成員域之間沒有重排序,則其可能的執行時序爲:

 


如上圖所示,寫普通域的操做被編譯器重排序到了構造函數以外,讀線程B錯誤地讀取了普通變量i初始化以前的值。而寫final域的操做,被寫final域的重排序規則「限定」在了構造函數以內,讀線程B正確地讀取了final變量初始化以後的值。寫final域的重排序規則能夠確保:在對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,而普通域不具備這個保障。
而對於讀final域來講,在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操做。由於初次讀對象引用與初次讀該對象包含的final域,這兩個操做之間存在間接依賴關係。
假設寫線程A沒有發生任何重排序,同時程序在不遵照間接依賴的處理器上執行,則下圖是一種可能的執行時序。

 


如上圖所示,讀對象的普通域的操做被處理器重排序到讀對象引用以前。讀普通域時,該域尚未被線程A寫入,這是一個錯誤的讀取操做。而讀final域的重排序規則會保證,對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,因此讀final域並不受影響。讀final域的重排序規則能夠確保:在讀一個對象的final域以前,必定會先讀包含這個final域的對象的引用。

上面咱們討論的是final域是基礎數據類型,若是final域是引用類型,將會有什麼效果?
以下圖實例代碼:

 


對於引用類型,寫final域的重排序規則對編譯器和處理器增長了以下約束:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。(上圖6和2不能被重排序)
假設首先線程A執行writerOne()方法,執行完後線程B執行writerTwo()方法,執行完後線程C執行reader()方法。則下圖是一種可能的線程執行時序:

 


JMM能夠確保讀線程C至少能看到寫線程A在構造函數中對final引用對象的成員域的寫入。而寫線程B對數組元素的寫入,讀線程C可能看獲得,也可能看不到。(若是要確保看的到,須要使用同步原語lock或volatile來保證內存可見性)

爲何final引用不能從構造函數內」溢出「?
前面咱們提到,寫final域的重排序規則能夠確保:在引用變量爲任意線程可見以前,該引用變量指向的對象的final域已經在構造函數中被正確初始化過了。其實,要獲得這個效果,還須要一個保證:對象引用不能在構造函數中」溢出「
實例:

 


假設線程A執行writer()方法,另外一個線程B執行reader()方法。這裏的操做2使得對象還未完成構造前就爲線程B可見。即便操做2是構造函數的最後一步,且程序中操做2排在操做1後面,執行reader()方法的線程仍然可能沒法看到final域被初始化後的值,由於操做1和操做2可能發生重排序。(寫final域重排序規則並不能禁止這一點,由於他只會在寫final域以後,構造函數return以前,插入一個StoreStrore屏障,這個屏障只能保證final域的寫不會重排序到構造函數以外)。下圖爲可能的執行時序圖:

 


如上圖,在構造函數返回前,被構造對象的引用不能爲其餘線程可見,由於此時final域可能尚未被初始化。在構造函數返回後,任意線程都將保證能看到fianl域正確初始化以後的值。

【備註】:本文圖片均摘自《Java併發編程的藝術》·方騰飛,若本文有錯或不恰當的描述,請各位不吝斧正。謝謝!--------------------- 做者:李鵬DoubleS 來源:CSDN 原文:https://blog.csdn.net/qq_24982291/article/details/78681590

相關文章
相關標籤/搜索