啃碎併發(10):記憶體模型之內部原理

猿碼道發表於2019-02-26

0 前言

如上一篇文章所述,Java記憶體模型規範了Java虛擬機器與計算機記憶體是如何協同工作的。Java虛擬機器是一個完整計算機的模型,因此,這個模型自然會包含一個記憶體模型—又稱為Java記憶體模型。

如果你想設計表現良好的併發程式,理解Java記憶體模型是非常重要的。Java記憶體模型規定了如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數

1 Java記憶體模型

我們先來看看Java 執行緒執行記憶體示意圖,如下圖所示:

Java 執行緒執行記憶體示意圖

這張圖告訴我們線上程執行的時候有一個記憶體專用的一小塊記憶體,當Java程式會將變數同步到執行緒所在的記憶體,這時候會操作工作記憶體中的變數,而執行緒中變數的值何時同步回主記憶體是不可預期的

因此,依據上面圖的執行緒執行記憶體示意圖,Java記憶體模型在JVM內部抽象劃分為執行緒棧和堆。如下圖所示:

JMM劃分為執行緒棧和堆

1.1 執行緒棧與堆

每一個執行在Java虛擬機器裡的執行緒都擁有自己的執行緒棧。這個執行緒棧包含了 執行緒呼叫的方法當前執行點相關的資訊,同時執行緒棧具有如下特性:

  1. 一個執行緒僅能訪問自己的執行緒棧;
  2. 一個執行緒建立的本地變數對其它執行緒不可見,僅自己可見;

即使兩個執行緒執行同樣的程式碼,這兩個執行緒任然在在自己的執行緒棧中的程式碼來建立本地變數。因此,每個執行緒擁有每個本地變數的獨有版本

所有原始型別的本地變數都存放線上程棧上,因此對其它執行緒不可見。一個執行緒可能向另一個執行緒傳遞一個原始型別變數的拷貝,但是它不能共享這個原始型別變數自身。

堆上包含在Java程式中建立的所有物件,無論是哪一個物件建立的。這包括原始型別的物件版本。如果一個物件被建立然後賦值給一個區域性變數,或者用來作為另一個物件的成員變數,這個物件任然是存放在堆上

所以,呼叫棧和本地變數存放線上程棧上,物件存放在堆上,如下圖所示:

執行緒棧與堆 & 變數、物件、呼叫棧

  1. 一個本地變數可能是原始型別,在這種情況下,它總是“呆在”執行緒棧上
  2. 一個本地變數也可能是指向一個物件的一個引用。在這種情況下,引用(這個本地變數)存放線上程棧上,但是物件本身存放在堆上
  3. 一個物件可能包含方法,這些方法可能包含本地變數。這些本地變數任然存放線上程棧上,即使這些方法所屬的物件存放在堆上
  4. 一個物件的成員變數可能隨著這個物件自身存放在堆上。不管這個成員變數是原始型別還是引用型別。
  5. 靜態成員變數跟隨著類定義一起也存放在堆上

存放在堆上的物件可以被所有持有對這個物件引用的執行緒訪問。當一個執行緒可以訪問一個物件時,它也可以訪問這個物件的成員變數。如果兩個執行緒同時呼叫同一個物件上的同一個方法,它們將會都訪問這個物件的成員變數,但是每一個執行緒都擁有這個本地變數的私有拷貝。

上面說到的幾點,如下圖所示:

棧、堆 & 本地變數、靜態變數

1.2 CPU與記憶體

眾所周知,CPU是計算機的大腦,它負責執行程式的指令。記憶體負責存資料,包括程式自身資料。同樣大家都知道,記憶體比CPU慢很多,現在獲取記憶體中的一條資料大概需要200多個CPU週期(CPU cycles),而CPU暫存器一般情況下1個CPU週期就夠了。下面是CPU Cache的簡單示意圖:

CPU Cache示意圖

隨著多核的發展,CPU Cache分成了三個級別:L1,L2,L3。級別越小越接近CPU,所以速度也更快,同時也代表著容量越小。

  1. L1是最接近CPU的,它容量最小,例如32K,速度最快,每個核上都有一個L1 Cache(準確地說每個核上有兩個L1 Cache, 一個存資料 L1d Cache, 一個存指令 L1i Cache)。
  2. L2 Cache 更大一些,例如256K,速度要慢一些,一般情況下每個核上都有一個獨立的L2 Cache。
  3. L3 Cache是三級快取中最大的一級,例如12MB,同時也是最慢的一級, 在同一個CPU插槽之間的核共享一個L3 Cache。
從CPU到 大約需要的CPU週期 大約需要的時間(單位ns)
暫存器 1 cycle
L1 Cache ~3-4 cycles ~0.5-1 ns
L2 Cache ~10-20 cycles ~3-7 ns
L3 Cache ~40-45 cycles ~15 ns
跨槽傳輸 ~20 ns
記憶體 ~120-240 cycles ~60-120ns

在Linux下面用 cat /proc/cpuinfo,或Ubuntu下 lscpu 看看自己機器的快取情況,更細的可以通過以下命令看看:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K
$ cat /sys/devices/system/cpu/cpu0/cache/index0/type
Data
$ cat /sys/devices/system/cpu/cpu0/cache/index0/level
1
$ cat /sys/devices/system/cpu/cpu3/cache/index3/level
3
複製程式碼

就像資料庫cache一樣,獲取資料時首先會在最快的cache中找資料,如果沒有命中(Cache miss) 則往下一級找,直到三層Cache都找不到,那隻要向記憶體要資料了。一次次地未命中,代表獲取資料消耗的時間越長。

同時,為了高效地存取快取,不是簡單隨意地將單條資料寫入快取的。快取是由快取行組成的,典型的一行是64位元組。可以通過下面的shell命令,檢視 cherency_line_size 就知道知道機器的快取行是多大:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
複製程式碼

CPU存取快取都是以“行”為最小單位操作的。比如:一個Java long型佔8位元組,所以從一條快取行上你可以獲取到8個long型變數。所以如果你訪問一個long型陣列,當有一個long被載入到cache中, 你將無消耗地載入了另外7個。所以你可以非常快地遍歷陣列。

2 快取一致性

由於CPU和主存的處理速度上存在一定差別,為了匹配這種差距,提升計算機能力,人們在CPU和主存之間增加了多層快取記憶體。每個CPU會有L1、L2甚至L3快取,在多核計算機中會有多個CPU,那麼就會存在多套快取,在這多套快取之間的資料就可能出現不一致的現象。為了解決這個問題,有了記憶體模型。記憶體模型定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範。通過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。

其實Java記憶體模型告訴我們通過使用關鍵詞 “synchronized”“volatile” 可以讓Java保證某些約束:

  1. “volatile” — 保證讀寫的都是主記憶體的變數。
  2. “synchronized” — 保證在塊開始時都同步主記憶體的值到工作記憶體,而塊結束時將變數同步回主記憶體。

通過以上描述我們就可以寫出執行緒安全的Java程式,JDK也同時幫我們遮蔽了很多底層的東西。

所以,在編譯器各種優化及多種型別的微架構平臺上,Java語言規範制定者試圖建立一個虛擬的概念並傳遞到Java程式設計師,讓他們能夠在這個虛擬的概念上寫出執行緒安全的程式來,而編譯器實現者會根據Java語言規範中的各種約束在不同的平臺上達到Java程式設計師所需要的執行緒安全這個目的

那麼,在多種型別微架構平臺上,又是如何解決快取不一致性問題的呢?這是眾多CPU廠商必須解決的問題。為了解決前面提到的快取資料不一致的問題,人們提出過很多方案,通常來說有以下2種方案:

  1. 通過在匯流排加LOCK#鎖的方式;
  2. 通過快取一致性協議(Cache Coherence Protocol);

2.1 匯流排的概念

首先,上面的兩種方案,其實都涉及到了匯流排的概念,那到底什麼是匯流排呢?匯流排是處理器與主存以及處理器與處理器之間進行通訊的媒介,有兩種基本的互聯結構:SMP(symmetric multiprocessing 對稱多處理)和NUMA(nonuniform memory access 非一致記憶體訪問)

