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

引入高速快取概念

  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 區別和底層原理