Java高併發學習筆記(四):volatile關鍵字

1 來源

  • 來源:《Java高併發編程詳解 多線程與架構設計》,汪文君著
  • 章節:第12、十三章

本文是兩章的筆記整理。html

2 CPU緩存

2.1 緩存模型

計算機中的全部運算操做都是由CPU完成的,CPU指令執行過程須要涉及數據讀取和寫入操做,可是CPU只能訪問處於內存中的數據,而內存的速度和CPU的速度是遠遠不對等的,所以就出現了緩存模型,也就是在CPU和內存之間加入了緩存層。通常現代的CPU緩存層分爲三級,分別叫L1緩存、L2緩存和L3緩存,簡略圖以下:java

在這裏插入圖片描述

  • L1緩存:三級緩存中訪問速度最快,可是容量最小,另外L1緩存還被劃分紅了數據緩存(L1ddata首字母)和指令緩存(L1iinstruction首字母)
  • L2緩存:速度比L1慢,可是容量比L1大,在現代的多核CPU中,L2通常被單個核獨佔
  • L3緩存:三級緩存中速度最慢,可是容量最大,現代CPU中也有L3是多核共享的設計,好比zen3架構的設計

在這裏插入圖片描述

緩存的出現,是爲了解決CPU直接訪問內存效率低下的問題,CPU進行運算的時候,將須要的數據從主存複製一份到緩存中,由於緩存的訪問速度快於內存,在計算的時候只須要讀取緩存並將結果更新到緩存,運算結束再將結果刷新到主存,這樣就大大提升了計算效率,總體交互圖簡略以下:編程

在這裏插入圖片描述

2.2 緩存一致性問題

雖然緩存的出現,大大提升了吞吐能力,可是,也引入了一個新的問題,就是緩存不一致。好比,最簡單的一個i++操做,須要將內存數據複製一份到緩存中,CPU讀取緩存值並進行更新,先寫入緩存,運算結束後再將緩存中新的刷新到內存,具體過程以下:緩存

  • 讀取內存中的i到緩存中
  • CPU讀取緩存i中的值
  • i進行加1操做
  • 將結果寫回緩存
  • 再將數據刷新到主存

這樣的i++操做在單線程不會出現問題,但在多線程中,由於每一個線程都有本身的工做內存(也叫本地內存,是線程本身的緩存),變量i在多個線程的本地內存中都存在一個副本,若是有兩個線程執行i++操做:多線程

  • 假設兩個線程爲A、B,同時假設i初始值爲0
  • 線程A從內存中讀取i的值放入緩存中,此時i的值爲0,線程B也同理,放入緩存中的值也是0
  • 兩個線程同時進行自增操做,此時A、B線程的緩存中,i的值都是1
  • 兩個線程將i寫入主內存,至關於i被兩次賦值爲1
  • 最終結果是i的值爲1

這個就是典型的緩存不一致問題,主流的解決辦法有:架構

  • 總線加鎖
  • 緩存一致性協議

2.2.1 總線加鎖

這是一種悲觀的實現方式,具體來講,就是經過處理器發出lock指令,鎖住總線,總線收到指令後,會阻塞其餘處理器的請求,直到佔用鎖的處理器完成操做。特色是隻有一個搶到總線鎖的處理器運行,可是這種方式效率低下,一旦某個處理器獲取到鎖其餘處理器只能阻塞等待,會影響多核處理器的性能。併發

2.2.2 緩存一致性協議

圖示以下:app

在這裏插入圖片描述

緩存一致性協議中最出名的就是MESI協議,MESI保證了每個緩存中使用的共享變量的副本都是一致的。大體思想是,CPU操做緩存中的數據時,若是發現該變量是一個共享變量,操做以下:ide

  • 讀取:不作其餘處理,只是將緩存中數據讀取到寄存器中
  • 寫入:發出信號通知其餘CPU將該變量的緩存行設置爲無效狀態(Invalid),其餘CPU進行該變量的讀取時須要到主存中再次獲取

具體來講,MESI中規定了緩存行使用4種狀態標記:高併發

  • MModified,被修改
  • EExclusive,獨享的
  • SShared,共享的
  • IInvalid,無效的

有關MESI詳細的實現超出了本文的範圍,想要詳細瞭解能夠參考此處此處

3 JMM

看完了CPU緩存再來看一下JMM,也就是Java內存模型,指定了JVM如何與計算機的主存進行工做,同時也決定了一個線程對共享變量的寫入什麼時候對其餘線程可見,JMM定義了線程和主內存之間的抽象關係,具體以下:

  • 共享變量存儲於主內存中,每一個線程均可以訪問
  • 每一個線程都有私有的工做內存或者叫本地內存
  • 工做內存只存儲該線程對共享變量的副本
  • 線程不能直接操做主內存,只有先操做了工做內存以後才能寫入主內存
  • 工做內存和JMM內存模型同樣也是一個抽象概念,其實並不存在,涵蓋了緩存、寄存器、編譯期優化以及硬件等

簡略圖以下:

在這裏插入圖片描述

MESI相似,若是一個線程修改了共享變量,刷新到主內存後,其餘線程讀取工做內存的時候發現緩存失效,會從主內存再次讀取到工做內存中。

而下圖表示了JVM與計算機硬件分配的關係:

在這裏插入圖片描述

4 併發編程的三個特性

文章都看了大半了還沒到volatile?別急別急,先來看看併發編程中的三個重要特性,這對正確理解volatile有很大的幫助。

4.1 原子性

原子性就是在一次或屢次操做中:

  • 要麼全部的操做所有都獲得了執行,且不會受到任何因素的干擾而中斷
  • 要麼全部的操做都不執行

一個典型的例子就是兩我的轉帳,好比A向B轉帳1000元,那麼這包含兩個基本的操做:

  • A的帳戶扣除1000元
  • B的帳戶增長1000元

這兩個操做,要麼都成功,要麼都失敗,也就是不能出現A帳戶扣除1000可是B帳戶金額不變的狀況,也不能出現A帳戶金額不變B帳戶增長1000的狀況。

須要注意的是兩個原子性操做結合在一塊兒未必是原子性的,好比i++。本質上來講,i++涉及到了三個操做:

  • get i
  • i+1
  • set i

這三個操做都是原子性的,可是組合在一塊兒(i++)就不是原子性的。

4.2 可見性

另外一個重要的特性是可見性,可見性是指,一個線程對共享變量進行了修改,那麼另外的線程能夠當即看到修改後的最新值。

一個簡單的例子以下:

public class Main {
    private int x = 0;
    private static final int MAX = 100000;
    public static void main(String[] args) throws InterruptedException {
        Main m = new Main();
        Thread thread0 = new Thread(()->{
            while(m.x < MAX) {
                ++m.x;
            }
        });

        Thread thread1 = new Thread(()->{
            while(m.x < MAX){
            }
            System.out.println("finish");
        });

        thread1.start();
        TimeUnit.MILLISECONDS.sleep(1);
        thread0.start();
    }
}

線程thread1會一直運行,由於thread1x讀入工做內存後,會一直判斷工做內存中的值,因爲thread0改變的是thread0工做內存的值,並無對thread1可見,所以永遠也不會輸出finish,使用jstack也能夠看到結果:

在這裏插入圖片描述

4.3 有序性

有序性是指代碼在執行過程當中的前後順序,因爲JVM的優化,致使了代碼的編寫順序未必是代碼的運行順序,好比下面的四條語句:

int x = 10;
int y = 0;
x++;
y = 20;

有可能y=20x++前執行,這就是指令重排序。通常來講,處理器爲了提升程序的效率,可能會對輸入的代碼指令作必定的優化,不會嚴格按照編寫順序去執行代碼,但能夠保證最終運算結果是編碼時的指望結果,固然,重排序也有必定的規則,須要嚴格遵照指令之間的數據依賴關係,並非能夠任意重排序,好比:

int x = 10;
int y = 0;
x++;
y = x+1;

y=x+1就不能先優於x++執行。

在單線程下重排序不會致使預期值的改變,但在多線程下,若是有序性得不到保證,那麼將可能出現很大的問題:

private boolean initialized = false;
private Context context;
public Context load(){
    if(!initialized){
        context = loadContext();
        initialized = true;
    }
    return context;
}

若是發生了重排序,initialized=true排序到了context=loadContext()的前面,假設兩個線程A、B同時訪問,且loadContext()須要必定耗時,那麼:

  • 線程A經過判斷後,先設置布爾變量的值爲true,再進行loadContext()操做
  • 線程B中因爲布爾變量被設置爲true,會直接返回一個未加載完成的context

5 volatile

好了終於到了volatile了,前面說了這麼多,目的就是爲了能完全理解和明白volatile。這部分分爲四個小節:

  • volatile的語義
  • 如何保證有序性以及可見性
  • 實現原理
  • 使用場景
  • synchronized區別

先來介紹一下volatile的語義。

5.1 語義

volatile修飾的實例變量或者類變量具備兩層語義:

  • 保證了不一樣線程之間對共享變量操做時的可見性
  • 禁止對指令進行重排序操做

5.2 如何保證可見性以及有序性

先說結論:

  • volatile能保證可見性
  • volatile能保證有序性
  • volatile不能保證原子性

下面分別進行介紹。

5.2.1 可見性

Java中保證可見性有以下方式:

  • volatile:當一個變量被volatile修飾時,對共享資源的讀操做會直接在主內存中進行(準確來講也會讀取到工做內存中,可是若是其餘線程進行了修改就必須從主內存從新讀取),寫操做是先修改工做內存,可是修改結束後當即刷新到主內存中
  • synchronizedsynchronized同樣能保證可見性,可以保證同一時刻只有一個線程獲取到鎖,而後執行同步方法,而且確保鎖釋放以前,變量的修改被刷新到主內存中
  • 使用顯式鎖LockLocklock方法能保證同一時刻只有一個線程可以獲取到鎖而後執行同步方法,而且確保鎖釋放以前可以將對變量的修改刷新到主內存中

具體來講,能夠看一下以前的例子:

public class Main {
    private int x = 0;
    private static final int MAX = 100000;
    public static void main(String[] args) throws InterruptedException {
        Main m = new Main();
        Thread thread0 = new Thread(()->{
            while(m.x < MAX) {
                ++m.x;
            }
        });

        Thread thread1 = new Thread(()->{
            while(m.x < MAX){
            }
            System.out.println("finish");
        });

        thread1.start();
        TimeUnit.MILLISECONDS.sleep(1);
        thread0.start();
    }
}

上面說過這段代碼會不斷運行,一直沒有輸出,就是由於修改後的x對線程thread1不可見,若是在x的定義中加上了volatile,就不會出現沒有輸出的狀況了,由於此時對x的修改是線程thread1可見的。

5.2.2 有序性

JMM中容許編譯期和處理器對指令進行重排序,在多線程的狀況下有可能會出現問題,爲此,Java一樣提供了三種機制去保證有序性:

  • volatile
  • synchronized
  • 顯式鎖Lock

另外,關於有序性不得不提的就是Happens-before原則。Happends-before原則說的就是若是兩個操做的執行次序沒法從該原則推導出來,那麼就沒法保證有序性,JVM或處理器能夠任意重排序。這麼作的目的是爲了儘量提升程序的並行度,具體規則以下:

  • 程序次序規則:在一個線程內,代碼按照編寫時的次序執行,編寫在後面的操做發生與編寫在前面的操做以後
  • 鎖定規則:若是一個鎖處於鎖定狀態,則unlock操做要先行發生於對同一個鎖的lock操做
  • volatile變量規則:對一個變量的寫操做要早於對這個變量以後的讀操做
  • 傳遞規則:若是操做A先於操做B,操做B先於操做C,那麼操做A先於操做C
  • 線程啓動規則:Thread對象的start()方法先行發生於對該線程的任何動做
  • 線程中斷規則:對線程執行interrupt()方法確定要優於捕獲到中斷信號,換句話說,若是收到了中斷信號,那麼在此以前一定調用了interrupt()
  • 線程終結規則:線程中全部操做都要先行發生於線程的終止檢測,也就是邏輯單元的執行確定要發生於線程終止以前
  • 對象終結規則:一個對象初始化的完成先行發生於finalize()以前

對於volatile,會直接禁止對指令重排,可是對於volatile先後無依賴關係的指令能夠隨意重排,好比:

int x = 0;
int y = 1;
//private volatile int z;
z = 20;
x++;
y--;

z=20以前,先定義x或先定義y並無要求,只須要在執行z=20的時候,能夠保證x=0,y=1便可,同理,x++y--具體先執行哪個並無要求,只須要保證二者執行在z=20以後便可。

5.2.3 原子性

Java中,全部對基本數據類型變量的讀取賦值操做都是原子性的,對引用類型的變量讀取和賦值也是原子性的,可是:

  • 將一個變量賦值給另外一個變量的操做不是原子性的,由於涉及到了一個變量的讀取以及一個變量的寫入,兩個原子性操做結合在一塊兒就不是原子性操做
  • 多個原子性操做在一塊兒就不是原子性操做,好比i++
  • JMM只保證基本讀取和賦值的原子性操做,其餘的均不保證,若是須要具有原子性,那麼可使用synchronizedLock,或者JUC包下的原子操做類

也就是說,volatile並不能保證原子性,例子以下:

public class Main {
    private volatile int x = 0;
    private static final CountDownLatch latch = new CountDownLatch(10);

    public void inc() {
        ++x;
    }

    public static void main(String[] args) throws InterruptedException {
        Main m = new Main();
        IntStream.range(0, 10).forEach(i -> {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    m.inc();
                }
                latch.countDown();
            }).start();
        });
        latch.await();
        System.out.println(m.x);
    }
}

最後輸出的x的值會少於10000,並且每次運行的結果也並不相同,至於緣由,能夠從兩個線程A、B開始分析,圖示以下:

在這裏插入圖片描述

  • 0-t1:線程A將x讀入工做內存,此時x=0
  • t1-t2:線程A時間片完,CPU調度線程B,線程B將x讀入工做內存,此時x=0
  • t2-t3:線程B對工做內存中的x進行自增操做,並更新到工做內存中
  • t3-t4:線程B時間片完,CPU調度線程A,同理線程A對工做內存中的x自增
  • t4-t5:線程A將工做內存中的值寫回主內存,此時主內存中的值爲x=1
  • t5之後:線程A時間片完,CPU調度線程B,線程B也將本身的工做內存寫回主內存,再次將主內存中的x賦值爲1

也就是說,多線程操做的話,會出現兩次自增可是實際上只進行一次數值修改的操做。想要x的值變爲10000也很簡單,加上synchronized便可:

new Thread(() -> {
    synchronized (m) {
        for (int j = 0; j < 1000; j++) {
            m.inc();
        }
    }
    latch.countDown();
}).start();

5.3 實現原理

前面已經知道,volatile能夠保證有序性以及可見性,那麼,具體是如何操做的呢?

答案就是一個lock;前綴,該前綴實際上至關於一個內存屏障,該內存屏障會爲指令的執行提供以下幾個保障:

  • 確保指令重排序時不會將其後面的代碼排到內存屏障以前
  • 確保指令重排序時不會將其前面的代碼排到內存屏障以後
  • 確保執行到內存屏障修飾的指令時前面的代碼所有執行完成
  • 強制將線程工做內存中的值修改刷新到主存中
  • 若是是寫操做,會致使其餘線程工做內存中的緩存數據失效

5.4 使用場景

一個典型的使用場景是利用開關進行線程的關閉操做,例子以下:

public class ThreadTest extends Thread{
    private volatile boolean started = true;

    @Override
    public void run() {
        while (started){
            
        }
    }

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

若是布爾變量沒有被volatile修飾,那麼極可能新的布爾值刷新不到主內存中,致使線程不會結束。

5.5 與synchronized的區別

  • 使用上的區別:volatile只能用於修飾實例變量或者類變量,可是不能用於修飾方法、方法參數、局部變量等,另外能夠修飾的變量爲null。但synchronized不能用於對變量的修飾,只能修飾方法或語句塊,並且monitor對象不能爲null
  • 對原子性的保證:volatile沒法保證原子性,可是synchronized能夠保證
  • 對可見性的保證:volatilesynchronized都能保證可見性,可是synchronized是藉助於JVM指令monitor enter/monitor exit保證的,在monitor exit的時候全部共享資源都被刷新到主內存中,而volatile是經過lock;機器指令實現的,迫使其餘線程工做內存失效,須要到主內存加載
  • 對有序性的保證:volatile可以禁止JVM以及處理器對其進行重排序,而synchronized保證的有序性是經過程序串行化執行換來的,而且在synchronized代碼塊中的代碼也會發生指令重排的狀況
  • 其餘區別:volatile不會使線程陷入阻塞,但synchronized
相關文章
相關標籤/搜索