1. 程式人生 > >從硬件緩存模型到Java內存模型原理淺析

從硬件緩存模型到Java內存模型原理淺析

自動 wap 內部實現 完成 需求 順序 相等 這一 協議

參考Google的這個問題what is a store buffer?
一、硬件方面的問題
1、背景
在現代系統的CPU中,所有的內存訪問都是通過層層緩存進行的。CPU的讀/寫(以及指令)單元正常情況下甚至都不能直接與內存進行訪問,這是物理結構決定的。CPU和緩存進行通信,而緩存才能與內存進行通信。處理器保證從系統內存中讀取或者寫入一個字節是原子的,但是復雜的內存操作處理器是不能保證其原子性的,比如跨總線操作、跨多個緩存行和跨頁表的訪問。但是處理器提供了總線鎖定和緩存鎖定兩個機制來保證復雜內存操作的原子性。
硬件緩存模型如下圖所示:
解釋:具體這些會在MESI協議裏面講,先有個概念
(1) CPU就是CPU
(2)Store Bufferes存儲緩存
(3)Cache 就是代表高速緩存
(4)Invalidate Queues 無效隊列
(5)Memory 內存
技術分享圖片
2、問題與解決
問題:如果有多個CPU,每個CPU都有自己的緩存,其中一個修改了緩存,會發生什麽?答案是什麽也不會發生。我們希望擁有多組緩存的時候,需要它們保持同步。或者說,系統的內存在各個CPU之間無法做到與生俱來的同步,我們實際上是需要一個大家都能遵守的方法來達到同步的目的。接下來看帶來的問題與解決方案。
(1)原子性問題
我們以一個原子操作的i++為例,來講解這個問題。
如果多個處理器同時對共享變量i進行讀該寫操作,那麽共享變量就會被多個處理器就行同時操作,這樣的讀寫該操作就不是原子的,操作完成之後共享變量的值會和期望的不一致。如果i=1,我們CPU0進行i++操作,CPU1進行i++操作,我們期望結果是3,但是有可能結果是2。
原因可能是多個處理器同時從各自的緩存中讀取變量i,分別進行i++,操作,然後分別寫入系統。這就不是原子的了,讀改寫被分開了。所以要解決這個問題就必須保證CPU0進行讀改寫時,CPU1不行進行讀改寫操作。
通過總線鎖來保證原子性
所謂處理器總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器總線上輸出此信號時,其它處理器的請求將被阻塞住,那麽該處理器可以獨占共享內存。
通過緩存鎖定來保證原子性
總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其它處理器不能操作其它內存地址的數據,所以總線鎖的開銷比較大,所以就引進了緩存鎖定來代替總線鎖定來進行優化。
所謂“緩存鎖定”是指內存區域如果被緩存在處理器的緩存中,那麽當它執行鎖操作會寫內存時,處理器不需要再總線上加鎖,而是修改內存地址,並允許處理器的緩存一致性協議“來保證操作的原子性,因為緩存一致性協議會阻止同時修改由兩個以上處理器緩存區的內存區域數據,當其它處理器回寫已被修改的緩存行數據實,會使其它緩存行無效。
緩存一致性協議(MESI)大多數支持
結合硬件緩存模型圖來講解:

  • 失效(Invalid)緩存段,要麽已經不在緩存中,要麽它的內容已經過時。為了達到緩存的目的,這種狀態的段將會被忽略。一旦緩存段被標記為失敗,那效果就等同於它從來沒被加載到緩存中。
  • 共享(Shared)緩存段,它是和主內存內容保持一致的一份拷貝,在這種狀態下的緩存段只能被讀取,不能被寫入。多組緩存可以同時擁有針對同一內存地址的共享緩存段,這就是名稱的由來。
  • 獨占(Exclusive)緩存段,也是和主內存內容保持一致的一份拷貝。區別在於,如果一個處理器持有了某個E狀態的緩存段,那其他處理器就不能同時擁有它,所以叫”獨占“。這意味著,如果其他處理器原來也持有同一段換成段,那麽它會馬上變成”失敗“狀態。
  • 已修改(Modified)緩存段,屬於臟段,它們已經被所屬的處理器修改了。如果一個段處於已修改狀態,那麽它在其他處理器緩存中的拷貝馬上會變成失效狀態,這個規律和E狀態一樣。此外,已修改緩存段如果被丟棄或標記為失敗,那麽先要把它的內容回寫到內存中-這和回寫模式下常規的臟段處理方。
    狀態轉換時需要發送的消息:

  • Read消息
    該消息包含讀的物理地址,一般用於加載數據。
  • Read Respionse消息
    該消息包含前面Read消息所請求的數據,可以由其他CPU緩存或者內存發出。
  • Invalidate消息
    該消息包含無效的物理地址,由某個CPU緩存發出,所有接收到消息的緩存需要移除對應的數據項(置無效)。
  • Invalidate Acknowledge消息
    在接收到Invalidate消息並移除對應數據後,相應的CPU緩存需要發送此消息。
  • Read Invalidate消息
    該消息包含讀的物理地址,同時讓其他CPU緩存移除對應數據。該消息可以接收到一個Read Response消息和一系列Invalidate Acknowledge消息。
  • Writeback消息
    該消息包含物理地址和需要被會寫內存的數據,這個消息允許緩存為存放其他數據清除該數據所占的空間,否則該數據不能被移除。
    狀態轉換
