併發編程(三):Volatile關鍵字

一,一段代碼引發可見性思考

    1,代碼片段:從代碼可以看出,子線程會一直在循環中阻塞,當主線程已經修改flag的值爲true後,子線程並沒有對flag值做同步修改。當給flag加上volatile關鍵字修飾後,則子線程會獲取到最新的flag值,並打印出結果(不做演示)

package com.gupao;

/**
 * @author pj_zhang
 * @create 2019-09-07 20:50
 **/
public class VolatileTest {

    // 初始化表示位
    private static boolean flag = false;

    private static int i;

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            while (!flag) {
                i++;
            }
            System.out.println("子線程執行完成: i = " + i);
        }, "線程_1").start();

        // 等待100毫秒,保證子線程已經加載flag值到線程高速緩存區
        Thread.sleep(100);
        // 修改flag爲true
        flag = true;
        System.out.println("主線程執行完成。。。");
    }
}

二,從硬件層面瞭解可見性本質

    1,緩存一致性

        1.1,在計算機迭代發展中,CPU、內存和I/O設備有一個非常核心的矛盾點,即三者的處理速度的差異(CPU > 內存 > I/O)。爲了提升計算性能,CPU從單核升級到多核甚至使用超線程技術最大化提高CPU的處理性能,但是後面兩者的速度沒有跟上CPU的速度。爲了平衡三者的速度差異,最大化的利用CPU的計算性能,從硬件,操作系統,編譯器等方面做了很大優化

            a,CPU增加了高速緩存

            b,操作系統增加了線程和進程,通過CPU的時間片切換最大限度的提升CPU的使用率

            c,編譯器的指令優化,更合理的去利用好CPU的告訴緩存

但是每一種優化,都會帶來線程安全性問題,這也是可見性的本質

        1.2,CPU高速緩存

            a,因爲CPU計算速度與內存或者I/O計算速度差異,現代計算機都會增加一層讀寫速度儘可能接近CPU運算速度的高速緩存,以此來作爲CPU和內存之間的緩衝:將線程使用到的數據複製到高速緩存中,等運算結束後再從高速緩存同步到主內存。

            b,通過CPU高速緩存確實最大化解決了CPU和主內存間的運行差異,但是同時增加了計算機系統的複雜度,帶來了緩存一致性的問題。在多CPU環境下,在同一主內存中的數據可能會被多核CPU共享,如果運行在多核CPU的多線程加載到各自CPU高速緩存中的數據,並各自進行修改,則可能存在緩存不一致問題。爲了解決緩存不一致問題,CPU層面提供了兩種解決方式

                   * 總線鎖:即對主內存加鎖,各個CPU在進行數據處理時串行執行,如Synchornized那樣。這種方式在一定程度上抹殺了多核CPU的優勢,顯然是不合適的

                   * 緩存鎖:基於MESI緩存一致性協議

        1.3,緩存一致性協議 -- MESI

             a,MESI狀態詳解

                   * M(Modify):緩存修改,表示緩存在當前CPU高速緩存中的數據已經被修改,即緩存數據與主內存數據不一致;如果存在其他CPU同時緩存該數據,則數據狀態置爲I(Invalid)

                   * E(Exclusive):緩存獨佔,數據只緩存在當前CPU高速緩存中,並且沒有被修改

                   * S(Shard):緩存共享,表示多個CPU高速緩存同時緩存當前數據

                   * I(Invalid):緩存實效,當前CPU高速緩存緩存數據狀態爲S(Shard),且存在其他CPU高速緩存數據已經被修改M(Modify),則設置其他CPU高速緩存中該數據狀態爲I(Invalid)

             b,MESI狀態轉換

                   * 在MESI協議中,每一個緩存的緩存控制器不僅知道當前緩存的讀寫操作,同時也需要監聽其他CPU高速緩存的操作,進行狀態變更

                   * 單個CPU從主內存緩存數據到高速緩存,此時高速緩存的數據狀態爲E(Exclusive)

                   * 多個CPU同時從主內存中緩存數據到高速緩存,此時高速緩存的數據狀態爲S(Shard)

                   * CPU對高速緩存中的數據進行修改,則數據狀態修改爲M(Modify)。此時,如果之前數據狀態爲E(Exclusive),則直接修改狀態爲M(Modify)(E -> M)。如果之前數據狀態爲S(Shard),則修改當前CPU高速緩存中該數據狀態爲M(Modify)(S -> M),同時發送消息到其他CPU,通知其他CPU修改高速緩存中該數據狀態爲I(Invalid)(S -> I)

                   * CPU讀請求:高速緩存中數據狀態爲M(Modify),E(Exclusive),S(Shard)的數據都可以被讀取,I(Invalid)狀態數據需要從主內存中重新讀取

                   * CPU寫請求:高速緩存中數據狀態爲M(Modify),E(Exclusive)的數據都可以被直接寫到主內存,S(Shard)狀態的數據寫操作,需要通知其他CPU修改高速緩存中該數據狀態爲I(Invalid)

         1.4,緩存一致性帶來的問題

                   * 在狀態修改時,各個CPU修改狀態是通過傳遞消息進行的。如果此時一個CPU進行了數據變更,則需要通知其他CPU將高速緩存中的數據狀態置爲無效,並且到等到各個CPU的確認回執。在這個階段,當前CPU一直處於阻塞狀態。

                   * 爲了避免阻塞帶來的CPU資源浪費,在CPU中引入了Store Bufferes

    2,Store Bufferes

        1.1,Store Bufferes發送消息流程

              * CPU在寫入共享數據時,直接將數據寫入到Store Bufferes中,同時發送 Invalidate 消息,然後去處理其他指令(CPU去阻塞)

              * 接收到其他CPU返回的 invalidate acknowledge 後,再將Store Bufferes中的數據存儲至CPU高速緩存

              * 最終再從CPU高速緩存中同步數據到主內存

        1.2,Store Bufferes存在問題

                * 加入Store Bufferes流程後,CPU取數據增加了一道流程,需要先從Store Bufferes中獲取被修改的數據,如果Store Bufferes中不存在該數據,則繼續到CPU高速緩存或者主內存中去取數據

                * 因爲加入Store Bufferes併發送消息,再到接收到ACK回執進行數據同步完全是異步操作,數據提交時間不確定,這就會造成CPU的指令重排問題

    3,CPU指令重排問題

        1.1,從一張圖開始思考;多核多線程下,下圖斷言的執行結果可能會是 false

             * 在上圖中,對於成員變量 value = 3,在多核CPU下,該值被多個CPU加載後,在各個CPU高速緩存中數據狀態爲S(Shard)

             * 此時CPU0對 value 值進行修改,在CPU0中,該值狀態爲M(Modify)。修改完成後,CPU0需要發消息通知其他CPU將高速緩存中該值狀態修改爲I(Invalid)。此時,CPU0需要做兩件事情:1)將 (value = 10)存儲到Store BUfferes中;2)發送Invalidate消息。之後,CPU0繼續向下執行,執行語句(isFinsh = true, isFinsh狀態爲E(Exclusive)時會直接寫入到主內存),此時各個CPU還沒有返回ACK回執,Stroe Bufferes中的數據並沒有寫入到高速緩存和主內存

             * 此時存在CPU1執行 if 判斷,從主內存獲取到 isFinsh 的最新值 true,接着執行 assert value == 3,但是此時由於CPU0修改的 value 值還沒有同步到主內存,則對於 CPU1來講,value值依舊爲3,則執行結果爲false。在顯式上看,好像CPU0先執行了( isFinsh = true),然後在沒有執行(value = 10)時,CPU1線程已經搶先執行,所以執行結果爲false,這就是CPU的指令重排序

    4,CPU層面的內存屏障

        4.1,硬件層面優化到這種程度,就目前技術來看,已經沒有了優化空間。但是還是不符合功能要求,那就需要硬件層面上添加指令處理。所以,在CPU層面上提供了Memory Barrier(內存屏障)的指令,這個指令就是用來(flush store bufferes)中的指令,軟件層面可以在合適的地方加入內存屏障(volatile是加入了storeload屏障)。CPU層面提供了三種屏障:

              * 寫屏障(Store Barrier):寫屏障之前的已經存儲到Store Buffer的數據同步到主內存

              * 讀屏障(Load Barrier):讀屏障之後的讀操作, 都在讀屏障之後執行, 配合寫屏障, 使得寫屏障之前的內存更新對於讀屏障之後的操作都是可見的

              * 全屏障(Full Barrier): 確保屏障前的讀寫操作全部更新到主內存後, 再進行屏障後的讀寫操作

