一文吃透Volatile,征服面試官

根據圖所展現的知識點,有目的性的閱讀如下內容!!!java

前情省略一千字....面試


此時,小黃內心十分緊張的坐在面試官面前,看着面試官來回翻動本身的簡歷,準備接受暴風驟雨的洗禮。緩存


此時,面試官擡起頭,目光如炬,看着小黃,笑了笑。多線程


面試官:平時項目中有沒有用到volatile關鍵字?併發


小黃:用到了,爲了在多線程處理器環境下能保證共享變量的可見性。app


面試官:不錯,那你以爲什麼是可見性?異步


小黃:在多線程狀況下,讀和寫發生在不一樣的線程中,而讀線程未能及時的讀到寫線程寫入的最新的值。oop


面試官:對的,那麼你以爲volatile關鍵字是如何保證線程的可見性呢?post


小黃:我以爲,首先咱們須要從硬件層面瞭解可見性的本質。一臺機算機最核心的組件是CPU,內存,以及I/O設備。可是這三者在處理速度上有很大的差別,可是最終總體的計算效率仍是取決於最慢的那個設備,爲了平衡三者的速度差別,最大化的利用CPU提高性能,不管是硬件,操做系統仍是編譯器都作了不少的優化。性能


  1. CPU增長了告訴緩存

  2. 操做系統增長了進程,線程,經過時間片切換最大化的提高CPU性能

  3. 編譯器的指令優化,更合理的去利用好CPU的高速緩存



面試官:什麼是CPU高速緩存?


小黃:因爲計算機的存儲設備與處理器的運算速度差距很是大,因此現代計算機系統都會增長讀寫速度儘量接近處理器運算速度的高速緩存來做爲內存和處理器之間的緩衝:將運算須要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存中同步到內存之中。


面試官:很棒,可是使用CPU告訴緩存會不會帶來一些問題呢?


小黃:是的,它雖然很好的解決了處理器與內存之間的速度矛盾,可是也引入了一個新的問題,緩存一致性。


有了高速緩存後,每一個CPU處理過程變成這樣:先將計算機須要用到的數據緩存在CPU高速緩存中,在CPU進行計算時,直接從高速緩存中讀取數據而且計算完成以後寫到緩存中,在整個運算完成後,再把緩存中的數據同步到內存。


因爲在多CPU中,每一個線程可能會運行在不一樣的CPU中,而且每一個線程都有本身的高速緩存,同一份數據可能會被緩存到多個CPU中,若是在不一樣CPU中運行的不一樣線程看到同一分內存的緩存值不同,就會存在緩存不一致的問題。


面試官:有沒有什麼解決方案?


小黃:1.總線鎖。2.緩存鎖。


面試官:解釋一下什麼是總線鎖?


小黃:要不我畫個圖吧,(因而有了下面這張圖),可是總線鎖開銷較大,因此須要優化,最好的方法就是控制鎖的粒度,咱們只須要保證,對於被多個CPU緩存的同一份數據是一致的就行,因此引入了緩存鎖,他的核心機制就是緩存一致性協議。




面試官:什麼是緩存一致性協議?


小黃:爲了達成數據訪問的一致性,須要各個處理器在訪問內存時,遵循一些協議,在讀寫時根據協議來操做,常見的協議有,MSI,MESI,MOSI等等,最多見的就是MESI協議;


MESI表示緩存行的四種狀態,分別是:

    M(modify)表示共享數據只緩存當前CPU緩存中,而且是被修改狀態,也就是緩存的數據和主內存中的數據不一致。

    E(Exclusive)表示線程的獨佔狀態,數據只緩存在當前的CPU緩存中,而且沒有被修改

    S(Shared)表示數據可能被多個CPU 緩存,而且各個緩存中的數據和主內存數據一致

    I(Invalid) 表示緩存已經失效

在MESI協議中,每一個緩存的緩存控制器不只知道本身的讀寫操做,並且監聽(snoop)其餘的Cache的讀寫操做


對於MESI協議,從CPU讀寫角度來講會遵循如下原則:

CPU讀請求:緩存處於M,E,S狀態均可以被讀取,I狀態CPU還能從主存中讀取數據

CPU寫請求:緩存處於M,E狀態下才能夠被寫,對於S狀態的寫,須要將其餘CPU中緩存置於無效纔可寫


使用總線鎖和緩存鎖後,CPU對於內存的操做大概能夠抽象成下面這樣的結構,從而達成緩存一致性效果



