第十一章 多線程編程的硬件基礎與java內存模型--《java多線程編程實戰指南-核心篇》

目錄

 

填補處理器與內存之間的鴻溝:高速緩存

 數據世界的交通規則:緩存一致性協議

硬件緩衝區:寫緩衝器和無效化隊列

存儲轉發

再探內存重排序

再探可見性

基本內存屏障

java同步機制與內存屏障

volatile關鍵字的實現

synchronized關鍵字的實現

java虛擬機對內存屏障使用的優化

java內存模型

什麼是java內存模型(java內存模型可以參見深入理解JVM第12章)


填補處理器與內存之間的鴻溝:高速緩存

爲了彌補處理器與主內存處理器能力之間的鴻溝,硬件設計者在主內存和處理器之間引入高速緩存,如下圖所示:

 高速緩存是一種存取速度比主內存大而容量遠比主內存小的存儲部件,每個處理器都有其高速緩存。引入高速緩存後,處理器在執行內存讀、寫操作的時候並不直接與主內存打交道,而是通過高速緩存進行的。高速緩存相當於一個由硬件實現的容量極小的散列表,其key是一個內存地址,其值是內存數據的副本或者準備寫入內存的數據。從內存結構來看,高速緩存相當於一個拉鍊散列表,他包含若干桶,每個桶又可以包含緩存條目,結構有點類似於hashmap,如下圖所示:

 緩存條路可被進一步劃分爲Tag、Data Block以及Flag這三個部分,如下圖所示。其中,Data Block也被稱爲緩存行,它是高速緩存與主內存之間的數據交換最小單元,用於存儲從內存中讀取的或者準備寫往內存的數據。Tag則包含了緩存行中數據相應的內存地址的部分信息。Flag用於表示相應緩存行的狀態信息。緩存行的容量(也被稱爲緩存行寬度)通常是2的倍數,其大小在16-256字節之間。

處理器在執行內存訪問操作時會將相應的內存地址解碼。內存地址的解碼結果包括tag、index以及offset這三部分數據。其中,index相當於桶編號,它可以用來定位內存地址對應的桶;一個桶可能包含多個緩存條目,tag相當於緩存條目的相對編號,其作用在於用來與同一個桶中的各個緩存條目中的tag部分進行比較,以定位一個具體的緩存條目;一個緩存條目中的緩存行可以用來存儲多個變量,offset是緩存行內的位置偏移,其作用在於確定一個變量在一個緩存行中的存儲起始位置。根據這個內存地址的解碼結果,如果高速緩存子系統能夠找到相應的緩存行並且緩存行所在的緩存條目的Flag表示相應緩存條目是有效的,那麼我們就稱相應的內存操作產生了緩存命中;否則,我們就稱相應的內存操作產生了緩存未命中。

具體來說,緩存未命中包括讀未命中和寫未命中,分別對應內存讀和寫操作。當讀未命中產生時,處理器所需要讀取的數據會從主內存中加載並被存入相應的緩存行之中。這個過程會導致處理器停頓而不能執行其它指令,這不利於發揮處理器的處理能力。因此,從性能的角度來看我們應該儘可能的減少緩存未命中。

現代處理器一般具有多個層次的高速緩存,如下圖所示。在這個層級中,相應的高速緩存通常被稱爲一級緩存,二級緩存,三級緩存等。一級緩存可能直接被集成在處理器的內核裏,因此其訪問效率非常高。

 數據世界的交通規則:緩存一致性協議

多個線程併發訪問同一個變量的時候,這些線程的執行處理器上的高速緩存各自都會保留一份該共享變量的副本,這就帶來一個問題-一個處理器對其副本數據進行更新之後,其他處理器如何察覺到該更新並做出適當反應,以確保這些處理器後續讀取該共享變量時能夠讀取到這個更新。這就是緩存一致性問題,其實質就是如何防止讀髒數據和丟失更新的問題。爲了解決這個問題,處理器之間需要一種通信機制--緩存一致性協議。

MESI協議是一種廣爲使用的緩存一致性協議。MESI協議對內存數據訪問的控制類似於讀寫鎖,它使得針對同一地址的讀內存操作是併發的,而針對同一地址的寫操作是獨佔的,即針對同一內存地址進行的寫操作在任意一個時刻只能夠由一個處理器執行。在MESI協議中,一個處理器往內存中寫數據時必須持有該數據的所有權。

爲了保障數據的一致性,MESI將緩存條目的狀態劃分爲Modified,Exclusive,Shared和Invalid這四種,並在此基礎上定義了一組消息用於協調各個處理器的讀、寫內存操作。

