全網最硬核 Java 新內存模型解析與實驗 - 3. 硬核理解內存屏障(CPU+編譯器)

個人創作公約:本人聲明創作的所有文章皆爲自己原創,如果有參考任何文章的地方,會標註出來,如果有疏漏,歡迎大家批判。如果大家發現網上有抄襲本文章的,歡迎舉報,並且積極向這個 github 倉庫 提交 issue,謝謝支持~

本篇文章參考了大量文章,文檔以及論文,但是這塊東西真的很繁雜,我的水平有限,可能理解的也不到位,如有異議歡迎留言提出。本系列會不斷更新,結合大家的問題以及這裏的錯誤和疏漏,歡迎大家留言

如果你喜歡單篇版,請訪問:全網最硬核 Java 新內存模型解析與實驗單篇版(不斷更新QA中) 如果你喜歡這個拆分的版本,這裏是目錄:

內存屏障,CPU 與內存模型相關:

x86 CPU 相關資料:

ARM CPU 相關資料:

各種一致性的理解:

Aleskey 大神的 JMM 講解:

相信很多 Java 開發,都使用了 Java 的各種併發同步機制,例如 volatile,synchronized 以及 Lock 等等。也有很多人讀過 JSR 第十七章 Threads and Locks(地址:https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html),其中包括同步、Wait/Notify、Sleep & Yield 以及內存模型等等做了很多規範講解。但是也相信大多數人和我一樣,第一次讀的時候,感覺就是在看熱鬧,看完了只是知道他是這麼規定的,但是爲啥要這麼規定,不這麼規定會怎麼樣,並沒有很清晰的認識。同時,結合 Hotspot 的實現,以及針對 Hotspot 的源碼的解讀,我們甚至還會發現,由於 javac 的靜態代碼編譯優化以及 C1、C2 的 JIT 編譯優化,導致最後代碼的表現與我們的從規範上理解出代碼可能的表現是不太一致的。並且,這種不一致,導致我們在學習 Java 內存模型(JMM,Java Memory Model),理解 Java 內存模型設計的時候,如果想通過實際的代碼去試,結果是與自己本來可能正確的理解被帶偏了,導致誤解。 我本人也是不斷地嘗試理解 Java 內存模型,重讀 JLS 以及各路大神的分析。這個系列,會梳理我個人在閱讀這些規範以及分析還有通過 jcstress 做的一些實驗而得出的一些理解,希望對於大家對 Java 9 之後的 Java 內存模型以及 API 抽象的理解有所幫助。但是,還是強調一點,內存模型的設計,出發點是讓大家可以不用關心底層而抽象出來的一些設計,涉及的東西很多,我的水平有限,可能理解的也不到位,我會盡量把每一個論點的論據以及參考都擺出來,請大家不要完全相信這裏的所有觀點,如果有任何異議歡迎帶着具體的實例反駁並留言

5. 內存屏障

5.1. 爲何需要內存屏障

內存屏障(Memory Barrier),也有叫內存柵欄(Memory Fence),還有的資料直接爲了簡便,就叫 membar,這些其實意思是一樣的。內存屏障主要爲了解決指令亂序帶來了結果與預期不一致的問題,通過加入內存屏障防止指令亂序(或者稱爲重排序,reordering)。

那麼爲什麼會有指令亂序呢?主要是因爲 CPU 亂序(CPU亂序還包括 CPU 內存亂序以及 CPU 指令亂序)以及編譯器亂序。內存屏障可以用於防止這些亂序。如果內存屏障對於編譯器和 CPU 都生效,那麼一般稱爲硬件內存屏障,如果只對編譯器生效,那麼一般被稱爲軟件內存屏障。我們這裏主要關注 CPU 帶來的亂序,對於編譯器的重排序我們會在最後簡要介紹下。

5.2. CPU 內存亂序相關

我們從 CPU 高速緩存以及緩存一致性協議出發,開始分析爲何 CPU 中會有亂序。我們這裏假設一種簡易的 CPU 模型請大家一定記住,實際的 CPU 要比這裏列舉的簡易 CPU 模型複雜的多