面試官:既然說基於緩存一致性協議或者總線鎖就能達到一致性的要求,那麼爲何還須要voliate關鍵字呢?


小黃:MESI優化帶來了可見性的問題:MESI 協議雖然能夠實現緩存的一致性,可是也會存在一些問題。就是各個 CPU 緩存行的狀態是經過消息傳遞來進行的。如果 CPU0 要對一個在緩存中共享的變量進行寫入,首先需要發送一個失效的消息給到其餘緩存了該數據的 CPU。並且要等到他們的確認回執。CPU0 在這段時間內都會處於阻塞狀態。


因此爲了不阻塞帶來的資源浪費。在 cpu 中引入了 Store Bufferes。


CPU0 只須要在寫入共享數據時,直接把數據寫入到 store bufferes 中同時發送 invalidate 消息,而後繼續去處理其餘指令。當收到其餘全部 CPU 發送了 invalidate acknowledge 消息時,再將 store bufferes 中的數據數據存儲至 cache line中。最後再從緩存行同步到主內存。


可是這種優化存在兩個問題

    1. 數據何時提交是不肯定的,由於須要等待其餘 cpu給回覆纔會進行數據同步。這裏實際上是一個異步操做

    2. 引入了 storebufferes 後,處理器會先嚐試從 storebuffer中讀取值,若是 storebuffer 中有數據,則直接從storebuffer 中讀取,不然就再從緩存行中讀取