當前狀態 操作 操作分析 之後狀態
M 本核讀(read) M表示已修改,緩存和內存不一致,本核讀緩存中取值,狀態不變 M
M 本核寫(write) 本核修改內容,已經為已修改,再次修改狀態不變 M
M other核讀(read) 當本核監聽到總線別的核要讀取內存時,需要先將數據寫到內存,然後其它核在讀,和別的核共享,狀態變為S S
M other核寫(write) 當本核監聽到總線別的核要寫時,需要先將本數據寫到內存,然後在讓其它核在這個基礎上修改,狀態變為I I
E 本核讀(read) E表示獨占,緩存和內存一致,緩存讀取,狀態不變 E
E 本核寫(write) 本核修改內容,寫入緩存,緩存和內存不一致,狀態改變為M M
E other核讀(read) 當本核監聽到總線別的核要讀取內存時,和別的核共享,狀態變為S S
E other核寫(write) 當本核監聽到總線別的核要寫時,首先肯定在這之前先共享了數據S,然後在由其它核修改數據,寫回內存,本緩存變為無效I I
S 本核讀(read) S表示分享,多個核共享數據,和內存中一致,從緩存中讀,狀態不變S S
S 本核寫(write) 本核修改內容,發起總線請求,其它核設置無效I,然後修改,寫入緩存,緩存和內存不一致,狀態改變為M M
S other核讀(read) 當本核監聽到總線別的核要讀取內存時,和別的核共享,狀態變為S S
S other核寫(write) 當本核監聽到總線別的核要寫時,本核數據無效,狀態改變為I I
I 本核讀(read) I表示無效,緩存沒有數據,需要讀取內存,情況如下:(1) 別的核沒有數據,從內存中讀取,獨占E。(2)別的核有數據,可能為E或S,是E就先寫入內存,然後本核讀取內存,本核和其它核狀態都是S S或者E
I 本核寫(write) 首先要讀,然後在寫,如果是E,修改然後狀態為E,如果是S,通知其它線程緩存無效,然後改狀態為M M
I other核讀(read) 和本核無關 I
I other核寫(write) 和本核無關 I

我們發現上面的狀態轉換只有當緩存段處於E或M狀態時,處理器才能去寫它,也就是說只有這兩種狀態下,處理器是獨占這個緩存段的。當處理器想寫某個緩存段時,如果它沒有獨占權,它必須先發送一條“我要獨占權”的請求給總線,這會通知其他處理器,把它們擁有的同一緩存段的拷貝失效(如果它們有的話)。只有在獲得獨占權後,處理器才能開始修改數據——並且此時,這個處理器知道,這個緩存段只有一份拷貝,在我自己的緩存裏,所以不會有任何沖突。反之,如果有其他處理器想讀取這個緩存段(我們馬上能知道,因為我們一直在窺探總線),獨占或已修改的緩存段必須先回到“共享”狀態。如果是已修改的緩存段,那麽還要先把內容回寫到內存中。
MESI協議的問題(性能)
我們來分析一個例子,如下代碼示例:

int a = 5;
public void  add () {
        a = a + 2;
}