5.2.1. 簡易 CPU 模型 - CPU 高速緩存的出發點 - 減少 CPU Stall

我們在這裏會看到,現代的 CPU 的很多設計,一切以減少 CPU Stall 出發。什麼是 CPU Stall 呢?舉一個簡單的例子,假設 CPU 需要直接讀取內存中的數據(忽略其他的結構,例如 CPU 緩存,總線與總線事件等等):

image

CPU 發出讀取請求,在內存響應之前,CPU 需要一直等待,無法處理其他的事情。這一段 CPU 就是處於 Stall 狀態。如果 CPU 一直直接從內存中讀取,CPU 直接訪問內存消耗時間很長,可能需要幾百個指令週期,也就是每次訪問都會有幾百個指令週期內 CPU 處於 Stall 狀態什麼也幹不了,這樣效率會很低。一般需要引入若干個高速緩存(Cache)來減少 Stall:高速緩存即與處理器緊挨着的小型存儲器,位於處理器和內存之間。

我們這裏不關心多級高速緩存,以及是否存在多個 CPU 共用某一緩存的情況,我們就簡單認爲是下面這個架構: image 當需要讀取一個地址的值時,訪問高速緩存看是否存在:存在代表命中(hit),直接讀取。不存在被稱爲缺失(miss)。同樣的,如果需要寫一個值到一個地址,這個地址在緩存中存在也就不需要訪問內存了。大部分程序都表現出較高的局部性(locality):

  • 如果處理器讀或寫一個內存地址,那麼它很可能很快還會讀或寫同一個地址
  • 如果處理器讀或寫一個內存地址,那麼它很可能很快還會讀或寫附近的地址

針對局部性,高速緩存一般會一次操作不止一個字,而是一組臨近的字,稱爲緩存行

但是呢,由於告訴緩存的存在,就給更新內存帶來了麻煩:當一個 CPU 需要更新一塊緩存行對應內存的時候,它需要將其他 CPU 緩存中這塊內存的緩存行也置爲失效。爲了維持每個 CPU 的緩存數據一致性,引入了緩存一致性協議(Cache Coherence Protocols)

5.2.2. 簡易 CPU 模型 - 一種簡單的緩存一致性協議(實際的 CPU 用的要比這個複雜) - MESI

現代的緩存一致性的協議以及算法非常複雜,緩存行可能會有數十種不同的狀態。這裏我們並不需要研究這種複雜的算法,我們這裏引入一個最經典最簡單的緩存一致性協議即 4 狀態 MESI 協議(再次強調,實際的 CPU 用的協議要比這個複雜,MESI 其實本身有些問題解決不了),MESI 其實指的就是緩存行的四個狀態:

  • Modified:緩存行被修改,最終一定會被寫回入主存,在此之前其他處理器不能再緩存這個緩存行。
  • Exclusive:緩存行還未被修改,但是其他的處理器不能將這個緩存行載入緩存
  • Shared:緩存行未被修改,其他處理器可以加載這個緩存行到緩存
  • Invalid:緩存行中沒有有意義的數據

根據我們前面的 CPU 緩存結構圖中所示,假設所有 CPU 都共用在同一個總線上,則會有如下這些信息在總線上發送:

  1. Read:這個事件包含要讀取的緩存行的物理地址。
  2. Read Response:包含前面的讀取事件請求的數據,數據來源可能是內存或者是其他高速緩存,例如,如果請求的數據在其他緩存處於 modified 狀態的話,那麼必須從這個緩存讀取緩存行數據作爲 Read Response
  3. Invalidate:這個事件包含要過期掉的緩存行的物理地址。其他的高速緩存必須移除這個緩存行並且響應 Invalidate Acknowledge 消息。
  4. Invalidate Acknowledge:收到 Invalidate 消息移除掉對應的緩存行之後,回覆 Invalidate Acknowledge 消息。
  5. Read Invalidate:是 Read 消息還有 Invalidate 消息的組合,包含要讀取的緩存行的物理地址。既讀取這個緩存行並且需要 Read Response 消息響應,同時發給其他的高速緩存,移除這個緩存行並且響應 Invalidate Acknowledge 消息。
  6. Writeback:這個消息包含要更新的內存地址以及數據。同時,這個消息也允許狀態爲 modified 的緩存行被剔除,以給其他數據騰出空間。

