之前就看過很多關于volatile的資料,本文是作者對volatile關鍵字的一些總結,在這裡先感謝《java記憶體模型》的作者程曉明。
目錄
-
-
- 目錄
- java關鍵字volatile總結
- 線程的可見性
- 指令重排序
-
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 = ;
private static CountDownLatch end = new CountDownLatch();
public static void addNum() {
num++;
}
public static void main(String[] args) {
for(int i = ; i < ; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = ; i < ; 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+;
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) {
//...
}
}
}
參考:http://ifeve.com/java-memory-model-0/
如果有不對的地方,歡迎大家指正。