來源:blog.csdn.net/fumitzuki/article/details/81630048
volatile關鍵字是由JVM提供的最輕量級同步機制。與被濫用的synchronized不同,我們並不習慣使用它。想要正確且完全的理解它並不容易。
Java內存模型
Java內存模型由Java虛擬機規范定義,用來屏蔽各個平台的硬件差異。簡單來說:
- 所有變量儲存在主內存。
- 每條線程擁有自己的工作內存,其中保存了主內存中線程使用到的變量的副本。
- 線程不能直接讀寫主內存中的變量,所有操作均在工作內存中完成。
線程,主內存,工作內存的交互關系如圖。
內存間的交互操作有很多,和volatile有關的操作為:
- read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用
- load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
- use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
- assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
- store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的write的操作。
- write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。
對被volatile修飾的變量進行操作時,需要滿足以下規則:
- 規則1:線程對變量執行的前一個動作是load時才能執行use,反之只有后一個動作是use時才能執行load。線程對變量的read,load,use動作關聯,必須連續一起出現。-----這保證了線程每次使用變量時都需要從主存拿到最新的值,保證了其他線程修改的變量本線程能看到。
- 規則2:線程對變量執行的前一個動作是assign時才能執行store,反之只有后一個動作是store時才能執行assign。線程對變量的assign,store,write動作關聯,必須連續一起出現。-----這保證了線程每次修改變量后都會立即同步回主內存,保證了本線程修改的變量其他線程能看到。
- 規則3:有線程T,變量V、變量W。假設動作A是T對V的use或assign動作,P是根據規則2、3與A關聯的read或write動作;動作B是T對W的use或assign動作,Q是根據規則2、3與B關聯的read或write動作。如果A先與B,那么P先與Q。------這保證了volatile修飾的變量不會被指令重排序優化,代碼的執行順序與程序的順序相同。
使用volatile關鍵字的特性
1.被volatile修飾的變量保證對所有線程可見。
由上文的規則1、2可知,volatile變量對所有線程是立即可見的,在各個線程中不存在一致性問題。那么,我們是否能得出結論:volatile變量在並發運算下是線程安全的呢?
這確實是一個非常常見的誤解,寫個簡單的例子:
public class VolatileTest extends Thread{
static volatile int increase = 0;
static AtomicInteger aInteger=new AtomicInteger();//對照組
static void increaseFun() {
increase++;
aInteger.incrementAndGet();
}
public void run(){
int i=0;
while (i < 10000) {
increaseFun();
i++;
}
}
public static void main(String[] args) {
VolatileTest vt = new VolatileTest();
int THREAD_NUM = 10;
Thread[] threads = new Thread[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
threads[i] = new Thread(vt, "線程" + i);
threads[i].start();
}
//idea中會返回主線程和守護線程,如果用Eclipse的話改為1
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println("volatile的值: "+increase);
System.out.println("AtomicInteger的值: "+aInteger);
}
}
這個程序我們跑了10個線程同時對volatile修飾的變量進行10000的自增操作(AtomicInteger實現了原子性,作為對照組),如果volatile變量是並發安全的話,運行結果應該為100000,可是多次運行后,每次的結果均小於預期值。顯然上文的說法是有問題的。
volatile修飾的變量並不保值原子性,所以在上述的例子中,用volatile來保證線程安全不靠譜。我們用Javap對這段代碼進行反編譯,為什么不靠譜簡直一目了然:
getstatic指令把increase的值拿到了操作棧的頂部,此時由於volatile的規則,該值是正確的。
iconst_1和iadd指令在執行的時候increase的值很有可能已經被其他線程加大,此時棧頂的值過期。
putstatic指令接着把過期的值同步回主存,導致了最終結果較小。
volatile關鍵字只保證可見性,所以在以下情況中,需要使用鎖來保證原子性:
- 運算結果依賴變量的當前值,並且有不止一個線程在修改變量的值。
- 變量需要與其他狀態變量共同參與不變約束
那么volatile的這個特性的使用場景是什么呢?
- 模式1:狀態標志
- 模式2:獨立觀察(independent observation)
- 模式3:“volatile bean” 模式
- 模式4:開銷較低的“讀-寫鎖”策略
具體場景:
2.禁止指令重排序優化。
由上文的規則3可知,volatile變量的第二個語義是禁止指令重排序。指令重排序是什么?簡單點說就是
jvm會把代碼中沒有依賴賦值的地方打亂執行順序,由於一些規則限定,我們在單線程內觀察不到打亂的現象(線程內表現為串行的語義),但是在並發程序中,從別的線程看另一個線程,操作是無序的。
一個非常經典的指令重排序例子:
public class SingletonTest {
private volatile static SingletonTest instance = null;
private SingletonTest() { }
public static SingletonTest getInstance() {
if(instance == null) {
synchronized (SingletonTest.class){
if(instance == null) {
instance = new SingletonTest(); //非原子操作
}
}
}
return instance;
}
}
這是單例模式中的“雙重檢查加鎖模式”,我們看到instance用了volatile修飾,由於 instance = new SingletonTest();
可分解為:
memory =allocate();
//分配對象的內存空間ctorInstance(memory);
//初始化對象instance =memory;
//設置instance指向剛分配的內存地址
操作2依賴1,但是操作3不依賴2,所以有可能出現1,3,2的順序,當出現這種順序的時候,雖然instance不為空,但是對象也有可能沒有正確初始化,會出錯。
總結
並發三特征可見性和有序性和原子性中,volatile通過新值立即同步到主內存和每次使用前從主內存刷新機制保證了可見性。通過禁止指令重排序保證了有序性。無法保證原子性。
而我們知道,synchronized關鍵字通過lock和unlock操作保證了原子性,通過對一個變量unlock前,把變量同步回主內存中保證了可見性,通過一個變量在同一時刻只允許一條線程對其進行lock操作保證了有序性。
他的“萬能”也間接導致了我們對synchronized關鍵字的濫用,越泛用的控制,對性能的影響也越大,雖然jvm不斷的對synchronized關鍵字進行各種各樣的優化,但是我們還是要在合適的時候想起volatile關鍵字啊,哈哈哈哈。
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2021最新版)
2.別在再滿屏的 if/ else 了,試試策略模式,真香!!
3.卧槽!Java 中的 xx ≠ null 是什么新語法?
4.Spring Boot 2.5 重磅發布,黑暗模式太炸了!
覺得不錯,別忘了隨手點贊+轉發哦!