【併發編程】volatile關鍵字最全詳解,看這一篇就夠了

目錄

一、引入

1.1 可見性問題:

1.2 重排序問題:

二、前言

三、CPU緩存

3.1 CPU多級緩存架構

3.2 使用CPU緩存帶來的問題

3.3 lock指令做了什麼

3.3 緩存一致性協議

3.4 內存屏障(Memory Barrier)

3.4.1 volatile語義中的內存屏障

3.4.2 final語義中的內存屏障

3.5 彙編指令LOCK與內存屏障之間的關係?

四、JMM與多線程通信

五、Volatile關鍵字的語義分析

5.1 volatile的原理

總結:

5.2 volatile的用法

5.3 volatile的作用

5.3.1 volatile與可見性

5.3.2 volatile與有序性

5.3.3 volatile與原子性

六、Volatile的適用場景

七、Volatile不適用的場景

volatile不適合複合操作

八、volatile與synchronized的區別

8.1 使用上的區別

8.2 對原子性的保證

8.3 對可見性的保證

8.4 對有序性的保證

8.5 性能上的區別

九、總結


一、引入

1.1 可見性問題:

public class ReaderAndUpdater {
    final static int MAX=5;
    static int init_value=0;
    public static void main(String[] args) {
        new Thread(()->{
                int localValue=init_value;
                while(localValue<MAX){
                    if(localValue!=init_value){
                        System.out.println("Reader:"+init_value);
                        localValue=init_value;
                    }
                }
        },"Reader").start();

        new Thread(()->{
            int localValue=init_value;
            while(localValue<MAX){
                System.out.println("updater:"+(++localValue));
                init_value=localValue;
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"Updater").start();
    }
}

一個讀線程,一個寫線程。有一個全局靜態變量init_value每一個線程還有一個自己的局部變量localValue。寫線程將自己的局部變量localValue自增,然後賦值給全局靜態變量init_value。讀線程當發現自己的本地局部變量與全局靜態變量值不相同,則讀入最新的全局靜態變量init_value,然後更新自己的本地局部變量。

運行結果:

updater:1
Reader:1
updater:2
updater:3
updater:4
updater:5

由結果可發現讀線程感知不到寫線程對init_value變量的更新,寫線程讀取到的全局靜態變量一直沒有被更新,還是舊值。這就出現了併發編程的可見性問題,讀線程對寫線程的數據修改結果不可見,使程序出現了問題。

 

1.2 重排序問題:

public class NoVisibility {
    private static boolean ready;
    private static int number;
    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }
    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

NoVisibility可能會持續循環下去,因爲讀線程可能永遠都看不到ready的值,這就是上面講到的可見性問題。也有可能NoVisibility可能會輸出0,因爲讀線程雖然看到了寫入ready的值,但卻沒有看到之後寫入number的值,在主線程對number和ready的賦值給顛倒了,這種現象被稱爲“重排序”。只要在某個線程中無法檢測到重排序情況(即使在其他線程中可以明顯地看到該線程中的重排序),那麼就無法確保線程中的操作將按照程序中指定的順序來執行。當主線程首先寫入number,然後在沒有同步的情況下寫入ready,那麼讀線程看到的順序可能與寫入的順序完全相反。

在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多線程程序中,要想對內存操作的執行春旭進行判斷,無法得到正確的結論。

 

二、前言

以上就是在併發編程中可能出現的問題,今天我們就在講解一下使用volatile關鍵字來解決這些問題。

我們知道volatile關鍵字的作用是保證變量在多線程之間的可見性,它是java.util.concurrent包的核心,沒有volatile就沒有這麼多的併發類給我們使用。

本文詳細解讀一下volatile關鍵字如何保證變量在多線程之間的可見性,在此之前,有必要講解一下CPU緩存的相關知識,掌握這部分知識一定會讓我們更好地理解volatile的原理,從而更好、更正確地地使用volatile關鍵字。

 

三、CPU緩存

3.1 CPU多級緩存架構

CPU緩存的出現主要是爲了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因爲CPU運算速度要比內存讀寫速度快得多,舉個例子:

  • 一次主內存的訪問通常在幾十到幾百個時鐘週期
  • 一次L1高速緩存的讀寫只需要1~2個時鐘週期
  • 一次L2高速緩存的讀寫也只需要數十個時鐘週期

這種訪問速度的顯著差異,導致CPU可能會花費很長時間等待數據到來或把數據寫入內存。

基於此,現在CPU大多數情況下讀寫都不會直接訪問內存(CPU都沒有連接到內存的管腳),取而代之的是CPU緩存cache,CPU緩存是位於CPU與內存之間的臨時存儲器,它的容量比內存小得多但是交換速度卻比內存快得多。而緩存中的數據是內存中的一小部分數據,但這一小部分是短時間內CPU即將訪問的,當CPU調用大量數據時,就可先從緩存中讀取,從而加快讀取速度。

按照讀取順序與CPU結合的緊密程度,CPU緩存可分爲:

  • 一級緩存:簡稱L1 Cache,位於CPU內核的旁邊,是與CPU結合最爲緊密的CPU緩存
  • 二級緩存:簡稱L2 Cache,分內部和外部兩種芯片,內部芯片二級緩存運行速度與主頻相同,外部芯片二級緩存運行速度則只有主頻的一半
  • 三級緩存:簡稱L3 Cache,部分高端CPU纔有

每一級緩存中所存儲的數據全部都是下一級緩存中的一部分,這三種緩存的技術難度和製造成本是相對遞減的,所以其容量也相對遞增。

當CPU要讀取一個數據時,首先從一級緩存中查找,如果沒有再從二級緩存中查找,如果還是沒有再從三級緩存中或內存中查找。一般來說每級緩存的命中率大概都有80%左右,也就是說全部數據量的80%都可以在一級緩存中找到,只剩下20%的總數據量才需要從二級緩存、三級緩存或內存中讀取。

 

3.2 使用CPU緩存帶來的問題

用一張圖表示一下CPU-->CPU緩存-->主內存數據讀取之間的關係:

當系統運行時,CPU執行計算的過程如下:

  1. 程序以及數據被加載到主內存
  2. 指令和數據被加載到CPU緩存
  3. CPU執行指令,把結果寫到高速緩存
  4. 高速緩存中的數據寫回主內存

如果服務器是單核CPU,那麼這些步驟不會有任何的問題,但是如果服務器是多核CPU,每個CPU都有自己獨享的Cache,那麼問題來了,以Intel Core i7處理器的高速緩存概念模型爲例(圖片摘自《深入理解計算機系統》):

試想下面一種情況:

  1. 核0讀取了一個字節,根據局部性原理,它相鄰的字節同樣被被讀入核0的緩存
  2. 核3做了上面同樣的工作,這樣核0與核3的緩存擁有同樣的數據
  3. 核0修改了那個字節,被修改後,那個字節被寫回核0的緩存,但是該信息並沒有寫回主存
  4. 核3訪問該字節,由於核0並未將數據寫回主存,數據不同步

爲了解決這個問題,CPU製造商制定了一個規則(MESI):當一個CPU修改緩存中的字節時,服務器中其他CPU會被通知,它們的緩存將視爲無效,即將數據所對應的Cache Line置爲無效。於是,在上面的情況下,核3發現自己的緩存中數據已無效,核0將立即把自己的數據寫回主存,然後核3重新讀取該數據。

 

反彙編Java字節碼,查看彙編層面對volatile關鍵字做了什麼

有了上面的理論基礎,我們可以研究volatile關鍵字到底是如何實現的。首先寫一段簡單的代碼:

public class LazySingleton {
    private static volatile LazySingleton instance = null;
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
    public static void main(String[] args) {
        LazySingleton.getInstance();
    }
}

首先反編譯一下這段代碼的.class文件,看一下生成的字節碼:

沒有任何特別的。要知道,字節碼指令,比如上圖的getstatic、ifnonnull、new等,最終對應到操作系統的層面,都是轉換爲一條一條指令去執行,我們使用的PC機、應用服務器的CPU架構通常都是IA-32架構的,這種架構採用的指令集是CISC(複雜指令集),而彙編語言則是這種指令集的助記符。

因此,既然在字節碼層面我們看不出什麼端倪,那下面就看看將代碼轉換爲彙編指令能看出什麼端倪。

代碼生成的彙編指令爲:

 1 Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
 2 CompilerOracle: compileonly *LazySingleton.getInstance
 3 Loaded disassembler from D:\JDK\jre\bin\server\hsdis-amd64.dll
 4 Decoding compiled method 0x0000000002931150:
 5 Code:
 6 Argument 0 is unknown.RIP: 0x29312a0 Code size: 0x00000108
 7 [Disassembling for mach='amd64']
 8 [Entry Point]
 9 [Verified Entry Point]
10 [Constants]
11   # {method} 'getInstance' '()Lorg/xrq/test/design/singleton/LazySingleton;' in 'org/xrq/test/design/singleton/LazySingleton'
12   #           [sp+0x20]  (sp of caller)
13   0x00000000029312a0: mov     dword ptr [rsp+0ffffffffffffa000h],eax
14   0x00000000029312a7: push    rbp
15   0x00000000029312a8: sub     rsp,10h           ;*synchronization entry
16                                                 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@-1 (line 13)
17   0x00000000029312ac: mov     r10,7ada9e428h    ;   {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
18   0x00000000029312b6: mov     r11d,dword ptr [r10+58h]
19                                                 ;*getstatic instance
20                                                 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@0 (line 13)
21   0x00000000029312ba: test    r11d,r11d
22   0x00000000029312bd: je      29312e0h
23   0x00000000029312bf: mov     r10,7ada9e428h    ;   {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
24   0x00000000029312c9: mov     r11d,dword ptr [r10+58h]
25   0x00000000029312cd: mov     rax,r11
26  0x00000000029312d0: shl     rax,3h            ;*getstatic instance
27                                                 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@16 (line 17)
28   0x00000000029312d4: add     rsp,10h
29   0x00000000029312d8: pop     rbp
30   0x00000000029312d9: test    dword ptr [330000h],eax  ;   {poll_return}
31   0x00000000029312df: ret
32   0x00000000029312e0: mov     rax,qword ptr [r15+60h]
33   0x00000000029312e4: mov     r10,rax
34   0x00000000029312e7: add     r10,10h
35   0x00000000029312eb: cmp     r10,qword ptr [r15+70h]
36   0x00000000029312ef: jnb     293135bh
37   0x00000000029312f1: mov     qword ptr [r15+60h],r10
38   0x00000000029312f5: prefetchnta byte ptr [r10+0c0h]
39   0x00000000029312fd: mov     r11d,0e07d00b2h   ;   {oop('org/xrq/test/design/singleton/LazySingleton')}
40   0x0000000002931303: mov     r10,qword ptr [r12+r11*8+0b0h]
41   0x000000000293130b: mov     qword ptr [rax],r10
42   0x000000000293130e: mov     dword ptr [rax+8h],0e07d00b2h
43                                                 ;   {oop('org/xrq/test/design/singleton/LazySingleton')}
44   0x0000000002931315: mov     dword ptr [rax+0ch],r12d
45   0x0000000002931319: mov     rbp,rax           ;*new  ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
46   0x000000000293131c: mov     rdx,rbp
47   0x000000000293131f: call    2907c60h          ; OopMap{rbp=Oop off=132}
48                                                 ;*invokespecial <init>
49                                                 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@10 (line 14)
50                                                 ;   {optimized virtual_call}
51   0x0000000002931324: mov     r10,rbp
52   0x0000000002931327: shr     r10,3h
53   0x000000000293132b: mov     r11,7ada9e428h    ;   {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
54   0x0000000002931335: mov     dword ptr [r11+58h],r10d
55   0x0000000002931339: mov     r10,7ada9e428h    ;   {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
56   0x0000000002931343: shr     r10,9h
57   0x0000000002931347: mov     r11d,20b2000h
58   0x000000000293134d: mov     byte ptr [r11+r10],r12l
59   0x0000000002931351: lock add dword ptr [rsp],0h  ;*putstatic instance
60                                                 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
61   0x0000000002931356: jmp     29312bfh
62   0x000000000293135b: mov     rdx,703e80590h    ;   {oop('org/xrq/test/design/singleton/LazySingleton')}
63   0x0000000002931365: nop
64   0x0000000002931367: call    292fbe0h          ; OopMap{off=204}
65                                                 ;*new  ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
66                                                 ;   {runtime_call}
67   0x000000000293136c: jmp     2931319h
68   0x000000000293136e: mov     rdx,rax
69   0x0000000002931371: jmp     2931376h
70   0x0000000002931373: mov     rdx,rax           ;*new  ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
71   0x0000000002931376: add     rsp,10h
72   0x000000000293137a: pop     rbp
73   0x000000000293137b: jmp     2932b20h          ;   {runtime_call}
74 [Stub Code]
75   0x0000000002931380: mov     rbx,0h            ;   {no_reloc}
76   0x000000000293138a: jmp     293138ah          ;   {runtime_call}
77 [Exception Handler]
78   0x000000000293138f: jmp     292fca0h          ;   {runtime_call}
79 [Deopt Handler Code]
80   0x0000000002931394: call    2931399h
81   0x0000000002931399: sub     qword ptr [rsp],5h
82   0x000000000293139e: jmp     2909000h          ;   {runtime_call}
83   0x00000000029313a3: hlt
84   0x00000000029313a4: hlt
85   0x00000000029313a5: hlt
86   0x00000000029313a6: hlt
87   0x00000000029313a7: hlt

 這麼長的彙編代碼,可能大家不知道CPU在哪裏做了手腳,沒事不難,定位到59、60兩行:

0x0000000002931351: lock add dword ptr [rsp],0h  ;
*putstatic instance; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)

之所以定位到這兩行是因爲這裏結尾寫明瞭line 14,line 14即volatile變量instance賦值的地方。後面的add dword ptr [rsp],0h都是正常的彙編語句,意思是將雙字節的棧指針寄存器+0,這裏的關鍵就是add前面的lock指令lock是彙編語言的命令,後面詳細分析一下lock指令的作用和爲什麼加上lock指令後就能保證volatile關鍵字的內存可見性。

 

3.3 lock指令做了什麼

這裏的Lock是彙編語言指令,不是之前寫過的Java內存模型指定的8中操作中的lock

lock指令的幾個作用:

  1. 鎖總線,其它CPU對內存的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖緩存替代鎖總線(降低粒度,以前鎖了總線,使總線只能被一個CPU獨享,所有的CPU公用一條總線,那麼所有的CPU就都不能使用,這樣大大降低了吞吐量,影響效率。鎖緩存行只是將該數據的緩存行鎖住,讓對其操作的CPU獨佔,其他的緩存行不受影響,其他CPU還能對別的數據進行操作),因爲鎖總線的開銷比較大,鎖總線期間其他CPU沒法訪問內存。(計算機組成原理中學的總線結構)(不管是鎖總線還是鎖緩存行,其根本目的就是使CPU對內存中某個數據的操作是獨佔的,在CPU1對一個數據操作時lock指令會鎖總線或者通過緩存一致性協議來鎖緩存行來使其他的CPU無法對該數據進行操作,使CPU1能獨享給數據的操作權)
  2. lock後的寫操作會向主內存中回寫已修改的數據,同時讓其它CPU相關緩存行(Cache Line)失效,從而重新從主存中加載最新的數據
  3. 不是內存屏障卻能完成類似內存屏障的功能,阻止屏障兩遍的指令重排序。

以上可以看出lock指令就可以實現可見性有序性

第一條中寫了由於效率問題,實際後來的處理器都採用鎖緩存來替代鎖總線,這種場景下多緩存的數據一致是通過緩存一致性協議來保證的,我們來看一下什麼是緩存一致性協議。 

 

3.3 緩存一致性協議

講緩存一致性之前,先說一下緩存行(Cache Line的概念:

緩存是分段(line)的,一個段對應一塊存儲空間,我們稱之爲緩存行(Cache Line,它是CPU緩存中可分配的最小存儲單元,大小32字節、64字節、128字節不等,這與CPU架構有關,通常來說是64字節。當CPU看到一條讀取內存的指令時,它會把內存地址傳遞給一級數據緩存,一級數據緩存會檢查它是否有這個內存地址對應的緩存段,如果沒有就把整個緩存段從內存(或更高一級的緩存)中加載進來。注意,這裏說的是一次加載整個緩存段,這就是上面提過的局部性原理

上面說了,LOCK會鎖總線,實際上這不現實,因爲鎖總線效率太低了。因此最好能做到:使用多組緩存,但使它們的行爲看起來只有一組緩存那樣。緩存一致性協議就是爲了做到這一點而設計的,就像名稱所暗示的那樣,這類協議就是要使多組緩存的內容保持一致

緩存一致性協議有多種,但是日常處理的大多數計算機設備都屬於"嗅探(snooping"協議,它的基本思想是:

  • 所有內存的傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條總線:緩存本身是獨立的,但是內存是共享資源,所有的內存訪問都要經過仲裁(同一個指令週期中,只有一個CPU緩存可以讀寫內存)。
  • CPU緩存不僅僅在做內存傳輸的時候才與總線打交道,而是不停在嗅探總線上發生的數據交換,跟蹤其他緩存在做什麼。所以當一個緩存代表它所屬的處理器去讀寫內存時,其它處理器都會得到通知,它們以此來使自己的緩存保持同步。只要某個處理器一寫內存,其它處理器馬上知道這塊內存在它們的緩存段中已失效。

MESI協議是當前最主流的緩存一致性協議,在MESI協議中,每個緩存行有4個狀態,可用2個bit表示,它們分別是:

這裏的I、S和M狀態已經有了對應的概念:失效/未載入、乾淨以及髒的緩存段。所以這裏新的知識點只有E狀態,代表獨佔式訪問,這個狀態解決了"在我們開始修改某塊內存之前,我們需要告訴其它處理器"這一問題:只有當緩存行處於E或者M狀態時,處理器才能去寫它,也就是說只有在這兩種狀態下,處理器是獨佔這個緩存行的。當處理器想寫某個緩存行時,如果它沒有獨佔權,它必須先發送一條"我要獨佔權"的請求給總線,這會通知其它處理器把它們擁有的同一緩存段的拷貝失效(如果有)。只有在獲得獨佔權後,處理器才能開始修改數據----並且此時這個處理器知道,這個緩存行只有一份拷貝,在我自己的緩存裏,所以不會有任何衝突。

反之,如果有其它處理器想讀取這個緩存行(馬上能知道,因爲一直在嗅探總線),獨佔或已修改的緩存行必須先回到"共享"狀態。如果是已修改的緩存行,那麼還要先把內容回寫到內存中。

簡單來說,MESI協議就是:

  1. 讀操作:不做任何事情,把Cache中的數據讀到寄存器
  2. 寫操作:發出信號通知其他的CPU將該變量的Cache line置爲無效,其他的CPU要訪問這個變量的時候,只能從主內存中獲取。

 

3.4 內存屏障(Memory Barrier)

Memory barrier 能夠讓 CPU 或編譯器在內存訪問上有序。一個 Memory barrier 之前的內存訪問操作必定先於Memory barrier之後的操作完成。

Memory barrier是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題。Java編譯器也會根據內存屏障的規則禁止重排序。

有的處理器的重排序規則較嚴,無需內存屏障也能很好的工作,Java編譯器會在這種情況下不放置內存屏障。

Memory Barrier可以被分爲以下幾種類型:

  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能

 

3.4.1 volatile語義中的內存屏障

  • 在每個volatile寫操作前插入StoreStore屏障,在寫操作後插入StoreLoad屏障;
  • 在每個volatile讀操作前插入LoadLoad屏障,在讀操作後插入LoadStore屏障;

volatile的內存屏障策略非常嚴格保守,保證了線程可見性。

 

3.4.2 final語義中的內存屏障

  • 新建對象過程中,構造體中對final域的初始化寫入(StoreStore屏障)和這個對象賦值給其他引用變量,這兩個操作不能重排序;
  • 初次讀包含final域的對象引用和讀取這個final域(LoadLoad屏障),這兩個操作不能重排序;

 

3.5 彙編指令LOCK與內存屏障之間的關係?

LOCK彙編命令使相應的機器碼指令中添加了相關內存屏障指令,也就是說彙編層面LOCK指令的功能是通過CPU層面的內存屏障機器碼實現的。

 

四、JMM與多線程通信

Java線程之間的通信由Java內存模型(JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。

從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在,它涵蓋了緩存,寫緩衝區,寄存器以及其他的硬件和編譯器優化。Java內存模型的抽象示意圖如下:

線程間通信的步驟:

  1. 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  2. 然後,線程B到主內存中去讀取線程A之前已更新過的共享變量。

本地內存A和B有主內存中共享變量x的副本。

  1. 假設初始時,這三個內存中的x值都爲0。線程A在執行時,把更新後的x值(假設值爲1)臨時存放在自己的本地內存A中。
  2. 當線程A和線程B需要通信時(如何激發?--隱式),線程A首先會把自己本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變爲了1。
  3. 隨後,線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變爲了1。

從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來爲java程序員提供內存可見性保證。

 

五、Volatile關鍵字的語義分析

5.1 volatile的原理

由lock指令回看volatile變量讀寫。可以知道volatile實際是靠lock指令(這是彙編語言的LOCK指令,不是JMM中的lock操作,JMM中的lock操作是加鎖,它是synchronized的實現基礎)爲基礎來實現的。

相信有了上面對於lock的解釋,以及對CPU多級緩存架構以及JAVA內存模型的理解,volatile關鍵字的實現原理應該是一目瞭然了。由上面JMM的結構圖可知,工作內存Work Memory其實就是對CPU寄存器和高速緩存的抽象,或者說每個線程的工作內存也可以簡單理解爲CPU寄存器和高速緩存。

那麼當寫兩條線程Thread-A與Threab-B同時操作主存中的一個volatile變量i時

  • Thread-A寫了變量i,那麼:
    1. Thread-A發出LOCK#指令
    2. 發出的LOCK#指令鎖總線(或鎖緩存行),同時讓Thread-B高速緩存中的緩存行內容失效
    3. Thread-A向主存回寫最新修改的i
  • Thread-B讀取變量i,那麼:
    1. Thread-B發現對應地址的緩存行被鎖了,等待鎖的釋放,緩存一致性協議會保證它讀取到最新的值,也就是當鎖釋放之後Thread-B發現對應的Cache Line已經失效了,只能去主內存中讀取最新的值。

由此可以看出,volatile關鍵字的讀和普通變量的讀取相比基本沒差別,差別主要還是在變量的寫操作上

 

當對volatile變量進行寫操作的時候,JVM會向處理器發送一條lock前綴的指令,將這個緩存中的變量回寫到系統主存中。

但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現上面所講的緩存一致性協議

 

緩存一致性協議:每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存裏把數據讀到處理器緩存裏。

所以,如果一個變量被volatile所修飾的話,在每次數據變化之後,其值都會被強制刷入主存。而其他處理器的緩存由於遵守了緩存一致性協議,也會把這個變量的值從主存加載到自己的緩存中。這就保證了一個volatile在併發編程中,其值在多個緩存中是可見的

 

總結:

volatile可以保證線程可見性且提供了一定的有序性,但是無法保證原子性。在JVM底層volatile是採用“內存屏障”來實現的。觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令,lock前綴指令實際上相當於一個內存屏障(也稱內存柵欄),內存屏障會提供3個功能:

  • 它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;(保證有序性)
  • 它會強制將對緩存的修改操作立即寫入主存;(保證可見性)
  • 如果是寫操作,它會導致其他CPU中對應的緩存行無效。(保證可見性)

 

5.2 volatile的用法

volatile通常被比喻成"輕量級的synchronized",也是Java併發編程中比較重要的一個關鍵字。和synchronized不同,volatile是一個變量修飾符,只能用來修飾變量。無法修飾方法及代碼塊等。

volatile的用法比較簡單,只需要在聲明一個可能被多線程同時訪問的變量時,使用volatile修飾就可以了。

 

5.3 volatile的作用

讓其他線程能夠馬上感知到某一線程多某個變量的修改(兩種作用對應的實現原理見總結):

  1. 保證可見性
  2. 保證有序性

volatile不能保證原子性

 

線程寫volatile變量的過程:

  1. 改變線程工作內存中volatile變量副本的值
  2. 將改變的副本的值從工作內存中刷新到主內存中

線程讀volatile變量的過程:

  1. 1.從主內存中讀取volatile變量的最新值到工作內存中
  2. 2.從工作內存中讀取volatile變量的副本

 

5.3.1 volatile與可見性

可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

Java內存模型規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存,線程的工作內存中保存了該線程中是用到的變量的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量的傳遞均需要自己的工作內存和主存之間進行數據同步進行。所以,就可能出現線程1改了某個變量的值,但是線程2不可見的情況。

前面的關於volatile的作用中介紹過了,Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改後可以立即同步到主內存,被其修飾的變量在每次是用之前都從主內存刷新。因此,可以使用volatile來保證多線程操作時變量的可見性。

現在回到最開始引入的第一個代碼出現的問題,就可以用volatile關鍵字來解決。將init_value變量用volatile修飾

public class ReaderAndUpdater {
    final static int MAX=5;
    static volatile int init_value=0;
    public static void main(String[] args) {
        new Thread(()->{
                int localValue=init_value;
                while(localValue<MAX){
                    if(localValue!=init_value){
                        System.out.println("Reader:"+init_value);
                        localValue=init_value;
                    }
                }
        },"Reader").start();
        new Thread(()->{
            int localValue=init_value;
            while(localValue<MAX){
                System.out.println("updater:"+(++localValue));
                init_value=localValue;
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"Updater").start();
    }
}

運行結果:

updater:1
Reader:1
updater:2
Reader:2
updater:3
Reader:3
updater:4
Reader:4
updater:5
Reader:5

可以看出現在代碼就實現了同步,讀線程能夠感知到寫線程對init_value變量的修改,也就保證了可見性。

 

5.3.2 volatile與有序性

有序性即程序執行的順序按照代碼的先後順序執行。

除了引入了時間片以外,由於處理器優化和指令重排等,CPU還可能對輸入代碼進行亂序執行,比如load->add->save 有可能被優化成load->save->add 。這就是可能存在有序性問題。

而volatile除了可以保證數據的可見性之外,還有一個強大的功能,那就是它可以禁止指令重排優化等。

普通的變量僅僅會保證在該方法的執行過程中所依賴的賦值結果的地方都能獲得正確的結果,而不能保證變量的賦值操作的順序與程序代碼中的執行順序一致。

volatile可以禁止指令重排,這就保證了代碼的程序會嚴格按照代碼的先後順序執行。這就保證了有序性。被volatile修飾的變量的操作,會嚴格按照代碼順序執行,load->add->save 的執行順序就是:load、add、save。

即執行到volatile變量時,其前面的所有語句都執行完,後面所有語句都未執行。且前面語句的結果對volatile變

量及其後面語句可見。

 

5.3.3 volatile與原子性

原子性是指一個操作是不可中斷的,要全部執行完成,要不就都不執行。

線程是CPU調度的基本單位。CPU有時間片的概念,會根據不同的調度算法進行線程調度。當一個線程獲得時間片之後開始執行,在時間片耗盡之後,就會失去CPU使用權。所以在多線程場景下,由於時間片在線程間輪換,就會發生原子性問題

爲了保證原子性,需要通過字節碼指令monitorenter和monitorexit,但是volatile和這兩個指令之間是沒有任何關係的。

所以,volatile是不能保證原子性的。

我們來看一下volatile和原子性的例子:

public class Test {
    public volatile int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

 以上代碼比較簡單,就是創建10個線程,然後分別執行1000次i++操作。正常情況下,程序的輸出結果應該是10000,但是,多次執行的結果都小於10000。這其實就是volatile無法滿足原子性的原因。

爲什麼會出現這種情況呢,那就是因爲雖然volatile可以保證inc在多個線程之間的可見性。但是無法inc++的原子性。

 

六、Volatile的適用場景

使用volatile修飾的變量最好滿足以下條件:

1.運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程會修改變量的值。

  • 不滿足:num++、count = count * 5
  • 滿足:boolean值變量,記錄溫度變化的變量等等

2.該變量沒有包含在具有其他變量的不變式中

  • 不滿足:low < up

如果滿足以上的條件的任意一個,就可以不用synchronized,用volatile就可以。一般的應用場景很多會不滿足其中一個,所以volatile的使用沒有synchronized這麼廣泛。

 

這裏舉幾個比較經典的場景:

  • 狀態標記量(開關模式),就是前面引入中重排序問題的例子.
public class ShutDowsnDemmo extends Thread{
	private volatile boolean started=false;
	@Override
	void run() {
		while(started){
		     dowork();
	    }
    }

	public void shutdown(){
		started=false;
	}
}

狀態標記兩可以用作某種操作的開關,一個作業線程在關閉狀態無法執行,它的開關標記是一個volatile修飾的變量,另一個線程修改該變量值,作業線程就能立刻感知到開關被修改,就能進入運行狀態。

  • 一次性安全發佈.雙重檢查鎖定問題(單例模式的雙重檢查  double-checked-locking  DCL).
public class Singleton {
	private volatile static Singleton instance;
	public static Singleton getInstance(){
		 if(instance==null){
		    synchronized (Singleton.class){
		        instance=new Singleton();
		    }
		 }
	    return instance;
	}
}

【設計模式】單例模式最常見的幾種實現方法以及各自的特點

  • 獨立觀察.如果系統需要使用最後登錄的人員的名字,這個場景就很適合.
  • 開銷較低的“讀-寫鎖”策略.當讀操作遠遠大於寫操作,可以結合使用鎖和volatile來提升性能.

 

七、Volatile不適用的場景

不滿足前面所講的Volatile適用場景的條件的話,就說明需要保證原子性,就需要加鎖(使用synchronized、lock或者java.util.concurrent中的Atomic原子類)來保證併發中的原子性。

 

volatile不適合複合操作

例如,inc++不是一個原子性操作,可以由讀取、加、賦值3步組成,所以結果並不能達到10000。

public class Test {
    public volatile int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

 解決方法:

1.採用synchronized

2.採用Lock

3.採用java併發包中的原子操作類,原子操作類是通過CAS循環的方式來保證其原子性的

 

八、volatilesynchronized的區別

8.1 使用上的區別

Volatile只能修飾變量,使用範圍較小;synchronized可以修飾方法和語句塊,作用域可以是對象或者類,適用範圍更廣

 

8.2 對原子性的保證

synchronized可以保證原子性,Volatile不能保證原子性

 

8.3 對可見性的保證

都可以保證可見性,但實現原理不同

Volatile對變量加了lock,synchronized使用monitorEnter和monitorexit  monitor  JVM

 

8.4 對有序性的保證

Volatile能保證有序,synchronized可以保證有序性,但是代價(重量級)併發退化到串行

 

8.5 性能上的區別

synchronized是靠加鎖實現的,引起阻塞

volatile是靠Lock指令實現的,不需要加鎖,不會引起阻塞

性能上volatile比synchronized要好,volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。但是volatile的性能比synchronized加鎖好很多。

 

九、總結

volatile是輕量級同步機制,與synchronized相比,他的開銷更小一些,同時安全性也有所降低,在一些特定的場景下使用它可以在完成併發目標的基礎上有一些性能上的優勢.但是同時也會帶來一些安全上的問題,且比較難以排查,使用時需要謹慎.volatile並不能保證操作的原子性,想要保證原子性請使用synchronized關鍵字加鎖.


其他相關文章:【併發編程】synchronized關鍵字最全詳解,看這一篇就夠了 
                         【併發編程】線程安全和線程不安全的定義以及實現線程安全的方法有哪些
                         【併發編程】Java中的鎖有哪些?各自都有什麼樣的特性?


參考鏈接:
http://www.cnblogs.com/xrq730/p/7048693.html
https://blog.csdn.net/vking_wang/article/details/8574376
https://blog.csdn.net/u012723673/article/details/80682208
【Java內存模型】Java內存模型(JMM)詳解以及併發編程的三個重要特性(原子性,可見性,有序性)
深入理解Java虛擬機:JVM高級特性與最佳實踐(第3版) - 周志明

發佈了32 篇原創文章 · 獲贊 26 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章