前言
不管是在面試還是實際開發中 volatile
都是一個應該掌握的技能。
首先來看看為什麼會出現這個關鍵字。
記憶體可見性
由於 Java
記憶體模型(JMM
)規定,所有的變數都存放在主記憶體中,而每個執行緒都有著自己的工作記憶體(快取記憶體)。
執行緒在工作時,需要將主記憶體中的資料拷貝到工作記憶體中。這樣對資料的任何操作都是基於工作記憶體(效率提高),並且不能直接操作主記憶體以及其他執行緒工作記憶體中的資料,之後再將更新之後的資料重新整理到主記憶體中。
這裡所提到的主記憶體可以簡單認為是堆記憶體,而工作記憶體則可以認為是棧記憶體。
如下圖所示:
所以在併發執行時可能會出現執行緒 B 所讀取到的資料是執行緒 A 更新之前的資料。
顯然這肯定是會出問題的,因此 volatile
的作用出現了:
當一個變數被
volatile
修飾時,任何執行緒對它的寫操作都會立即重新整理到主記憶體中,並且會強制讓快取了該變數的執行緒中的資料清空,必須從主記憶體重新讀取最新資料。
volatile
修飾之後並不是讓執行緒直接從主記憶體中獲取資料,依然需要將變數拷貝到工作記憶體中。
記憶體可見性的應用
當我們需要在兩個執行緒間依據主記憶體通訊時,通訊的那個變數就必須的用 volatile
來修飾:
public class Volatile implements Runnable{ private static volatile boolean flag = true ; @Override public void run() { while (flag){ } System.out.println(Thread.currentThread().getName() +"執行完畢"); } public static void main(String[] args) throws InterruptedException { Volatile aVolatile = new Volatile(); new Thread(aVolatile,"thread A").start(); System.out.println("main 執行緒正在執行") ; Scanner sc = new Scanner(System.in); while(sc.hasNext()){ String value = sc.next(); if(value.equals("1")){ new Thread(new Runnable() { @Override public void run() { aVolatile.stopThread(); } }).start(); break ; } } System.out.println("主執行緒退出了!"); } private void stopThread(){ flag = false ; } }
但這裡有個誤區,這樣的使用方式容易給人的感覺是:主執行緒在修改了標誌位使得執行緒 A 立即停止,如果沒有用 volatile
修飾,就有可能出現延遲。
對
volatile
修飾的變數進行併發操作是執行緒安全的。
這裡要重點強調,volatile
並不能保證執行緒安全性!
如下程式:
public class VolatileInc implements Runnable{ private static volatile int count = 0 ; //使用 volatile 修飾基本資料記憶體不能保證原子性 //private static AtomicInteger count = new AtomicInteger() ; @Override public void run() { for (int i=0;i<10000 ;i++){ count ++ ; //count.incrementAndGet() ; } } public static void main(String[] args) throws InterruptedException { VolatileInc volatileInc = new VolatileInc() ; Thread t1 = new Thread(volatileInc,"t1") ; Thread t2 = new Thread(volatileInc,"t2") ; t1.start(); //t1.join(); t2.start(); //t2.join(); for (int i=0;i<10000 ;i++){ count ++ ; //count.incrementAndGet(); } System.out.println("最終Count="+count); } }
當我們三個執行緒(t1,t2,main)同時對一個 int
進行累加時會發現最終的值都會小於 30000。
這是因為雖然
volatile
保證了記憶體可見性,每個執行緒拿到的值都是最新值,但count ++
這個操作並不是原子的,這裡面涉及到獲取值、自增、賦值的操作並不能同時完成。
-
所以想到達到執行緒安全可以使這三個執行緒序列執行(其實就是單執行緒,沒有發揮多執行緒的優勢)。
-
也可以使用
synchronize
或者是鎖的方式來保證原子性。 -
還可以用
Atomic
包中AtomicInteger
來替換int
,它利用了CAS
演算法來保證了原子性。
指令重排
記憶體可見性只是 volatile
的其中一個語義,它還可以防止 JVM
進行指令重排優化。
舉一個虛擬碼:
1
2
3
|
int a=10 ;//1
int b=20 ;//2
int c= a+b ;//3
|
一段特別簡單的程式碼,理想情況下它的執行順序是:1>2>3
。但有可能經過 JVM 優化之後的執行順序變為了 2>1>3
。
可以發現不管 JVM 怎麼優化,前提都是保證單執行緒中最終結果不變的情況下進行的。
可能這裡還看不出有什麼問題,那看下一段虛擬碼:
private static Map<String,String> value ; private static volatile boolean flag = fasle ; //以下方法發生線上程 A 中 初始化 Map public void initMap(){ //耗時操作 value = getMapValue() ;//1 flag = true ;//2 } //發生線上程 B中 等到 Map 初始化成功進行其他操作 public void doSomeThing(){ while(!flag){ sleep() ; } //dosomething doSomeThing(value); }
所以加上 volatile
之後可以防止這樣的重排優化,保證業務的正確性。
指令重排的的應用
一個經典的使用場景就是雙重懶載入的單例模式了:
public class Singleton { private static volatile Singleton singleton; private Singleton() { } public static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { //防止指令重排 singleton = new Singleton(); } } } return singleton; } }
如果不用 ,singleton = new Singleton();
,這段程式碼其實是分為三步:
- 分配記憶體空間。(1)
- 初始化物件。(2)
- 將
singleton
物件指向分配的記憶體地址。(3)
加上 volatile
是為了讓以上的三步操作順序執行,反之有可能第二步在第三步之前被執行就有可能某個執行緒拿到的單例物件是還沒有初始化的,以致於報錯。
總結
volatile
在 Java
併發中用的很多,比如像 Atomic
包中的 value
、以及 AbstractQueuedLongSynchronizer
中的 state
都是被定義為 volatile
來用於保證記憶體可見性。
將這塊理解透徹對我們編寫併發程式時可以提供很大幫助。