三,JMM(Java Memory Model)-- java內存模型

    1,JMM內存模型:就是一種符合內存模型規範的,屏蔽了各種硬件和操作系統的訪問差異的,保證了Java程序在各種平臺下對內存的訪問都能保證效果一致的機制及規範。JMM實際上就是提供了合理的禁用緩存和禁用重排序的方法。最核心的價值在於解決可見性和有序性

    2,JMM解決重排序問題

        2.1,爲了提升程序的執行性能,編譯器和處理器都會對指令做重排序,處理器的重排序前面已經分析。編譯器的重排序值程序編寫的指令在編譯之後,會產生重排序來優化程序的執行性能。從源代碼到最終執行的指令,可能會經過三種重排序

        2.2,1屬於編譯器的重排序,JMM提供了禁止特定類型的編譯器重排序

        2.3,2和3屬於處理器的重排序,JMM會要求編譯器生成指令時,插入內存屏障來保障處理器重排序(例如volatile的storeLoad),當然並不是所有指令都會被重排序,符合Happen-Before規則的不會參與重排序

    3,JMM層面內存屏障

        * loadload():讀屏障

        * storestore():寫屏障

        * loadStore():讀寫屏障

        * storeLoad():寫讀屏障

    4,Volatile關鍵字使用了 storeload() 內存屏障,在JVM層面進行了內存屏障處理,保證了可見性和有序性

四,HappenBefore

    1,它的意思表示的是前一個操作的結果對於後續操作是可見的,所以它是一種表達多個線程之間對於內存的可見性。所以我們可以認爲在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作必須要存在happens before 關係 。 這兩個操作可以是同一個線程,也可以是不同的線程

    2,能建立Happens-Before原則的操作

            * 程序的順序規則
            * volatile規則
            * 傳遞性規則
            * start()規則, 線程可見
            * join()規則
            * 監視器規則(鎖規則)

五,Volatile可以保證線程的有序性和可見性,但是不能保證原子性。因爲在多線程環境下,存在線程在寫數據時,會產生併發覆蓋場景

package com.gupao.concurrent;

/**
 * @author pj_zhang
 * @create 2019-09-26 22:26
 **/
public class VolatileAddTest {

    private static volatile int i = 0;

    public static void main(String[] args) {
        for (int count = 0; count < 100; count++){
            new Thread(() -> {
                i++;
            }).start();
        }
        System.out.println(i);
    }
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章