高效並發下的高速緩存和指令重排


1. 前言

    關於計算機系統處理器資源的高效使用,計算機系統設計就引入高速緩存以解決CPU 運算速度與主內存存儲速度之間的速度不匹配問題;引入指令重排來提升 CPU 內部運算單元的執行利用效率。

    提升計算機處理器的運算能力,最簡單、最有效的手段是讓計算機支持多任務處理,可以充分利用處理器的運算能力。當然計算機操作系統的運算能力不單單取決於處理器,還需考慮系統中並行化與串行化的比重,磁盤I/O讀寫速度,網絡通信,數據庫交互等。

2. 高速緩存

2.1 高速緩存與緩存一致性

在這里插入圖片描述

2.1.1 高速緩存

    計算機處理器運算速度遠遠超出計算機存儲設備的讀寫速度。一定程度上存儲設備的讀寫速度限制了計算機系統的運算能力,引入高速緩存作為處理器和存儲設備之間的一層緩沖。高速緩存的存儲速度接近處理器的運算速度,處理器無需等待主內存緩慢的讀寫操作,使得處理器高效的工作。

2.1.2 緩存一致性

  • 緩存一致性問題
    引入高速緩存很好的處理了主內存讀寫速度與處理器運算速度相差幾個數量級的問題。
    但多處理器計算機系統下,存在某個時刻下,主內存中某個數據在不同處理器高速緩存中的數據不一致的情況。
  • 處理方案
    (1)處理器都是通過總線來和主存儲器(主內存)進行交互的,所以可以通過給總線加鎖,解決緩存一致性問題;

(2)可以通過引入緩存一致性協議,來處理緩存一致性問題。

    總線,總線英文標識為 Bus,公共汽車,總線是連接多個設備或者接入點的數據傳輸通路,處理器所有傳出的數據都要通過總線交互主存儲器。

    緩存一致性協議,要求處理器要遵循這些協議,這些協議規定了讀寫操作的規范來保證緩存一致性。

    Inter 處理器一般采用的是 MESI 協議。MESI(Modified Exclusive Shared Or Invalid)(也稱為伊利諾斯協議,是因為該協議由伊利諾斯州立大學提出)是一種廣泛使用的支持寫回策略的緩存一致性協議,該協議被應用在Intel奔騰系列的CPU中。

2.2 工作內存與主內存

在這里插入圖片描述

    理解了高速緩存,工作內存相似的,高速緩存是從處理器角度出發,工作內存是從線程角度出發。

    所有的變量存儲在主內存中,每條線程有自己的工作內存。此處主內存僅是虛擬機內存的一部分,與 Java 內存模型(程序計數器、Java 堆、非堆、虛擬機棧、本地方法棧) 沒有關聯。

  • 工作內存中保存了當前線程使用到變量的主內存拷貝,
  • 線程對變量所有的操作都在工作內存中進行。
  • 不同線程之間無法直接訪問對方工作內存的變量
  • 線程間變量值的傳遞均需通過主內存來完成,工作內存交互主內存。

2.3 線程間工作內存交互主內存

    每個線程都對應自己的工作內存,修改共享變量的值后,從當前工作內存保存並寫入到主內存。同樣的,共享變量被其他線程修改后的新值,當前線程需要從主內存讀取並載入到當前工作內存,才能進行使用。

    Java 內存模型定義了以下八種原子操作來作用於線程工作內存與主內存的交互。

操作 名稱 作用內存 操作說明
lock 鎖定 主內存 標識某個變量為線程獨占
unlock 解鎖 主內存 釋放某個被線程獨占的變量
read 讀取 主內存 變量的值從主內存傳輸到工作內存
load 載入 工作內存 把讀取到的值放入工作內存的變量副本
use 使用 工作內存 把變量值傳給執行引擎
assign 賦值 工作內存 把執行引擎接收到的值賦給變量
store 存儲 工作內存 把工作內存變量的值傳輸到主內存
write 寫入 主內存 變量值放入到主內存的變量中

3. 原子性、可見性、有序性

3.1.1 性質

  • 原子性
    眾所周知,原子操作是不可再拆分的操作,即原子性操作是並發安全的;

    原子操作包含 read、load、assign、use、store、write。

    lock 和 unlock 操作支持我們對一個更大范圍操作提供原子性保證。直觀來說,synchronized 關鍵字,被該關鍵字修飾的代碼塊具有原子性,使用該關鍵字能保證代碼塊的線程安全。

    synchronized 反映到字節碼指令,包含 monitorenter 和 monitorexit 指令,這兩個指令隱式調用了 lock、unlock 操作。

  • 有序性
    在某個線程中所有的操作都是有序的。Java 程序中在另一個線程觀察當前線程的操作,都是無序的。

    volatile 和 synchronized 都可保證線程之間操作的有序性。

    volatile 具備禁止指令重排序的能力。

    synchronized 具備 lock、unlock 能力,支持一個變量在同一時刻只許一個線程對其進行 lock 鎖定操作。

  • 可見性
    可見性表現在多線程之間,一個線程修改了某個共享變量(線程間共享變量)的值,其他線程可以立即得到這個修改,即新值對其他線程是實時可見的。

    volatile 關鍵字修飾的共享變量,線程寫入新值,線程間是可見的。

    volatile 變量與普通變量的區別,在於 volatile 變量的新值會立即 store 存儲到主內存中,在使用 volatile 變量時會先從主內存 read 讀取新值 load 載入到當前工作內存。而普通變量使用時不會立即從主內存刷新,當前工作內存若存在,則直接使用工作內存中變量的值。

