深入理解Volitile關鍵字
Volatile
概念:JVM提供的一個輕量級的同步機制
作用:
- 防止JVM對 Long/Double 等64位的非原子性協議進行的 誤操作(讀取半個資料);
- 可以使某一個變數對所有的執行緒立即可見(某一個執行緒如果修改了工作記憶體中的變數副本,那麼加上Volatile關鍵字之後,該變數就會立即同步到其他執行緒的工作記憶體當中)。
- 禁止指令 "重排序" 優化。
前面兩點在之前的穩文章中都有提到,下面我們來看什麼是指令"重排序"。看指令重排序之前,首先要理解什麼是原子性!
原子性
原子性是指一個操作是不可中斷的,要麼全部執行成功要麼全部執行失敗,有著“同生共死”的感覺。即使在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所幹擾。
-
原子性
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()
不是一個原子性操作,在執行過程中會拆分為一下幾步:
- JVM會為 instance 分配記憶體地址以及記憶體空間。
- 在執行時通過構造方法例項化物件。
- 將 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 還可以拆分為以下兩步:
- num + 1;
- 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);
}
}