1. 程式人生 > 實用技巧 >深入理解Volitile關鍵字

深入理解Volitile關鍵字

Volatile

概念:JVM提供的一個輕量級的同步機制

作用:

  1. 防止JVM對 Long/Double 等64位的非原子性協議進行的 誤操作(讀取半個資料);
  2. 可以使某一個變數對所有的執行緒立即可見(某一個執行緒如果修改了工作記憶體中的變數副本,那麼加上Volatile關鍵字之後,該變數就會立即同步到其他執行緒的工作記憶體當中)。
  3. 禁止指令 "重排序" 優化。

前面兩點在之前的穩文章中都有提到,下面我們來看什麼是指令"重排序"。看指令重排序之前,首先要理解什麼是原子性!

原子性

原子性是指一個操作是不可中斷的,要麼全部執行成功要麼全部執行失敗,有著“同生共死”的感覺。即使在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所幹擾。

  • 原子性

    num = 10; 就是一個原子操作,這段程式碼在程式的底層就這麼一句話,不會再拆開了!

  • 非原子性

    int num = 10; 如果現在先定義變數,再賦值。這個操作就是非原子性的。

    這段程式碼在程式底層會拆分成兩步,這兩步已經不能再拆分了,所以是原子性的。

    1、int num;

    2、num = 10;

重排序

為了效能優化,編譯器和處理器會進行指令重排序。排序的物件就是 原子性 操作!

比如上面的例子,int num = 10不是原子性操作。所以程式會在底層將它變成 int num 和 num = 10,把它變成原子性後在進行重排序。

下面通過一個例子理解重排序,有一個直觀的映像:

int a = 10;		//1  int a; a= 10;
int b;			//2
b = 20;			//3
int c = a * b;	//4

重排序不會影響單執行緒的執行規則,因此以上程式在經過重排序後,可能的執行過程為1234或者2314,1234就是按照上面正常的執行流程,2314為

int b;			//2
b = 20;			//3
int a = 10;		//1
int c = a * b;	//4

在單例模式的實現上有一種雙重檢驗鎖定的方式(Double-checked Locking)。程式碼如下:

/**
 * @author leizige
 */
public class Singleton {

    private Singleton(){

    }

    public  static Singleton instance = null;

    public static Singleton getInstance() {
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    /* 不是一個原子性操作 */
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

以上程式碼在併發環境中會出現問題,原因是 instance = new Singleton()不是一個原子性操作,在執行過程中會拆分為一下幾步:

  1. JVM會為 instance 分配記憶體地址以及記憶體空間。
  2. 在執行時通過構造方法例項化物件。
  3. 將 instance 指向在第一步分配好的記憶體地址 。

根據我們前面重排序的知識,以上程式碼在真正執行時可能是 1、2、3,也可能是 1、3、2。

如果在多執行緒環境下,使用1、3、2可能會出現問題:

假設執行緒A剛執行了1、3步驟,但還沒有執行2,此時 instance 已經指向了JVM分配的記憶體地址。如果現線上程B進入 if(instance == null) ,會直接拿到 instance 的物件(此instance是剛才執行緒A並沒有new的物件)。這時拿到的 instance 物件是null,如果直接使用必然會報錯!

解決方案就是新增 Volatile 關鍵字來禁止 程式使用1、3、2的重排序順序。

public volatile static Singleton instance = null;

Volatile 是否能保證變數的原子性、 執行緒安全

不能!

下面通過一段程式碼來驗證一下:

/**
 * @author leizige
 */
public class TestVolatile {

    private volatile static int num = 0;

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

        /**
         * 每個執行緒num++300次,100個執行緒線上程安全時,結果應該為300w;
         */
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 30000; j++) {
                    /* 不是一個原子性操作 */
                    num++;
                }
            }).start();
        }

        /**
         * 這裡不能直接列印num,程式碼當中開了100個執行緒,main也是一個執行緒。
         * 假如100個執行緒,每個執行緒執行需要5ms
         * 但是從main方法開始到列印num,可能只需要花2ms
         * 如果main執行緒執行完,子執行緒還沒執行完,所以會發生錯誤
         * 所以需要先暫停1s,讓子執行緒執行完
         */
        Thread.sleep(1000);

        System.err.println(num);
    }

}

以上程式碼執行結果與預期的300w不符,下面我們分析一下執行緒不安全的原因:

其實造成原因的程式碼還是 num++,這句程式碼不是一個原子性操作。

num++ 等價與 num = num +1;

num = num +1 還可以拆分為以下兩步:

  1. num + 1;
  2. num = 第一步的結果;

假設兩個執行緒在執行時通過執行 num + 1;(假設此時num的值為10)

執行緒A執行 10 +1 = 11;

執行緒B執行 10 +1 = 11;

正常執行完執行緒A和B之後num的值應該為12,在併發環境下可能出現兩個執行緒同時+1,就造成了漏加的情況,所以結果與預期不符合。

如何將 num 變成原子性的呢,只要使用 java.util.concurrent.atomic包下的 AtomicInteger。該類能夠保證原子性的核心是因為提供了compareAndSet()方法,該方法提供了 CAS演算法(無鎖演算法)。

/**
 * @author leizige
 */
public class TestVolatile {

    //    private volatile static int num = 0;
    private static AtomicInteger num = new AtomicInteger(0);

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

        /**
         * 每個執行緒num++300次,100個執行緒線上程安全時,結果應該為300w;
         */
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 30000; j++) {
//                    num++
                    /* 一個原子性操作 */
                    num.incrementAndGet();
                }
            }).start();
        }

        System.err.println(num);
    }

}