MESI協議中一個緩存條目的Flag值有以下四種可能:

  • Invalid(無效的,記爲1)。該狀態下表示相應緩存行中不包含任何內存地址對應的有效副本數據。該狀態是緩存條目的初始狀態。
  • Shared(共享的,記爲S)。該狀態表示相應緩存行包含相應內存地址所對應的副本數據。並且,其他處理器上的高速緩存中也可能包含相同內存地址對應的副本數據。因此,一個緩存條目的狀態如果爲Shared,並且其他處理器上也存在Tag值與該緩存條目的Tag值相同的緩存條目,那麼這些緩存條目的狀態也爲Shared,處於該狀態的緩存條目,其緩存行中包含的數據與主內存包含的數據一致。
  • Exclusive(獨佔的,記爲E)。該狀態表示相應緩存行包含相應內存地址所對應的副本數據。並且,該緩存行以獨佔的方式保留了相應內存地址的副本數據,即其他所有處理器上的高速緩存當前都不保留該數據的有效副本。處於該狀態的緩存條目,其緩存行中包含的數據與主內存中包含的數據一致。
  • Modified(更改過的,記爲M)。該協議表示相應緩存行包含對相應內存地址所做的更新結果數據。由於MESI協議中的任意一個時刻只能夠有一個處理器對同一內存地址對應的數據進行更新,因此在多個處理器上的高速緩存中Tag值相同的緩存條目中,任意一個時刻只能夠有一個緩存條目處於該狀態。處於該狀態的緩存條目,其緩存行中包含的數據與主內存中包含的數據不一致。

 MESI協議定義了一組消息用於協調各個處理器的讀、寫內存操作,如下圖所示。比照HTTP協議,我們可以將MESI協議中的消息分爲請求消息和響應消息。處理器在執行內存讀、寫操作時在必要的情況下會往總線(Bus)中發送特定的請求消息,同時每個處理器還嗅探(也稱攔截)總線中由其他處理器發出的請求消息並在一定條件下往總線中回覆相應的響應消息。

下面看看使用MESI協議的處理器是如何實現內存讀、寫操作的。假設內存地址A上的數據S是處理器Processor0和Processor1可能共享的數據。

下面討論在Processor0上讀取數據S的實現。Processor0會根據地址A找到對應的緩存條目,並讀取該緩存條目的Tag和Flag值。爲了方便討論,這裏我們不討論Tag值的匹配問題。Processor0找到的緩存條目的狀態如果是M、E或者S,那麼該處理器可以直接從相應的緩存行中讀取地址A所對應的數據,而無需往總線中發送任何消息。Processor0找到的緩存條目的狀態如果爲I,則說明該處理器的高速緩存中並不包含S的有效副本數據,此時Processor0需要往總線發送Read消息以讀取地址A對應的數據,而其他處理器Processor1或者主內存則需要回復Read Response以提供相應的數據,如下圖所示:

Processor0接收到Read Response消息時,會將其中攜帶的數據(包含數據S的數據塊)存入相應的緩存行並將相應緩存條目的狀態更新爲S。Processor0接收到的Read Response消息可能來自主內存也可能來自其他處理器(Processor1)。Processor1會嗅探總線中由其他處理器發送的消息。Processor1嗅探到Read消息的時候,會從該消息中取出待讀取的內存消息,並根據該地址在高速緩存中查找對應的緩存條目。如果Processor1找到的緩存條目的狀態不爲I,則說明該處理器的高速緩存中有待讀取的數據的副本,此時Processor1會構造相應的Read Response消息並將相應緩存行所存儲的整塊數據(而不僅僅是Processor0所請求的數據S)塞入該消息。如果Processor1找到的相應緩存條目的狀態是M,那麼Processor1可能在往總線發送發送Read Response消息前將相應緩存行中的數據寫入主內存。Processor1往總線發送發送Read Response之後,相應緩存條目的狀態會被更新爲S。如果Processor1找到的高速緩存條目的狀態爲I,那麼Processor0所接收到的Read Response消息就來自於主內存。可見,在Processor0讀取內存的時候,即便Processor1對相應的內存數據進行了更新且這種更新還停留在Processor1的高速緩存中而造成高速緩存數據與主內存中的數據不一致,在MESI消息的協調下這種不一致也並不會導致Processor0讀取到一個過時的舊值。

下面討論Processor0往地址A寫數據的實現。任何一個處理器執行內存寫操作時必須擁有相應數據的所有權。在執行內存寫操作時,Processor0會先根據內存地址A找到相應的緩存條目。Processor0所找到的緩存條目的狀態若爲E或者M,則說明該處理器已經擁有相應數據的所有權,此時該處理器可以直接將數據寫入相應的緩存行並將相應緩存條目的狀態更新爲M。Processor0所找到的緩存條目的狀態如果不爲E、M,則該處理器需要往總線發送Invalidate消息以獲得數據的所有權。其他處理器接收到Invalidate消息後會將其高速緩存中相應的緩存條目狀態更新爲I(相當於刪除相應的副本數據)並回復Invalidate Acknowledge消息。發送Invalidate消息的處理器(即內存寫操作的執行處理器),必須在接收到其他所有處理器回覆的所有Invalidate Acknowledge消息之後再將數據更新到相應的緩存行中,如下圖所示:

Processor0所找到的緩存條目的狀態若爲S,則說明Processor1上的高速緩存可能也保留了地址A對應的數據副本(場景1),此時Processor0需要往總線發送Invalidate消息。Processor0在接受到其他所有處理器所回覆的Invalidate Acknowledge消息之後會將相應的緩存條目的狀態更新爲E,此時Processor0獲得了地址A上數據的所有權。接着,Processor0便可以將數據寫入相應的緩存行,並將相應的緩存條目的狀態更新爲M。Processor0所找到的緩存條目的狀態若爲I,則表示該處理器不包含地址A對應的有效副本數據(場景2),此時Processor0需要往總線發送Read Invalidate消息。Processor0在接收到Read Resonse消息以及其他所有處理器所回覆的Invalidate Acknowledge消息之後,會將相應緩存條目的狀態更新爲E,這表明該處理器已經獲得相應數據的所有權。接着,Processor0便可以往相應的緩存行中寫入數據了並將相應緩存條目的狀態更新爲M。其他處理器在接收到Invalidate消息或者Read Invalidate消息之後,必須根據消息中包含的內存地址在該處理器的高速緩存中查找相應的高速緩存條目。若Processor1所找到的高速緩存條目的狀態不爲I(場景2),那麼Processor1必須將相應緩存條目的狀態更新爲I,以刪除相應的副本數據並給總線回覆Invalidate Acknowledge消息。可見,Invalidate消息和Invalidate Acknowledge消息使得針對同一個內存地址的寫操作在任意一個時刻只能有一個處理器執行,從而避免了多個處理器同時更新同一數據可能導致的數據不一致問題。

從上述例子來看,在多個線程共享變量的情況下,MESI協議已經能夠保障一個線程對共享變量的更新對其他處理器上運行的線程來說是可見的;既然如此,第二章所說的可見性又可以存在?這需要從寫緩衝器和無效化隊列的角度來解釋。

硬件緩衝區:寫緩衝器和無效化隊列

MESI協議解決了緩存一致性問題,但是其自身也存在一個性能弱點--處理器執行寫內存操作時,必須等待其他所有處理器將其高速緩存中的相應副本數據刪除並接受到這些處理器所回覆的Invalidate Acknowledge/Read Response消息之後才能將數據寫入高速緩存。爲了規避和減少這種等待造成的寫操作的延遲,硬件設計者引入了寫緩衝器和無效化隊列,如下圖所示:

寫緩衝器(Store Buffer)是處理器內部的一個容量比高速緩存還小的私有高速存儲部件,每個處理器都有其寫緩衝器,寫緩衝器內部可能包含若干條目。一個處理器無法讀取另一個處理器上的寫緩衝器中的內容。

引入寫緩衝器之後,處理器在執行寫操作時會做這樣的處理:如果相應的緩存條目狀態爲E或者M,那麼處理器可能會直接將數據寫入相應的緩存行而無需發送任何消息;如果相應的緩存條目狀態爲S,那麼處理器會先將寫操作的相關數據(包括數據和待操作的內存地址)存入寫緩衝器的條目中,併發送Invalidate消息;如果相應的緩存條目狀態爲I,我們就稱相應的寫操作遇到了寫未命中,那麼此時處理器會先將寫操作相關數據存入寫緩衝器的條目之中,併發送Read Invalidate消息。我們知道在其他所有處理器的高速緩存都未保存指定地址的副本數據的情況下,Read消息回覆者是主內存,也就是說Read消息可能導致內存讀操作。因此,寫未命中的開銷是比較大的,內存寫操作的執行處理器再將寫操作的相關數據寫入寫緩衝器之後便認爲該寫操作已經完成,即該處理器並不等待其他處理器返回Invalidate Acknowledge/Read Response消息而是繼續執行其他指令(比如執行讀操作)。一個處理器接收到其他處理器所回覆的針對同一個緩存條目的所有Invalidate Acknowledge消息的時候,該處理器會將寫緩衝器中針對相應地址的寫操作的結果寫入相應的緩存行中,此時寫操作對於其他執行處理器之外的其他處理器來說纔算是完成。

由此可見,寫緩衝器的引入使得處理器在執行寫操作的時候可以不等待Invalidate  Acknowledge消息,從而減少了寫操作的延時,這使得寫操作的執行處理器在其他處理器回覆Invalidate Acknowledge/Read Response消息這段時間內能夠執行其他指令,從而提高了處理器的指令執行效率。

引入無效化隊列之後,處理器在接受到Invalidate消息之後並不刪除消息中指定地址對應的副本數據,而是將消息存入無效化隊列之後並不刪除消息中指定地址對應的副本數據,而是將消息存入無效化隊列之後就回復Invalidate Acknowledge消息,從而減少了寫操作執行處理器所需的等待時間。

寫緩衝器和無效化隊列的引入又引入了內存重排序和可見性問題。