3.1.2 可見性演示實例

    關於可見性,郭嬸(郭霖)舉了一個栗子,有助理解,這邊就直接拿來了。

/**
 * @className: VisibilityDemo 
 * @description: 可見性演示實例
 **/
public class VisibilityDemo {
    private static volatile boolean flag = true;

    public static void main(String... args) {
        Thread thread1 = new Thread(() -> {
            while (true) {
                if (flag) {
                    flag = false;
                    System.out.println("Thread1 set flag to false");
                }
            }
        }, "Thread-01");
        Thread thread2 = new Thread(() -> {
            while (true) {
                if (!flag) {
                    flag = true;
                    System.out.println("Thread2 set flag to true");
                }
            }
        }, "Thread-02");
        // 分別啟動兩個線程
        thread1.start();
        thread2.start();
    }
}
  • 當共享變量 flag 為普通變量 private static boolean flag 時,程序中兩線程會交替打印信息到控制台,一段時間后,兩線程內部分支條件不再滿足,將不再打印信息到控制台;
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
  • 當共享變量由 volatile 修飾時 private static volatile boolean flag,程序中兩線程會持續交替打印信息到控制台;
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
...

3.1.3 可見性演示實例問題分析

    由於線程工作內存與主內存存在緩存延時問題

    一個普通的線程共享變量private static boolean flag,在上例中存在,隨着程序的運行,在某個時刻線程 Thread-01 的 flag 為 false,線程 Thread-02 的 flag 為 true,此時兩者都不會進入分支結構體,不再執行賦值操作,不再刷新工作內存數據到主內存。兩個線程都會停止輸出信息到控制台。

    聲明為 volatile 變量private static volatile boolean flag,會保證共享變量每次賦值都會即時存儲到主內存,每次使用共享變量時,會從主內存讀取並載入到當前線程工作內存再使用。使用關鍵字后的程序,兩線程會持續交替輸出信息到控制台。

4. 指令重排

4.1 就你TMD叫指令重排啊

    在當前線程觀察 Java 程序,所有操作是有序的,但在其他線程觀察當前線程的操作是無序的。即線程內表現為串行的語義,多線程間存在工作內存與主內存同步延時及指令重排序現象。

4.2 指令重排的線程安全問題

  • 多線程下指令重排的線程安全問題
    我們知道處理器在指令集層面,會做一定的指令排序優化,來提升處理器運算速度。在單線程中可以保證對應高級語言的程序執行結果是正確的,即單線程下保證程序執行的有序性(及程序正確性);多線程情況下,在某個線程中觀察其他線程的操作是無序的(存在線程共享內存時,則無法保證程序正確性),這就是多線程下指令重排的線程安全問題。

4.2.1 指令重排演示實例

import lombok.SneakyThrows;

/**
 * @description: 指令重排:線程內表現為串行語義
 * @author: niaonao
 **/
public class OrderRearrangeDemo {
    static boolean initFlag;
    public static void main(String... args) {
        Runnable customRunnable = new CustomRunnable();
        new Thread(customRunnable, "Thread-01").start();
        new Thread(customRunnable, "Thread-02").start();
    }

    static class CustomRunnable implements Runnable {
        // @SneakyThrows 是 lombok 包下的注解
        // 繼承了 Throwable 用於捕獲異常
        @SneakyThrows
        @Override
        public void run() {
            initFlag = false;
            Integer number = null;
            number = 1;
            initFlag = true;
            // 等待初始化完成
            while (!initFlag) {
            }
            System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
        }
    }
}

    上面這個例子,在實際並發場景中很少出現線程安全問題,但存在指令重排引起線程安全問題的風險。

  • 一般情況下執行結果為
name: Thread-01, number: 1
name: Thread-02, number: 1

Process finished with exit code 0
  • 指令重排存在的風險結果可能為
name: Thread-01, number: 1
name: Thread-02, number: null

Process finished with exit code 0

4.2.2 指令重排演示實例問題分析

    線程內保證程序的有序性,多線程下處理器指令重排優化存在的情況如下(這里從高級語言來快速理解,其實指令我也做不到啊),下面並沒有列出所有情況。

    // 情況-01
    initFlag = false;
    Integer number = null;
    number = 1;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情況-02
    initFlag = false;
    Integer number = null;
    initFlag = true;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情況-03
    initFlag = false;
    initFlag = true;
    Integer number = null;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情況-04
    Integer number = null;
    initFlag = false;
    number = 1;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情況-05
    Integer number = null;
    initFlag = false;
    initFlag = true;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情況-06
    Integer number = null;
    number = 1;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情況-07
    Integer number = null;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    number = 1;
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情況-07
    Integer number = null;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    number = 1;
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    線程共享變量 initFlag 在線程 Thread-01 中已經執行 initFlag = true 操作后,在線程 Thread-02 中讀取到 initFlag 為 true,就會跳出 while 循環,此時由於指令重排,number 可能還沒有賦值為 1,程序打印到控制台的信息會是name: Thread-02, number: null

4.3 禁止指令重排序

    指令重排有線程安全風險,怎么避免呢?

    欸,問得好niaonao同學,請坐。Java 提供 volatile 關鍵字具備兩個特性,一是可見性,一是禁止指令重排。如4.2.1 指令重排演示實例,就用 volatile 修飾共享變量 static boolean initFlag 即可。

    可見性就不再贅述了。關於禁止指令重排的原理是通過 volatile 修飾的共享變量,會添加一個內存屏障,處理器在做重排序優化時,無法將內存屏障后面的指令放在內存屏障前面。

Powered By niaonao


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM