切換語言為:簡體
Java 併發程式設計關鍵字 volatile 是如何實現的?

Java 併發程式設計關鍵字 volatile 是如何實現的?

  • 爱糖宝
  • 2024-07-31
  • 2068
  • 0
  • 0

在 Java 併發程式設計中,有 3 個最常用的關鍵字:synchronized、ReentrantLock 和 volatile。

雖然 volatile 並不像其他兩個關鍵字一樣,能保證執行緒安全,但 volatile 也是併發程式設計中最常見的關鍵字之一。例如,單例模式、CopyOnWriteArrayList 和 ConcurrentHashMap 中都離不開 volatile。

那麼,問題來了,我們知道 synchronized 底層是透過監視器 Monitor 實現的,ReentrantLock 底層是透過 AQS 的 CAS 實現的,那 volatile 的底層是如何實現的?

# 1.volatile 作用

在瞭解 volatile 的底層實現之前,我們需要先了解 volatile 的作用,因為 volatile 的底層實現和它的作用息息相關。

volatile 作用有兩個:保證記憶體可見性和有序性(禁止指令重排序)

# 1.1 記憶體可見性

說到記憶體可見性問題就不得不提 Java 記憶體模型,Java 記憶體模型(Java Memory Model)簡稱為 JMM,主要是用來遮蔽不同硬體和作業系統的記憶體訪問差異的,因為在不同的硬體和不同的作業系統下,記憶體的訪問是有一定的差異得,這種差異會導致相同的程式碼在不同的硬體和不同的作業系統下有著不一樣的行為,而 Java 記憶體模型就是解決這個差異,統一相同程式碼在不同硬體和不同作業系統下的差異的。

Java 記憶體模型規定:所有的變數(例項變數和靜態變數)都必須儲存在主記憶體中,每個執行緒也會有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒用到的變數和主記憶體的副本複製,執行緒對變數的操作都在工作記憶體中進行。執行緒不能直接讀寫主記憶體中的變數,如下圖所示:

Java 併發程式設計關鍵字 volatile 是如何實現的?

然而,Java 記憶體模型會帶來一個新的問題,那就是記憶體可見性問題,也就是當某個執行緒修改了主記憶體中共享變數的值之後,其他執行緒不能感知到此值被修改了,它會一直使用自己工作記憶體中的“舊值”,這樣程式的執行結果就不符合我們的預期了,這就是記憶體可見性問題,我們用以下程式碼來演示一下這個問題:

private static boolean flag = false;public static void main(String[] args) {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (!flag) {

            }
            System.out.println("終止執行");
        }
    });
    t1.start();
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("設定 flag=true");
            flag = true;
        }
    });
    t2.start();}

以上程式碼我們預期的結果是,線上程 1 執行了 1s 之後,執行緒 2 將 flag 變數修改爲 true,之後執行緒 1 終止執行,然而,因為執行緒 1 感知不到 flag 變數發生了修改,也就是記憶體可見性問題,所以會導致執行緒 1 會永遠的執行下去,最終我們看到的結果是這樣的:

Java 併發程式設計關鍵字 volatile 是如何實現的?

如何解決以上問題呢?只需要給變數 flag 加上 volatile 修飾即可,具體的實現程式碼如下:

private volatile static boolean flag = false;public static void main(String[] args) {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (!flag) {

            }
            System.out.println("終止執行");
        }
    });
    t1.start();
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("設定 flag=true");
            flag = true;
        }
    });
    t2.start();}

以上程式的執行結果如下圖所示:

Java 併發程式設計關鍵字 volatile 是如何實現的?

# 1.2 有序性

有序性也叫做禁止指令重排序。

指令重排序是指編譯器或 CPU 爲了最佳化程式的執行效能,而對指令進行重新排序的一種手段。

指令重排序的實現初衷是好的,但是在多執行緒執行中,如果執行了指令重排序可能會導致程式執行出錯。指令重排序最典型的一個問題就發生在單例模式中,比如以下問題程式碼:

public class Singleton {
    private Singleton() {}
    private static Singleton instance = null;
    public static Singleton getInstance() {
        if (instance == null) { // ①
            synchronized (Singleton.class) {
            	if (instance == null) {
                	instance = new Singleton(); // ②
                }
            }
        }
        return instance;
    }}

以上問題發生在程式碼 ② 這一行“instance = new Singleton();”,這行程式碼看似只是一個建立物件的過程,然而它的實際執行卻分為以下 3 步:

  1. 建立記憶體空間。

  2. 在記憶體空間中初始化物件 Singleton。

  3. 將記憶體地址賦值給 instance 物件(執行了此步驟,instance 就不等於 null 了)。

如果此變數不加 volatile,那麼執行緒 1 在執行到上述程式碼的第 ② 處時就可能會執行指令重排序,將原本是 1、2、3 的執行順序,重排為 1、3、2。但是特殊情況下,執行緒 1 在執行完第 3 步之後,如果來了執行緒 2 執行到上述程式碼的第 ① 處,判斷 instance 物件已經不為 null,但此時執行緒 1 還未將物件例項化完,那麼執行緒 2 將會得到一個被例項化“一半”的物件,從而導致程式執行出錯,這就是為什麼要給私有變數新增 volatile 的原因了。