假如現在有三個CPU,每個CPU都有a的緩存,狀態為S,現在CPU1要去修改a,我們要做些什麽操作了,首先要申請總線,獨占這一緩存,獲取成功後,給CPU2、CPU3發生Invalidate消息,使CPU2、CPU3的緩存失效,然後CPU2、CPU3是本緩存失效後,回復確認Invalidate Acknowledge消息,然後CPU1,才能去修改緩存,然後而這個過程中CPU1啥都不能幹,這就浪費了CPU的性能,所以硬件就提供了寫優化策略。
Store Bufferes和Invalidate Queues
Store Bufferes 緩存存儲,當處理器需要把修改寫入緩存時,然後在寫入內存這個過程時,我們處理器不需要等待了。只需要把指數據寫入Store Bufferes,然後發生Invalidate消息給其它CPU,然後本CPU就可以去執行其它指令了,等到我們都收所有回復確認Invalidate Acknowledge消息,在把Store Bufferes消息寫回緩存修改狀態為(M),如果有其它CPU來讀,就會刷新到內存,狀態變為S。Store Bufferes 的作用是讓 CPU 需要寫的時候僅僅將其操作交給 Store Buffere,然後繼續執行下去,Store Bufferes 在某個時刻就會完成一系列的同步行為。
Invalidate Queues 無效隊列,這麽理解吧我們在修改數據時,需要使其它處理器數據失效,這其實也是一系列的寫操作,如果我們這些消息都交給Store Bufferes處理,Store Bufferes速度快,但是容量很小,所以就設計出了Invalidate Queues,當別的CPU收到Invalidate消息時,把這個操作加入無效隊列,然後快速返回Invalidate Acknowledge消息,讓發起者做後續操作,然後Invalidate並不是馬上處理,而只是加入了隊列,也就是說其實不是立刻讓本CPU的緩存數據失效,而是等CPU處理無效隊列裏的無效消息時。
(2)可見性問題(Store Bufferes和Invalidate Queues產生)
Store Bufferes和Invalidate Queues問題;問題分析,我們發現Store Bufferes的寫入緩存和Invalidate Queues的處理失效,都是最終一致性的表現,這在單核操作時可能沒什麽問題,如果是多核操作(其實就是Java的並發)那麽數據修改的可見性就是不確定的。
代碼分析:兩個CPU同時操作()
我麽假設此時numone的狀態為共享(S),flag狀態為E,
我們假設CPU1中的執行update方法,CPU2執行test方法。
現在CPU1需要修改numone,由於numone為共享狀態,所以緩存和內存一致,所以我們獲取總線,通知其它CPU緩存的numone變為無效(I),然後CPU1把numone的8加入Store Bufferes裏面,就去執行其它指令了,CPU1執行修改flag,因為flag為E,所以直接修改,寫入緩存。CPU2,執行test方法,由於CPU1修改了flag所以需要刷新到內存,然後CPU2去從內存中讀取flag,CPU1和CPU2狀態變為S,此時CPU2可能收到無效消息,加入無效隊列,然後我們打印numone,結果是多少了,不確定,因為CPU1 何時把numone刷新至內存,CPU2何時執行無效消息,這都是不確定的,所以我們打印的numone可能是8或者0。
其實也可以理解為CPU指令的重排序,CPU1flag的寫入發生在了numone的前面,導致CPU2打印時不確定這個值是否寫入;CPU2的讀取numone可能發生在了無效命令前面。

public class StoreBufferesQuestion {

    private int  numone = 0;
    private Boolean  flag = false;

    public void update() {
        numone = 8;
        flag = true;        
    }

    public void test() {
        while (flag) {
            // numone 是多少?
            System.out.println(numone);
        }
    }
}

Store Bufferes和Invalidate Queues問題解決:硬件 level 上很難揣度軟件上這種前後數據依賴關系,因此往往無法通過某種手段自動的避免這種問題,因而只有通過軟件的手段表示(對應也需要硬件提供某種指令來支持這種語義),這個就是 Memory Barrier(內存屏障)。
Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一條告訴處理器在執行這之後的指令之前,應用所有已經在存儲緩存(store buffer)中的保存的指令。
Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一條告訴處理器在執行任何的加載前,先應用所有已經在失效隊列中的失效操作的指令。
再看下如下代碼:
這樣就保證了可見性。

public class StoreBufferesQuestion {

    private int  numone = 0;
    private Boolean  flag = false;

    public void update() {
        numone = 8;
        Store Memory Barrier指令;刷新Store Bufferes
        flag = true;        
    }

    public void test() {
        while (flag) {
            // numone 是多少?
            Load Memory Barrier指令;執行時效消息
            System.out.println(numone);
        }
    }
}

總結
我們看到硬件為了解決原子性,使用了總線鎖和緩存鎖,緩存鎖是基於緩存一致性協議實現的。緩存一致性協議帶來了指令執行順序問題,影響了多核處理器之間的可見性。因為硬件無法知道我們這些軟件數據在執行時的指令順序,所以硬件就制定了這樣一套硬件規則來滿足硬件需求,提供Memory Barrier來解決方案來應對軟件可能發生的問題,具體需要我們軟件自己去實現。
二、軟件層面的問題(JAVA)
我們在編寫並發程序時,也會出現問題原子性問題、可見性問題。
Java如何實現原子操作
在Java中可以通過鎖和循環CAS的方式實現原子操作。
CAS是英文單詞CompareAndSwap的縮寫,中文意思是:比較並替換。CAS需要有3個操作數:內存地址V,舊的預期值A,即將要更新的目標值B。從Java 1.5開始,JDK並發包提供了一些類支持原子操作。
CAS實現原子操作存在的問題
1)ABA問題。因為CAS需要在操作值得時候,檢查值沒有變化,如果沒有發生變化則更新,但是一個值原來是A,變成了B,由變成了A,那麽使用CAS允許檢查時會發現它的值沒有發生變化,但是實際卻發生了變化。ABA問題的解決思路就是使用版本號,每次變量更新的時候版本號加1,那麽A-B-A就會變成1A-2B-3A.從Java1.5開始,JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將引用和該標誌的值設置為給定的更新值。
2)循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。
3)只能保證一個共享變量的原子操作。
使用鎖機制來實現原子性:鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域。JVM內部實現了很多種鎖機制。
可見性問題
Java線程之間的通信對程序員完全透明,內存可見性問題很容易困擾Java程序員。
Java內存模型的抽象結構
在Java中,所有實例域、靜態域和數組元素都存在堆內存中,堆內存在線程之間共享。局部變量,方法定義參數和異常處理器參數不會再線程之間共享,他們不會存在內存可見性問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩存區、寄存器以及其他的硬件和編譯優化。如下圖所示。
技術分享圖片