緩存行狀態轉移與事件的關係:

image

這裏只是列出這個圖,我們不會深入去講的,因爲 MESI 是一個非常精簡的協議,具體實現的時候會有很多額外的問題 MESI 無法解決,如果詳細的去講,會把讀者繞進去,讀者會思考在某個極限情況下這個協議要怎麼做才能保證正確,但是 MESI 實際上解決不了這些。在實際的實現中,CPU 一致性協議要比 MESI 複雜的多得多,但是一般都是基於 MESI 擴展的

舉一個簡單的 MESI 的例子: image 1.CPU A 發送 Read 從地址 a 讀取數據,收到 Read Response 將數據存入他的高速緩存並將對應的緩存行置爲 Exclusive

2.CPU B 發送 Read 從地址 a 讀取數據,CPU A 檢測到地址衝突,CPU A 響應 Read Response 返回緩存中包含 a 地址的緩存行數據,之後,地址 a 的數據對應的緩存行被 A 和 B 以 Shared 狀態裝入緩存

image 3.CPU B 對於 a 馬上要進行寫操作,發送 Invalidate,等待 CPU A 的 Invalidate Acknowledge 響應之後,狀態修改爲 Exclusive。CPU A 收到 Invalidate 之後,將 a 所在的緩存行狀態置爲 Invalid 失效

4.CPU B 修改數據存儲到包含地址 a 的緩存行上,緩存行狀態置爲 modified

5.這時候 CPU A 又需要 a 數據,發送 Read 從地址 a 讀取數據,CPU B 檢測到地址衝突,CPU B 響應 Read Response 返回緩存中包含 a 地址的緩存行數據,之後,地址 a 的數據對應的緩存行被 A 和 B 以 Shared 狀態裝入緩存

我們這裏可以看到,MESI 協議中,發送 Invalidate 消息需要當前 CPU 等待其他 CPU 的 Invalidate Acknowledge,也就是這裏有 CPU Stall。爲了避免這個 Stall,引入了 Store Buffer

5.2.3. 簡易 CPU 模型 - 避免等待 Invalidate Response 的 Stall - Store Buffer

爲了避免這種 Stall,在 CPU 與 CPU 緩存之間添加 Store Buffer,如下圖所示: image

有了 Store Buffer,CPU 在發送 Invalidate 消息的時候,不用等待 Invalidate Acknowledge 的返回,將修改的數據直接放入 Store Buffer。如果收到了所有的 Invalidate Acknowledge 再從 Store Buffer 放入 CPU 的高速緩存的對應緩存行中。但是加入的這個 Store Buffer 又帶來了新的問題:

假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0,a 現在位於 CPU A 的緩存行中,b 現在位於 CPU B 的緩存行中:

假設 CPU B 要執行下面的代碼:

image 我們肯定是期望最後 b 會等於 2 的。但是真的會如我們所願麼?我們來詳細看下下面這個運行步驟:

image

1.CPU B 執行 a = 1:

(1)由於 CPU B 緩存中沒有 a,並且要修改,所以發佈 Read Invalidate 消息(因爲是要先把包含 a 的整個緩存行讀取後才能更新,所以發的是 Read Invalidate,而不只是 Invalidate)。

(2)CPU B 將 a 的修改(a=1)放入 Storage Buffer

(3)CPU A 收到 Read Invalidate 消息,將 a 所在的緩存行標記爲 Invalid 並清除出緩存,並響應 Read Response(a=0) 和 Invalidate Acknowlegde

image 2.CPU B 執行 b = a + 1:

(1)CPU B 收到來自於 CPU A 的 Read Response,這時候這裏面 a 還是等於 0。

(2)CPU B 將 a + 1 的結果(0+1=1)存入緩存中已經包含的 b。

3.CPU B 執行 assert(b == 2) 失敗

這個錯誤的原因主要是我們在加載到緩存的時候沒考慮從 store buffer 最新的值,所以我們可以加上一步,在加載到緩存的時候從 store buffer 讀取最新的值。這樣,就能保證上面我們看到的結果 b 最後是 2:

image

5.2.4. 簡易 CPU 模型 - 避免 Store Buffer 帶來的亂序執行 - 內存屏障

我們下面再來看一個示例:假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0。假設 CPU A (緩存行裏面包含 b,這個緩存行狀態是 Exclusive)執行:

image

假設 CPU B 執行:

image

如果一切按照程序順序預期執行,那麼我們期望 CPU B 執行 assert(a == 1) 是成功的,但是我們來看下面這種執行流程: image 1.CPU A 執行 a = 1:

(1)CPU A 緩存裏面沒有 a,並且要修改,所以發佈 Read Invalidate 消息。

(2)CPU A 將 a 的修改(a=1)放入 Storage Buffer

2.CPU B 執行 while (b == 0) continue:

(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。 image 3.CPU A 執行 b = 1:

(1)CPU A 緩存行裏面有 b,並且狀態是 Exclusive,直接更新緩存行。

(2)之後,CPU A 收到了來自於 CPU B 的關於 b 的 Read 消息。

(3)CPU A 響應緩存中的 b = 1,發送 Read Response 消息,並且緩存行狀態修改爲 Shared

(4)CPU B 收到 Read Response 消息,將 b 放入緩存

(5)CPU B 代碼可以退出循環了,因爲 CPU B 看到 b 此時爲 1

4.CPU B 執行 assert(a == 1),但是由於 a 的更改還沒更新,所以失敗了。

像這種亂序,CPU 一般是無法自動控制的,但是一般會提供內存屏障指令,告訴 CPU 防止亂序,例如:

image smp_mb() 會讓 CPU 將 Store Buffer 中的內容刷入緩存。加入這個內存屏障指令後,執行流程變成:

image 1.CPU A 執行 a = 1:

(1)CPU A 緩存裏面沒有 a,並且要修改,所以發佈 Read Invalidate 消息。

(2)CPU A 將 a 的修改(a=1)放入 Storage Buffer

2.CPU B 執行 while (b == 0) continue:

(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。 image 3.CPU B 執行 smp_mb():

(1)CPU B 將當前 Store Buffer 的所有條目打上標記(目前這裏只有 a,就是對 a 打上標記)

4.CPU A 執行 b = 1:

(1)CPU A 緩存行裏面有 b,並且狀態是 Exclusive,但是由於 Store Buffer 中有標記的條目 a,不直接更新緩存行,而是放入 Store Buffer(與 a 不同,沒有標記)。併發出 Invalidate 消息。

(2)之後,CPU A 收到了來自於 CPU B 的關於 b 的 Read 消息。

(3)CPU A 響應緩存中的 b = 0,發送 Read Response 消息,並且緩存行狀態修改爲 Shared

(4)CPU B 收到 Read Response 消息,將 b 放入緩存

(5)CPU B 代碼不斷循環,因爲 CPU B 看到 b 還是 0

(6)CPU A 收到前面對於 a 的 "Read Invalidate" 相關的消息響應,將 Store Buffer 中打好標記的 a 條目刷入緩存,這個緩存行狀態爲 modified。

(7)CPU B 收到 CPU A 發的 Invalidate b 的消息,將 b 的緩存行失效,回覆 Invalidate Acknowledge

(8)CPU A 收到 Invalidate Acknowledge,將 b 從 Store Buffer 刷入緩存。

(9)由於 CPU B 不斷讀取 b,但是 b 已經不在緩存中了,所以發送 Read 消息。

(10)CPU A 收到 CPU B 的 Read 消息,設置 b 的緩存行狀態爲 shared,返回緩存中 b = 1 的 Read Response

(11)CPU B 收到 Read Response,得知 b = 1,放入緩存行,狀態爲 shared

5.CPU B 得知 b = 1,退出 while (b == 0) continue 循環

6.CPU B 執行 assert(a == 1)(這個比較簡單,就不畫圖了): (1)CPU B 緩存中沒有 a,發出 Read 消息。 (2)CPU A 從緩存中讀取 a = 1,響應 Read Response (3)CPU B 執行 assert(a == 1) 成功

Store Buffer 一般都會比較小,如果 Store Buffer 滿了,那麼還是會發生 Stall 的問題。我們期望 Store Buffer 能比較快的刷入 CPU 緩存,這是在收到對應的 Invalidate Acknowledge 之後進行的。但是,其他的 CPU 可能在忙,沒發很快應對收到的 Invalidate 消息並響應 Invalidate Acknowledge,這樣可能造成 Store Buffer 滿了導致 CPU Stall 的發生。所以,可以引入每個 CPU 的 Invalidate queue 來緩存要處理的 Invalidate 消息。

5.2.5. 簡易 CPU 模型 - 解耦 CPU 的 Invalidate 與 Store Buffer - Invalidate Queues

加入 Invalidate Queues 之後,CPU 結構如下所示: image

有了 Invalidate Queue,CPU 可以將 Invalidate 放入這個隊列之後立刻將 Store Buffer 中的對應數據刷入 CPU 緩存。同時,CPU 在想主動發某個緩存行的 Invalidate 消息之前,必須檢查自己的 Invalidate Queue 中是否有相同的緩存行的 Invalidate 消息。如果有,必須等處理完自己的 Invalidate Queue 中的對應消息再發。

同樣的,Invalidate Queue 也帶來了亂序執行。

5.2.6. 簡易 CPU 模型 - 由於 Invalidate Queues 帶來的進一步亂序 - 需要內存屏障

假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0。假設 CPU A (緩存行裏面包含 a(shared), b(Exclusive))執行:

image CPU B(緩存行裏面包含 a(shared))執行:

image

image 1.CPU A 執行 a = 1:

(1)CPU A 緩存裏面有 a(shared),CPU A 將 a 的修改(a=1)放入 Store Buffer,發送 Invalidate 消息。

2.CPU B 執行 while (b == 0) continue:

(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。

(2)CPU B 收到 CPU A 的 Invalidate 消息,放入 Invalidate Queue 之後立刻返回。

(3)CPU A 收到 Invalidate 消息的響應,將 Store Buffer 中的緩存行刷入 CPU 緩存

3.CPU A 執行 smp_mb():

(1)因爲 CPU A 已經把 Store Buffer 中的緩存行刷入 CPU 緩存,所以這裏直接通過

image 4.CPU A 執行 b = 1:

(1)因爲 CPU A 本身包含 b 的緩存行 (Exclusive),直接更新緩存行即可。

(2)CPU A 收到 CPU B 之前發的 Read 消息,將 b 的緩存行狀態更新爲 Shared,之後發送 Read Response 包含 b 的最新值

(3)CPU B 收到 Read Response, b 的值爲 1

5.CPU B 退出循環,開始執行 assert(a == 1)

(1)由於目前關於 a 的 Invalidate 消息還在 Invalidate queue 中沒有處理,所以 CPU B 看到的還是 a = 0,assert 失敗

所以,我們針對這種亂序,在 CPU B 執行的代碼中也加入內存屏障,這裏內存屏障不僅等待 CPU 刷完所有的 Store Buffer,還要等待 CPU 的 Invalidate Queue 全部處理完。加入內存屏障,CPU B 執行的代碼是:

image

這樣,在前面的第 5 步,CPU B 退出循環,執行 assert(a == 1) 之前需要等待 Invalidate queue 處理完: (1)處理 Invalidate 消息,將 b 置爲 Invalid (2)繼續代碼,執行 assert(a == 1),這時候緩存內不存在 b,需要發 Read 消息,這樣就能看到 b 的最新值 1 了,assert 成功。

5.2.7. 簡易 CPU 模型 - 更細粒度的內存屏障

我們前面提到,在我們前面提到的 CPU 模型中,smp_mb() 這個內存屏障指令,做了兩件事:等待 CPU 刷完所有的 Store Buffer,等待 CPU 的 Invalidate Queue 全部處理完。但是,對於我們這裏 CPU A 與 CPU B 執行的代碼中的內存屏障,並不是每次都要這兩個操作同時存在:

image

所以,一般 CPU 還會抽象出更細粒度的內存屏障指令,我們這裏管等待 CPU 刷完所有的 Store Buffer 的指令叫做寫內存屏障(Write Memory Buffer),等待 CPU 的 Invalidate Queue 全部處理完的指令叫做讀內存屏障(Read Memory Buffer)。

5.2.8. 簡易 CPU 模型 - 總結

我們這裏通過一個簡單的 CPU 架構出發,層層遞進,講述了一些簡易的 CPU 結構以及爲何會需要內存屏障,可以總結爲下面這個簡單思路流程圖:

  1. CPU 每次直接訪問內存太慢,會讓 CPU 一直處於 Stall 等待。爲了減少 CPU Stall,加入了 CPU 緩存
  2. CPU 緩存帶來了多 CPU 間的緩存不一致性,所以通過 MESI 這種簡易的 CPU 緩存一致性協議協調不同 CPU 之間的緩存一致性
  3. 對於 MESI 協議中的一些機制進行優化,進一步減少 CPU Stall:
  4. 通過將更新放入 Store Buffer,讓更新發出的 Invalidate 消息不用 CPU Stall 等待 Invalidate Response。
  5. Store Buffer 帶來了指令(代碼)亂序,需要內存屏障指令,強制當前 CPU Stall 等待刷完所有 Store Buffer 中的內容。這個內存屏障指令一般稱爲寫屏障。
  6. 爲了加快 Store Buffer 刷入緩存,增加 Invalidate Queue,

5.3. CPU 指令亂序相關

CPU 指令的執行,也可能會亂序,我們這裏只說一種比較常見的 - 指令並行化。

5.3.1. 增加 CPU 執行效率 - CPU 流水線模式(CPU Pipeline)

現代 CPU 在執行指令時,是以指令流水線的模式來運行的。因爲 CPU 內部也有不同的組件,我們可以將執行一條指令分成不同階段,不同的階段涉及的組件不同,這樣僞解耦可以讓每個組件獨立的執行,不用等待一個指令完全執行完再處理下一個指令。

一般分爲如下幾個階段:取指(Instrcution Fetch,IF)、譯碼(Instruction Decode,ID)、執行(Execute,EXE)、存取(Memory,MEM)、寫回(Write-Back, WB) image

5.3.2. 進一步降低 CPU Stall - CPU 亂序流水線(Out of order execution Pipeline)

由於指令的數據是否就緒也是不確定的,比如下面這個例子:

image

倘若數據 a 沒有就緒,還沒有載入到寄存器,那麼我們其實沒必要 Stall 等待加載 a,可以先執行 c = 1; 由此,我們可以將程序中,可以並行的指令提取出來同時安排執行,CPU 亂序流水線(Out of order execution Pipeline)就是基於這種思路:

image

如圖所示,CPU 的執行階段分爲:

  1. Instructions Fetch:批量拉取一批指令,進行指令分析,分析其中的循環以及依賴,分支預測等等
  2. Instruction Decode:指令譯碼,與前面的流水線模式大同小異
  3. Reservation stations:需要操作數輸入的指令,如果輸入就緒,就進入 Functoinal Unit (FU) 處理,如果沒有沒有就緒就監聽 Bypass network,數據就緒發回信號到 Reservation stations,讓指令進圖 FU 處理。
  4. Functional Unit:處理指令
  5. Reorder Buffer:會將指令按照原有程序的順序保存,這些指令會在被 dispatched 後添加到列表的一端,而當他們完成執行後,從列表的另一端移除。通過這種方式,指令會按他們 dispatch 的順序完成。

這樣的結構設計下,可以保證寫入 Store Buffer 的順序,與原始的指令順序一樣。但是加載數據,以及計算,是並行執行的。前面我們已經知道了在我們的簡易 CPU 架構裏面,有着多 CPU 緩存 MESI, Store Buffer 以及 Invalidate Queue 導致讀取不到最新的值,這裏的亂序並行加載以及處理更加劇了這一點。並且,結構設計下,僅能保證檢測出同一個線程下的指令之間的互相依賴,保證這樣的互相依賴之間的指令執行順序是對的,但是多線程程序之間的指令依賴,CPU 批量取指令以及分支預測是無法感知的。所以還是會有亂序。這種亂序,同樣可以通過前面的內存屏障避免

5.4. 實際的 CPU

實際的 CPU 多種多樣,有着不同的 CPU 結構設計以及不同的 CPU 緩存一致性協議,就會有不同種類的亂序,如果每種單獨來看,就太複雜了。所以,大家通過一種標準來抽象描述不同的 CPU 的亂序現象(即第一個操作爲 M,第二個操作爲 N,這兩個操作是否會亂序,是不是很像 Doug Lea 對於 JMM 的描述,其實 Java 內存模型也是參考這個設計的),參考下面這個表格: image

我們先來說一下每一列的意思:

  1. Loads Reordered After Loads:第一個操作是讀取,第二個也是讀取,是否會亂序。
  2. Loads Reordered After Stores:第一個操作是讀取,第二個是寫入,是否會亂序。
  3. Stores Reordered After Stores:第一個操作是寫入,第二個也是寫入,是否會亂序。
  4. Stores Reordered After Loads:第一個操作是寫入,第二個是讀取,是否會亂序。
  5. Atomic Instructions Reordered With Loads:兩個操作是原子操作(一組操作,同時發生,例如同時修改兩個字這種指令)與讀取,這兩個互相是否會亂序。
  6. Atomic Instructions Reordered With Stores:兩個操作是原子操作(一組操作,同時發生,例如同時修改兩個字這種指令)與寫入,這兩個互相是否會亂序。
  7. Dependent Loads Reordered:如果一個讀取依賴另一個讀取的結果,是否會亂序。
  8. Incoherent Instruction Cache/Pipeline:是否會有指令亂序執行。

舉一個例子來看即我們自己的 PC 上面常用的 x86 結構,在這種結構下,僅僅會發生 Stores Reordered After Loads 以及 Incoherent Instruction Cache/Pipeline。其實後面要提到的 LoadLoad,LoadStore,StoreLoad,StoreStore 這四個 Java 中的內存屏障,爲啥在 x86 的環境下其實只需要實現 StoreLoad,其實就是這個原因。

5.5. 編譯器亂序

除了 CPU 亂序以外,在軟件層面還有編譯器優化重排序導致的,其實編譯器優化的一些思路與上面說的 CPU 的指令流水線優化其實有些類似。比如編譯器也會分析你的代碼,對相互不依賴的語句進行優化。對於相互沒有依賴的語句,就可以隨意的進行重排了。但是同樣的,編譯器也是隻能從單線程的角度去考慮以及分析,並不知道你程序在多線程環境下的依賴以及聯繫。再舉一個簡單的例子,假設沒有任何 CPU 亂序的環境下,有兩個變量 x = 0,y = 0,線程 1 執行:

image

線程 2 執行:

image

那麼線程 2 是可能 assert 失敗的,因爲編譯器可能會讓 x = 1y = 1 之間亂序。

編譯器亂序,可以通過增加不同操作系統上的編譯器屏障語句進行避免。例如線程一執行:

image

這樣就不會出現 x = 1y = 1 之間亂序的情況。

同時,我們在實際使用的時候,一般內存屏障指的是硬件內存屏障,即通過硬件 CPU 指令實現的內存屏障,這種硬件內存屏障一般也會隱式地帶上編譯器屏障。編譯器屏障一般被稱爲軟件內存屏障,僅僅是控制編譯器軟件層面的屏障,舉一個例子即 C++ 中的 volaile,它與 Java 中的 volatile 不一樣, C++ 中的 volatile 僅僅是禁止編譯器重排即有編譯器屏障,但是無法避免 CPU 亂序。

以上,我們就基本搞清楚了亂序的來源,以及內存屏障的作用。接下來,我們即將步入正題,開始我們的 Java 9+ 內存模型之旅。在這之前,再說一件需要注意的事情:爲什麼最好不要自己寫代碼驗證 JMM 的一些結論,而是使用專業的框架去測試

微信搜索“我的編程喵”關注公衆號,加作者微信,每日一刷,輕鬆提升技術,斬獲各種offerimage 我會經常發一些很好的各種框架的官方社區的新聞視頻資料並加上個人翻譯字幕到如下地址(也包括上面的公衆號),歡迎關注:

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