SMP(對稱多處理)和NUMA(非一致記憶體訪問)

SMP系統結構非常普通,因為它們最容易構建,很多小型伺服器採用這種結構。處理器和儲存器之間採用匯流排互聯,處理器和儲存器都有負責傳送和監聽匯流排廣播的資訊的匯流排控制單元。但是同一時刻只能有一個處理器(或儲存控制器)在匯流排上廣播,所有的處理器都可以監聽。很容易看出,對匯流排的使用是SMP結構的瓶頸。

NUMP系統結構中,一系列節點通過點對點網路互聯,像一個小型網際網路,每個節點包含一個或多個處理器和一個本地儲存器。一個節點的本地儲存對於其他節點是可見的,所有節點的本地儲存一起形成了一個可以被所有處理器共享的全域性儲存器。可以看出,NUMP的本地儲存是共享的,而不是私有的,這點和SMP是不同的。NUMP的問題是網路比匯流排複製,需要更加複雜的協議,處理器訪問自己節點的儲存器速度快於訪問其他節點的儲存器。NUMP的擴充套件性很好,所以目前很多大中型的伺服器在採用NUMP結構

對於上層程式設計師來說,最需要理解的是匯流排線是一種重要的資源,使用的好壞會直接影響程式的執行效能

2.2 匯流排加Lock

在早期的CPU當中,是可以通過在匯流排上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。在匯流排上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從其記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

但是由於在鎖住匯流排期間,其他CPU無法訪問記憶體,會導致效率低下。因此出現了第二種解決方案,通過快取一致性協議來解決快取一致性問題。

2.3 快取一致性協議

一致性要求是指,若cache中某個欄位被修改,那麼在主存(以及更高層次)上,該欄位的副本必須立即或最後加以修改,並確保它者引用主存上該字內容的正確性。     當代多處理器系統中,每個處理器大都有自己的cache。同一主存塊的拷貝能同時存於不同cache中,若允許處理器各自獨立地修改自己的cache,就會出現不一致問題。解決此問題有軟體辦法和硬體辦法。硬體辦法能動態地識別出不一致產生的條件並予以及時處理,從而使cache的使用有很高的效率。並且此辦法對程式設計師和系統軟體開發人員是透明的,減輕了軟體研製負擔,從而普遍被採用。

軟體辦法最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。MESI協議是一種採用寫--無效方式的監聽協議。它要求每個cache行有兩個狀態位,用於描述該行當前是處於修改態(M)、專有態(E)、共享態(S)或者無效態(I)中的哪種狀態,從而決定它的讀/寫操作行為。這四種狀態的定義是:

  1. 修改態(Modified):這行資料有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本Cache中。
  2. 專有態(Exclusive):這行資料有效,資料和記憶體中的資料一致,資料只存在於本Cache中。
  3. 共享態(Shared):這行資料有效,資料和記憶體中的資料一致,資料存在於很多Cache中。
  4. 無效態(Invalid):這行資料無效。

MESI協議適合以匯流排為互連機構的多處理器系統各cache控制器除負責響應自己CPU的記憶體讀寫操作(包括讀/寫命中與未命中)外,還要負責監聽匯流排上的其它CPU的記憶體讀寫活動(包括讀監聽命中與寫監聽命中)並對自己的cache予以相應處理所有這些處理過程要維護cache一致性,必須符合MESI協議狀態轉換規則

MESI的匯流排監聽與狀態轉換

