1. 程式人生 > 其它 >CCF-CSP 202104-4 校門外的樹(DP/好題)

CCF-CSP 202104-4 校門外的樹(DP/好題)

volatile 如果解決記憶體可見性和重排序? https://www.jianshu.com/p/64240319ed60
一文解決記憶體屏障
記憶體屏障是硬體之上、作業系統或JVM之下,對併發作出的最後一層支援。再向下是是硬體提供的支援;
向上是作業系統或JVM對記憶體屏障作出的各種封裝。記憶體屏障是一種標準,
本文僅為了幫助理解JVM提供的併發機制。首先,從volatile的語義引出可見性與重排序問題;接下來,闡述問題的產生原理,
瞭解為什麼需要記憶體屏障;然後,淺談記憶體屏障的標準、廠商對記憶體屏障的支援,並以volatile為例討論記憶體屏障如何解決這些問題;
最後,補充介紹JVM在記憶體屏障之上作出的幾個封裝。為了幫助理解,會簡要討論硬體架構層面的一些基本原理
(特別是CPU架記憶體屏障的實現涉及大量硬體架構層面的知識,又需要作業系統或JVM的配合才能發揮威力,單純從任何一個層面都無法理解。本文整合了這三個層面的大量知識,篇幅較長,希望能在一篇文章內,把記憶體屏障的基本問題講述清楚。
如有疏漏,還望指正!

volatile變數規則
一個用於引出記憶體屏障的好例子是volatile變數規則。

volatile關鍵字可參考猴子剛開部落格時的文章volatile關鍵字的作用、原理。
volatile變數規則描述了volatile變數的偏序語義;這裡從volatile變數規則的角度來講解,順便做個複習。

定義
volatile變數規則:對volatile變數的寫入操作必須在對該變數的讀操作之前執行。
volatile變數規則只是一種標準,要求JVM實現保證volatile變數的偏序語義。結合程式順序規則、傳遞性,該偏序語義通常表現為兩個作用:

保持可見性
禁用重排序(讀操作禁止重排序之後的操作,寫操作禁止重排序之前的操作)
補充:

程式順序規則:如果程式中操作A在操作B之前,那麼線上程中操作A將在操作B之前執行。
傳遞性:如果操作A在操作B之前執行,並且操作B在操作C之前執行,那麼操作A必須在操作C之前執行。
後文,如果僅涉及可見性,則指明“可見性”;如果二者均涉及,則以“偏序”代稱。重排序一定會帶來可見性問題,
因此,不會出現單獨討論重排序的場景

正確姿勢
之前的文章多次涉及volatile變數規則的用法。

簡單的僅利用volatile變數規則對volatile變數本身的可見性保證:

面試中單例模式有幾種寫法?:“飽漢 - 變種 3”在DCL的基礎上,使用volatile修飾單例,以保證單例的可見性。
複雜的利用volatile變數規則(結合了程式順序規則、傳遞性)保證變數本身及周圍其他變數的偏序:

原始碼|併發一枝花之ReentrantLock與AQS(1):lock、unlock:exclusiveOwnerThread藉助於volatile變數state保證其相對於state的偏序。
原始碼|併發一枝花之CopyOnWriteArrayList:CopyOnWriteArrayList藉助於volatile變數array,對外提供偏序語義。
可見性與重排序
前文多次提到可見性與重排序的問題,記憶體屏障的存在就是為了解決這些問題。到底什麼是可見性?什麼是重排序?為什麼會有這些問題?

可見性
定義
可見性的定義常見於各種併發場景中,以多執行緒為例:當一個執行緒修改了執行緒共享變數的值,其它執行緒能夠立即得知這個修改。

從效能角度考慮,沒有必要在修改後就立即同步修改的值——如果多次修改後才使用,那麼只需要最後一次同步即可,
在這之前的同步都是效能浪費。因此,實際的可見性定義要弱一些,只需要保證:
當一個執行緒修改了執行緒共享變數的值,其它執行緒在使用前,能夠得到最新的修改值。
可見性可以認為是最弱的“一致性”(弱一致),只保證使用者見到的資料是一致的,
但不保證任意時刻,儲存的資料都是一致的(強一致)。下文會討論“快取可見性”問題,部分文章也會稱為“快取一致性”問題。

問題來源
一個最簡單的可見性問題來自計算機內部的快取架構:

快取大大縮小了高速CPU與低速記憶體之間的差距。以三層快取架構為例:

L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每個核上都有一個L1 Cache。
L2 Cache容量更大(如256K)、速度更低, 一般情況下,每個核上都有一個獨立的L2 Cache。
L3 Cache最接近記憶體,容量最大(如12MB),速度最低,在同一個CPU插槽之間的核共享一個L3 Cache。
準確地說,每個核上有兩個L1 Cache, 一個存資料 L1d Cache, 一個存指令 L1i Cache。

單核時代的一切都是那麼完美。然而,多核時代出現了可見性問題。一個badcase如下:

Core0與Core1命中了記憶體中的同一個地址,那麼各自的L1 Cache會快取同一份資料的副本。
最開始,Core0與Core1都在友善的讀取這份資料。
突然,Core0要使壞了,它修改了這份資料,使得兩份快取中的資料不同了,更確切的說,Core1 L1 Cache中的資料失效了。
單核時代只有Core0,Core0修改Core0讀,沒什麼問題;但是,現在Core0修改後,Core1並不知道資料已經失效,繼續傻傻的使用,
輕則資料計算錯誤,重則導致死迴圈、程式崩潰等。

實際的可見性問題還要擴充套件到兩個方向:

除三級快取外,各廠商實現的硬體架構中還存在多種多樣的快取,都存在類似的可見性問題。例如,暫存器就相當於CPU與L1 Cache之間的快取。
各種高階語言(包括Java)的多執行緒記憶體模型中,線上程棧內自己維護一份快取是常見的優化措施,但顯然在CPU級別的快取可見性問題面前,一切都失效了。
以上只是最簡單的可見性問題,不涉及重排序等。

重排序也會導致可見性問題;同時,快取上的可見性也會引起一些看似重排序導致的問題。


重排序
定義
重排序並沒有嚴格的定義。整體上可以分為兩種:

真·重排序:編譯器、底層硬體(CPU等)出於“優化”的目的,按照某種規則將指令重新排序(儘管有時候看起來像亂序)。
偽·重排序:由於快取同步順序等問題,看起來指令被重排序了。
重排序也是單核時代非常優秀的優化手段,有足夠多的措施保證其在單核下的正確性。
在多核時代,如果工作執行緒之間不共享資料或僅共享不可變資料,重排序也是效能優化的利器。
然而,如果工作執行緒之間共享了可變資料,由於兩種重排序的結果都不是固定的,會導致工作執行緒似乎表現出了隨機行為。

第一次接觸重排序的概念一定很迷糊,耐心,耐心。

問題來源
重排序問題無時無刻不在發生,源自三種場景:

編譯器編譯時的優化
處理器執行時的亂序優化
快取同步順序(導致可見性問題)
場景1、2屬於真·重排序;場景3屬於偽·重排序。場景3也屬於可見性問題,為保持連貫性,我們先討論場景3。


可見性導致的偽·重排序
快取同步順序本質上是可見性問題。

假設程式順序(program order)中先更新變數v1、再更新變數v2,不考慮真·重排序:

Core0先更新快取中的v1,再更新快取中的v2(位於兩個快取行,這樣淘汰快取行時不會一起寫回記憶體)。
Core0讀取v1(假設使用LRU協議淘汰快取)。
Core0的快取滿,將最遠使用的v2寫回記憶體。
Core1的快取中本來存有v1,現在將v2載入入快取。
重排序是針對程式順序而言的,如果指令執行順序與程式順序不同,就說明這段指令被重排序了。

此時,儘管“更新v1”的事件早於“更新v2”發生,但Core1只看到了v2的最新值,卻看不到v1的最新值。這屬於可見性導致的偽·重排序:雖然沒有實際上沒有重排序,但看起來發生了重排序。

可以看到,快取可見性不僅僅導致可見性問題,還會導致偽·重排序。因此,只要解決了快取上的可見性問題,也就解決了偽·重排序。

MESI協議
回到可見性問題中的例子和可見性的定義。要解決這個問題很簡單,套用可見性的定義,只需要:在Core0修改了資料v後,讓Core1在使用v前,能得到v最新的修改值。

這個要求很弱,既可以在每次修改v後,都同步修改值到其他快取了v的Cache中;又可以只同步使用前的最後一次修改值。後者效能上更優,如何實現呢:

Core0修改v後,傳送一個訊號,將Core1快取的v標記為失效,並將修改值寫回記憶體。
Core0可能會多次修改v,每次修改都只發送一個訊號(發訊號時會鎖住快取間的匯流排),Core1快取的v保持著失效標記。
Core1使用v前,發現快取中的v已經失效了,得知v已經被修改了,於是重新從其他快取或記憶體中載入v。
以上即是MESI(Modified Exclusive Shared Or Invalid,快取的四種狀態)協議的基本原理,不算嚴謹,但對於理解快取可見性(更常見的稱呼是“快取一致性”)已經足夠。

MESI協議解決了CPU快取層面的可見性問題。

以下是MESI協議的快取狀態機,簡單看看即可:

記憶體屏障相當於消除編譯器對於變數存入暫存器的優化

image.png
狀態:

M(修改, Modified): 本地處理器已經修改快取行, 即是髒行, 它的內容與記憶體中的內容不一樣. 並且此cache只有本地一個拷貝(專有)。
E(專有, Exclusive): 快取行內容和記憶體中的一樣, 而且其它處理器都沒有這行資料。
S(共享, Shared): 快取行內容和記憶體中的一樣, 有可能其它處理器也存在此快取行的拷貝。
I(無效, Invalid): 快取行失效, 不能使用。

==================================================== volatile ==========================================================

volatile 寫 - 讀建立的 happens before 關係
上面講的是 volatile 變數自身的特性,對程式設計師來說,volatile 對執行緒的記憶體可見性的影響比 volatile 自身的特性更為重要,
也更需要我們去關注。

從 JSR-133 開始,volatile 變數的寫 - 讀可以實現執行緒之間的通訊。

從記憶體語義的角度來說,volatile 與監視器鎖有相同的效果:
volatile 寫和監視器的釋放有相同的記憶體語義;
volatile 讀與監視器的獲取有相同的記憶體語義。


class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}