看下下面這個例子:

    int value=0;複製代碼void exeToCPU0{複製代碼  value=10;複製代碼  isFinish=true;複製代碼}複製代碼void exeToCPU1{複製代碼  if(isFinish){複製代碼    assert value==10;  複製代碼  }複製代碼}複製代碼

    exeToCPU0和exeToCPU1分別在兩個獨立的CPU上執行。


    假如 CPU0 的緩存行中緩存了 isFinish 這個共享變量,而且狀態爲(E)、而 Value 多是(S)狀態。


    那麼這個時候,CPU0 在執行的時候,會先把 value=10 的指令寫入到storebuffer中。而且通知給其餘緩存了該value變量的 CPU。在等待其餘 CPU 通知結果的時候,CPU0 會繼續執行 isFinish=true 這個指令。而由於當前 CPU0 緩存了 isFinish 而且是 Exclusive 狀態,因此能夠直接修改 isFinish=true。這個時候 CPU1 發起 read操做去讀取 isFinish 的值可能爲 true,可是 value 的值不等於 10。


    這種狀況咱們能夠認爲是 CPU 的亂序執行,也能夠認爲是一種重排序,而這種重排序會帶來可見性的問題。


    面試官:如何解決重排序帶來的可見性問題?


    小黃:CPU內存屏障用來解決這個問題,內存屏障就是將 store bufferes 中的指令寫入到內存,從而使得其餘訪問同一共享內存的線程的可見性。


    X86 的 memory barrier 指令包括 lfence(讀屏障) sfence(寫屏障) mfence(全屏障)。


    Store Memory Barrier(寫屏障) 告訴處理器在寫屏障以前的全部已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來講就是使得寫屏障以前的指令的結果對屏障以後的讀或者寫是可見的。


    Load Memory Barrier(讀屏障) 處理器在讀屏障以後的讀操做,都在讀屏障以後執行。配合寫屏障,使得寫屏障以前的內存更新對於讀屏障以後的讀操做是可見的。


    Full Memory Barrier(全屏障) 確保屏障前的內存讀寫操做的結果提交到內存以後,再執行屏障後的讀寫操做有了內存屏障之後,對於上面這個例子,咱們能夠這麼來改,從而避免出現可見性問題。


    總的來講,內存屏障的做用能夠經過防止 CPU 對內存的亂序訪問來保證共享數據在多線程並行執行下的可見性,可是這個屏障怎麼來加呢?回到最開始咱們講 volatile 關鍵字的代碼,這個關鍵字會生成一個 Lock 的彙編指令,這個指令其實就至關於實現了一種內存屏障。


    面試官: 說到volatile,那麼你能說說什麼是JMM嗎?


    小黃:JMM全稱是java內存模型,由於致使可見性的根本緣由是緩存和重排序,而JMM則合理的禁用了緩存和禁用了重排序,因此他最核心的價值就是解決了可見性和有序性。


    面試官:JMM是如何解決可見性和有序性的?


    小黃:JMM基於CPU層面提供的內存屏障指令來限制編譯器的重排序,從而解決併發問題。


    JMM提供了一些禁止緩存和禁止重排序的方法,好比上面說的volatile,以及synchronize(後續會單獨寫一篇關於synchronize的文章),final;


    JMM 如何解決順序一致性問題 

    重排序問題

    爲了提升程序的執行性能,編譯器和處理器都會對指令作重排序,其中處理器的重排序在前面已經分析過了。所謂的重排序其實就是指執行的指令順序。編譯器的重排序指的是程序編寫的指令在編譯以後,指令可能會產生重排序來優化程序的執行性能。從源代碼到最終執行的指令,可能會通過三種重排序。


    2 和 3 屬於處理器重排序。這些重排序可能會致使可見性問題。編譯器的重排序,JMM 提供了禁止特定類型的編譯器重排序。


    處理器重排序,JMM 會要求編譯器生成指令時,會插入內存屏障來禁止處理器重排序,固然並非全部的程序都會出現重排序問題。


    編譯器的重排序和 CPU 的重排序的原則同樣,會遵照數據依賴性原則,編譯器和處理器不會改變存在數據依賴關係的兩個操做的執行順序,好比下面的代碼,


      a=1; b=a; 複製代碼
      複製代碼a=1;a=2;複製代碼
      複製代碼a=b;b=1;複製代碼


      這三種狀況在單線程裏面若是改變代碼的執行順序,都會致使結果不一致,因此重排序不會對這類的指令作優化。

      這種規則也成爲 as-if-serial。無論怎麼重排序,對於單個線程來講執行結果不能改變。好比

        int a=2; //1複製代碼
        複製代碼int b=3; //2複製代碼
        複製代碼int rs=a*b; //3複製代碼

        1 和 三、2 和 3 存在數據依賴,因此在最終執行的指令中,3 不能重排序到 1 和 2 以前,不然程序會報錯。因爲 1 和 2不存在數據依賴,因此能夠從新排列 1 和 2 的順序。


        JMM 層面的內存屏障


        爲了保證內存可見性,Java 編譯器在生成指令序列的適當位置會插入內存屏障來禁止特定類型的處理器的重排序,在 JMM 中把內存屏障分爲四類

        HappenBefore,它的意思表示的是前一個操做的結果對於後續操做是可見的,因此它是一種表達多個線程之間對於內存的可見性。


        因此咱們能夠認爲在 JMM 中,若是一個操做執行的結果須要對另外一個操做課件,那麼這兩個操做必需要存在happens-before 關係。


        這兩個操做能夠是同一個線程,也能夠是不一樣的線程,JMM 中有哪些方法創建 happen-before 規則 呢?


        程序順序規則 

        1. 一個線程中的每一個操做,happens-before 於該線程中的任意後續操做; 能夠簡單認爲是 as-if-serial。單個線程中的代碼順序無論怎麼變,對於結果來講是不變的順序規則表示 1 happenns-before 2;

          3 happens-before 4


        2. volatile 變量規則,對於 volatile 修飾的變量的寫的操做,必定 happen-before 後續對於 volatile 變量的讀操做;根據 volatile 規則,2 happens before 3


        3. 傳遞性規則,若是 1 happens-before 2; 2 happens-before 3; 那麼傳遞性規則表示: 1 happens-before 3;


        4. start 規則,若是線程 A 執行操做 ThreadB.start(),那麼線程 A 的 ThreadB.start()操做 happens-before 線程 B 中的任意操做


        5. join 規則,若是線程 A 執行操做 ThreadB.join()併成功返回,那麼線程 B 中的任意操做 happens-before 於線程A 從 ThreadB.join()操做成功返回。

        6. 監視器鎖的規則,對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖

               假設 x 的初始值是 10,線程 A 執行完代碼塊後 x 的值會變成 12(執 行完自動釋放鎖),線程 B 進入代碼塊時,可以看到線程 A 對 x 的寫操做,也就是線程 B 可以看到 x==12。


        面試官(os):這麼厲害?用不起用不起,回去等通知吧!


        小黃內心一萬隻草泥馬奔騰而過。。。。。。

        版權聲明:本站原創文章,於2019-10-08,由 TopJavaer 發表。 轉載請註明來源 juejin.im/post/5d9c8a…,謝謝。

        相關文章
        相關標籤/搜索