Java併發專題(三)深入理解volatile關鍵字

GrimMjx發表於2018-12-25

前言

  上一章節簡單介紹了執行緒安全以及最基礎的保證執行緒安全的方法,建議大家手敲程式碼去體會。這一章會提到volatile關鍵字,雖然看起來很簡單,但是想徹底搞清楚需要具備JMM、CPU快取模型的知識。不要小看這個關鍵字,它在整個併發包(concurrent包)使用的非常廣泛,掌握volatile關鍵字是非常重要的。

   如果你是一個急性子,請看下面3點就行:

  • 保證了多執行緒讀取變數的可見性,一個執行緒修改volatile修飾的變數,另外一個執行緒會立即讀取到新的值
  • 禁止指令重排序
  • volatile關鍵字不會像synchronized關鍵字一樣造成執行緒阻塞,也就是說無鎖

1.1 初識volatile關鍵字

  我先寫一個例子,在主執行緒啟動2個執行緒,一個執行緒負責寫,一個執行緒負責讀,讀寫的該變數就是共享變數,那麼結果是你想的那樣嗎?

/**
 * volatile第一個演示Demo類。
 *
 * @author GrimMjx
 */
public class VolatileDemo1 {

    //i的初始值為0
    public static int i;
    //i的最大值為3
    public static int MAX = 3;