存儲轉發

引入寫緩衝器之後,處理器在執行讀操作的時候不能根據相應的內存地址直接讀取相應緩存行中的數據作爲該操作的結果。這是因爲一個處理器在更新一個變量之後緊接着又讀取該變量的值的時候,由於該處理器先前對該變量的更新結果可能仍然還停留在寫緩衝器之中,因此該變量相應的內存地址所對應的緩存行中存儲的值是該變量的舊值。這種情況下爲了避免讀操作所返回的結果是一箇舊值,處理器在執行讀操作的時候會根據相應的內存地址查詢寫緩衝期。如果寫緩衝器存在相應的條目,那麼該條目所代表的寫操作的結果數據就會直接作爲該讀操作的結果返回;否則,處理器纔會從高速緩存中讀取數據。這種處理器直接從磁軛緩衝器中讀取數據來實現內存讀操作的技術被稱爲存儲轉發。存儲轉發使得寫操作的執行處理器能夠在不影響該處理器執行讀操作的情況下將寫數據的結果存入寫緩衝器。

再探內存重排序

寫緩衝器和無效化隊列都可能導致內存重排序。

寫緩衝器可能導致StoreLoad重排序(Stores Reordered After Loads)。StoreLoad重排序是絕大多處理器都允許的一種內存重排序。假設處理器Processor0和Processor1上的兩個線程都未使用任何同步措施而各自按照程序順序並依照表11-4所示的線程交錯順序執行。其中變量X、Y爲共享變量,其初始值均爲0,r1、r2爲局部變量。當Processor0上的線程執行到L2時,雖然在此之前S3已經被Processor1執行完畢,但是由於S3的執行結果可能仍然還停留在Processor1的寫緩衝器中,而一個處理器無法讀取到另外一個處理器的寫緩衝器中的內容,因此Processor0此時讀取到的Y的值仍然是其高速緩存中存儲的該變量的初始值0。同理,Processor1執行到L4時所讀取到變量X的值也可能是該變量的初始值0。因此,從Processor1的角度來看,Processor1執行L4的那一刻Processor0已經執行了L2而S1卻像是尚未被執行,即Processor1對Processor0執行的兩個操作的感知順序是L2-->S1,也就是說此時寫緩衝器導致了S1(寫操作)被重排序到了L2(讀操作)之後。

StoreLoad重排序可能導致某些算法失效。例如,Peterson算法中兩個線程的操作序列與表11-4類似,因此StoreLoad重排序就可能導致該算法失效。

寫緩衝器可能導致StoreStore重排序(Stores Reordered After Stores)。假設處理器Processor0和Processor1上的兩個線程未使用任何同步措施而各自按照程序順序並依照表11-5所示的線程交錯順序執行。其中變量data、ready爲共享變量,其初始值分別爲0和false。假設Processor0執行S1、S2時該處理器的高速緩存中包含變量ready的副本但不包含變量data的副本,那麼S1的執行結果會先被存入寫緩衝器而S2的執行結果會直接被存入高速緩存(因爲寫緩衝器中沒有該變量的副本)。L3被執行時S2對ready的更新通過一致性協議可以被Processor1讀取到,於是,由於ready值已經變爲true,因此Processor1繼續執行L4.L4被執行的時候,由於S1對data的更新結果可能停留在Processor0的寫緩衝器之中,因此Processor1此時讀取到的變量data的值可能仍然是其初始值0,即L4的輸出結果可能仍然是0而不是Processor1所期望的新值(Processor0更新之後的值)。從Processor1的角度來看,這就造成了一種現象--S2像是先於S1被執行,即S1(寫操作)被重排序(內存重排序)到S2(寫操作)之後。同樣StoreStore重排序也可能導致某些算法失效。

另外,某些處理器爲了充分利用總線帶寬以提高將寫緩衝器中的內容沖刷(寫入)到高速緩存的效率,會將針對連續內存地址的寫操作併入同一個寫緩衝器條目中,這種處理就被稱爲寫合併。寫合併也可能導致StoreStore重排序。

無效化隊列可能導致LoadLoad重排序(Loads Reordered After Loads)。假設處理器Processor0和Processor1上的兩個線程未使用任何同步措施而各自按照程序順序並依照表11-5所示的線程交錯順序執行。其中變量data、ready爲共享變量,其初始值分別爲0和false,進一步假設Processor0的高速緩存中存有變量data和ready的副本,Processor1僅存有變量data的副本而未存有變量ready的副本。那麼,Processor0和Processor1有可能按照如下順序執行一系列操作:

①Processor1執行S1。此時由於Processor1上也存有變量data的副本,因此Processor0會發出Invalidate消息並將S1的操作結果存入寫緩衝器。

②Processor1接收到Processor0發出的Invalidate消息時將該消息存入無效化隊列並回復Invalidate Acknowledge消息。

③Processor0接收到Invalidate Acknowledge消息,隨即將S1的操作結果寫入高速緩存。然後,Processor0執行S2。此時由於只有Processor0上存有變量ready的副本,因此Processor0無需發送任何消息,直接將S2的操作結果存入高速緩存即可。

④Processor1執行L3.此時由於Processor1的高速緩存中並沒有存儲變量ready的副本,因此Processor1會發出一個Ready消息。

⑤Processor0接收到Processor1發出的Read消息並回復Read Response消息。由於此時Processor0已經執行過S2,因此該Read Response消息包含的ready變量值爲true。

⑥Processor1接收到Read Response消息並從中取出ready變量的新值(true),此時L3中的循環語句可以結束。

⑦Processor1執行L4。此時,由於Processor0爲了更新變量data而發出的Invalidate消息可能仍然還停留在Processor1的無效化隊列中,因此Processor1從其高速緩存中讀取的變量仍然還停留在Processor1的無效化隊列中,因此Processor1從其高速緩存中讀取的變量data的值仍然是其初始值。因此,L4所打印的變量值可能是一箇舊值。

由此可見,儘管Processor0對共享變量data、ready的更新是按照程序順序先後到達高速緩存的,但是由於無效化隊列的作用Processor1像是在ready變量不爲true的情況下提前讀取了變量data的值,然而,程序的實際處理邏輯是僅在ready變量之爲true的情況下才讀取變量data,因此這裏Processor1實際讀取到的變量(data)值是一箇舊值。也就是說,從Processor0的角度來看,L4(讀操作)被重排序到了L3(讀操作)之前。可見,LoadLoad重排序會導致類似StroeStore重排序的效果。

再探可見性

寫緩衝器是處理器內部的私有存儲部件,一個處理器中的寫緩衝器所存儲的內容是無法被其他處理器鎖讀取的。因此,一個處理器上運行的線程更新了一個共享變量之後,其他處理器上運行的線程再來讀取該變量時這些線程可能仍然無法讀取到前一個線程對該變量所做的更新,因爲這個更新可能還停留在前一個線程所在的處理器上的寫緩衝器之中。這種線程就是前面章節所說的可見性問題。因此,我們說寫緩衝器是可見性問題的硬件根源。爲了使一個處理器上運行的線程對共享變量所做的更新可以被其他處理器上運行的其他線程所讀取,我們必須將寫緩衝器中的內容寫入其所在的處理器上的高速緩存之中,從而使得該更新在緩存一致性協議的作用下可以被其他處理器讀取到。實現着一點就是前面章節所說的保證一個處理器上運行的線程對其共享變量所做的更新可以被其他處理器(及其上運行的線程)同步。處理器在一些特定條件下(比如寫緩衝器、I/O指令被執行)會將寫緩衝器排空或者沖刷,即將寫緩衝器中的內容寫入到高速緩存,但是從程序對一個或者一組變量更新的角度來看,處理器本身無法保證這種沖刷對程序來說是及時的。因此,爲了保證一個處理器對共享變量所做的更新可以被其他處理器同步,編譯器等底層系統需要藉助一類被稱爲內存屏障的特殊指令。內存屏障中的存儲屏障(Store Barrier)可以使執行該指令的處理器沖刷其寫緩衝器。

然而,沖刷寫緩衝器只是解決了可見性問題的一半。因爲可見性問題的另一半是無效化隊列導致的。無效化隊列的引入本身也會導致新的問題--處理器在執行內存讀取操作前如果沒有根據無效化隊列中的內容將該處理器上的高速緩存中的相關副本數據刪除,那麼就可能導致該處理器讀取到的數據是過時的舊數據,從而使得其他處理器所做的更新丟失。因此,爲了使一個處理器上運行的線程能夠讀取到另一個處理器上運行的線程對共享變量所做的更新,該處理器必須先根據無效化隊列中存儲的Invalidate消息刪除其高速緩存中的相應副本數據,從而使其他處理器上運行的線程對共享變量所做的更新在緩存一致性協議的作用下能夠被同步到該處理器的高速緩存中。內存屏障中的加載屏障(Load Barrier)正是用來解決這個問題的。加載屏障會根據無效化隊列內容所指定的內存地址,將相應處理器上的高速緩存中相應的緩存條目的狀態都標記爲I,從而使該處理器後續執行鍼對相對地址(無效化隊列內容中指定的地址)的讀內存操作時必須發送Read消息,以將其他處理器對相關共享變量所做的更新同步到該處理器的高速緩存中。

因此,解決可見性問題首先要使寫線程對共享變量所做的更新能夠到達(被存儲到)高速緩存,從而使該更新對其他處理器是可同步的。其次,讀線程所在的處理器要將其無效化隊列中的內容應用到其高速緩存上,這樣才能夠將其他處理器對共享變量所做的更新同步到該處理器的高速緩存中。而這兩點是通過存儲屏障與加載屏障的成對使用實現的:寫線程在執行處理器所執行的存儲屏障保障了該線程對共享變量所做的更新對讀線程來說是同步的;讀線程的執行處理器所執行的加載屏障將寫線程對共享變量所做的更新同步到該處理器的高速緩之中。