要使以上單例模式變為執行緒安全的程式,需要給 instance 變數新增 volatile 修飾,它的最終實現程式碼如下:

public class Singleton {
    private Singleton() {}
    // 使用 volatile 禁止指令重排序
    private static volatile Singleton instance = null; // 【主要是此行程式碼發生了變化】
    public static Singleton getInstance() {
        if (instance == null) { // ①
            synchronized (Singleton.class) {
            	if (instance == null) {
                	instance = new Singleton(); // ②
                }
            }
        }
        return instance;
    }}

# 2.volatile 實現原理

volatile 實現原理和它的作用有關,我們首先先來看它的記憶體可見性。

# 2.1 記憶體可見性實現原理

volatile 記憶體可見性主要透過 lock 字首指令實現的,它會鎖定當前記憶體區域的快取(快取行),並且立即將當前快取行資料寫入主記憶體(耗時非常短),回寫主記憶體的時候會透過 MESI 協議使其他執行緒快取了該變數的地址失效,從而導致其他執行緒需要重新去主記憶體中重新讀取資料到其工作執行緒中。

# 什麼 MESI 協議?

MESI 協議,全稱為 Modified, Exclusive, Shared, Invalid,是一種快取記憶體一致性協議。它是爲了解決多處理器(CPU)在併發環境下,多個 CPU 快取不一致問題而提出的。 MESI 協議定義了快取記憶體中資料的四種狀態:

  1. Modified(M):表示快取行已經被修改,但還沒有被寫回主儲存器。在這種狀態下,只有一個 CPU 能獨佔這個修改狀態。

  2. Exclusive(E):表示快取行與主儲存器相同,並且是主儲存器的唯一複製。這種狀態下,只有一個 CPU 能獨佔這個狀態。

  3. Shared(S):表示此快取記憶體行可能儲存在計算機的其他快取記憶體中,並且與主儲存器匹配。在這種狀態下,各個 CPU 可以併發的對這個資料進行讀取,但都不能進行寫操作。

  4. Invalid(I):表示此快取行無效或已過期,不能使用。

MESI 協議的主要用途是確保在多個 CPU 共享記憶體時,各個 CPU 的快取資料能夠保持一致性。當某個 CPU 對共享資料進行修改時,它會將這個資料的狀態從 S(共享)或 E(獨佔)狀態轉變為 M(修改)狀態,並等待適當的時機將這個修改寫回主儲存器。同時,它會向其他 CPU 廣播一個“無效訊息”,使得其他 CPU 將自己快取中對應的資料狀態轉變為I(無效)狀態,從而在下次訪問這個資料時能夠從主儲存器或其他 CPU 的快取中重新獲取正確的資料。

這種協議可以確保在多處理器環境中,各個 CPU 的快取資料能夠正確、一致地反映主儲存器中的資料狀態,從而避免由於快取不一致導致的資料錯誤或程式異常。

# 2.2 有序性實現原理

volatile 的有序性是透過插入記憶體屏障(Memory Barrier),在記憶體屏障前後禁止重排序最佳化,以此實現有序性的。

# 什麼是記憶體屏障?

記憶體屏障(Memory Barrier 或 Memory Fence)是一種硬體級別的同步操作,它強制處理器按照特定順序執行記憶體訪問操作,確保記憶體操作的順序性,阻止編譯器和 CPU 對記憶體操作進行不必要的重排序。記憶體屏障可以確保跨越屏障的讀寫操作不會交叉進行,以此維持程式的記憶體一致性模型。

在 Java 記憶體模型(JMM)中,volatile 關鍵字用於修飾變數時,能夠保證該變數的可見性和有序性。關於有序性,volatile 透過記憶體屏障的插入來實現:

  • 寫記憶體屏障(Store Barrier / Write Barrier): 當執行緒寫入 volatile 變數時,JMM 會在寫操作前插入 StoreStore 屏障,確保在這次寫操作之前的所有普通寫操作都已完成。接著在寫操作後插入 StoreLoad 屏障,強制所有後來的讀寫操作都在此次寫操作完成之後執行,這就確保了其他執行緒能立即看到 volatile 變數的最新值。

  • 讀記憶體屏障(Load Barrier / Read Barrier): 當執行緒讀取 volatile 變數時,JMM 會在讀操作前插入 LoadLoad 屏障,確保在此次讀操作之前的所有讀操作都已完成。而在讀操作後插入 LoadStore 屏障,防止在此次讀操作之後的寫操作被重排序到讀操作之前,這樣就確保了對 volatile 變數的讀取總是能看到之前對同一變數或其他相關變數的寫入結果。

透過這種方式,volatile 關鍵字有效地實現了記憶體操作的順序性,從而保證了多執行緒環境下對 volatile 變數的操作遵循 happens-before 原則,確保了併發程式設計的正確性。

# 2.3 簡單回答

因為記憶體屏障的作用既能保證記憶體可見性,同時又能禁止指令重排序。因此你也可以籠統的回答 volatile 是透過記憶體屏障實現的。但是,回答的越細,面試的成績越高,面試的透過率也就越高。

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.