volatile 關鍵字你真的瞭解嗎?

導讀

今天,讓咱們一塊兒來探討 Java 併發編程中的知識點:volatile 關鍵字java

本文主要從如下三點講解 volatile 關鍵字:數據庫

  1. volatile 關鍵字是什麼?
  2. volatile 關鍵字能解決什麼問題?使用場景是什麼?
  3. volatile 關鍵字實現的原理?


1、volatile 關鍵字是什麼?

在 Sun 的 JDK 官方文檔是這樣形容 volatile 的:編程

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes. A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.設計模式

也就是說,若是一個變量加了 volatile 關鍵字,就會告訴編譯器和 JVM 的內存模型:這個變量是對全部線程共享的、可見的,每次 JVM 都會讀取最新寫入的值並使其最新值在全部 CPU 可見。volatile 能夠保證線程的可見性而且提供了必定的有序性,可是沒法保證原子性。在 JVM 底層 volatile 是採用內存屏障來實現的。緩存

經過這段話,咱們能夠知道 volatile 有兩個特性:安全

  1. 保證可見性、不保證原子性
  2. 禁止指令重排序


2、原子性和可見性

原子性是指一個操做或多個操做要麼所有執行而且執行的過程不會被任何因素打斷,要麼都不執行。性質和數據庫中事務同樣,一組操做要麼都成功,要麼都失敗。看下面幾個簡單例子來理解原子性:bash

i == 0;       //1
j = i;        //2
i++;          //3
i = j + 1;    //4複製代碼

在看答案以前,能夠先思考一下上面四個操做,哪些是原子操做?哪些是非原子操做?多線程

答案揭曉:併發

1——是:在Java中,對基本數據類型的變量賦值操做都是原子性操做(Java 有八大基本數據類型,分別是byte,short,int,long,char,float,double,boolean)
2——不是:包含兩個動做:讀取 i 值,將 i 值賦值給 j
3——不是:包含了三個動做:讀取 i 值,i+1,將 i+1 結果賦值給 i
4——不是:包含了三個動做:讀取 j 值,j+1,將 j+1 結果賦值給 i複製代碼
  • 也就是說,只有簡單的讀取、賦值(並且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操做)纔是原子操做。
  • 注:因爲之前的操做系統是 32 位, 64 位數據(long 型,double 型)在 Java 中是 8 個字節表示,一共佔用 64 位,所以須要分紅兩次操做採用完成一個變量的賦值或者讀取操做。隨着 64 位操做系統愈來愈普及,在 64 位的 HotSpot JVM 實現中,對64 位數據(long 型,double 型)作原子性處理(因爲 JVM 規範沒有明確規定,不排除別的 JVM 實現仍是按照 32 位的方式處理)。
  • 在單線程環境中咱們能夠認爲上述步驟都是原子性操做,可是在多線程環境下,Java 只保證了上述基本數據類型的賦值操做是原子性的,其餘操做都有可能在運算過程當中出現錯誤。爲此在多線程環境下爲了保證一些操做的原子性引入了鎖和 synchronized 等關鍵字。
  • 上面說到 volatile 關鍵字保證了變量的可見性,不保證原子性。原子性已經說了,下面說下可見性。
  • 可見性其實和 Java 內存模型的設定有關:Java 內存模型規定全部的變量都是存在主存(線程共享區域)當中,每一個線程都有本身的工做內存(私有內存)。線程對變量的全部操做都必須在工做內存中進行,而不直接對主存進行操做。而且每一個線程不能訪問其餘線程的工做內存。

舉個簡單栗子:app

好比上面 i++ 操做,在 Java 中,執行 i++ 語句:

  • 執行線程首先從主存中讀取 i(原始值)到工做內存中,而後在工做內存中執行運算 +1 操做(主存的 i 值未變),最後將運算結果刷新到主存中。
  • 數據運算是在執行線程的私有內存中進行的,線程執行完運算後,並不必定會當即將運算結果刷新到主存中(雖然最後必定會更新主存),刷新到主存動做是由 CPU 自行選擇一個合適的時間觸發的。假設數值未更新到主存以前,當其餘線程去讀取時(並且優先讀取的是工做內存中的數據而非主存),此時主存中可能仍是原來的舊值,就有可能致使運算結果出錯。

如下代碼是測試代碼:

package com.wupx.test;

/**
 * @author wupx
 * @date 2019/10/31
 */
public class VolatileTest {

    private boolean flag = false;

    class ThreadOne implements Runnable {
        @Override
        public void run() {
            while (!flag) {
                System.out.println("執行操做");
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("任務中止");
        }
    }

    class ThreadTwo implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(2000L);
                System.out.println("flag 狀態改變");
                flag = true;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        VolatileTest testVolatile = new VolatileTest();
        Thread thread1 = new Thread(testVolatile.new ThreadOne());
        Thread thread2 = new Thread(testVolatile.new ThreadTwo());
        thread1.start();
        thread2.start();
    }
}複製代碼

上述結果有可能在線程 2 執行完 flag = true 以後,並不能保證線程 1 中的 while 能當即中止循環,緣由在於 flag 狀態首先是在線程 2 的私有內存中改變的,刷新到主存的時機不固定,並且線程 1 讀取 flag 的值也是在本身的私有內存中,而線程 1 的私有內存中 flag 仍未 false,這樣就有可能致使線程仍然會繼續 while 循環。運行結果以下:

執行操做
執行操做
執行操做
flag 狀態改變
任務中止複製代碼

避免上述不可預知問題的發生就是用 volatile 關鍵字修飾 flag,volatile 修飾的共享變量能夠保證修改的值會在操做後當即更新到主存裏面,當有其餘線程須要操做該變量時,不是從私有內存中讀取,而是強制從主存中讀取新值。即一個線程修改了某個變量的值,這新值對其餘線程來講是當即可見的。


3、指令重排序

通常來講,處理器爲了提升程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行前後順序同代碼中的順序一致,可是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

好比下面的代碼

int i = 0;             
boolean flag = false;
i = 1;        // 1
flag = true;  // 2複製代碼
  • 代碼定義了一個 int 型變量,定義了一個 boolean 類型變量,而後分別對兩個變量進行賦值操做。從代碼順序上看,語句 1 是在語句 2 前面的,那麼 JVM 在真正執行這段代碼的時候會保證語句 1 必定會在語句 2 前面執行嗎?不必定,爲何呢?這裏可能會發生指令重排序(InstructionReorder)。
  • 語句 1 和語句 2 誰先執行對最終的程序結果並無影響,那麼就有可能在執行過程當中,語句 2 先執行而語句 1 後執行。
可是要注意,雖然處理器會對指令進行重排序,可是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:
int a = 10;     // 1
int r = 2;      // 2
a = a + 3;      // 3
r = a * a;      // 4複製代碼

這段代碼執行的順序多是 1->2->3->4 或者是 2->1->3->4,可是 3 和 4 的執行順序是不會變的,由於處理器在進行重排序時是會考慮指令之間的數據依賴性,若是一個指令 Instruction2 必須用到 Instruction1 的結果,那麼處理器會保證 Instruction1 會在 Instruction2 以前執行。

雖然重排序不會影響單個線程內程序執行的結果,可是多線程呢?下面看一個例子:

// 線程1
String config = initConfig();    // 1
boolean inited = true;           // 2
 
// 線程2
while(!inited){
       sleep();
}
doSomeThingWithConfig(config);複製代碼
  • 上面代碼中,因爲語句 1 和語句 2 沒有數據依賴性,所以可能會被重排序。假如發生了重排序,在線程 1 執行過程當中先執行語句 2,而此時線程 2 會覺得初始化工做已經完成,那麼就會跳出 while 循環,去執行 doSomeThingWithConfig(config) 方法,而此時 config 並無被初始化,就會致使程序出錯。
  • 從上面能夠看出,指令重排序不會影響單個線程的執行,可是會影響到線程併發執行的正確性。

那麼 volatile 關鍵字修飾的變量禁止重排序的含義是:

  1. 當程序執行到 volatile 變量的讀操做或者寫操做時,在其前面的操做確定已經所有進行,且對後面的操做可見,在其後面的操做確定尚未進行
  2. 在進行指令優化時,不能將 volatile 變量以前的語句放在對 volatile 變量的讀寫操做以後,也不能把 volatile 變量後面的語句放到其前面執行

舉個栗子:

x=0;             // 1
y=1;             // 2
volatile z = 2;  // 3
x=4;             // 4
y=5;             // 5複製代碼

變量z爲 volatile 變量,那麼進行指令重排序時,不會將語句 3 放到語句 一、語句 2 以前,也不會將語句 3 放到語句 四、語句 5 後面。可是語句 1 和語句 二、語句 4 和語句 5 之間的順序是不做任何保證的,而且 volatile 關鍵字能保證,執行到語句 3 時,語句 1 和語句 2 一定是執行完畢了的,且語句 1 和語句 2 的執行結果是對語句 三、語句 四、語句 5是可見的。

回到以前的例子:

// 線程1
String config = initConfig();   // 1
volatile boolean inited = true; // 2
 
// 線程2
while(!inited){
       sleep();
}
 
doSomeThingWithConfig(config);複製代碼
  • 以前說這個例子提到有可能語句2會在語句1以前執行,那麼就可能致使執行 doSomThingWithConfig() 方法時就會致使出錯。
  • 這裏若是用 volatile 關鍵字對 inited 變量進行修飾,則能夠保證在執行語句 2 時,一定能保證 config 已經初始化完畢。


4、volatile 應用場景

synchronized 關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程序執行效率,而 volatile 關鍵字在某些狀況下性能要優於 synchronized,可是要注意 volatile 關鍵字是沒法替代 synchronized 關鍵字的,由於 volatile 關鍵字沒法保證操做的原子性。一般來講,使用 volatile 必須具有如下三個條件:

  1. 對變量的寫入操做不依賴變量的當前值,或者能確保只有單個線程更新變量的值
  2. 該變量不會與其餘狀態變量一塊兒歸入不變性條件中
  3. 在訪問變量時不須要加鎖

上面的三個條件只須要保證是原子性操做,才能保證使用 volatile 關鍵字的程序在高併發時可以正確執行。建議不要將 volatile 用在 getAndOperate 場合,僅僅 set 或者 get 的場景是適合 volatile 的。

經常使用的兩個場景是:

  1. 狀態標記量
volatile boolean flag = false;

while (!flag) {
    doSomething();
}

public void setFlag () {
    flag = true;
}

volatile boolean inited = false;
// 線程 1
context = loadContext();
inited = true;

// 線程 2
while (!inited) {
    sleep();
}
doSomethingwithconfig(context);複製代碼
  1. DCL雙重校驗鎖-單例模式
public class Singleton {
    private volatile static Singleton instance = null;

    private Singleton() {
    }

    /**
     * 當第一次調用getInstance()方法時,instance爲空,同步操做,保證多線程實例惟一
     * 當第一次後調用getInstance()方法時,instance不爲空,不進入同步代碼塊,減小了沒必要要的同步
     */
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}複製代碼

推薦閱讀:設計模式-單例模式

使用 volatile 的緣由在上面解釋重排序時已經講過了。主要在於 instance = new Singleton(),這並不是是一個原子操做,在 JVM 中這句話作了三件事情:

  1. 給 instance分配內存
  2. 調用 Singleton 的構造函數來初始化成員變量
  3. 將 instance 對象指向分配的內存庫存空間(執行完這步 instance 就爲非 null 了)

可是 JVM 即時編譯器中存在指令重排序的優化,也就是說上面的第二步和第三步順序是不能保證的,最終的執行順序多是 1-2-3,也多是 1-3-2。若是是後者,線程 1 在執行完 3 以後,2 以前,被線程 2 搶佔,這時 instance 已是非 null(可是並無進行初始化),因此線程 2 返回 instance 使用就會報空指針異常。


6、volatile 特性是如何實現的呢?

前面講述了關於 volatile 關鍵字的一些使用,下面咱們來探討一下 volatile 到底如何保證可見性和禁止指令重排序的。

在《深刻理解Java虛擬機》這本書中說道:

觀察加入volatile關鍵字和沒有加入 volatile 關鍵字時所生成的彙編代碼發現,加入 volatile 關鍵字時,會多出一個 lock 前綴指令。

接下來舉個栗子:

volatile 的 Integer 自增(i++),其實要分紅 3 步:

  1. 讀取 volatile 變量值到 local
  2. 增長變量的值
  3. 把 local 的值寫回,讓其它的線程可見

這 3 步的 JVM 指令爲:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier複製代碼

lock 前綴指令實際上至關於一個內存屏障(也叫內存柵欄),內存屏障會提供 3 個功能:

  1. 它確保指令重排序時不會把其後面的指令排到內存屏障以前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操做已經所有完成(知足禁止重排序)
  2. 它會強制將對緩存的修改操做當即寫入主存(知足可見性)
  3. 若是是寫操做,它會致使其餘 CPU 中對應的緩存行無效(知足可見性)

volatile 變量規則是 happens-before(先行發生原則)中的一種:對一個變量的寫操做先行發生於後面對這個變量的讀操做。(該特性能夠很好解釋 DCL 雙重檢查鎖單例模式爲何使用 volatile 關鍵字來修飾能保證併發安全性)


7、總結

  • 變量聲明爲 volatile 類型時,編譯器與運行時都會注意到這個變量是共享的,不會將該變量上的操做與其餘內存操做一塊兒重排序。volatile 變量不會被緩存在寄存器或者對其餘處理器不可見的地方,所以在讀取 volatile 類型的變量時總會返回最新寫入的值。
  • 在訪問 volatile 變量時不會執行加鎖操做,也就不會使執行線程阻塞,所以 volatile 變量是比 sychronized 關鍵字更輕量級的同步機制。
  • 加鎖機制既能夠確保可見性和原子性,而 volatile 變量只能確保可見性。


最後

歡迎你們有興趣的能夠關注個人公衆號【java小瓜哥的分享平臺】,文章都會在裏面更新,還有各類java的資料都是免費分享的。但願與你們一塊兒進步努力!

相關文章
相關標籤/搜索