線程之間的通信:如下圖所示
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A 已更新過的共享變量。
技術分享圖片
影響可見性的因素(重排序)
在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序。分為3中類型。
1)編譯器重排序。編譯器在不改變單線程語義的前提下,可以重新安排語句的執行順序。
2)指令級並行的重排序。現代處理器采用了指令並行技術。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
3)內存系統的重排序。由於處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是亂序執行。(處理器的重排序)
從Java源代碼到最終的指令序列,會經歷下面三種排序,如下圖所示:
技術分享圖片
重排序的規則;as-if-serial語義:不管怎麽重排序,單線程程序的執行結果不能被改變。編譯和處理器都必須遵循as-if-serial語義。但是如果操作之間沒有數據依賴關系,這些操作就可以被重排序。
對於處理器的(內存和指令)重排序,JMM的處理器重排序規則會要求Java編譯器生成指令時,插入特定類型的內存屏障指令。JMM把內存屏障分為4類:

屏障類型 指令示例 說明
LoadLoad Barries Load1;LoadLoad;Load2 確保Load1數據的裝載先於Load2及後續所有裝載指令的裝載
StoreStore Barries Store1;LoadLoad;Store2 確保Store1數據對其它處理器可見(刷新到內存)先於Store2機後續所有指令的存儲
LoadStore Barries Load1;LoadLoad;Store2 確保Load1數據的裝載先於Store2及所有後續的存儲指令的刷新到內存
StoreLoad Barries Store1;LoadLoad;Load2 確保Store1數據對其他處理器變得可見(刷新到內存)先於Load2及後續所有指令的裝載

happens-before(JMM可見性的保證)
JSR-133使用happens-before的概念來指定連個操作之間的執行順序。由於這兩個操作可以在一個線程之內,也可以在不同線程之間。因此,JMM可以通過happens-before關系向程序員提供跨線程的內存可見性。
happens-before關系定義
(1)如果一個操作happens-before另一個操作,那麽第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作前面。這是JMM對程序員的保證。
(2)兩個操作之間存在happens-before關系,並不意味著Java平臺的具體實現必須要按照happens-before的指向順序來執行。如果重排序之後的執行結果,與按happens-before關系的執行結果一致,這種重排序並不非法(也就是說,JMM允許這種重排序)。這是JMM對重排序指定的規則,只要不改變程序的執行結果(單線程和正確同步的線程),怎麽優化都行。
happens-before規則(滿足規則即滿足可見性)
1)程序順序規則:一個線程中的每個操作,happens-before與該線程中的任意後續操作。
2)監視器鎖規則:對一個鎖的解鎖,happens-before與隨後對這個鎖的加鎖。
3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
4)傳遞性:如果A happens-before B,且B happens-before C,那麽A happens-before C。
5)start()規則:如果線程A 執行操作ThreadB.start()(線程B啟動),那麽A線程的ThreadB.start()操作happens-before於線程B中的任意操作。
6)join規則:如果線程A執行操作ThreadB.join()並成功返回,那麽線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功的返回。
總結:Java提供了鎖和CAS來保證原子性操作,通過JMM的規則來禁止一些重排序,通過JMM的happens-before規則來保證內存的可見性。我們可以看到規則裏面有一些關鍵字,volatile(通過內存屏障)、鎖保證了可見性,我們在下面的章節詳解---------------------------以下是個人理解:我們結合硬件緩存模型來看,其實JMM是對處理器緩存模型的一種實現,硬件實現了最終緩存在一致性的方案,並提供了強一致性緩存的解決方案(內存屏障的指令),JMM實現了這個方案,在我們需要的時候(插入內存屏障)提供強大的可見性保證,不需要時遵循硬件的優化策略(可以進行指令重排序優化,提高執行性能)。

從硬件緩存模型到Java內存模型原理淺析