Java多線程之volatile詳解

本文目錄

  • 從多線程交替打印A和B開始
  • Java 內存模型中的可見性、原子性和有序性
  • Volatile原理
    • volatile的特性
    • volatile happens-before規則
    • volatile 內存語義
    • volatile 內存語義的實現
  • CPU對於Volatile的支持
    • 緩存一致性協議
  • 工作內存(本地內存)並不存在
  • 總結
  • 參考資料

從多線程交替打印A和B開始

面試中經常會有一道多線程交替打印A和B的問題,可以通過使用Lock和一個共享變量來完成這一操作,代碼如下,其中使用num來決定當前線程是否打印

public class ABTread {

    private static int num=0;
    private static Lock lock=new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {

        Thread A=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    lock.lock();
                    if (num==0){
                        System.out.println("A");
                        num=1;
                    }
                    lock.unlock();
                }
            }
        },"A");
        Thread B=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    lock.lock();
                    if (num==1){
                        System.out.println("B");
                        num=0;
                    }
                    lock.unlock();
                }
            }
        },"B");
        A.start();
        B.start();
    }
}

這一過程使用了一個可重入鎖,在以前可重入鎖的獲取流程中有分析到,當鎖被一個線程持有時,後繼的線程想要再獲取鎖就需要進入同步隊列還有可能會被阻塞。
現在假設當A線程獲取了鎖,B線程再來獲取鎖且B線程獲取失敗則會調用LockSupport.park()導致線程B阻塞,線程A釋放鎖時再還行線程B
是否會經常存在阻塞線程和還行線程的操作呢,阻塞和喚醒的操作是比較費時間的。是否存在一個線程剛釋放鎖之後這一個線程又再一次獲取鎖,由於共享變量的存在,
則獲取鎖的線程一直在做着毫無意義的事情。

可以使用volatile關鍵字來修飾共享變量來解決,代碼如下:

public class ABTread {

    private static volatile  int num=0;
    public static void main(String[] args) throws InterruptedException {

        Thread A=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    if (num==0){        //讀取num過程記作1
                        System.out.println("A");
                        num=1;          //寫入num記位2
                    }
                }
            }
        },"A");
        Thread B=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    if (num==1){        //讀取num過程記作3
                        System.out.println("B");
                        num=0;          ////寫入num記位4
                    }
                }
            }
        },"B");
        A.start();
        B.start();
    }
}

Lock可以通過阻止同時訪問來完成對共享變量的同時訪問和修改,必要的時候阻塞其他嘗試獲取鎖的線程,那麼volatile關鍵字又是如何工作,
在這個例子中,是否效果會優於Lock呢。

Java 內存模型中的可見性、原子性和有序性

  • 可見性:指線程之間的可見性,一個線程對於狀態的修改對另一個線程是可見的,也就是說一個線程修改的結果對於其他線程是實時可見的。
    可見性是一個複雜的屬性,因為可見性中的錯誤總是會違背我們的直覺(JMM決定),通常情況下,我們無法保證執行讀操作的線程能實時的看到其他線程的寫入的值。
    為了保證線程的可見性必須使用同步機制。退一步說,最少應該保證當一個線程修改某個狀態時,而這個修改時程序員希望能被其他線程實時可見的,
    那麼應該保證這個狀態實時可見,而不需要保證所有狀態的可見。在 Javavolatilesynchronizedfinal 實現可見性。

  • 原子性:如果一個操作是不可以再被分割的,那麼我們說這個操作是一個原子操作,即具有原子性。但是例如i++實際上是i=i+1這個操作是可分割的,他不是一個原子操作。
    非原子操作在多線程的情況下會存在線程安全性問題,需要是我們使用同步技術將其變為一個原子操作。javaconcurrent包下提供了一些原子類,
    我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicIntegerAtomicLongAtomicReference等。在 Javasynchronized 和在 lockunlock 中操作保證原子性

  • 有序性:一系列操作是按照規定的順序發生的。如果在本線程之內觀察,所有的操作都是有序的,如果在其他線程觀察,所有的操作都是無序的;前半句指“線程內表現為串行語義”後半句指“指令重排序”和“工作內存和主存同步延遲”
    Java 語言提供了 volatilesynchronized 兩個關鍵字來保證線程之間操作的有序性。volatile 是因為其本身包含“禁止指令重排序”的語義,
    synchronized 是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。

Volatile原理

volatile定義:Java編程語言允許線程訪問共享變量,為了確保共享變量能被準確和一致的更新,線程應該通過獲取排他鎖單獨獲取這個變量;
java提供了volatile關鍵字在某些情況下比鎖更好用。

  • Java語言提供了volatile了關鍵字來提供一種稍弱的同步機制,他能保證操作的可見性和有序性。當把變量聲明為volatile類型后,
    編譯器與運行時都會注意到這個變量是一個共享變量,並且這個變量的操作禁止與其他的變量的操作重排序。

  • 訪問volatile變量時不會執行加鎖操作。因此也不會存在阻塞競爭的線程,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。

volatile的特性

volatile具有以下特性:

  • 可見性:對於一個volatile的讀總能看到最後一次對於這個volatile變量的寫
  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但對於類似於i++這種複合操作不具有原子性。
  • 有序性:

volatile happens-before規則

根據JMM要求,共享變量存儲在共享內存當中,工作內存存儲一個共享變量的副本,
線程對於共享變量的修改其實是對於工作內存中變量的修改,如下圖所示:

從多線程交替打印A和B開始章節中使用volatile關鍵字的實現為例來研究volatile關鍵字實現了什麼:
假設線程A在執行num=1之後B線程讀取num指,則存在以下happens-before關係

1)  1 happens-before 2,3 happens-before 4
2)  根據volatile規則有:2 happens-before 3
3)  根據heppens-before傳遞規則有: 1 happens-before 4

至此線程的執行順序是符合我們的期望的,那麼volatile是如何保證一個線程對於共享變量的修改對於其他線程可見的呢?

volatile 內存語義

根據JMM要求,對於一個變量的獨寫存在8個原子操作。對於一個共享變量的獨寫過程如下圖所示:

對於一個沒有進行同步的共享變量,對其的使用過程分為readloaduseassign以及不確定的storewrite過程。
整個過程的語言描述如下:

- 第一步:從共享內存中讀取變量放入工作內存中(`read`、`load`)
- 第二步:當執行引擎需要使用這個共享變量時從本地內存中加載至**CPU**中(`use`)
- 第三步:值被更改后使用(`assign`)寫回工作內存。
- 第四步:若之後執行引擎還需要這個值,那麼就會直接從工作內存中讀取這個值,不會再去共享內存讀取,除非工作內存中的值出於某些原因丟失。
- 第五步:在不確定的某個時間使用`store`、`write`將工作內存中的值回寫至共享內存。

由於沒有使用鎖操作,兩個線程可能同時讀取或者向共享內存中寫入同一個變量。或者在一個線程使用這個變量的過程中另一個線程讀取或者寫入變量。
上圖中1和6兩個操作可能會同時執行,或者在線程1使用num過程中6過程執行,那麼就會有很嚴重的線程安全問題,
一個線程可能會讀取到一個並不是我們期望的值。

那麼如果希望一個線程的修改對後續線程的讀立刻可見,那麼只需要將修改后存儲在本地內存中的值回寫到共享內存
並且在另一個線程讀的時候從共享內存重新讀取而不是從本地內存中直接讀取即可;事實上
當寫一個volatile變量時,JMM會把該線程對應的本地內存中共享變量值刷新會共享內存;
而當讀取一個volatile變量時,JMM會從主存中讀取共享變量
,這也就是volatile的寫-讀內存語義。

volatile的寫-讀內存語義:

  • volatile寫的內存語義:當寫一個volatile變量時,JMM會把該線程對應的本地內存中共享變量值刷新會共享內存
  • volatile讀的內存語義:當讀一個volatile變量時,JMM會把該線程對應的本地內存置為無效,線程接下來將從主內存中讀取共享變量。

如果將這兩個步驟綜合起來,那麼線程3讀取一個volatile變量后,寫線程1在寫這個volatile變量之前所有可見的共享變量的值都將樂客變得對線程3可見。

volatile變量的讀寫過程如下圖:

需要注意的是:在各個線程的工作內存中是存在volatile變量的值不一致的情況的,只是每次使用都會從共享內存讀取並刷新,執行引擎看不到不一致的情況,
所以認為volatile變量在本地內存中不存在不一致問題。

volatile 內存語義的實現

在前文Java內存模型中有提到重排序。為了實現volatile的內存語義,JMM會限制重排序的行為,具體限制如下錶:

是否可以重排序 第二個操作 第二個操作 第二個操作
第一個操作 普通讀/寫 volatile volatile
普通讀/寫 NO
volatile NO NO NO
volatile NO NO

說明:

- 若第一個操作時普通變量的讀寫,第二個操作時volatile變量的寫操作,則編譯器不能重排序這兩個操作
- 若第一個操作是volatile變量的讀操作,不論第二個變量是什麼操作不餓能重排序這兩個操作
- 若第一個操作時volatile變量的寫操作,除非第二個操作是普通變量的獨寫,否則不能重排序這兩個操作

為了實現volatile變量的內存語義,編譯器生成字節碼文件時會在指令序列中插入內存屏障來禁止特定類型的處理器排序。
為了實現volatile變量的內存語義,插入了以下內存屏障,並且在實際執行過程中,只要不改變volatile的內存語義,
編譯器可以根據實際情況省略部分不必要的內存屏障

- 在每個volatile寫操作前面插入StoreStore屏障
- 在每個volatile寫操作後面插入StoreLoad屏障
- 在每個volatile讀操作後面插入LoadLoad屏障
- 在每個volatile讀操作後面插入LoadStore屏障

插入內存屏障后volatile寫操作過程如下圖:

插入內存屏障后volatile讀操作過程如下圖:

至此在共享內存和工作內存中的volatile的寫-讀的工作過程全部完成

但是現在的CPU中存在一個緩存,CPU讀取或者修改數據的時候是從緩存中獲取並修改數據,那麼如何保證CPU緩存中的數據與共享內存中的一致,並且修改后寫回共享內存呢?

CPU對於Volatile的支持

緩存行:cpu緩存存儲數據的基本單位,cpu不能使數據失效,但是可以使緩存行失效。

對於CPU來說,CPU直接操作的內存時高速緩存,而每一個CPU都有自己L1、L2以及共享的L3級緩存,如下圖:

那麼當CPU修改自身緩存中的被volatile修飾的共享變量時,如何保證對其他CPU的可見性。

緩存一致性協議

在多處理器的情況下,每個處理器總是嗅探總線上傳播的數據來檢查自己的緩存是否過期,當處理器發現自己對應的緩存對應的地址被修改,
就會將當前處理器的緩存行設置為無效狀態,當處理器對這個數據進行操作的時候,會重新從系統中把數據督導處理器的緩存里。這個協議被稱之為緩存一致性協議。

緩存一致性協議的實現又MEIMESIMOSI等等。

MESI協議緩存狀態

狀態 描述
M(modified)修改 該緩存指被緩存在該CPU的緩存中並且是被修改過的,即與主存中的數據不一致,該緩存行中的數據需要在未來的某個時間點寫回主存,當寫回註冊年之後,該緩存行的狀態會變成E(獨享)
E(exclusive)獨享 該緩存行只被緩存在該CPU的緩存中,他是未被修改過的,與主存中數據一致,該狀態可以在任何時候,當其他的CPU讀取該內存時編程共享狀態,同樣的,當CPU修改該緩存行中的內容時,該狀態可以變為M(修改)
S(share)共享 該狀態意味着該緩存行可能被多個CPU緩存,並且各個緩存中的數據與主存中的數據一致,當有一個CPU修改自身對應的緩存的數據,其它CPU中該數據對應的緩存行被作廢
I(Invalid)無效 該緩存行無效

MESI協議可以防止緩存不一致的情況,但是當一個CPU修改了緩存中的數據,但是沒有寫入主存,也會存在問題,那麼如何保證CPU修改共享被volatile修飾的共享變量后立刻寫回主存呢。

在有volatile修飾的共享變量進行寫操作的時候會多出一條帶有lock前綴的彙編代碼,而這個lock操作會做兩件事:

  1. 將當前處理器的緩存行的數據協會到系統內存。lock信號確保聲言該信號期間CPU可以獨佔共享內存。在之前通過鎖總線的方式,現在採用鎖緩存的方式。
  2. 這個寫回操作會使其他處理器的緩存中緩存了該地址的緩存行無效。在下一次這些CPU需要使用這些地址的值時,強制要求去共享內存中讀取。

如果對聲明了volatile的共享變量進行寫,JVM會向CPU發送一條lock指令,使得將這個變量所在的緩存行緩存的數據寫回到內存中。而其他CPU通過嗅探總線上傳播的數據,
使得自身緩存行失效,下一次使用時會從主存中獲取對應的變量。

工作內存(本地內存)並不存在

根據JAVA內存模型描述,各個線程使用自身的工作內存來保存共享變量,那麼是不是每個CPU緩存的數據就是從工作內存中獲取的。這樣的話,在CPU緩存寫回主存時,
協會的是自己的工作內存地址,而各個線程的工作內存地址並不一樣。CPU嗅探總線時就嗅探不到自身的緩存中緩存有對應的共享變量,從而導致錯誤?

事實上,工作內存並不真實存在,只是JMM為了便於理解抽象出來的概念,它涵蓋了緩存,寫緩衝區、寄存器及其他的硬件編譯器優化。所以緩存是直接和共享內存交互的。
每個CPU緩存的共享數據的地址是一致的。

總結

  • volatile提供了一種輕量級同步機制來完成同步,它可以保操作的可見性、有序性以及對於單個volatile變量的讀/寫具有原子性,對於符合操作等非原子操作不具有原子性。

  • volatile通過添加內存屏障及緩存一致性協議來完成對可見性的保證。

最後Lock#lock()是如何保證可見性的呢??

Lock#lock()使用了AQSstate來標識鎖狀態,而statevolatile標記的,由於對於volatile的獨寫操作時添加了內存屏障的,所以在修改鎖狀態之前,
一定會將之前的修改寫回共享內存。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※教你寫出一流的銷售文案?