深度解析volatile關鍵字,就是這麼簡單

2021-02-14 郭霖
本文章講解的內容是深入了解volatile關鍵字,建議對著示例項目閱讀文章,示例項目連結如下:https://github.com/TanJiaJunBeyond/VolatileDemo查看彙編代碼的hsdis-amd64.dylib文件連結如下:https://github.com/TanJiaJunBeyond/VolatileDemo/blob/master/hsdis-amd64.dylib關鍵字volatile是Java虛擬機提供的最輕量級的同步機制,當一個變量被關鍵字volatile修飾之後,它有如下兩個特性:

保證了這個變量對所有線程的可見性

禁止指令重排序優化

關鍵字volatile可以保證變量對所有線程的可見性,也就是當一個線程修改了這個變量的值,其他線程能夠立即得到修改的值。普通變量是做不到這樣,普通變量的值需要通過主內存在線程之間傳遞。舉個例子:線程A修改一個普通變量的值,然後傳送給主內存,另外一個線程B需要等到傳送完主內存後才能夠從主內存進行讀取操作,這樣變量最新的值才會對線程B可見。先看下如下例子,代碼如下所示:

/**
 * Created by TanJiaJun on 2020-08-16.
 */
class VolatileDemo {

    private static final int THREADS_COUNT = 10;

    private static volatile int value = 0;

    private static void increase() {
        // 對value變量進行自增操作
        value++;
    }

    public static void main(String[] args) {
        // 創建10個線程
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    // 每個線程對value變量進行1000次自增操作
                    increase();
            });
            threads[i].start();
        }
        // 主線程等待子線程運行結束
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("value的值:" + value);
    }

}

這段代碼的意思是發起10個線程,然後每個線程對value變量進行1000次自增操作,如果這段代碼正確地並發操作,最後的結果value的值應該是10000,但是實際上多次運行後,value的值都是小於等於10000的值。這段代碼中increase方法調用i++,也就是i = i + 1,它不是原子性操作,Java內存模型直接保證的原子性變量操作包括read、load、assign、use、store和write,我們可以認為基本數據類型的讀寫都具備原子性,有個例外就是long和double的非原子性協定,不過我們無須太過在意,雖然Java內存模型允許虛擬機不把long和double的變量的讀寫實現為原子性操作。但是現在的商用虛擬機都幾乎把這些操作實現為原子性操作,原子性操作是指執行一系列操作,這些操作要麼全部執行,要麼全部不執行,不存在只執行其中一部分的情況,舉個例子:i = 1就是個原子性操作,但是i = i + 1就不是原子性操作,因為這個操作是由多條字節碼指令構成的,我用Javap反編譯上面的示例代碼,先找到生成的Class文件,路徑是/Users/tanjiajun/IdeaProjects/VolatileDemo/out/production/VolatileDemo/VolatileDemo.class就是在VolatileDemo目錄下的out文件夾中,然後執行javap -p -v VolatileDemo命令,生成如下字節碼:然後找到對應的increase方法的字節碼,字節碼如下所示:

private static void increase();
    descriptor: ()V
    flags: (0x000a) ACC_PRIVATE, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7                  // Field value:I
         3: iconst_1
         4: iadd
         5: putstatic     #7                  // Field value:I
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

可以看到value++是由四條指令構成的,分別是getstatic、iconst_1、iadd和putstatic,getstatic指令是獲取靜態欄位value的值並且放入操作棧頂,iconst_1指令是把常量1放入操作棧頂,iadd指令是把當前操作棧頂中兩個值相加並且把結果放入操作棧頂,putstatic指令是把操作棧頂的結果賦值給靜態變量value,關鍵字volatile可以保證執行getstatic指令後的值是正確的。如果在並發環境下,可能有其他線程在執行iconst_1指令或者iadd指令時,增加了value的值,導致操作棧頂的值就變成了過期的數據,在執行putstatic指令後可能把較小的value的值同步回主內存中,導致不能得到正確的結果。從上面的例子可以得知,volatile變量只保證可見性,以下兩條規則的運算環境可以保證這些操作的原子性:如果不符合以上兩條規則的話,就需要通過加鎖來保證這些操作的原子性,可以使用關鍵字synchronized或者java.util.concurrent中的原子類。Java內存模型中的一個語義是線程內表現為串行的語義(Within-Thread As-If-Serial Semantics),它是指普通變量只能保證在該方法在執行過程中所有依賴賦值結果的地方都能得到正確的結果,但是不保證變量的賦值操作的順序和程序代碼中的執行順序是一致的。舉個例子,代碼如下所示:

int i = 1;
int j = 2;
int k = i + j;

