volatile 手摸手帶你解析

前言

volatile 是 Java 裏的一個重要的指令,它是由 Java 虛擬機裏提供的一個輕量級的同步機制。一個共享變量聲明爲 volatile 後,特別是在多線程操作時,正確使用 volatile 變量,就要掌握好其原理。

特性

volatile 具有可見性有序性的特性,同時,對 volatile 修飾的變量進行單個讀寫操作是具有原子性

這幾個特性到底是什麼意思呢?

  • 可見性: 當一個線程更新了 volatile 修飾的共享變量,那麼任意其他線程都能知道這個變量最後修改的值。簡單的說,就是多線程運行時,一個線程修改 volatile 共享變量後,其他線程獲取值時,一定都是這個修改後的值。

  • 有序性: 一個線程中的操作,相對於自身,都是有序的,Java 內存模型會限制編譯器重排序和處理器重排序。意思就會說 volatile 內存語義單個線程中是串行的語義。

  • 原子性: 多線程操作中,非複合操作單個 volatile 的讀寫是具有原子性的。

可見性

可見性是在多線程中保證共享變量的數據有效,接下來我們通過有 volatile 修飾的變量和無 volatile 修飾的變量代碼的執行結果來做對比分析。

無 volatile 修飾變量

以下是沒有 volatile 修飾變量代碼,通過創建兩個線程,來驗證 flag 被其中一個線程修改後的執行情況。

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ValatileDemo {
    static Boolean flag = true;
    public static void main(String[] args) {
        // A 線程,判斷其他線程修改 flag 之後,數據是否對本線程有效
        new Thread(() -> {
            while (flag) {
            }
            System.out.printf("********** %s 線程執行結束!**********", Thread.currentThread().getName());
        }, "A").start();
        // B 線程,修改 flag 值
        new Thread(() -> {
            try {
                // 避免 B 線程比 A 線程先運行修改 flag 值
                TimeUnit.SECONDS.sleep(1);
                flag = false;
                // 如果 flag 值修改後,讓 B 線程先打印信息
                TimeUnit.SECONDS.sleep(2);
                System.out.printf("********** %s 線程執行結束!**********", Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "B").start();
    }
}

上面代碼中,當 flag 初始值 true,被 B 線程修改爲 false。如果修改後的值對 A 線程有效,那麼正常情況下 A 線程會先於 B 線程結束。執行結果如下:

執行結果是:當 B 線程執行結束後, flag=false並未對 A 線程生效,A 線程死循環。

volatile 修飾變量

在上述代碼中,當我們把 flag 使用 volatile 修飾:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ValatileDemo {
    static volatile Boolean flag = true;
    public static void main(String[] args) {
        // A 線程,判斷其他線程修改 flag 之後,數據是否對本線程有效
        new Thread(() -> {
            while (flag) {
            }
            System.out.printf("********** %s 線程執行結束!**********", Thread.currentThread().getName());
        }, "A").start();
        

        // B 線程,修改 flag 值
        new Thread(() -> {
            try {
                // 避免 B 線程比 A 線程先運行修改 flag 值
                TimeUnit.SECONDS.sleep(1);
                flag = false;
                // 如果 flag 值修改後,讓 B 線程先打印信息
                TimeUnit.SECONDS.sleep(2);
                System.out.printf("********** %s 線程執行結束!**********", Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "B").start();
    }
}

執行結果:

B 線程修改 flag 值後,對 A 線程數據有效,A 線程跳出循環,執行完成。所以 volatile 修飾的變量,有新值寫入後,對其他線程來說,數據是有效的,能被其他線程讀到。

主內存和工作內存

上面代碼中的變量加了 volatile 修飾,爲什麼就能被其他線程讀取到,這就涉及到 Java 內存模型規定的變量訪問規則。

  • 主內存:主內存是機器硬件的內存,主要對應Java 堆中的對象實例數據部分。

  • 工作內存:每個線程都有自己的工作內存,對應虛擬機棧中的部分區域,線程對變量的讀/寫操作都必須在工作內存中進行,不能直接讀寫主內存的變量。

上面 volatile修飾變量部分的代碼執行示意圖如下:

當 A 線程讀取到 flag 的初始值爲 true,進行 while 循環操作,B 線程將工作內存 B 裏的 flag 更新爲 false,然後將值發送到主內存進行更新。隨後,由於此時的 A 線程不會主動刷新主內存中的值到工作內存 A 中,所以線程 A 所取得 flag 值一直都是 true,A 線程也就爲死循環不會停止下來。

上面 volatile修飾變量部分的代碼執行示意圖如下:

當 B 線程更新 volatile 修飾的變量時,會向 A 線程通過線程之間的通信發送通知(JDK5 或更高版本),並且將工作內存 B 中更新的值同步到主內存中。A 線程接收到通知後,不會再讀取工作內存 A 中的值,會將主內存的變量通過主內存和工作內存之間的交互協議,拷貝到工作內存 A 中,這時讀取的值就是線程 A 更新後的值 flag=false。整個變量值得傳遞過程中,線程之間不能直接訪問自身以外的工作內存,必須通過主內存作爲中轉站傳遞變量值。在這傳遞過程中是存在拷貝操作的,但是對象的引用,虛擬機不會整個對象進行拷貝,會存在線程訪問的字段拷貝。

有序性

volatile 包含禁止指令重排的語義,Java 內存模型會限制編譯器重排序和處理器重排序,簡而言之就是單個線程內表現爲串行語義。那什麼是重排序?重排序的目的是編譯器和處理器爲了優化程序性能而對指令序列進行重排序,但在單線程和單處理器中,重排序不會改變有數據依賴關係的兩個操作順序。比如:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ReorderDemo {
    static int a = 0;
    static int b = 0;
    public static void main(String[] args) {
        a = 2;
        b = 3;
    }
}
// 重排序後:
public class ReorderDemo {
    static int a = 0;
    static int b = 0;
    public static void main(String[] args) {
        b = 3;  // a 和 b 重排序後,調換了位置
        a = 2;
    }
}

但是如果在單核處理器和單線程中數據之間存在依賴關係則不會進行重排序,比如:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ReorderDemo {
    static int a = 0;
    static int b = 0;
    public static void main(String[] args) {
        a = 2;
        b = a;
    }
}
// 由於 a 和 b 存在數據依賴關係,則不會進行重排序

volatile 實現特有的內存語義,Java 內存模型定義以下規則(表格中的 No 代表不可以重排序):

Java 內存模型在指令序列中插入內存屏障來處理 volatile 重排序規則,策略如下:

  • volatile 寫操作前插入一個 StoreStore 屏障

  • volatile 寫操作後插入一個 StoreLoad 屏障

  • volatile 讀操作後插入一個 LoadLoad 屏障

  • volatile 讀操作後插入一個 LoadStore 屏障

該四種屏障意義:

  • StoreStore:在該屏障後的寫操作執行之前,保證該屏障前的寫操作已刷新到主內存。

  • StoreLoad:在該屏障後的讀取操作執行之前,保證該屏障前的寫操作已刷新到主內存。

  • LoadLoad:在該屏障後的讀取操作執行之前,保證該屏障前的讀操作已讀取完畢。

  • LoadStore:在該屏障後的寫操作執行之前,保證該屏障前的讀操作已讀取完畢。

原子性

前面有提到 volatile 的原子性是相對於單個 volatile 變量的讀/寫具有,比如下面代碼:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class AtomicDemo {
    static volatile int num = 0;
    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {  // 創建 10 個線程
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {    // 每個線程累加 1000
                    num ++;
                }
                latch.countDown();
            }, String.valueOf(i+1)).start();
        }
        latch.await();
        // 所有線程累加計算的數據
        System.out.printf("num: %d", num);
    }
}

上面代碼中,如果 volatile 修飾 num,在 num++ 運算中能持有原子性,那麼根據以上數量的累加,最後應該是 num:10000。代碼執行結果:

結果與我們預計數據的相差挺多,雖然 volatile 變量在更新值的時候回通知其他線程刷新主內存中最新數據,但這隻能保證其基本類型變量讀/寫的原子操作(如:num = 2)。由於 num++是屬於一個非原子操作的複合操作,所以不能保證其原子性。

使用場景

  1. volatile 變量最後的運算結果不依賴變量的當前值,也就是前面提到的直接賦值變量的原子操作,比如:保存數據遍歷的特定條件的一個值。

  2. 可以進行狀態標記,比如:是否初始化,是否停止等等。

總結

volatile 是一個簡單又輕量級的同步機制,但在使用過程中,侷限性比較大,要想使用好它,必須瞭解其原理及本質,所以在使用過程中遇到的問題,相比於其他同步機制來說,更容易出現問題。但使用好 volatile,在某些解決問題上能獲取更佳的性能。


關注【Java後端技術棧】,更多原創好文

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