public void reader() {
if (flag) { //3
int i = a; //4
……
}
}
}

volatile 寫 - 讀的記憶體語義
volatile 寫的記憶體語義如下:

當寫一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體。
以上面示例程式 VolatileExample 為例,假設執行緒 A 首先執行 writer() 方法,隨後執行緒 B 執行 reader() 方法,
初始時兩個執行緒的本地記憶體中的 flag 和 a 都是初始狀態。下圖是執行緒 A 執行 volatile 寫後,共享變數的狀態示意圖:
如果我們把 volatile 寫和 volatile 讀這兩個步驟綜合起來看的話,在讀執行緒 B 讀一個 volatile 變數後,
寫執行緒 A 在寫這個 volatile 變數之前所有可見的共享變數的值都將立即變得對讀執行緒 B 可見。

下面對 volatile 寫和 volatile 讀的記憶體語義做個總結:

執行緒 A 寫一個 volatile 變數,實質上是執行緒 A 向接下來將要讀這個 volatile 變數的某個執行緒發出了(其對共享變數所在修改的)訊息。
執行緒 B 讀一個 volatile 變數,實質上是執行緒 B 接收了之前某個執行緒發出的(在寫這個 volatile 變數之前對共享變數所做修改的)訊息。
執行緒 A 寫一個 volatile 變數,隨後執行緒 B 讀這個 volatile 變數,這個過程實質上是執行緒 A 通過主記憶體向執行緒 B 傳送訊息。

