我們個人pc都有乙個叫做記憶體的硬體,有4g、8g、16g不等的容量。但我們的cpu執行時執行的指令並不是直接從記憶體中獲取,而是從cpu自身的快取記憶體中獲得指令並執行,指令執行完畢再寫回快取,然後待到特定的時機才把資料在寫回主記憶體。那cpu是如何將比自己容量大的多的記憶體放進自己的快取記憶體中的呢?在記憶體的對映進cpu時又有什麼問題需要注意?下面有一張儲存器層次結構圖,能夠直觀的看到為了填補cpu高速執行與讀取記憶體時花費的「漫長」時間之間的溝壑,現代cpu都引入快取記憶體(l1,l2,l3 cache)。
cpu執行指令時先從l1 cache中讀取,讀取不到再從l2 cache讀取,還讀不到繼續從l3中讀取。如果l3還是沒有所需的資料就需要從記憶體中讀取,當讀取到後依次寫入l3、l2、l1 cache中,然後再繼續執行指令。
各個儲存器只和相鄰的一層儲存器打交道,並且隨著一層層向下,儲存器的容量逐層增大,訪問速度逐層變慢,而單位儲存成本也逐層下降,也就構成了我們日常所說的儲存器層次結構。
cpu在讀取記憶體的資料進快取記憶體時並不是說需要什麼資料就根據這個資料的記憶體位址讀取,而是一塊(64kb)一塊的讀取,這樣做的好處是根據空間上的區域性性原理,同時也不會浪費匯流排的頻寬。
空間區域性性原理:在需要執行執行的資料的周圍大概率也會被cpu執行我們把讀取到的這一塊記憶體位址叫做data block,它將會跟另外一些資料組成乙個cache line放進cpu的快取記憶體中。
對照上面這副圖,我們可以把記憶體是如何對映進cpu的流程講清楚。
因為我們的快取記憶體的容量遠遠小於記憶體的容量,所以根本無法把全部的記憶體資料裝載進來,現代cpu其實是先把快取記憶體劃分成一塊一塊的快取塊(圖中的cache line),然後把主記憶體也是劃分成一塊一塊的。然後通過乙個對映策略將記憶體塊對映進快取記憶體中。
比如對映策略採用的是mod運算(求餘運算)。記憶體被我們劃分成32塊,快取記憶體被劃分成8塊。cpu想要執行第21塊記憶體塊,則對映到快取記憶體中就是21 mod 8 = 5。因此就將第21塊記憶體塊放進第5塊cache line中,算出來的『5』就對應了圖中的索引(index)
剛剛的例子中用21餘8等於5,那麼5、13餘8也會等於5,那cpu怎麼知道是取21記憶體塊還是5或者是13記憶體塊的呢?這時候就需要組標記(tag),通過組標記我們能夠定位到到底取哪一塊記憶體塊的資料。組標記中的內容是cpu要訪問的記憶體位址(二進位制)的高位,而索引中放置的內容是低位,因此也就確定了想要讀取的資料的真正位址內容。
由於我們從記憶體中讀取的資料是一塊一塊的讀取,而cpu只想要其中的一些資料,因此把這一塊讀進快取記憶體後cpu通過偏移量(offset)來定位到真實的cpu想要執行的資料
乙個記憶體的訪問位址對映到快取記憶體,最終包括高位代表的組標記、低位代表的索引,以及在對應的 data block 中定位對應字的位置偏移量。當cpu把指令執行完畢後會有訪存、寫回這兩個指令操作,同樣的cpu的暫存器只會與下一級的快取記憶體打交道。那麼現在寫入到快取記憶體中的資料怎麼再次寫回記憶體呢?
對於寫入策略通常有兩種一種是寫直達(write-through),另一種是寫回(write-back)。
寫直達是最簡單的一種策略,在寫回記憶體時會先判斷是否存在於快取記憶體中了,如果存在我們就先更新cache中的資料並寫回主記憶體,如果不在就直接寫回主記憶體。這種方式都是要寫回主記憶體,因此效率方面並不是很高。而寫回只會寫回對於的cache line中,並不會寫回主記憶體。
寫回策略的流程圖如下
資料只會被寫回cache line中並標記為臟資料,當cache line接收到其他寫回記憶體的請求時(如多核cpu訪問相同的記憶體時通過匯流排發出的訪問請求)才會被寫回記憶體。因此次策略的效率優於寫直達方式。
如今的cpu都是多核的,每乙個核心有著自己的li、l2 cache,然後共享l3 cache。那麼此時就會引發乙個問題,1號cpu與與2號cpu同時將記憶體載入進自己的快取記憶體中,1號cpu對資料進行了修改,但此時2號cpu中的資料還是以前的資料。這就是快取一致性問題。
解決快取一致性主要有兩個思想
基於這兩個思想我們就有匯流排嗅探(bus snooping)跟基於匯流排嗅探的快取一致性協議,如mesi、msi等。
匯流排嗅探:把所有的讀寫請求都通過匯流排(bus)廣播給所有的 cpu 核心,然後讓各個核心去「嗅探」這些請求並對自身的cache做出相應的操作mesi協議是一種寫失效(write invalidate)的協議。在這個協議中只有乙個cpu核心負責寫入資料,完成寫入後就通過匯流排向其他cpu核心進行廣播,如果其他核的cpu也存在被修改的資料,則將改cache line標記為失效。
mesi就是用來標記cache line中的狀態:
已修改比較好理解,就是我們上文中提到的寫回策略中的『髒資料』。已失效同樣也是上文提及到的,如果已失效就需要從記憶體中重新讀取資料。
而獨佔跟共享就比較有意思,這兩個狀態的cache line中的資料都是『乾淨』的,也就是快取中的資料跟主記憶體的資料是一毛一樣的。當只有乙個cpu核心的cache line中擁有這個記憶體資料塊時,我們就標記為獨享。這個時候我們對這個資料進行讀寫不用通過匯流排通知其他cpu核心,只要讀寫到本核的快取中並修改為【已修改狀態】即可,也不用立馬寫回記憶體。當有其他cpu要對這塊記憶體進行資料的讀取時,就會立刻將修改的資料寫回主記憶體,並修改cache line的狀態為【共享狀態】。當然,後來讀取到這個資料的cpu對應的cache line也會被標記為【共享狀態】。
看到這裡,有人就會問,既然cpu層面已經有了匯流排嗅探、快取一致性協議來保證資料的一致。那麼volatile關鍵字的作用到底是什麼?
對此我在網上找了一段比較解釋的通的答案
1,並不是所有的硬體架構都提供了相同的一致性保證,jvm需要 volatile 統一語義(就算是mesi,也只解決cpu快取層面的問題,沒有涉及其他層面)。第乙個比較好理解,就是雖然cpu有快取一致性協議保證,但是其他硬體層面可能也會有一致性問題,而且volatile的語義統一所有層面的一致性。2,可見性問題不僅僅侷限於cpu快取內,jvm自己維護的記憶體模型中也有可見性問題。使用 volatile 做標記,可以解決jvm層面的可見性問題。
3,另外,編譯器的指令重排序,如果不加 volatile 也會導致可見性問題。
第二點的意思是說jvm內維護了自己的一套記憶體模型,比如不同的棧記憶體中存在自己的記憶體副本。這些記憶體副本互不干擾,但是在副本中使用了堆記憶體中的資料也會引發jvm層面的一致性問題,因此需要volatile保證變數的可見性。
[參考]:徐文浩 [深入淺出計算機組成原理]
CPU快取記憶體行
cpu 為了更快的執行 於是當從記憶體中讀取資料時,並不是唯讀自己想要的部分。而是讀取足夠的位元組來填入快取記憶體行。根據不同的 cpu,快取記憶體行大小不同。如 x86是 32bytes 而alpha 是64bytes 並且始終在第 32個位元組或第 64個位元組處對齊。這樣,當 cpu訪問相鄰的...
總結 CPU快取記憶體
我們都知道,程式是由一條條指令和資料組成的,cpu在執行時的工作也是周而復始的執行一條條用途各異的指令。在一開始,程式載入到主存,在程式執行的過程中,將指令一條條的從主存中取出並執行,巨集觀上來說我們把主存看做是乙個很大的一維位元組陣列,位址即可看作為陣列的下標。這樣的結構可以確保程式可以順利執行。...
CPU快取記憶體行
cpu 為了更快的執行 於是當從記憶體中讀取資料時,並不是唯讀自己想要的部分。而是讀取足夠的位元組來填入快取記憶體行。根據不同的 cpu,快取記憶體行大小不同。如 x86是 32bytes 而alpha 是64bytes 並且始終在第 32個位元組或第 64個位元組處對齊。這樣,當 cpu訪問相鄰的...