將常量1賦值給i

將常量2賦值給j

取到i的值

取到j的值

將i的值和j的值相加後賦值給k

在上面這五個步驟中,步驟1可能會和步驟2和步驟4重排序,步驟2可能會和步驟1和步驟3重排序,步驟3可能會和步驟2和步驟4重排序,步驟4可能會和步驟1和步驟3重排序,但是步驟1、步驟3和步驟5之間不能重排序,步驟2、步驟4和步驟5之間不能重排序,因為它們之間存在依賴關係,一旦重排序,線程表現為串行的語義將無法得到保證。再看個例子,使用雙重檢查鎖定(DCL)實現單例模式,代碼如下所示:

/**
 * Created by TanJiaJun on 2020/8/23.
 */
class Singleton {

    // 用關鍵字volatile修飾變量sInstance,禁止指令重排序優化
    private static volatile Singleton sInstance;

    // 私有構造方法
    private Singleton() {
        // 防止通過反射調用構造方法導致單例失效
        if (sInstance != null)
            throw new RuntimeException("Cannot construct a singleton more than once.");
    }

    // 獲取單例的方法
    public static Singleton getInstance() {
        // 第一次判斷sInstance是否為空,用於判斷是否需要同步,提高性能和效率
        if (sInstance == null) {
            // 使用synchronized修飾代碼塊,取Singleton的Class對象作為鎖對象
            synchronized (Singleton.class) {
                // 第二次判斷sInstance是否為空,用於判斷是否已經創建實例
                if (sInstance == null) {
                    // 創建Singleton對象
                    sInstance = new Singleton();
                }
            }
        }
        // 返回sInstance
        return sInstance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }

}

然後使用HSDIS插件反彙編上面的代碼,我只截取了對變量sInstance賦值(第25行)的那部分彙編代碼,如果想要看全部的彙編代碼,可以在查看SingletonAssemblyCodeWithVolatile.log,彙編代碼如下所示:

0x000000011b33f4c7:   mov    0x38(%rsp),%rax
  0x000000011b33f4cc:   movabs $0x61ff0ac48,%rdx            ;   {oop(a &apos;java/lang/Class&apos;{0x000000061ff0ac48} = &apos;Singleton&apos;)}
  0x000000011b33f4d6:   movsbl 0x30(%r15),%esi
  0x000000011b33f4db:   cmp    $0x0,%esi
  0x000000011b33f4de:   jne    0x000000011b33f6e9
  0x000000011b33f4e4:   mov    %rax,%r10
  0x000000011b33f4e7:   shr    $0x3,%r10
  0x000000011b33f4eb:   mov    %r10d,0x70(%rdx)
  0x000000011b33f4ef:   lock addl $0x0,-0x40(%rsp)
  0x000000011b33f4f5:   mov    %rdx,%rsi
  0x000000011b33f4f8:   xor    %rax,%rsi
  0x000000011b33f4fb:   shr    $0x15,%rsi
  0x000000011b33f4ff:   cmp    $0x0,%rsi
  0x000000011b33f503:   jne    0x000000011b33f708           ;*putstatic sInstance {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - Singleton::getInstance@24 (line 25)

然後把代碼中的關鍵字volatile去掉,再生成彙編代碼,我只截取了對變量sInstance賦值(第25行)的那部分彙編代碼,如果想要看全部的彙編代碼,可以在查看SingletonAssemblyCodeWithNoVolatile.log,彙編代碼如下所示:

0x0000000116f2a4c7:   mov    0x38(%rsp),%rax
  0x0000000116f2a4cc:   movabs $0x61ff0acb8,%rdx            ;   {oop(a &apos;java/lang/Class&apos;{0x000000061ff0acb8} = &apos;Singleton&apos;)}
  0x0000000116f2a4d6:   movsbl 0x30(%r15),%esi
  0x0000000116f2a4db:   cmp    $0x0,%esi
  0x0000000116f2a4de:   jne    0x0000000116f2a6e1
  0x0000000116f2a4e4:   mov    %rax,%r10
  0x0000000116f2a4e7:   shr    $0x3,%r10
  0x0000000116f2a4eb:   mov    %r10d,0x70(%rdx)
  0x0000000116f2a4ef:   mov    %rdx,%rsi
  0x0000000116f2a4f2:   xor    %rax,%rsi
  0x0000000116f2a4f5:   shr    $0x15,%rsi
  0x0000000116f2a4f9:   cmp    $0x0,%rsi
  0x0000000116f2a4fd:   jne    0x0000000116f2a700           ;*putstatic sInstance {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - Singleton::getInstance@24 (line 25)

通過對比可以發現,如果變量sInstance被關鍵字volatile修飾,會在賦值(mov    %r10d,0x70(%rdx))後多執行一個lock addl $0x0,-0x40(%rsp)指令,這個指令是一個內存屏障(Memory Barrier),它可以使內存屏障前的指令和內存屏障後的指令不會因為系統優化而導致亂序執行,後面會詳細講解,lock addl $0x0,-0x40(%rsp)(%rsp是堆棧指針寄存器,通常會指向棧頂位置,堆棧的pop操作和push操作是通過改變%rsp的值來移動堆棧指針的位置來實現)是一個空操作。查詢IA32手冊可得知,使用這個空操作,而不是使用空操作指令nop是因為前綴lock不允許配合nop指令使用,其中前綴lock,查詢IA32手冊可得知,它的作用是使得本CPU的緩存寫入內存,相當於對緩存中的變量執行store操作和write操作,這個寫入動作可以讓其他CPU或者別的內核無效化(Invalidata)其緩存,可以讓前面對被關鍵字volatile修飾的變量的修改對其他線程立即可見。內存屏障(Memory Barrier),也稱為內存柵欄、內存柵障和屏障指令等,是一類同步屏障指令,它使得CPU或者編譯器在對內存進行操作的時候,嚴格按照一定的順序執行,大多數現代計算機為了提高性能而採用亂序執行,它就可以使內存屏障前的指令和內存屏障後的指令不會因為系統優化而導致亂序執行。內存屏障的語義是內存屏障前的所有寫操作都要寫入內存,內存屏障後的所有讀操作都可以獲得同步屏障之前的讀操作的結果。確保Load1要載入的數據能夠在被Load2和後面的load指令載入數據前載入。序列:①Store1②StoreStore③Store2確保Store1要存儲的數據能夠在Store2和後面的store指令同步回主內存前對其它處理器可見。序列:①Load1②LoadStore③Store2確保Load1要載入的數據能夠在Store2和後面的store指令同步回主內存前載入。序列:①Store1②StoreLoad③Load2確保Store1要存儲的數據能夠在Load2和後面的load指令載入數據前對其它處理器可見。它是這四種內存屏障中開銷最大的,它也是一個萬能屏障,具有其它三種內存屏障的功能。下圖展示了這些內存屏障如何符合JSR-133排序規則:

/**
 * Created by TanJiaJun on 2020/8/23.
 */
class MemoryBarrierTest {

    private int a, b;
    private volatile int c, d;

    private void test() {
        int i, j;
        i = a; // load a
        j = b; // load b
        i = c; // load c
        // LoadLoad
        j = d; // load d
        // LoadStore
        a = i; // store a
        b = j; // store b
        // StoreStore
        c = i; // store c
        // StoreStore
        d = j; // store d
        // StoreLoad
        i = d; // load d
        // LoadLoad
        // LoadStore
        j = b; // load b
        a = i; // store a
    }

}

另外,為了保證關鍵字final的特殊語義,會在下面的序列中加入內存屏障:①x.finalField = v;②StoreStore③sharedRef = x;總結下Java內存模型中對被關鍵字volatile修飾的變量進行read(讀取)、load(載入)、use(使用)、assign(賦值)、store(存儲)和write(寫入)操作定義的特殊規則:假設有一個線程A,有一個被關鍵字volatile修飾的變量i;只有當線程A對變量i執行的前一個操作是load操作的時候,線程A才能對變量i進行use操作;並且,只有線程A對變量i執行的後一個操作是use操作的時候,線程A才能對變量i執行load操作,也就是說,線程A對變量i執行use操作是和對其執行read操作和load操作相關聯的,它們都必須要連續一起出現。這條規則要求在工作內存中,每次使用volatile變量都必須從主內存中刷新最新的值,用於保證能看見其他線程對volatile變量的修改後的值。假設有一個線程A,有一個被關鍵字volatile修飾的變量i;只有當線程A對變量i執行的前一個操作是assign操作的時候,才能對其進行store操作;並且,只有線程A對變量i執行後一個操作是store操作的時候,線程A才能對變量i進行assign操作,也就是說,線程A對變量i執行assign操作是和對其執行store操作和write操作相關聯的,它們都必須要連續一起出現。這條規則要求在工作內存中,每次修改volatile變量時都要立刻同步回主內存,用於保證其他線程能看見volatile變量修改後的值。假設有一個線程A,有兩個被關鍵字volatile修飾的變量,分別為i和j;假定動作A是線程A對volatile變量i執行use操作或者assign操作,假定動作B是和動作A相關聯的load操作或者store操作,假定動作C是和動作B相關聯的read操作或者write操作;假定動作D是線程A對volatile變量j執行use操作或者assign操作,假定動作E是和動作D相關聯的load操作或者store操作,假定動作F是和動作E相關聯的read操作或者write操作;如果動作A先於動作D,那麼動作C先於動作F。這條規則要求被關鍵字volatile修飾的變量不會被指令重排序優化,保證了代碼的執行順序和程序的順序相同。

相關焦點

  • 從底層原理深度剖析volatile關鍵字,徹底徵服面試官
    本篇文章從底層原理層面深度剖析volatile關鍵字是如何實現內存可見性的,同時引入了Java內存模型、指令重排序以及內存屏障等知識點作為原理分析的知識支撐。閱讀本文之前,推薦大家先閱讀作者之前的一篇關於happens-before的文章,這樣更有助於大家對volatile關鍵字底層原理的理解。
  • Java並發編程徹底搞懂volatile關鍵字
    本篇文章主要對volatile關鍵字進行解剖。解決辦法是使用volatile關鍵字。關鍵字volatile的作用是強制從公共堆棧中取得變量的值,而不是從線程私有數據棧中取得變量值。線程私有堆棧圖上述問題解決方案其實很簡單,跟同步死循環解決方案一致使用volatile關鍵字,其內存結構如下:
  • Java內存模型與volatile關鍵字
    Java的內存模型大概樣子還是有必要了解下的,今天就學習了下,順便學習了一點volatile關鍵字!關鍵字volatile一個變量如果被volatile修飾那麼他有兩個特性:1、變量對所有線程的可見性,意思是如果一條線程修改了這個變量的值,那麼其他線程就可以立刻知道的。
  • 你應該要理解的java並發關鍵字volatile
    提高java的並發編程,就不得不提volatile關鍵字,不管是在面試還是實際開發中 volatile都是一個應該掌握的技能。他的重要性不言而喻。因此也有必要學好。一、為什麼要用到volatile關鍵字?
  • 什麼時候需要使用volatile關鍵字
    想必大家平時都見過volatile關鍵字,可是大家知道什麼時候需要使用volatile關鍵字嗎?這裡為了產生預期的行為,需要阻止編譯器做這種優化,可以使用volatile關鍵字修飾。volatile int a = 100;volatile關鍵字和const關鍵字相對應,const關鍵字告訴編譯器其修飾的變量是只讀的,編譯器根據只讀屬性做一些操作,而volatile關鍵字告訴編譯器其修飾的變量是易變的,同理編譯器根據易變屬性也會做一些操作。它會確保修飾的變量每次都讀操作都從內存裡讀取,每次寫操作都將值寫到內存裡。
  • 知名公司面試題:談談你對volatile關鍵字的理解
    作為一名java程式設計師,求職面試時,關於volatile關鍵字時常會遇到。張工最近到某知名網際網路公司面試,面試官提出這樣的一個問題:談談你對volatile關鍵字的理解張工一時間沒有回答上來,面試官:你都工作三年了,怎麼對volatile關鍵字都沒掌握啊。
  • 可惜了,面試敗在了volatile關鍵字上,直擊痛點搞懂volatile
    這不,有位同學就來找我訴苦了,前兩次面試都挺順利的,到了三面竟然栽在了volatile關鍵字上。下面我們就來好好聊聊volatilevolatilevolatile 是一個類型修飾符。總結來說就是JMM內部會有指令重排,並且會有af-if-serial跟happen-before的理念來保證指令的正確性。內存屏障就是基於4個彙編級別的關鍵字來禁止指令重排的,其中volatile的規則如下:第一個為讀操作時,第二個任何操作不可重排序到第一個前面。
  • C語言中volatile關鍵字的使用
    打開APP C語言中volatile關鍵字的使用 發表於 2018-03-17 11:55:00 volatile的意思是易變的
  • volatile關鍵字詳解
    ;    }}class Mythread{    //不加volatile,主線程無法得知num的值發生了改變,從而陷入死循環    volatile int num = 0;    public void increment(){        ++num;    }}
  • 分享牛人解釋的volatile關鍵字
    精確地說就是,優化器在用到這個變量時必須每次都小心地重新讀取這個變量的值,而不是使用保存在寄存器裡的備份。下面是volatile變量的幾個例子:1). 並行設備的硬體寄存器(如:狀態寄存器)2).一般說來,volatile用在如下的幾個地方:1、中斷服務程序中修改的供其它程序檢測的變量需要加volatile;2、多任務環境下各任務間共享的標誌應該加volatile;3、存儲器映射的硬體寄存器通常也要加volatile說明,因為每次對它的讀寫都可能由不同意義;另外,以上這幾種情況經常還要同時考慮數據的完整性(相互關聯的幾個標誌讀了一半被打斷了重寫),在1
  • Java並發之volatile關鍵字內存可見性問題
    Java並發之volatile關鍵字內存可見性問題線程之間數據共享案例我們先來看一個場景:Main函數啟動後,調用一個線程向list中添加數據。而主內存(也就是系統內存非程序自己需要的內存)flag變量對所有共享這個變量的線程來說,都應該是可見的才可以。那麼這個時候,在Java中怎麼實現線程之間共享數據的內存可見性呢?這裡就是我們今天需要講解的關鍵字:volatile。
  • Java中volatile關鍵字概覽
    一、第一章 volatile關鍵字概覽多線程下變量的不可見性概述在多線程並發執行下,多個線程修改共享的成員變量,會出現一個線程修改了共享變量的值後,另一個線程不能直接 看到該線程修改後的變量的最新值。變量不可見性解決方案概述如何實現在多線程下訪問共享變量的可見性:也就是實現一個線程修改變量後,對其他線程可見呢?
  • 就是要你懂 Java 中 volatile 關鍵字實現原理
    前言我們知道volatile關鍵字的作用是保證變量在多線程之間的可見性,它是java.util.concurrent包的核心,沒有volatile就沒有這麼多的並發類給我們使用本文詳細解讀一下volatile關鍵字如何保證變量在多線程之間的可見性,在此之前,有必要講解一下CPU緩存的相關知識,掌握這部分知識一定會讓我們更好地理解volatile的原理,從而更好、更正確地地使用volatile關鍵字。
  • C語言丨深入理解volatile關鍵字
    來源:小職(z_zhizuobiao)找我:✅ 解鎖高薪工作 ✅ 免費獲取乾貨教程在C語言中,還有一個並不常用但卻非常有用的關鍵字volatile。許多程式設計師無法正確的理解C語言關鍵字volatile。這並不奇怪,大多數C原因書籍不過一兩句一帶而過。本文將告訴你如何正確使用它。
  • 如何理解volatile關鍵字
    面試中常常會問道,說說你對volatile的理解?在講解之前,我們先來了解一個Java並發編程中可見性的問題。可以看出線程1和線程2都是在操作變量data,但是線程1修改了data的值之後,線程2是看不到的,它只能看到自己工作內存中的那個副本值,這個就是Java並發過程中的可見性問題。
  • Java程式設計師面試必備:Volatile全方位解析
    &&答案解析 公眾號:撿田螺的小男孩 「github 地址」https://github.com/whx123/JavaHome1.volatile的用法volatile關鍵字是Java虛擬機提供的的「最輕量級的同步機制」,它作為一個修飾符出現,用來「
  • 為什麼要理解volatile關鍵字,才能夠明白線程操作的意義?
    為什麼說理解volatile關鍵字,才能理解線程操作的意義?對於初學者來說,volatile只是簡單的了解,只知道volatile作用就是「保證可見性」、「禁止指令重排序」,但是仍然存在一些誤區。對於volatile關鍵字,可以說是Java虛擬機提供的最輕量級的同步機制,但是它「不能保證原子性」,所以很多程式設計師都習慣去避免使用它,遇到需要處理多線程數據競爭問題的時候一律使用 synchronized來進行同步。今天就花點時間聊一聊volatile關鍵字。
  • 1分鐘讀懂java中的volatile關鍵字
    本文將以儘量簡潔的方式介紹java中的volatile關鍵字。如果覺得寫的不錯,記得,如果寫的不好歡迎批評指正,讓我們一起進步!1.volatile簡介先來看volatile這個單詞的本義:說簡單點,volatile就是表示某人或某物是不穩定的、易變的。
  • 教學筆記:這樣來學習Java volatile關鍵字
    相信大多數Java程式設計師都學習過volatile這個關鍵字的用法。百度百科上對volatile的定義:volatile是一個類型修飾符(type specifier),被設計用來修飾被不同線程訪問和修改的變量。volatile的作用是作為指令關鍵字,確保本條指令不會因編譯器的優化而省略,且要求每次直接讀值。
  • Java裡面volatile關鍵字修飾引用變量的陷阱
    Java裡面volatile關鍵字修飾引用變量的陷阱如果我現在問你volatile的關鍵字的作用,你可能會回答對於一個線程修改的變量對其他的線程立即可見。嚴謹的回答應該是volatile關鍵字對於基本類型的修改可以在隨後對多個線程的讀保持一致,但是對於引用類型如數組,實體bean,僅僅保證引用的可見性,但並不保證引用內容的可見性。