volatile 記憶體語義的實現
下面,讓我們來看看 JMM 如何實現 volatile 寫 / 讀的記憶體語義。

前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現 volatile 記憶體語義,JMM 會分別限制這兩種型別的重排序型別。
下面是 JMM 針對編譯器制定的 volatile 重排序規則表:
是否能重排序 第二個操作 第一個操作 普通讀 / 寫 volatile 讀 volatile 寫 普通讀 / 寫 NO volatile 讀 NO NO NO volatile 寫 NO NO 舉例來說,
第三行最後一個單元格的意思是:在程式順序中,當第一個操作為普通變數的讀或寫時,如果第二個操作為 volatile 寫,則編譯器不能重排序這兩個操作。

上圖中的 StoreStore 屏障可以保證在 volatile 寫之前,其前面的所有普通寫操作已經對任意處理器可見了。
這是因為 StoreStore 屏障將保障上面所有的普通寫在 volatile 寫之前重新整理到主記憶體。

這裡比較有意思的是 volatile 寫後面的 StoreLoad 屏障。這個屏障的作用是避免 volatile 寫與後面可能有的 volatile 讀 / 寫操作重排序。
因為編譯器常常無法準確判斷在一個 volatile 寫的後面,是否需要插入一個 StoreLoad 屏障(比如,一個 volatile 寫之後方法立即 return)。
為了保證能正確實現 volatile 的記憶體語義,
JMM 在這裡採取了保守策略:在每個 volatile 寫的後面或在每個 volatile 讀的前面插入一個 StoreLoad 屏障。
從整體執行效率的角度考慮,JMM 選擇了在每個 volatile 寫的後面插入一個 StoreLoad 屏障。因為 volatile 寫 - 讀記憶體語義的常見使用模式是:
一個寫執行緒寫 volatile 變數,多個讀執行緒讀同一個 volatile 變數。當讀執行緒的數量大大超過寫執行緒時,
選擇在 volatile 寫之後插入 StoreLoad 屏障將帶來可觀的執行效率的提升。從這裡我們可以看到 JMM 在實現上的一個特點:
首先確保正確性,然後再去追求執行效率。


鎖是 java 併發程式設計中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取同一個鎖的執行緒傳送訊息。

下面是鎖釋放 - 獲取的示例程式碼:

class MonitorExample {
int a = 0;

public synchronized void writer() { //1
a++; //2
} //3

public synchronized void reader() { //4
int i = a; //5
……
} //6
}

假設執行緒 A 執行 writer() 方法,隨後執行緒 B 執行 reader() 方法。根據 happens before 規則,這個過程包含的 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。
上述 happens before 關係的圖形化表現形式如下:



public class FinalExample {
int i; // 普通變數
final int j; //final 變數
static FinalExample obj;

public void FinalExample () { // 建構函式
i = 1; // 寫普通域
j = 2; // 寫 final 域
}

public static void writer () { // 寫執行緒 A 執行
obj = new FinalExample ();
}

public static void reader () { // 讀執行緒 B 執行
FinalExample object = obj; // 讀物件引用
int a = object.i; // 讀普通域
int b = object.j; // 讀 final 域
}
}

寫 final 域的重排序規則
寫 final 域的重排序規則禁止把 final 域的寫重排序到建構函式之外。這個規則的實現包含下面 2 個方面:

JMM 禁止編譯器把 final 域的寫重排序到建構函式之外。
編譯器會在 final 域的寫之後,建構函式 return 之前,插入一個 StoreStore 屏障。這個屏障禁止處理器把 final 域的寫重排序到建構函式之外。
現在讓我們分析 writer () 方法。writer () 方法只包含一行程式碼:finalExample = new FinalExample ()。這行程式碼包含兩個步驟:

構造一個 FinalExample 型別的物件;
把這個物件的引用賦值給引用變數 obj。


JMM 向程式設計師提供的 happens- before 規則能滿足程式設計師的需求。JMM 的 happens- before 規則不但簡單易懂,
而且也向程式設計師提供了足夠強的記憶體可見性保證(有些記憶體可見性保證其實並不一定真實存在,比如上面的 A happens- before B)。
JMM 對編譯器和處理器的束縛已經儘可能的少。從上面的分析我們可以看出,JMM 其實是在遵循一個基本原則:
只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。
比如,如果編譯器經過細緻的分析後,認定一個鎖只會被單個執行緒訪問,那麼這個鎖可以被消除。再比如,
如果編譯器經過細緻的分析後,認定一個 volatile 變數僅僅只會被單個執行緒訪問,那麼編譯器可以把這個 volatile 變數當作一個普通變數來對待。這些優化既不會改變程式的執行結果,又能提高程式的執行效率。





Volatile的重排序

1、當第二個操作為volatile寫操做時,不管第一個操作是什麼(普通讀寫或者volatile讀寫),都不能進行重排序。
這個規則確保volatile寫之前的所有操作都不會被重排序到volatile之後;

2、當第一個操作為volatile讀操作時,不管第二個操作是什麼,都不能進行重排序。這個規則確保volatile讀之後的所有操作都不會被重排序到volatile之前;

3、當第一個操作是volatile寫操作時,第二個操作是volatile讀操作,不能進行重排序。

這個規則和前面兩個規則一起構成了:兩個volatile變數操作不能夠進行重排序;

除以上三種情況以外可以進行重排序。

比如:

1、第一個操作是普通變數讀/寫,第二個是volatile變數的讀;
2、第一個操作是volatile變數的寫,第二個是普通變數的讀/寫;
————————————————
版權宣告:本文為CSDN博主「qinjianhuang」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處連結及本宣告。
原文連結:https://blog.csdn.net/sinat_35512245/article/details/60325685



volatile總結

監視器鎖的 happens-before 規則保證釋放監視器和獲取監視器的兩個執行緒之間的記憶體可見性,這意味著對一個 volatile 變數的讀,
總是能看到(任意執行緒)對這個 volatile 變數最後的寫入。

volatile 變數自身具有下列特性:

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

編譯器重排序 -- https://blog.csdn.net/CringKong/article/details/99759216
java記憶體模型 -- https://www.infoq.cn/news/java-memory-model-4
記憶體屏障 -- https://www.jianshu.com/p/64240319ed60
Volatile的重排序 -- https://blog.csdn.net/sinat_35512245/article/details/60325685
https://blog.csdn.net/ityouknow/article/details/88840168
java interview -- https://blog.csdn.net/sufu1065/article/details/88051083
紙上學來終覺淺,覺知此事需躬行