java volatile關鍵字總結
之前就看過很多關於volatile的資料,本文是作者對volatile關鍵字的一些總結,在這裡先感謝《java記憶體模型》的作者程曉明。
目錄
java關鍵字volatile總結
關於volatile修飾的變數,虛擬機器做出如下保證:
- 執行緒的可見性
- 禁止指令的重排序
執行緒的可見性
java記憶體模型(簡稱JMM)規定了所有的變數都儲存在主存中,每個執行緒都有自己的工作記憶體,工作記憶體中儲存了主存中對應變數的拷貝,對變數的修改是在工作記憶體中完成,然後同步至主存中。JMM模型如圖:
由上述可以得出,多個執行緒對主存中同一普通變數的修改,是存在”可見性”問題的,也就是指在一個執行緒中對變數修改後,其他執行緒不一定及時知道。而虛擬機器會保證對於volatile的變數,修改是對其他執行緒立即可見的。那麼虛擬機器是如何做到這一點的呢?
在JMM中定義了八種操作來實現工作記憶體與主存的互動,這些操作都是原子操作,期間不會發生其他的執行緒切換:
- Lock:將主存中的變數標記為一條執行緒獨佔狀態;
- Unlock:將鎖定的變數釋放;
- Read:將主存中的變數傳輸到工作記憶體中;
- Load:把read操作接收到的變數值放入工作記憶體的變數副本中;
- Use:把工作記憶體中的值傳遞給執行引擎;
- Assign:把從執行引擎中接收到的值賦值給工作記憶體中的變數;
- Store:把工作記憶體中的變數傳遞至主存;
- Write:將store接收到的變數的值賦值給主存中的變數;
在虛擬機器中,對於volatile有如下規則,假設T表示一個執行緒,P和Q表示兩個volatile變數,在進行上面描述的操作時:
- 只有當T對P執行的前一個動作是load時,T才能對P執行use動作,並且只有T對P執行的後一個動作是use時,T才能對P進行load操作;這樣就保證執行引擎每次在使用變數之前,都會從主存中讀取最新的值。
- 只有當T對P執行的前一個動作是assign時,T才能對P進行store操作,並且只有T對P執行的後一個動作是store時,T才能對P執行assign;這樣就保證每次工作記憶體中的值修改後,會馬上寫入主存中。
- 保證volatile的重排序規則(下文會有說明)
既然虛擬機器對volatile變數做了這麼多規定,這樣可以保證volatile修飾的變數就是執行緒安全的嗎?看例子:
package test;
import java.util.concurrent.CountDownLatch;
public class Test {
public static volatile int num = 0;
private static CountDownLatch end = new CountDownLatch(20);
public static void addNum() {
num++;
}
public static void main(String[] args) {
for(int i = 0; i < 20; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = 0; i < 10000; i++) {
addNum();
}
} finally {
end.countDown();
}
}
}).start();
}
try {
end.await();
System.out.println(num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
說明:20個執行緒,每個執行緒對num進行10000次自增操作,如果volatile是執行緒安全的,那執行完所有執行緒後應輸出200000,但結果每次輸出都不同,但都小於200000.
但是虛擬機器不是規定對volatile變數的操作會對其他執行緒立即可見嗎?怎麼還會輸出錯誤的結果呢?原因是:對num的操作 num++其實是一個複合操作而不是原子操作,也就是說,在執行num++時,會出現”可見性”問題。為了便於理解,可以參照synchronized關鍵字:
public class SynaTest {
private volatile int num;//volatile變數
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
public void add() {
num++;
}
}
等價於
public class SynaTest {
private int num; //普通變數
public synchronized int getNum() {
return num;
}
public synchronized void setNum(int num) {
this.num = num;
}
public void add() {
int tmp = getNum();
tmp = tmp+1;
setNum(tmp);
}
}
至此,關於第一點”對其他執行緒的可見”說完。
指令重排序
處理器和編譯器為提高效率,可能會對程式進行指令重排序,但我們不會意識到這種操作,因為重排序不會影響程式的輸出結果,當然,這裡不影響輸出結果只是在單執行緒中。那麼JMM是如何是volatile修飾的變數不會發生指令重排序呢?
先來說說記憶體屏障,在JMM中,記憶體屏障可以分為:
屏障型別 | 指令示例 | 說明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 確保Load1資料的裝載,之前於Load2及所有後續裝載指令的裝載 |
StoreStore | Store1;StoreStore;Store2 | 確保Store1資料對其他處理器可見(重新整理到記憶體),之前於Store2及後續儲存指令的儲存 |
LoadStore | Load1;LoadStore;Store2 | 確保Load1資料裝載,之前於Store2及後續的儲存指令 |
StoreLoad | Store1;StoreLoad;Load2 | 確保Store1資料對其他處理器變得可見(重新整理到記憶體),之前於Load2及後續裝載指令的裝載。StoreLoad會使屏障之前的所有記憶體指令(儲存和裝載)完成之後,才執行該屏障之後的記憶體訪問指令 |
在JMM中,關於volatile的重排序規則定義如下:
- 當第二個操作是volatile寫時,不論前一個操作是什麼,都不能進行重排序。
- 當第一個操作是volatile讀時,不論後一個操作是什麼,都不能進行重排序。
- 第一個操作是volatile寫,後一個操作是volatile讀時,不能進行重排序
為了實現上述三點,JMM採用插入記憶體屏障:
- 在每個volatile寫操作的前面插入一個StoreStore屏障
- 在每個volatile寫操作的後面插入一個StoreLoad屏障
- 在每個volatile讀操作的後面插入一個LoadLoad屏障
- 在每個volatile讀操作的後面插入一個LoadStore屏障
通過這幾個記憶體屏障,JMM就可以保證volatile語義:當寫一個volatile變數時,JMM會把該執行緒對應的工作記憶體中的值重新整理到主存中;檔讀一個volatile變數時,JMM會把工作記憶體中對應的變數值設為無效,從主存中獲取變數值。
通過上述的描述,可以看出其實volatile並不是” 執行緒安全”的,如果要保證同步,還需要額外的同步手段,比如通過synchronized關鍵字或者java.util.concurrent工具,但是volatile在某些情況下是非常適用的,比如只有單一執行緒對volatile變數進行寫操作:
public class VolaTest {
volatile boolean stop = false;
public void shutdown() {//呼叫該方法後,可以使所有執行緒的doWork立即停下來
stop = true;
}
public void doWork() {
while(!stop) {
//...
}
}
}
如果有不對的地方,歡迎大家指正。