    public static void main(String[] args) {
        //讀執行緒
        new Thread(() -> {
            int index = i;
            while (index < MAX) {
                if (i != index) {
                    System.out.println("i = " + i);
                    index = i;
                }
            }
        }).start();

        //寫執行緒
        new Thread(() -> {
            int index = i;
            while (index < MAX) {
                System.out.println("new i = " + ++i);
                index = i;
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

  我貼上一份我執行的結果:

new i = 1
i = 1
new i = 2
new i = 3

  程式不會停,需要手動停。那麼問題來了,為什麼明明寫入了i了,讀執行緒還是無法讀到新的i的值呢?讀執行緒壓根沒有感知到i的變化!只要我們把變數i的定義改變一下,那麼就可以解決這個問題。

public volatile static int i;

  改好之後再試一下,確實如我們預料的執行了且讀執行緒也不會死迴圈。只是一個關鍵字的差別,會發生很大的不同。那接下來請帶著疑問去學習。

new i = 1
i = 1
new i = 2
i = 2
new i = 3
i = 3

 

1.2 機器CPU

  所有的指令都是CPU暫存器完成的,CPU指令的過程中涉及到資料的寫入和讀取。CPU能訪問的所有資料只能是RAM(計算機記憶體)。雖然CPU頻率不斷提升,但是RAM訪問速度沒有很大突破,因此CPU處理速度和記憶體的訪問速度差距巨大,一次主記憶體的訪問通常在幾十到幾百個甚至上千個時鐘週期,一次L1快取記憶體的讀寫需2個左右時鐘週期,一次L2快取記憶體的讀寫需要幾十個時鐘週期。

1.2.1 CPU Cache模型

  可以直觀看到兩邊的速度嚴重不對等,於是有了在CPU和主記憶體之間增加快取,最靠近CPU的快取成為L1快取記憶體,其次是L2,L3和主記憶體。我們先看一張各級快取之間響應時間差距,以及記憶體到底有多慢。

 

  接下來我們看一下CPU Cache模型:

1.2.2 CPU快取一致性

  快取大大提高了訪問速度,但是同時也引入了快取不一致的問題,比如i++;這個操作。具體的過程如下:

  1. 讀取主記憶體的i到CPU Cache中
  2. 將i+1
  3. 將結果寫回CPU Cache
  4. 將資料重新整理回主記憶體

  i++在單執行緒完全不會有問題,但是多執行緒的時候就會有問題,每個執行緒都有自己的工作記憶體(本地記憶體),如果在2個執行緒都執行i++;操作,A執行緒和B執行緒此時的工作記憶體中的i都是0,加1之後都變成1。最後經過計算再寫入主記憶體可能結果還是1。這就是快取不一致問題。如果想要解決這個問題,主流方法是通過快取一致性協議(MESI協議)。這個協議的大致思想就是如果當CPU在操作Cache中的資料時,其他Cache也存在一份副本,那麼會進行如下操作:

  1. 讀取操作,不做任何處理,只是將Cache中的資料讀取到暫存器
  2. 寫入操作,發出訊號通知其他CPU將其變數中的Cache line置為無效狀態,其他CPU在進行該變數的讀取時候不得不到主記憶體中再次獲取

1.3 Java記憶體模型

  JMM指定了JVM和計算機RAM如何進行工作的,同時也決定了一個執行緒對共享變數的寫入何時對其他執行緒可見,有以下幾個要點:

  • 每個執行緒都有自己的工作記憶體,也成為本地記憶體
  • 工作執行緒只儲存執行緒對共享變數的副本
  • 執行緒不能直接操作主記憶體,只能操作工作記憶體
  • 工作記憶體和JMM都是一個抽象的概念,實際並不存在,覆蓋了暫存器,編譯器優化等等

  主記憶體和工作記憶體的關係和CPU與CPUCache之間的關係是非常類似的,所以通過圖示和講解,我們發現理解volatile關鍵字會比synchronized關鍵字困難很多,需要了解機器CPU還有JMM。volatile在JDK5以後的concurrent包運用非常廣泛,所以掌握volatile關鍵字很重要。

 

1.4 深入理解volatile關鍵字

  說到併發,有三大特性,原子性,有序性和可見性,那我們從三個方面來介紹

1.4.1 原子性

  volatile不具備原子性

  原子性的意思就是在一次操作中,所有的操作全部執行或者都不執行,就像名字一樣是不可分割的。Java中,對變數的讀取和賦值操作都是源自的,但是多個原子性的操作在一起,不一定是個原子操作。JMM只保證了基本的讀取和賦值的原子性,其他的均不保證。說回volatile,如果在上一章節的UnsafeAdd的例子,用volatile修飾變數i,是否可以解決多執行緒併發問題呢,結果是不可以的,可以自己去試試。

  就像是i++操作,他其實包含了3步驟

  1.從主記憶體獲取i,快取到工作記憶體

  2.在工作記憶體中進行+1

  3.刷回主記憶體。

  這也就是剛剛說的多個原子性的操作在一起,不一定是個原子操作

  Java中想要保證原子性,需要使用synchronized關鍵字,concurrent包的lock,原子封裝類和迴圈CAS的方式(原子變數是一種更好的volatile,後面會講到)

1.4.2 可見性

  volatile具備可見性

  讀取:當一個變數被volatile關鍵字修飾時,當其他執行緒對此變數進行了修改,則會迫使其他執行緒的工作記憶體中的該變數失效,所以必須從主記憶體重新獲取。(使用的是機器指令lock)

  寫入:當然是先修改工作記憶體,修改後立即將其重新整理到主記憶體中。

  Java中volatile,synchronized關鍵字和顯式鎖lock都保證可見性

1.4.3 有序性

  volatile具備有序性

  首先volatile遵循happens-before原則:對一個變數的寫操作要早於這個變數之後的讀操作。也就是說,如果一個變數使用volatile關鍵字修飾,一個執行緒對這個變數進行寫操作,另外一個執行緒對這個變數進行讀操作。那麼寫操作肯定要先發生於讀操作。

  volatile對順序性非常霸道,直接禁止JVM和處理器進行指令重排序,但是對於volatile前後無依賴關係的執行可以隨便排序。

  Java中volatile,synchronized關鍵字和顯式鎖lock都保證有序性

 

1.5 volatile的正確開啟姿勢

  • 確保它們所引用狀態的可見性
  • 標識一些重要的程式生命週期事件發生(init,destroy)
  • 確保只有一個執行緒更新變數的值
  • 不會用就不要用:)

 

相關文章