從快取入門到並行程式設計三要素詳解 Java中 volatile 、final 等關鍵字解析案例

2022-10-03 21:00:26

引入快取記憶體概念

  1. 在計算機在執行程式時,以指令為單位來執行,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到資料的讀取和寫入。

  2. 由於程式執行過程中的臨時資料是存放在主記憶體(實體記憶體)當中的,這時就存在一個問題,由於CPU執行指令的速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程相對很慢,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。因此就引入了快取記憶體

  3. 特性:快取(Cache memory)是硬碟控制器上的一塊記憶體,是硬碟內部儲存和外界介面之間的緩衝器。

快取記憶體作用呢?

  1. 預讀取

    ​ 相當於提前載入,猜測你可能會用到硬碟相鄰儲存地址的資料,它會提前進行載入到快取中,後面你需要時,CPU就不需要去硬碟讀取資料,直接讀取快取中的資料傳輸到記憶體中就OK了,由於讀取快取的速度遠遠高於讀取硬碟時磁頭讀寫的速度,所以能夠明顯的改善效能。

  2. 對寫入動作進行快取

    ​ 硬碟接到寫入資料的指令之後,並不會馬上將資料寫入到碟片上,而是先暫時儲存在快取裡,然後傳送一個「資料已寫入」的訊號給系統,這時系統就會認為資料已經寫入,並繼續執行下面的工作,而硬碟則在空閒(不進行讀取或寫入的時候)時再將快取中的資料寫入到碟片上。

  3. 換到應用程式層面也就是,當程式在執行過程中,會將運算需要的資料從主記憶體複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料同步到主記憶體當中

舉個簡單的例子,比如下面的這段程式碼:

i = i + 1;
  • 當執行緒執行這個語句時,會先從主記憶體當中讀取i的值,然後複製一份到快取記憶體當中,然後CPU執行指令對i進行加1操作,然後將資料寫入快取記憶體,最後將快取記憶體中i最新的值重新整理到主記憶體當中。

  • 這個程式碼在單執行緒中執行是沒有任何問題的,但是在多執行緒中執行就會有問題了(存在臨界區)。在多核CPU中,每條執行緒可能執行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體區(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的)。

比如有兩個執行緒像下列執行順序:

  1. 執行緒一執行 i = i + 1,執行緒二執行var = i
  2. 執行緒二此時去主記憶體中獲取變數 i,執行緒一隻是在快取記憶體中更新了變數,還未將變數i寫會主記憶體
  3. 執行緒二讀到的i不是最新值,此時多執行緒導致資料不一致

​ 類似上面這種情況即為快取一致性問題讀寫場景、雙寫場景都會存在快取一致性問題,但讀讀不會。前提是需要在多執行緒執行的環境下,並且需要多執行緒去存取同一個共用變數。

​ 這裡的共用又可以回到上文中,即為上面所說,他們每個執行緒都有自己的快取記憶體區,但是都是從同一個主記憶體同步獲取變數。

那麼這種問題應該怎樣解決呢?

解決快取不一致問題(硬體層面)

  1. 匯流排加鎖模式
    • 由於CPU在執行命令和其他元件進行通訊的時候都需要通過匯流排,倘若對匯流排加鎖的話,執行緒一執行i = i + 1 整個命令過程中,其他執行緒是無法存取主記憶體的。
    • 優缺只有一個,可以解決本問題;缺點的話除了優點全是缺點,效率低,成本高·····(誰也不會讓一個主記憶體同時只能幹一件事)
  2. 快取一致性協定
    • 協定可以保證每個快取中使用的共用變數的副本是一致的,原理:CPU對主記憶體中的共用變數有寫入操作時,會立即通知其他CPU將該變數快取行置為無效狀態。其他CPU發現該變為無效狀態時,就會重新去主記憶體中讀取該變數最新值。
    • 優點就是可以解決問題,讀多寫少效率還OK;缺點就是實現繁瑣,較耗費效能,在對於寫多的場景下效率很不可觀

問題執行緒為什麼會不安全?

​ 答:共用資源不能及時同步更新,歸根於 分時系統 上下文切換時 指令還未執行完畢 (沒有寫回結果) 更新異常

引入並解釋並行程式設計特性

​ 眾所周知現在的網際網路大型專案,都是採用分散式架構同時具有其「三高症狀」高並行、高可用、高效能。高並行為其中最重要的特性之一,在高並行場景下並行程式設計就顯得尤為重要,其並行程式設計的特性為原子性、可見性、有序性

原子性指的是一個或多個操作要麼全部執行成功要麼全部執行失敗,期間不能被中斷,也不存在上下文切換,執行緒切換會帶來原子性的問題。

  • 變數賦值問題:

    • b 變數賦值的底層位元組碼指令被分為兩步:第一步先定義 int b;第二步再賦值為 10。

    • 兩條指令之間不具有原子性,且在多執行緒下會發生執行緒安全性問題

      int b = 10;
      

可見性指的是當前執行緒對共用變數的修改對其他執行緒來說是可見的。以下案例中假設不會出現多執行緒原子性問題(比如多個執行緒寫入覆蓋問題等),即保證一次變數操作底層執行指令為原子性的。