存儲轉發技術也可能導致可見性問題。假設處理器Processor0在t1時刻更新了某個共享變量,隨後又在t2時刻讀取了該變量。在t1時刻到t2時刻之間的這段時間內其他處理器可能已經更新了該共享變量,並且這個更新的結果已經到到該處理器的高速緩存。但是如果Processor0在t1時刻所做的更新仍然停留在該處理器的寫緩衝器之中,那麼存儲轉發技術會使Processor0直接從其寫緩衝器讀取該共享變量的值。也就是說Processor0此時根本不從高速緩存中讀取該變量的值,也就使得另外一個處理器對該共享變量所做的更新無法被該處理器讀取,從而導致Processor0在t2時刻讀取到的變量值是一箇舊值。因此,考慮到存儲轉發技術的這個副作用,從讀線程的角度來看,爲了使讀線程能夠將其他線程對共享變量所做的更新同步到該線程所在的處理器的高速緩存中,我們需要清空該處理器上的寫緩衝器以及無效化隊列。

基本內存屏障

基本內存屏障--LoadLoad屏障、LoadStore屏障、StoreStore屏障和SotreLoad屏障。基本內存屏障可以統一使用XY來表示,其中的X和Y可以代表Load或者Store。基本內存屏障是對一類指令的稱呼,這類指令的作用是禁止該指令左側的任何X操作與該指令右側的任何Y操作之間進行重排序,從而確保該指令左側的所有X操作先於該指令右側的Y操作被提交,即內存操作作用到主內存(或者高速緩存)上,如下圖所示。

比如,StoreLoad屏障(即X代表Store,Y代表Load)能夠禁止其左側的任何寫操作與其右側的任何讀操作之間進行重排序,因此StoreLoad屏障就保障了該指令之前的寫操作的結果在該指令之後的任何讀操作的數據被加載之前對其他處理器來說可同步,即這些寫操作的結果會在該屏障之後的讀操作的數據被加載前被寫入高速緩存(或者主內存)。

基本內存屏障的作用只是保障其左側的X操作(比如讀,即X代表Load)先於其右側的Y操作(比如寫,即Y代表Store)被提交,它並不會全面禁止重排序。XY屏障兩側的內存操作仍然可以在不越過內存屏障本身的情況下在各自的範圍內進行重排序,並且XY屏障左側的非X操作與屏障右側的非Y操作之間仍然可以進行重排序(即越過內存屏障本身)。

編譯器(JIT編譯器)、運行時(java虛擬機)和處理器 都會尊重內存屏障,從而保障其作用得以落實。

LoadLoad屏障是通過清空無效化隊列來實現禁止LoadLoad重排序的。LoadLoad屏障會使其執行處理器根據無效化隊列中的Invalidate消息刪除其高速緩存中相應的副本。這個過程被稱爲將無效化隊列應用到高速緩存,也被稱爲清空無效化隊列,他使處理器有機會將其他處理器對共享變量所做的更新同步到該處理器的高速緩存中,從而消除了LoadLoad重排序的根源而實現了禁止LoadLoad重排序。

StoreStore屏障可以通過對寫緩衝器中的條目進行標記來實現禁止StoreStore重排序。StoreStore屏障會將寫緩衝器中的現有條目做一個標記,以表示這些條目代表的寫操作需要先於該屏障之後的寫操作被提交。處理器在執行寫操作的時候如果發現寫緩衝器中存在被標記的條目,那麼即使這個寫操作對應的高速緩存條目的狀態爲E或者M,此事處理器也不直接將寫操作的數據寫入高速緩存,而是將其寫入寫緩衝器,從而使得StoreStore屏障之前的任何寫操作先於該屏障之後的寫操作被提交。

就處理器的具體實現而言,許多處理器往往將StoreLoad屏障時限爲一個通用基本內存屏障,即StoreLoad屏障能夠實現其他三種基本內存屏障的效果。StoreLoad屏障能夠替代其他基本內存屏障,但是他的開銷也是最大的--StoreLoad屏障會清空無效化隊列,並將寫緩衝器中的條目沖刷高速緩存。因此,StoreLoad屏障既可以將其他處理器對共享變量所做的更新同步到該處理器的高速緩存中,又可以使執行處理器對共享變量所做的共享對其他處理器來說可同步。

java同步機制與內存屏障

java虛擬機synchronized、volatile和final關鍵字的語義的實現就是藉助內存屏障的。第三章介紹的獲取屏障和釋放屏障相當於由基本內存屏障組合而成的複合屏障。獲取屏障相當於LoadLoad屏障和LoadStore屏障的組合,它能夠禁止該屏障之前的任何讀操作與該屏障之後的任何讀、寫操作之間的重排序。釋放屏障相當於LoadStore屏障和StoreStore屏障的組合,它能夠禁止該屏障之前的任何讀、寫操作與該屏障之後的任何寫操作之間進行重排序。

