兩萬字深入剖析快取一致性協議,記憶體屏障
- 計算機基本硬體組成
- 總線
- I/O設備
- 主存盤器
- CPU
- 高速快取存盤器
- 簡介
- 區域性原理
- 具體結構
- 快取一致性協議
- MESI
- 協議狀態遷移
- MESI協議訊息
- Store Buffer / Invalidate Queue
- Store Buffer
- 存盤轉發
- 導致的問題
- StoreLoad重排序
- StoreStore重排序
- Invalidate Queue
- 導致的問題
- 可見性
- x86架構的記憶體屏障
- JMM
- 重排序
- JMM記憶體屏障
- 參考文獻
計算機基本硬體組成
現代計算機的硬體結構設計大體如下圖:

總線
貫穿整個系統的是一組電子管道,稱作總線,他攜帶資訊位元組并負責在各個部件間傳遞,可以理解為計算機中的高速公路,各種需要走這個高速的硬體互相在上面交流資訊,
系統總線分為資料總線(Data BUS)、地址總線(Address BUS)和控制總線(Control BUS),
- 資料總線:傳送CPU與記憶體或其他器件之間的資料,
- 地址總線:CPU用來訪問(讀取/寫入)計算機記憶體的物理地址
- 控制總線:CPU對外界器件進行控制
I/O設備
I/O(輸入/輸出)設備是系統與外部世界的聯系通道,每個I/O設備都通過一個控制器或配接器與 I/O總線相連,其中控制器或配接器用來在I/O總線和I/O設備之間傳遞資訊,
主存盤器
主存盤器就是我們平時說的記憶體,是一個臨時存盤設備,在處理器執行程式時,用來存放程式和程式處理的資料,
對于大多數個人計算機而言,記憶體按位元組來組織,單次訪問的最小單位是1位元組,這是最基本的存盤單位,如下圖,每個存盤單元中,各位的編號分別是 0~7,對應1個位元組8位元,
記憶體中的每位元組都對應著一個地址,如圖 所示,第 1 個位元組的地址是 0000H,第 2 個位元組的地址是 0001H,第 3 個位元組的地址是 0002H,其他依次類推,注意,H就代表這個數字采用的是十六進制表示法,作為一個例子,因為這個記憶體的容量是 65536 位元組,所以最后一個位元組的地址是FFFFH,
64 位處理器包含 64 位的暫存器和算術邏輯部件,盡管記憶體的最小組成單位是位元組,但是,經過精心的設計和安排,它能夠按位元組、字、雙字和四字進行訪問,換句話說,僅通過單次訪問就能處理 8 位、16 位、32 位或者 64 位的二進制數,

CPU
中央處理器 (英語:Central Processing Unit,縮寫:CPU),簡稱處理器,是解釋(或執行)存盤在主存中指令的引擎,處理器的核心是一個大小為 一位元組的存盤設備,稱為程式計數器(Program Counter),簡稱PC,在任何時候,PC都指向記憶體中的某潭訓器語言指令(即含有該條指令的地址),
暫存器檔案是一個小的存盤設備,由一些單個位元組的暫存器組成,每個暫存器都有唯一的名字,暫存器是CPU內部用來存放資料的一些小型存盤區域,用來暫時存放參與運算的資料和運算結果, ALU用來計算新的資料與地址值,
當執行一條指令時,首先需要根據PC中存放的指令地址,將指令由記憶體加載到暫存器中,此程序稱為“取指令”,與此同時,PC中的地址或自動加1或由轉移指標給出下一條指令的地址,此后經過分析指令,執行指令,完成第一條指令的執行,而后根據PC取出第二條指令的地址,如此回圈,執行每一條指令,

高速快取存盤器
簡介
CPU的運算速度和記憶體的訪問速度相差比較大,這就導致CPU每次操作記憶體都要耗費很多等待時間,記憶體的讀寫速度成為了計算機運行的瓶頸,于是就有了在CPU和主記憶體之間增加快取的設計,引入高速快取后,處理器在執行記憶體讀寫操作時并不直接與主記憶體打交道,而是通過高速快取進行,一般在CPU上集成了多級快取架構,常見的為三級快取結構,如下圖:

L1 Cache是物理位置最接近CPU的,它容量最小,例如256K,速度最快,分為資料快取(d-cache)和指令快取(i-cache),每個核上都是獨立的,L2 Cache更大一些,例如1M,速度要慢一些,一般情況下每個核上都有一個獨立的L2 Cache,但是L2 Cache位置比L1 Cache距離 CPU核心更遠,L3 Cache是三級快取中最大的一級,例如6MB,同時也是快取中最慢的一級,在同一個CPU插槽之間的核共享一個L3 Cache,
從下面這個圖可以看出:Memory的Latency(記憶體延遲:指等待對系統記憶體中存盤資料的訪問完成時引起的延期)要比Cache大得多,

tips:超執行緒技術:模擬出的兩個邏輯內核共享同一個CPU資源,所以同一時刻可以有兩個執行緒都占用CPU資源,因此這兩個執行緒都可以得到執行,這就是實作同一時間執行兩個執行緒的并行操作,邏輯執行緒會共享cache,導致cache的競爭,如下圖的四核八執行緒就是運用了超執行緒技術,了解即可,
一個運算單元對應兩個Register/PC
那你可能要問了,既然高速快取這么快,為什么還要用記憶體?其實,在下圖的層次結構中,自下到上,設備的訪問速度越來越快,但是每位元組的造價也越來越貴,同時CPU的空間也是有限的,并且在CPU訪問存盤設備時,無論是存取資料還是存取指令,都趨于聚集在一片連續的區域中,這就被稱為區域性原理, 而高速快取存盤器作為暫時的存盤區域,正存放了處理器近期可能需要的資訊,

區域性原理
- 時間區域性(Temporal Locality):如果一個資訊項正在被訪問,那么在近期它很可能還會被再次訪問, 比如回圈、遞回、方法的反復呼叫等,
- 空間區域性(Spatial Locality):如果一個存盤器的位置被參考,那么將來它附近的位置可能也會被參考, 比如順序執行的代碼、連續創建的兩個物件、陣列等,
具體結構
假設記憶體地址有m位,則可以形成M=2^m個不同的地址,同時高速快取被組織成一個有S=2^s個高速快取組(cache set)的陣列,每個組包含包含E個高速快取行(cache line),快取行大小通常為64byte,Intel Core i7高速快取的基本資訊如圖:

一個高速快取行包括下面三個結構:
- 資料塊(cache block):每個資料塊包含B=
2^b位元組,同時,資料總是以塊大小為傳送單元(transfer unit)在不同存盤器之間進行傳輸,雖然在層次結構中任何一對相鄰的層次之間塊大小是固定的,但是其他的層次對之間可以有不同的塊大小,例如,上圖中L1和L0之間的傳送通常使用一個位元組大小的塊,L2和L1的傳送通常使用幾十個位元組的塊, - 有效位(valid bit):指明這個行是否包含有意義的資訊,
- 標記位(tag bit):是當前塊的記憶體地址的位的一個子集,它們唯一地標識存盤在這個高速快取行中的塊,
一個地址(address)包含下面三個欄位:
- 組索引(Set index):有s位,對應
S=2^s中的s,每一個不同的組索引正好對應了一個陣列索引,通過它可以找到資料放在了哪個組, - 塊偏移(Block offset): 有b位,對應
B=2^b中的b,通過這個可以知道資料在資料塊中的字偏移, - 標記(tag):有
m-(s+b)位,用來告訴我們這個組中的哪一行包含了這個字,當且僅當設定了有效位并且該行的標記位與地址A中的標記位相匹配時,組中的一行才包含這個字,

符號小結如下:

上面有些抽象,舉個例子,假設記憶體地址有12位,即m=12,高速快取有64個組,即S=64,則地址(address)中的組索引位數量為log2(64)=5,每個組2行,每個塊8個位元組,則地址(address)中b=log2(8)=3,故標記位t=m-(s+b)=12-8=4

- 首先根據地址的組索引找到相對應的組,上圖組索引為00001,找到第一組(Set 1)