例如上述變數在讀寫場景下,不能保證其可見性,導致寫執行緒完成修改指令時但為同步到主記憶體中,讀執行緒並不能獲得最新值。這就是對於B執行緒來說沒有滿足可見性。

  • 案例解析:final關鍵字

    • final 變數可以保證其他執行緒獲取的該變數的值是唯一的。變數指成員變數或者靜態變數

    • b 變數賦值的底層位元組碼指令被分為兩步:第一步先定義 int b;第二步再賦值為 10

      final a = 10;             int b = 10;
      
    • final修飾的變數在其指令後自動加入了寫屏障,可以保證其變數的可見性

    • a 可以保證其他執行緒獲取的值唯一;b 不能保證其他執行緒獲取到的值一定是 10,有可能為 0。

    • 讀取 final 變數解析 :

      • 不加 final 讀取變數時去堆記憶體尋找,final 變數是在棧空間,讀取速度快
      • 讀取 final 變數時,直接將其在棧中的值複製一份,不用去 getstatic ,效能得到提升
      • 注意:不是所有被 final 修飾的變數都在棧中。當數值超過變數型別的 MAX_VALUE 時,將其值存入常數池中
      • 讀取變數的速度:棧 > 常數池 > 堆記憶體
  • final 可以加強執行緒安全,而且符合物件導向程式設計開閉原則中的close,例如子類不可繼承、方法不可重寫、初始化後不可改變、非法存取(如修飾引數時,該引數為唯讀模式)等

有序性指的是程式執行的順序按照程式碼的先後順序執行。

在Java中有序性問題會時常出現,由於我們的JVM在底層會對程式碼指令的執行順序進行優化(提升執行速度且保證結果),這隻能保證單執行緒下安全,不能保證多執行緒環境執行緒安全,會導致指令重排發生有序性問題。

案例:排名世界第一的程式碼被玩壞了的單例模式

DCL(double checked):加入 volatile 保證執行緒安全,其實就是保證有序性。

上程式碼:其中包括了三個問題並且有詳細註釋解釋。(鳴謝itheima滿一航老師)

  1. 為什麼加入 volatile 關鍵字?
  2. 對比實現3(給靜態程式碼塊加synchronized) 說出這樣做的意義?
  3. 為什麼要在這裡加空判斷,之前不是判斷過了嗎?
final class SingletonLazyVolatile {
    private SingletonLazyVolatile() { }
    // 問題1:為什麼加入 volatile 關鍵字?
    // 答:   防止指令重排序 造成返回物件不完整。 如 TODO
    private static volatile SingletonLazyVolatile INSTANCE = null;
    // 問題2:對比實現3(給靜態程式碼塊加synchronized) 說出這樣做的意義?
    // 答:沒有鎖進行判斷、效率較高
    public static SingletonLazyVolatile getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        // 問題3:為什麼要在這裡加空判斷,之前不是判斷過了嗎?
        // 答:假入t1 先進入判斷空成立,先拿到鎖, 然後到範例化物件這一步(未執行)
        //    同時 執行緒 t2 獲取鎖進入阻塞狀態,若 t1 完成建立物件後,t2 沒有在同步塊這進行判空,t2 會再新建立一個物件,
        //    導致 t1 的物件被覆蓋 造成執行緒不安全。
        synchronized (SingletonLazyVolatile.class) {  // t1
            if (INSTANCE != null) {
                return INSTANCE;
            }
            INSTANCE = new SingletonLazyVolatile();   // t1  這行程式碼會發生指令重排序,需要加入 volatile
            // 如:先賦值指令INSTANCE = new SingletonLazyVolatile,導致範例不為空,下一個執行緒會判空失敗直接返回該物件
            // 但是構造方法()指令還沒執行,返回的就是一個不完整的物件。
            return INSTANCE;
        }
    }
}

通過對並行程式設計的三要素介紹,也就是說,要想並行程式正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程式執行不正確。

補充volatile知識:

  • volatile 只保證可見性(多執行緒下對變數的修改是可見的)、有序性(禁止進行指令重排序)

  • volatile 的底層實現原理是記憶體屏障(記憶體柵欄),Memory Barrier(Memory Fence),記憶體屏障會提供3個功能:

    • 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成
    • 它會強制將對快取的修改操作立即寫入主記憶體
    • 如果是寫操作,它會導致其他CPU中對應的快取行無效
  • volatile修飾之後的變數會加入讀寫屏障

    • 寫屏障(sfence):保證在該屏障之前的,對共用變數的改動,都同步到主記憶體當中

    • 讀屏障(lfence):保證在該屏障之後的, 對共用變數的讀取,載入的是主記憶體中的最新資料

    • 對 volatile 變數的寫指令後會加入寫屏障

    • 對 volatile 變數的讀指令前會加入讀屏障

關於volatile 的用途像兩階段終止、單例雙重鎖等等:

兩階段終止--volatile

    @Log
    public class TwoPhaseStop {

        // 監控執行緒
        private Thread monitorThread;

        // 多執行緒共用變數 單執行緒寫入(停止執行緒) 多執行緒讀取 使用 volatile
        private volatile boolean stop = false;

        // 啟動監控執行緒
        public void start() {
            monitorThread = new Thread(() -> {
                log.info("開始監控");
                while (true) {
                    log.info("監控中");
                    Thread currentThread = Thread.currentThread();
                    if (stop) {
                        log.info("正在停止");
                        break;
                    }
                    try {
                        log.info("正常執行");
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        // sleep出現被打斷異常後、被打斷後會清除打斷標記
                        // 需要重新打斷標記
                        currentThread.interrupt();
                    }
                }
                log.info("已停止");
            },"monitor");
            monitorThread.start();
        }

        // 停止監控執行緒
        public void stop() {
            stop = true;
            monitorThread.interrupt();
        }

    }

·
·
·
·

下篇預告:synchronized 和 volatile 區別和底層原理