volatile關鍵字的實現

java虛擬機(JIT編譯器)在volatile變量寫操作之前插入的釋放屏障使得該屏障之前的任何讀、寫操作都先於這個volatile變量寫操作被提交,而java虛擬機(JIT編譯器)在volatile變量讀操作之後插入的獲取屏障使得這個volatile變量讀操作先於該屏障之後的任何讀、寫操作被提交。寫線程和讀線程通過各自執行的釋放屏障和獲取屏障保障了有序性。

假設寫線程、讀線程依照下表中的線程交錯執行(即讀線程執行時,寫線程對共享變量的更新操作已經完成)執行,A、B是普通共享變量,V是volatile變量。釋放屏障確保了寫線程對共享變量A、B的更新會先於對V的更新被提交,這就意味着讀線程在讀取到寫線程對V的更新情況下也能夠讀取到寫線程對A和B的更新。爲了保障讀線程對寫線程所執行的寫操作的感知順序和程序順序一致,讀線程必須依照與寫線程的程序順序的相反順序即先讀取V在讀取A或者B來執行讀操作。由於讀線程中的讀操作(或寫操作)也可能會被重排序(包括指令重排序和內存重排序),因此java虛擬機會在讀線程中的volatile讀操作之後插入一個獲取屏障,以保證該線程對共享變量B的讀取操作先於對A、B的讀取操作被提交。寫線程、讀線程通過釋放屏障和獲取屏障這種配對使用保障了讀線程對寫線程執行的寫操作的感知順序與程序順序一致,即保障了有序性。

值得注意的是,釋放屏障只是確保了該屏障之間的讀、寫操作先於該屏障之後的任何寫操作被提交,因此釋放屏障之前的操作之間,其提交順序可以與程序順序不一致。例如:上圖中寫線程對A、B的更新,處理器並無需保證對A的更新先於對B的更新被提交,而只需要保障對A以及B的更新先於對B的更新被提交即可。類似的,獲取屏障只是確保了該屏障之前的任何讀操作先於該屏障之後的任何讀、寫操作被提交。因此獲取屏障之後的操作之間,其提交順序可以與程序順序不一致。寫線程和讀線程通過配對使用釋放屏障和獲取屏障,使得上述內存操作提交順序與程序順序的不一致並不會對有序性產生影響。

java虛擬機(JIT編譯器)會在volatile變量寫操作之後插入一個StoreLoad屏障。該屏障不僅禁止該屏障之後的任何讀操作與該屏障之前的任何寫操作(包括該volatile寫操作)之間進行重排序,他還起到以下兩個作用。

  • 充當存儲屏障。StoreLoad屏障是一個通用存儲屏障,其功能涵蓋了其他3個基本內存屏障。StoreLoad屏障通過清空其執行處理器的寫緩衝器是的該屏障前的所有寫操作(包括volatile寫操作以及其他任何寫操作)的結果得以到達高速緩存,從而使這些更新對其他處理器而言是可同步的。
  • 充當加載屏障,以消除存儲轉發的副作用。假設處理器Processor0在t1時刻更新了某個volatile變量,在隨後的t2時刻又讀取了該變量。由於存儲轉發技術可能使得一個處理器無法將其他處理器對共享變量所做的更新同步到該處理器的高速緩存上,而java語言規範又要求volatile讀操作總是可以讀取到其他處理器對響應變量所做的更新,因此java虛擬機需要在volatile變量寫操作和隨後的volatile變量讀操作之間插入一個StoreLoad屏障。這是利用了StoreLoad屏障既能夠清空寫緩衝器還能夠清空無效化隊列的功能,從而使其他處理器對volatile變量所做的更新能夠被同步到volatile變量讀線程的執行處理器上

java虛擬機(JIT編譯器)在volatile變量讀操作前插入的一個加載屏障相當於LoadLoad屏障,他通過清空無效化隊列來使得其後的讀操作(包括volatile讀操作)有機會讀取到其他處理器對共享變量所做的更新。讀線程能夠讀取到寫線程對volatile變量所做的更新,有賴於寫線程在volatile寫操作後鎖執行的存儲屏障。可見,volatile對可見性的保障是通過寫線程、讀線程配對使用存儲屏障和加載屏障實現的。

java虛擬機對synchronized關鍵字的實現方式與對volatile的實現方式類似。java虛擬機在monitorenter(申請鎖)字節碼指令對應的機器碼指令之後的臨界區開始之前的地方所插入的獲取屏障以及在monitorexit(釋放鎖)字節碼指令對應的機器碼指令之前臨界區結束之後的地方所插入的釋放屏障確保了臨界區中的任何讀、寫操作無法被重排序到臨界區之外,這一點再加上鎖的排他性確保了臨界區中的操作成爲一個原子操作。

synchronized關鍵字的實現