- 第二步進行行匹配,它必須檢查多個行的標記位和有效位,如果高速快取找到,則快取命中(Cache Hit),如果快取不命中,高速快取就必須從記憶體中取出包含這個資料的塊,并根據某種策略替換某個快取行,
上面介紹的是簡化版的高速快取讀取程序,現代處理器一般有多級快取,CPU讀取資料程序如下:
CPU要取暫存器X的值,只需要一步:直接讀取,CPU要取L1 cache的某個值,需要1-3步(或者更多):把cache行鎖住,把某個資料拿來,解鎖,CPU要取L2 cache的某個值,先要到L1 cache里取,L1當中不存在,在L2里,L2開始加鎖,加鎖以后,把L2里的資料復制到L1,再執行讀L1的程序,上面的3步,再解鎖,CPU取L3 cache的也是一樣,只不過先由L3復制到L2,從L2復制到L1,從L1到CPU,CPU取記憶體則最復雜:通知記憶體控制器占用總線帶寬,通知記憶體加鎖,發起記憶體讀請求,等待回應,回應資料保存到L3,再由L3復制到L2,從L2復制到L1,從L1到CPU,之后解除總線鎖定,
快取一致性協議
在多處理器系統中,每個處理器都有自己的高速快取,而它們又共享同一主記憶體 (Main Memory),基于高速快取的存盤互動很好地解決了處理器與記憶體的速度矛盾,但是也引入了新的問題:快取一致性(Cache Coherence),當多個處理器的運算任務都涉及同一 塊主記憶體區域時,將可能導致各自的快取資料不一致的情況,
案例:為了討論方便,用下面簡化的模型進行討論,假設我們有個兩核cpu,每個cpu只有一個cache

- 假設
CPU0和CPU1分別讀取記憶體中的資料i(Cache0和Cache1操作的是一個變數,i類似于一個共享變數),此時Cache0和Cache1中的i都是0, CPU0首先進行加1操作,此時Cache0中的i為1,在Cache0還未寫到記憶體的時候,CPU0也進行加1操作,Cache1此時的i也為1,- 一會
Cache0和Cache1分別把資料寫回記憶體,記憶體中的i此時為1,
對共享變數i進行了兩次加1操作,i結果應該為2,卻為1,
public class Demo1 {
private static int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
counter++; //不是一個原子操作,第一輪回圈結果是沒有刷入主存,這一輪回圈已經無效
}
});
thread.start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("結果為"+counter);
}
}
輸出結果不是2000,與上面的原因類似,

