volatile簡介
volatile
被稱為輕量級的synchronized,執行時開銷比synchronized
更小,在多執行緒併發程式設計中發揮著同步共享變數、禁止處理器重排序的重要作用。建議在學習volatie
之前,先看一下Java記憶體模型《什麼是Java記憶體模型?》,因為volatile
和Java記憶體模型有著莫大的關係。
Java記憶體模型
在學習volatie
之前,需要補充下Java記憶體模型的相關(JMM)知識,我們知道Java執行緒的所有操作都是在工作區進行的,那麼工作區和主存之間的變數是怎麼進行互動的呢,可以用下面的圖來表示。
- lock:作用於主存,把變數標識為執行緒獨佔狀態。
- unlock:作用於主存,解除變數的獨佔狀態。
- read:作用於主存,把一個變數的值通過主存傳輸到執行緒的工作區記憶體。
- load:作用於工作區記憶體,把
read
操作傳過來的變數值儲存到工作區記憶體的變數副本中。 - use:作用於工作記憶體,把工作區記憶體的變數副本傳給執行引擎。
- assign:作用於工作區記憶體,把從執行引擎傳過來的值賦值給工作區記憶體的變數副本。
- store:作用於工作區記憶體,把工作區記憶體的變數副本傳給主存。
- write:作用於主存,把
store
操作傳過來的值賦值給主存變數。
這8
個操作每個操作都是原子性的,但是幾個操作連著一起就不是原子性了!
volatile原理
上面介紹了Java模型的8
個操作,那麼這8
個操作和volatile
又有著什麼關係呢。
volatile的可見性
什麼是可見性,用一個例子來解釋,先看一段程式碼,加入執行緒1
先執行,執行緒2
再執行
//執行緒1
boolean stop = false;
while (!stop) {
do();
}
//執行緒2
stop = true;
複製程式碼
執行緒1
執行後會進入到一個死迴圈中,當執行緒2
執行後,執行緒1
的死迴圈就一定會馬上結束嗎?答案是不一定,因為執行緒2
執行完stop = true
後,並不會馬上將變數stop
的值true
寫回主存中,也就是上圖中的assign
執行完成之後,store
和write
並不會隨著執行,執行緒1
沒有立即將修改後的變數的值更新到主存中,即使執行緒2
及時將變數stop
的值寫回主存中了,執行緒1
也沒有了解到變數stop
的值已被修改而去主存中重新獲取,也就是執行緒1
的load
、read
操作並不會馬上執行造成執行緒1
的工作區記憶體中的變數副本不是最新的。這兩個原因造成了執行緒1
的死迴圈也就不會馬上結束。
那麼如何避免上訴的問題呢?我們可以使用volatile
關鍵字修飾變數stop
,如下
//執行緒1
volatile boolean stop = false;
while (!stop) {
do();
}
//執行緒2
stop = true;
複製程式碼
這樣執行緒1
每次讀取變數stop
的時候都會先去主存中獲取變數stop
最新的值,執行緒2
每次修改變數stop
的值之後都會馬上將變數的值寫回主存中,這樣也就不會出現上述的問題了。
那麼關鍵字volatie
是如何做到的呢?volatie
規定了上述8
個操作的規則
- 只有當執行緒對變數執行的前一個操作是
load
時,執行緒才能對變數執行use
操作;只有執行緒的後一個操作是use
時,執行緒才能對變數執行load
操作。即規定了use
、load
、read
三個操作之間的約束關係,規定這三個操作必須連續的出現,保證了執行緒每次讀取變數的值前都必須去主存獲取最新的值。 - 只有當前程對變數執行的前一個操作是
assign
時,執行緒才能對變數執行store
操作;只有執行緒的後一個操作是store
時,執行緒才能對變數執行assign
操作,即規定了assign
、store
、write
三個操作之間的約束關係,規定了這三個操作必須連續的出現,保證執行緒每次修改變數後都必須將變數的值寫回主存。
volatile
的這兩個規則,也正是保證了共享變數的可見性。
volatile的有序性
有序性即程式執行的順序按照程式碼的先後順序執行,Java記憶體模型(JMM)允許編譯器和處理器對指令進行重排序,但是規定了as-if-serial
語義,即保證單執行緒情況下不管怎麼重排序,程式的結果不能改變,如
double pi = 3.14; //A
double r = 1; //B
double s = pi * r * r; //C
複製程式碼
上面的程式碼可能按照A->B->C
順序執行,也有可能按照B->A->C
順序執行,這兩種順序都不會影響程式的結果。但是不會以C->A(B)->B(A)
的順序去執行,因為C
語句是依賴於A
和B
的,如果按照這樣的順序去執行就不能保證結果不變了(違背了as-if-serial
)。
上面介紹的是單執行緒的執行,不管指令怎麼重排序都不會影響結果,但是在多執行緒下就會出現問題了。
下面看個例子
double pi = 3.14;
double r = 0;
double s = 0;
boolean start = false;
//執行緒1
r = 10; //A
start = true; //B
//執行緒2
if (start) { //C
s = pi * r * r; //D
}
複製程式碼
執行緒1
和執行緒2
同時執行,執行緒1
的A
和B
的執行順序可能是A->B
或者B->A
(因為A和B之間沒有依賴關係,可以指令重排序)。如果執行緒1
按照A->B
的順序執行,那麼執行緒2
執行後的結果s就是我們想要的正確結果,如果執行緒1
按照B->A
的順序執行,那麼執行緒2
執行後的結果s可能就不是我們想要的結果了,因為執行緒1
將變數stop
的值修改為true
後,執行緒2
馬上獲取到stop
為true
然後執行C
語句,然後執行D
語句即s = 3.14 * 0 * 0
,然後執行緒1
再執行B
語句,那麼結果就是有問題了。
那麼為了解決這個問題,我們可以在變數true
加上關鍵字volatile
double pi = 3.14;
double r = 0;
double s = 0;
volatile boolean start = false;
//執行緒1
r = 10; //A
start = true; //B
//執行緒2
if (start) { //C
s = pi * r * r; //D
}
複製程式碼
這樣執行緒1
的執行順序就只能是A->B
了,因為關鍵字發揮了禁止處理器指令重排序的作用,所以執行緒2
的執行結果就不會有問題了。
那麼volatile
是怎麼實現禁止處理器重排序的呢?
編譯器會在編譯生成位元組碼的時候,在加有volatile
關鍵字的變數的指令進行插入記憶體屏障來禁止特定型別的處理器重排序
我們先看記憶體屏障有哪些及發揮的作用
StoreStore
屏障:禁止屏障上面變數的寫和下面所有進行寫的變數進行處理器重排序。StoreLoad
屏障:禁止屏障上面變數的寫和下面所有進行讀的變數進行處理器重排序。LoadLoad
屏障:禁止屏障上面變數的讀和下面所有進行讀的變數進行處理器重排序。LoadStore
屏障:禁止屏障上面變數的讀和下面所有進行寫的變數進行處理器重排序。
再看volatile
是怎麼插入屏障的
- 在每個
volatile
變數的寫前面插入一個StoreStore
屏障。 - 在每個
volatile
變數的寫後面插入一個StoreLoad
屏障。 - 在每個
volatile
變數的讀後面插入一個LoadLoad
屏障。 - 在每個
volatile
變數的讀後面插入一個LoadStore
屏障。
注意:寫操作是在
volatile
前後插入一個記憶體屏障,而讀操作是在後面插入兩個記憶體屏障。
volatile
變數通過插入記憶體屏障禁止了處理器重排序,從而解決了多執行緒環境下處理器重排序的問題。
volatile有沒有原子性?
上面分別介紹了volatile
的可見性和有序性,那麼volatile
有原子性嗎?我們先看一段程式碼
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保證前面的執行緒都執行完
Thread.yield();
System.out.println(test.inc);
}
}
複製程式碼
我們開啟10
個執行緒對volatile
變數進行自增操作,每個執行緒對volatile
變數執行1000
次自增操作,那結果變數inc
會是10000
嗎?答案是,變數inc
的值基本都是小於10000
。
可能你會有疑問,volatile
變數inc
不是保證了共享變數的可見性了嗎,每次執行緒讀取到的都是最新的值,是的沒錯,但是執行緒每次將值寫回主存的時候並不能保證主存中的值沒有被其他的執行緒修過過。
如果所示:執行緒1
在主存中獲取了i
的最新值(i=1),執行緒2
也在主存中獲取了i
的最新值(i=1,注意這時候執行緒1
並未對變數i
進行修改,所以i
的值還是1
)),然後執行緒2
將i自增後寫回主存,這時候主存中i=2
,到這裡還沒有問題,然後執行緒1
又對i進行了自增寫回了主存,這時候主存中i=2
,也就是對i做了2次自增操作,結果i的結果只自增了1,問題就出來了這裡。
為什麼會有這個問題呢,前面我們提到了Java記憶體模型和主存之間互動的8
個操作都是原子性的,但是他們的操作連在一起就不是原子性了,而volatile
關鍵字也只是保證了use
、load
、read
三個操作連在一起時候的原子性,還有assign
、store
、write
這三個操作連在一起時候的原子性,也就是volatile
關鍵字保證了變數讀操作的原子性和寫操作的原子性,而變數的自增過程需要對變數進行讀和寫兩個過程,而這兩個過程連在一起就不是原子性操作了。
所以說volatile
變數對於變數的單獨寫操作/讀操作是保證了原子性的,而常說的原子性包括讀寫操作連在一起,所以說對於volatile
不保證原子性的。那麼如何解決上面程式的問題呢?只能給increase
方法加鎖,讓在多執行緒情況下只有一個執行緒能執行increase
方法,也就是保證了一個執行緒對變數的讀寫是原子性的。當然還有個更優的方案,就是利用讀寫都為原子性的CAS
,利用CAS
對volatile
進行操作,既解決了volatile
不保證原子性的問題,同時消耗也沒加鎖的方式大
volatile和CAS
學完volatile
之後,是不是覺得volatile
和CAS
有種似曾相識的感覺?那它們之間有什麼關係或者區別呢。
volatile
只能保證共享變數的讀和寫操作單個操作的原子性,而CAS
保證了共享變數的讀和寫兩個操作一起的原子性(即CAS是原子性操作的)。volatile
的實現基於JMM
,而CAS
的實現基於硬體。
參考
Java併發程式設計:volatile關鍵字解析
JAVA併發六:徹底理解volatile
Java記憶體模型與volatile
Java面試官最愛問的volatile關鍵字
原文地址:ddnd.cn/2019/03/19/…