java虛擬機(JIT)編譯器會在monitorenter(用於申請所的字節碼指令)對應的指令後臨界區開始前的地方插入一個獲取屏障。java虛擬機會在臨界區結束後monitorexit(用於釋放鎖的字節碼指令)對應的指令前的地方插入一個釋放屏障。這裏,獲取屏障和釋放屏障一起保證了臨界區內的任何讀、寫操作都無法被重排序到臨界區之外,再加上鎖的排他性,這使得臨界區內的操作具有原子性。

synchronized關鍵字對有序性的保障與volatile關鍵字對有序性的保障實現原理是一樣的,也是通過釋放屏障和獲取屏障的配對使用實現的。釋放屏障使得寫線程在臨界區中執行的讀、寫操作先於monitorexit對應的指令(相當於寫操作)被提交,而獲取屏障使得讀線程必須在獲得鎖之後才能夠執行臨界區中的操作。寫線程以及讀線程通過這種釋放屏障和獲取屏障的配對使用實現了有序性。

java虛擬機也會在monitorexit對應的指令之後插入一個StoreLoad屏障。這個處理器的目的與在volatile寫操作之後插入一個StoreLoad屏障類似。該屏障充當了存儲屏障,從而確保鎖的持有線程在釋放鎖之前鎖執行的所有操作的結果能夠到達高速緩存,並消除了存儲轉發的副作用。另外,該屏障禁止了monitorexit對應的指令與其他同步塊的monitorenter對應的指令進行重排序,這保障了monitorenter與monitorexit總是成對的,從而使synchronized塊的並列(一個synchronized塊之後又有其他synchronized塊)成爲可能。

java虛擬機對內存屏障使用的優化

內存屏障部分禁止重排序的代價就是他會阻止編譯器(JIT編譯器)、處理器做一些性能優化。這就好比我們在日常生活中打亂預定的順序往往可以提高辦事效率,而一味的按照預定的順序辦事反而可能降低辦事效率。

內存屏障的另外一種代價就是其實現往往涉及的沖刷寫緩衝器和清空無效化隊列,而這兩個動作可能是比較耗時的。

因此java虛擬機對內存屏障的使用往往會做一些優化。這些優化包括省略、合併等。例如,對於兩個連續的volatile寫操作,java虛擬機可能只在最後一個volatile寫操作之後插入StoreLoad屏障,而不是在每個volatile寫操作後插入一個StoreLoad屏障。

java內存模型

緩存一致性協議確保了一個處理器對某個內存地址進行寫操作的結果最終能夠被其他處理器所讀取。所謂最終就是帶有不確定性,換言之,即一個處理器對共享變量所做的更新具體在什麼時候能夠被其他處理器讀取到這一點,緩存一致性協議本身是不保證的。寫緩衝器、無效化隊列都可能導致一個處理器在某一個時刻讀取到共享變量的舊值。因此,從底層的角度來看,計算機必須解決這樣一個問題---一個處理器對共享變量所做的更新在什麼時候或者說什麼情況下才能夠被其他處理器所讀取,即可見性問題。可見性問題有衍生出一個新問題---一個處理器先後更新多個共享變量的情況下,其他處理器是以何種順序讀取到這些更新的,即有序性問題。

用於回答上述問題的模型就被稱爲內存一致性模型,也被稱爲內存模型。java作爲一個跨平臺的語言,爲了屏蔽不同處理器的內存模型的差異,以便java應用開發人員不繫根據不同的處理器編寫不同的代碼,它必須定義自己的內存模型,這個模型就被稱爲java內存模型。

什麼是java內存模型(java內存模型可以參見深入理解JVM第12章)

java內存模型定義了final、volatile和synchronized關鍵字的行爲並確保正確同步的java程序能夠正確的運行在不同架構的處理器之上。從應用開發人員的角度來看,java內存模型作爲一個模型,它從什麼的角度爲我們解答以下幾個線程安全方面的問題。

  • 原子性問題。針對實例變量、靜態變量(即共享變量而非局部變量)的讀、寫操作,那些具備原子性,那些可能不具備原子性。
  • 可見性問題。一個線程對實例變量、靜態變量(即共享變量)進行的更新在什麼情況下能夠被其他線程鎖讀取?
  • 有序性問題。一個線程對多個實例變量、靜態變量(即共享變量)進行的更新在什麼情況下其他線程看來可以是亂序的(即感知順序與程序順序不同)

在原子性方面、java內存模型規定對long/double型以外的基本數據類型以及引用類型的共享變量進行讀、寫操作都具有原子性。另外,java內存模型還特別規定對volatile修飾的long/double型共享變量進行讀、寫操作也具有原子性。換言之,對引用類型以及幾乎所有基本數據類型的共享變量進行讀、寫操作,java內存模型都能保證他們具有原子性,而對long/double型的共享變量進行的讀、寫操作是否具有原子性則取決於具體的java虛擬機實現。

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