如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?這個問題的本質就是如何 防止讀臟資料和丟失更新 的問題,為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、 MESI(Modified-Exclusive-Shard-Invalid)、MOSI等等,
MESI
MESI協議是一種廣為使用的快取一致性協議,x86處理器所使用的快取一致性協議就是基于MESI協議的,
”MESI“該名稱來自4個狀態的首字母的縮寫,分別為下面四種:
-
Modified(更改過的,記為M) :代表該快取行中的內容被修改了,并且該快取行只被快取在該CPU中,這個狀態的快取行中的資料和記憶體中的不一樣,在未來的某個時刻它會被寫入到記憶體中(當其他CPU要讀取該快取行的內容時,或者其他CPU要修改該快取對應的記憶體中的內容時,同時,由于MESI協議中的任意一個時刻只能夠有一個處理器對同一記憶體地址對應的資料進行更新,因此在多個處理器上的高速快取中,Tag值相同的快取條目,任意時刻只能夠有一個快取條目處于該狀態, 其快取行中包含的資料與主記憶體中包含的資料不一致,
-
Exclusive(獨占的,記為E) :和
Shared狀態一樣,表明該cache line是記憶體中某一段資料的拷貝,區別在于,該cache line獨占該記憶體地址的副本資料,其它處理器的cache line不能同時持有它,其快取行中包含的資料與主記憶體中包含的資料一致, -
Shared(共享的,記為S) :該狀態表示相應快取行包含相應記憶體地址所對應的副本資料,并且其他處理器上的高速快取中也可能包含相同記憶體地址對應的副本資料,當前 CPU 不能對其進行修改,要修改的話需要轉為 Exclusive 狀態后再進行;
-
Invalid(無效的,記為I) :表示相應快取行中不包含任何記憶體地址對應的有效副本資料,該狀態是快取條目的初始狀態,
協議狀態遷移
Snoopy 協議,這種協議更像是一種資料通知的總線型的技術,CPU Cache通過這個協議可以識別其它Cache上的資料狀態,如果有資料共享的話,可以通過廣播機制將共享資料的狀態通知給其它CPU Cache,這個協議要求每個CPU Cache 都可以“嗅探”資料事件的通知并做出相應的反應,

處理器對快取的請求:
PrRd: 處理器請求讀一個快取塊PrWr: 處理器請求寫一個快取塊
總線對快取的請求:
BusRd: 嗅探器請求指出其他處理器請求讀一個快取塊BusRdX: 嗅探器請求指出其他處理器請求寫一個該處理器不擁有的快取塊BusUpgr: 嗅探器請求指出其他處理器請求寫一個該處理器擁有的快取塊Flush: 嗅探器請求指出請求回寫整個快取到主存FlushOpt: 嗅探器請求指出整個快取塊被發到總線以發送給另外一個處理器(快取到快取的復制)
處理器操作帶來的狀態轉化

不同總線操作帶來的狀態轉化

| 步驟 | 請求 | CPU0 | CPU1 | CPU2 | 產生的總線請求 | 資料提供者 |
|---|---|---|---|---|---|---|
| 0 | 初始 | I | I | I | - | - |
| 1 | R0 | E | I | I | BusRd | Memory |
| 2 | W0 | M | I | I | - | - |
| 3 | R2 | S | I | S | BusRd | CPU0’s Cache |
| 4 | W2 | I | I | M | BusUpgr | - |
| 5 | R0 | S | I | S | BusRd | CPU2’s Cache |
| 6 | R2 | S | I | S | - | - |
| 7 | R1 | S | S | S | BusRd | CPU0/CPU2’s Cache |
https://www.scss.tcd.ie/Jeremy.Jones/vivio/caches/MESI.htm 這個網站的影片可以幫助理解
-
R0:
cpu0的讀操作,cpu0先在cpu0快取找,目前快取都是Invalid狀態找不到,所以給總線發BusRd信號,有一個地址總線,就是路由cpu的和主存,同時去cpu的快取和主存找,比較版本,去主存拿x,拿到x的值通過資料總線將值賦值cpu0的快取,因為其它快取都沒有有效的快取,故狀態轉換為Exclusive(E)狀態,

-
w0:無總線事務生成,直接獲取
cpu0的x=0,進行加1(這里不會更新主存,直接修改cpu0快取中的值)
-
R2:首先
cpu2請求讀一個快取塊,快取狀態為Invalid(I),給總線發BusRd信號,其它處理器看到BusRd信號,檢查自己是否有有效的資料副本,比如cpu0嗅探到BusRd信號,發現自己有這個資料,所以狀態變為共享(Shared),發出總線FlushOpt信號并發出塊的內容,接收者為最初發出BusRd的快取與主存控制器(回寫主存),
-
W2:其它操作與上述內容類似,照著上表理解一下即可,

-
R0:

-
R2:

-
R1:

MESI協議訊息
為了后續操作的描述,對上述訊息進行簡化,定義下面的訊息協調各個處理器的讀寫操作
| 訊息名 | 訊息型別 | 描述 |
|---|---|---|
| Read | 請求 | 通知其他處理器和記憶體,當前處理器準備讀取某個資料,該訊息內包含待讀取資料的記憶體地址 |
| Read Response | 回應 | 該訊息內包含被請求讀取的資料,該訊息可能是主記憶體提供的,也可能是嗅探Read訊息的其他高速快取提供的 |
| Invalidate | 請求 | 通知其他處理器將其高速快取中指定記憶體地址對應的快取條目狀態置為I,即通知這些處理器洗掉指定記憶體地址的副本資料(洗掉指邏輯洗掉,實際上是更新相應快取條目的Flag值) |
| Invalidate Acknowledge | 回應 | 接收到Invalidate訊息的處理器必須回復此訊息,表示已經洗掉了其高速快取內對應的資料副本 |
| Read Invalidate | 請求 | 此訊息為Read 和 Invalidate訊息組成的復合訊息,其作用在于通知其他處理器當前處理器準備更新一個資料,并請求其他處理器洗掉其高速快取內對應的副本資料,接收到該訊息的處理器必須回復Read Response 和 Invalidate Acknowledge訊息 |
| Writeback | 回應 | 訊息包含了需要寫入主記憶體的資料和其對應的記憶體地址 |

假設cpu0要讀取資料S,首先cpu0會根據地址找到對應的高速快取行,
- 如果該高速快取行的狀態為M,E或者S,那么該處理器可以直接從相應的快取行中讀取資料,而無需往總線發送任何訊息,
- 如果找到的高速快取行的狀態為I,則說明該處理器的高速快取行中并不存在該有效資料副本,此時cpu0會向總線發送Read訊息,cpu1嗅探到Read訊息后,會從該訊息中取出待讀取的記憶體地址,并根據該地址在其高速快取器中查找對應的高速快取行,
- 如果cpu1找到的高速快取行的狀態為I,則說明該處理器也沒有該快取資料,那么cpu0所接收到的Read Response訊息就來自主記憶體,
- 如果cpu1找到的高速快取行的狀態為S/E,那么cpu1會構造相應的Read Response訊息并將相應快取行中的資料塞入該資訊;
- 如果cpu1找到的高速快取行的狀態為M,那么cpu1可能在往總線發送Read Response訊息前將相應的資料寫入主記憶體,之后,該高速快取行的狀態也會被更新為S,

下面看一下cpu0往地址A寫資料的實作,任何一個資料執行記憶體寫操作時必須擁有相應資料的所有權,在執行記憶體寫操作的時候,cpu0會根據記憶體地址A找到相應的高速快取行,
- cpu0找的高速快取行的狀態為E/M,則說明該處理器已經擁有相應資料的使用權,此時該處理器可以直接將資料寫入相應的快取行并將相應快取行的狀態更新為M,
- cpu0找到的高速快取行的狀態如果為S,則說明cpu1上的高速快取也可能保留了地址A對應的資料副本,此時cpu0需要往總線發送Invalidate訊息,cpu0在接收到其他處理器所回復的Invalidate Acknowledge訊息之后會將相應的快取條目的狀態更新為E,此時cpu0獲得了地址A上資料的所有權,接著,cpu0便可以將資料寫入相應的快取行,并將相應高速快取行的狀態更新為M,
- cpu0找到的高速快取行的狀態如果為I,則表示該處理器不包含地址A對應的有效副本資料,此時cpu0會向總線發送Read Invalidate訊息,其他處理器接收到Invalidate訊息或者Read Invalidate訊息后,必須根據訊息中包含的記憶體地址在該處理器的高速快取中找到相應的高速快取行,如果cpu1找到的高速快取行的狀態不為I,那么cpu1必須將相應高速快取行的狀態更新為I,以洗掉相應的副本資料并給總線回復Invalidate Acknowledge訊息, cpu0在接收到Read Response訊息以及其它處理器所回復的Invalidate Acknowledge訊息之后,會將相應高速快取行中的狀態更新為E,接著cpu0便可以往相應的快取行中寫入資料并將狀態更新為M,
Store Buffer / Invalidate Queue
MESI解決了快取一致性問題,但是它有一個性能弱點:處理器執行寫操作時,必須等待其他處理器將其高速快取中的相應副本資料洗掉并接收到這些處理器所回復的Invalidate Acknowledge/Read Response訊息之后才能將資料寫入高速快取,

為了規避和減少這種等待造成的寫操作的延遲(Latency),硬體設計者引入了Store Buffer和Invalidate Queue,
Store Buffer
Store Buffer是處理器內部的一個容量比高速快取器還小的私有高速存盤部件,每個處理器都有其寫存盤器,并且一個處理器無法讀取另外一個處理器上的Store Buffer中的內容,

引入Store Buffer后,處理器在執行寫操作時
| 快取狀態 | 操作 |
|---|---|
| E/M | 處理器可能會直接將資料寫入相應的快取行而無需發送任何訊息(取決于具體處理器的實作) |
| S | 處理器會先將寫操作的相關資料存入Store Buffer中,并發送Read Invalidate訊息, |
| I | I狀態表示此處理器的高速快取不含有待操作的資料,遇到了寫未命中(Write Miss),處理器會先將寫操作的相關資料寫入Store Buffer,并發送Read Invalidate訊息, |
在執行完上面的某一個狀態的操作后,處理器可以并不等待其他處理器回傳Invalidate Acknowledge/Read Response訊息而是繼續執行其他指令,當一個處理器接收到其他處理器所回復的針對同一個快取行的所有Invalidate Acknowledge訊息的時候,該處理器會將Store Buffer中針對相應地址的寫操作的結果寫入相應的快取行,此時寫操作對于其他處理器來說才算完成,
存盤轉發
有了Store Buffer后,一個處理器在更新了一個變數之后,馬上又讀取了該變數的值,但是由于該處理器先前對該變數的更新結果可能仍然還停留在Store Buffer中,因此該變數相應的記憶體地址所對應的快取行中仍存盤著該變數的舊值,
因此處理器在執行讀操作的時候會根據記憶體地址查詢Store Buffer,
- 如果Store Buffer存在該資料,那么會直接將該資料作為結果回傳,
- 如果不存在,處理器會從高速快取中讀取資料,
這種處理器直接從Store Buffer中讀取資料來實作記憶體讀操作的技術被稱為存盤轉發(Store Buffer),
導致的問題
StoreLoad重排序
假設處理器cpu0和cpu1上的兩個執行緒未使用任何同步措施而各自按照下表的順序交錯執行,其中X,Y為共享變數,其初始值為0,r1,r2為區域變數,
| cpu0 | cpu1 |
|---|---|
| X=1;//S1 | Y=1;//S3 |
| r1=Y;//L2 | |
| r2=X;//L4 |
當cpu0執行到L2時,雖然在此之前S3已經被cpu1執行了,但是由于S3的執行結果可能仍然停留在cpu1的Store Buffer中,而一個處理器無法讀取另外一個處理器的Store Buffer中的內容,所以cpu0此時讀取到的Y值仍然是其高速快取中存在的該變數的初始值0,同理,cpu1執行到L4時所讀取到變數X的值也可能是該變數的初始值0,
因此,從cpu1的角度來看,cpu1執行L4的那一刻cpu0已經執行了L2而S1卻像是尚未被執行,即cpu1對cpu0執行的兩個操作的感知順序是L2——>S1,也就是此時Store Buffer導致了S1(寫操作)被重排序到了L2(讀操作)之后,這個即StoreLoad重排序,
StoreStore重排序
同樣假設處理器cpu0和cpu1上的兩個執行緒未使用任何同步措施而各自按照下表的順序交錯執行,其中data,ready為共享變數,其初始值分別為0、false,同時cpu0的Cache中包含變數ready(狀態為E/M)的副本,但不包含變數data的副本,那么S1的執行結果會被存入Store Buffer而S2的執行結果會直接存入Cache,

| cpu0 | cpu1 |
|---|---|
| data=1;//S1 | |
| ready=true;//S2 | |
| while(!ready) continue;//L3 | |
| print(data);//L4 |
L3被執行時S2對變數ready的更新通過快取一致性協議可以被cpu1讀取到,于是,由于變數ready變為true,cpu1繼續執行L4,L4被執行時,由于S1對data的更新結果仍然放在cpu0的Store Buffer中,因此cpu1此時讀取到的變數data的值可能仍是其初始值0,
從cpu1的角度來看,S2像是先于S1被執行,即S1(寫操作)被重排序到了S2(寫操作)之后,這個即StoreStore重排序,
Invalidate Queue
引入Invalidate Queue之后,處理器在接收到Invalidate訊息之后并不洗掉訊息中指定地址的副本資料,而是將訊息存入 Invalidate Queue之后就回復Invalidate Acknowledge訊息,從而減少了執行寫操作的處理器所需的等待問題,
導致的問題

假設還是按照上表執行,但此時cpu0的Cache中存有變數data和ready的副本,cpu1含有變數data的副本而不含有變數ready的副本,
- cpu0執行S1,此時由于cpu1上存有變數data的副本,因此cpu0會發出Invalidate訊息并將S1的結果存入StoreBuffer,
- cpu1接收到cpu0發出的Invalidate訊息時將該訊息存入其Invalidate Queue并立馬回復Invalidate Acknowledge訊息,
- cpu0接收到Invalidate Acknowledge訊息,隨即將S1的操作結果寫入高速快取,然后,cpu0執行S2,此時只有cpu0上存有變數ready的副本,因此cpu0無需發送任何訊息,直接將S2的操作結果存入高速快取即可,
- cpu1執行L3,由于cpu1的高速快取并沒有存盤變數ready的副本,因此cpu1發出一個Read訊息,
- cpu0接收到cpu1發出的Read訊息并回復Read Response訊息,其中該Read Response訊息包含的ready變數值為true,
- cpu1接收到Read Response訊息并從中取出ready變數的值(true),此時L3的回圈陳述句可以結束,
- cpu1執行L4,此時cpu0為了更新變數data而發出的Invalidate訊息可能仍停留在cpu1的Invalidate Queue中,因此cpu1從其高速快取中讀取的變數data的值仍然是其初始值0,
由上可知,由于Invalidate Queue的作用cpu1像是在變數ready不為true的情況下提前讀取了變數data的值,也就是說,從cpu0的角度看,L4(讀操作)被重排序到了L3(讀操作)之前,即LoadLoad重排序,
可見性
可見性即一個執行緒對共享變數值的修改,能夠及時被其他執行緒看到, 但是由于Store Buffer和Invalidate Queue的存在,使得資料可能不被其他執行緒及時看到,
- 現代處理器在一些特定條件下(比如Store Buffer滿,I/O指令被執行)會將Store Buffer中的內容寫入高速快取,但是這種寫入并不一定是及時的,也就是說Store Buffer中的資料可能并沒有重繪到高速快取,
- 處理器在執行記憶體讀取操作的時候如果沒有根據Invalidate Queue中的內容將該處理器上的高速快取中的相關副本資料洗掉,那么也可能導致該處理器讀到的資料是過時的資料,
為了解決上面的兩個問題,首先要使寫執行緒對共享變數所做的更新能夠及時到達高速快取,從而使該更新對其他處理器是同步的;其次,讀執行緒所在的處理器要將其Invalidate Queue中的內容進行處理,保證讀執行緒讀到的資料是新的,而底層系統會借助一類被稱為記憶體屏障的特殊指令,
- Store Barrier:可以使執行該指令的處理器沖刷其Store Buffer中的資料到快取,從而保證某執行緒對共享變數所做的更新對讀執行緒是可見的,
- Load Barrier:會根據Invalidate Queue中內容所指定的記憶體地址,將處理器上高速快取中的狀態標記為I,從而使該處理器后續執行這些地址的讀操作時必須發送Read訊息,從而保證了處理器讀到的資料是新的,
x86架構的記憶體屏障
目前的PC架構絕大多數都是Intel的x86架構,而x86處理器沒有使用Invalidate Queue,同時不管高速快取行的狀態是什么,它都會直接將一個寫操作的結果存入Store Buffer,因而x86處理器只允許StoreLoad重排序的發生,
x86指令集提供了三個記憶體屏障指令:
- lfence :x86處理器并沒有Invalidate Queue,這個指令的作用只是強制在Ifence指令之前的操作一定在該指令之前完成,
- sfence :實作了Store Barrier,強制所有在sfence指令之前的store指令,都在該sfence指令執行之前被執行,并把store buffer中的資料沖刷到CPU的Cache中;所有在sfence指令之后的store指令,都在該sfence指令執行之后被執行,
- mfence :綜合了sfence指令與lfence指令的作用,強制所有在mfence指令之前的store/load指令,都在該mfence指令執行之前被執行;所有在mfence指令之后的store/load指令,都在該mfence指令執行之后被執行,
JMM
上面我們花了大量的篇幅講述快取一致性協議,明白快取一致性協議確保了一個處理器對某個記憶體地址進行的寫操作的結果能夠被其他處理器讀取,但并不能保證一個處理器對共享變數所做的更新具體在什么時候能夠被其他處理器讀取,比如Store Buffer,Invalidate Queue的存在可能導致一個處理器讀取到共享變數的舊值,為了解決這個問題,又引入了記憶體屏障,但是由于多種處理器架構的存在,它們對有序性的保障也各不相同,例如x86處理器僅支持StoreLoad重排序,而ARM處理器支持四種重排序,
Java作為一個跨平臺(跨作業系統和硬體)的語言,為了屏蔽不同處理器的差異,避免Java程式員根據不同的處理器撰寫不同的代碼,定義了Java記憶體模型(Java Memory Model),簡稱JMM,Java記憶體模型是一套規范,描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數存盤到記憶體和從記憶體中讀取變數這樣的底層細節,
Java記憶體模型規定了所有的變數都存盤在主記憶體中,每條執行緒還有自己的作業記憶體,執行緒的作業記憶體中保存了該執行緒中是用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在作業記憶體中進行,而不能直接讀寫主記憶體,不同的執行緒之間也無法直接訪問對方作業記憶體中的變數,執行緒間變數的傳遞均需要自己的作業記憶體和主存之間進行資料同步進行,

這里面提到的主記憶體和作業記憶體,可以簡單的類比成計算機記憶體模型中的主存和快取的概念,特別需要注意的是,主記憶體和作業記憶體與JVM記憶體結構中的Java堆、堆疊、方法區等并不是同一個層次的記憶體劃分,無法直接類比,如果兩者一定要勉強對應起來,那么從變數、主記憶體、作業記憶體的定義來看,主記憶體主要對應于Java堆中的物件實體資料部分,而作業記憶體則對應于虛擬機堆疊中的部分區域,但這也只是大致劃分,從更基礎的層次上說,主記憶體直接對應于物理硬體的記憶體,而為了獲取更好的運行速度,虛擬機(或者是硬體、作業系統本身的優化措施)可能會讓作業記憶體優先存盤于暫存器和高速快取中,因為程式運行時主要訪問的是作業記憶體,
重排序
在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排序,重排序分3 種型別,
- 編譯器優化的重排序,編譯器在不改變單執行緒程式語意的前提下,可以重新安排 陳述句的執行順序,
- 指令級并行的重排序,現代處理器采用了指令級并行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行,如果不存在資料依賴性,處理器可以改變陳述句 對應機器指令的執行順序,
- 記憶體系統的重排序,由于處理器使用快取和讀/寫緩沖區,這使得加載和存盤操作 看上去可能是在亂序執行, 從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序,

對于編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序(不是所有的編譯器重排序都要禁止),對于2和3,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障(指令,通過記憶體屏障指令來禁止特定型別的處理器重排序,
JMM記憶體屏障
JVM 一共提供了四種 Barrier,比如 LoadLoad Barrier 就是放在兩次 Load 操作中間的 Barrier,LoadStore 就是放在 Load 和 Store 中間的 Barrier,具體如下:
|
| 屏障型別 | 指令示例 | 說明 |
|---|---|---|
| LoadLoad Barriers | Load1;LoadLoad;Load2 | 用于保證訪問 Load2 的讀取操作一定不能重排到 Load1 之前,類似于前面說的 Read Barrier,需要先處理 Invalidate Queue 后再讀 Load2; |
| StoreStore Barriers | Store1;StoreStore;Store2 | 用于保證 Store1 及其之后寫出的資料一定先于 Store2 寫出,即別的 CPU 一定先看到 Store1 的資料,再看到 Store2 的資料,可能會有一次 Store Buffer 的刷寫,也可能通過所有寫操作都放入 Store Buffer 排序來保證; |
| LoadStore Barriers | Load1;LoadStore;Store2 | 用于保證 Store2 及其之后寫出的資料被其它 CPU 看到之前,Load1 讀取的資料一定先讀入快取,甚至可能 Store2 的操作依賴于 Load1 的當前值, |
| StoreLoad Barriers | Store1;StoreLoad;Load2 | 用于保證 Store1 寫出的資料被其它 CPU 看到后才能讀取 Load2 的資料到快取,如果 Store1 和 Load2 操作的是同一個地址,StoreLoad Barrier 需要保證 Load2 不能讀 Store Buffer 內的資料,得是從記憶體上拉取到的某個別的 CPU 修改過的值,StoreLoad 一般會認為是最重的 Barrier 也是能實作其它所有 Barrier 功能的 Barrier, |
這四個 Barrier 只是 Java 為了跨平臺而設計出來的,實際上根據 CPU 的不同,對應 CPU 平臺上的 JVM 可能會優化掉一些 Barrier,比如在 x86 平臺的JVM上只剩下一個 StoreLoad Barrier被使用,
下一篇文章會介紹一下volatile
參考文獻
[1]周志明.深入理解Java虛擬機(第3版).機械工業出版社,2019.
[2]Randal E.Bryant / David O’Hallaron.深入理解計算機系統(原書第3版).機械工業出版社,2016.
[3] [美] Paul E.Mckenney(保羅·E·麥肯尼). 深入理解并行編程.電子工業出版社,2017.
[4] 黃文海. Java多執行緒編程實戰指南(核心篇).電子工業出版社,2017.
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/276191.html
標籤:其他