下面由圖的四個頂點出發,介紹匯流排監聽與狀態轉換規則:

  1. 該I無效行在 自身Cache讀未命中 將被相應記憶體塊填充以建立新行時,讀監聽命中,說明其它Cache正在讀同地址的記憶體塊,以建立新行。故為多Cache共享行,應為S狀態,並應繼續發出讀監聽廣播,使其它Cache的類似情況效仿。
  2. 該I無效行在 自身Cache讀未命中 將被相應記憶體塊填充以建立新行時,未有讀監聽命中,為本Cache專有,故新建行應為E狀態。
  3. 該I無效行在 自身Cache寫未命中 時,將先讀入相應記憶體塊填充新行後,再進行寫修改,與原記憶體正本的資料不一致,故新建行為M狀態。
  4. 該S共享行 寫監聽命中,說明別的Cache由於寫命中修改了同此地址的行,根據寫無效原則,此共享行應改變為無效(I)狀態。
  5. 該S共享行 讀命中,狀態不變。
  6. 該S共享行 讀監聽命中,說明其它Cache正在讀同地址記憶體塊,以建立新行,此時該共享行狀態不必改變,但應繼續發讀監聽廣播,供它者監聽。
  7. 該S共享行 寫命中,其中某字被改寫,與記憶體正本不一致,故應改為M狀態,且應發出共享行寫命中監聽廣播,使其它Cache同地址行作廢(同 4)。
  8. 該E專有行 讀監聽命中,說明別的Cache正在讀同地址的記憶體正本,以建立新行,故其狀態應改為S狀態,併發出讀監聽廣播,以使同此情況及1效仿之。
  9. 該E專有行 讀命中 不必改變狀態。
  10. 該E專有行 寫監聽命中,說明別的Cache由於寫未命中而訪問同地址的記憶體正本,該E態行內容即將過時,故應作廢。
  11. 該E專有行 寫命中,只改變狀態為M態即可,無須他者監聽。
  12. 該M修改行 寫命中 狀態不變。
  13. 該M修改行 讀命中 狀態不變。
  14. 該M修改行 讀監聽命中,應將該行最新資料寫回記憶體正本後變為S狀態。併發出讀監聽廣播,供他者監聽。
  15. 該M修改行 寫監聽命中,說明別的Cache由於寫未命中而訪問了同地址的記憶體塊(同3),將實行先讀後修改,此時本地M修改行應搶先寫回主存,然後作廢,以保證別的Cache讀出整行而未被修改資料的正確性
  16. 該M修改行 整行寫監聽命中,說明別的Cache由於寫未命中而訪問了同地址的記憶體塊,將實行先讀後整行的修改,此時本地M修改行不必寫回主存,只作廢即可。

上述分析可以看出,雖然各cache控制器隨時都在監聽系統匯流排,但能監聽到的只有讀未命中、寫未命中以及共享行寫命中三種情況。匯流排監控邏輯並不複雜,增添的系統匯流排傳輸開銷也不大,MESI協議卻有力地保證了主存塊髒拷貝在多cache中的唯一性,並能及時寫回,保證cache主存存取的正確性。

但是,值得注意的是,傳統的MESI協議中有兩個行為的執行成本比較大。一個是將某個Cache Line標記為Invalid狀態,另一個是當某Cache Line當前狀態為Invalid時寫入新的資料。所以CPU通過Store Buffer和Invalidate Queue元件來降低這類操作的延時。如下圖所示:

CPU通過Store Buffer和Invalidate Queue元件來降低這類操作的延時

  1. 當一個CPU進行寫入時,首先會給其它CPU傳送Invalid訊息,然後把當前寫入的資料寫入到Store Buffer中,然後非同步在某個時刻真正的寫入到Cache中。
  2. 當前CPU核如果要讀Cache中的資料,需要先掃描Store Buffer之後再讀取Cache
  3. 但是此時其它CPU核是看不到當前核的Store Buffer中的資料的,要等到Store Buffer中的資料被刷到了Cache之後才會觸發失效操作
  4. 而當一個CPU核收到Invalid訊息時,會把訊息寫入自身的Invalidate Queue中,隨後非同步將其設為Invalid狀態
  5. 和Store Buffer不同的是,當前CPU核心使用Cache時並不掃描Invalidate Queue部分,所以可能會有極短時間的髒讀問題

所以,MESI協議,可以保證快取的一致性,但是無法保證實時性,可能會有極短時間的髒讀問題

其實,並非所有情況都會使用快取一致性的,如:被操作的資料不能被快取在CPU內部或運算元據跨越多個快取行(狀態無法標識),則處理器會呼叫匯流排鎖定;另外當CPU不支援快取鎖定時,自然也只能用匯流排鎖定了,比如說奔騰486以及更老的CPU。

相關文章