深入解析volatile關鍵字
前言
很高興遇見你~ 歡迎閱讀我的文章。
volatile關鍵字在Java多線程編程編程中起的作用是很大的,合理使用可以減少很多的線程安全問題。但其實可以發現使用這個關鍵字的開發者其實很少,包括我自己。遇到同步問題,首先想到的一定是加鎖,也就是synchronize關鍵字,暴力鎖解決一切多線程疑難雜症。但,鎖的代價是很高的。線程阻塞、系統線程調度這些問題,都會造成很嚴重的性能影響。如果在一些合適的場景,使用volatile,既保證了線程安全,又極大地提高了性能。
那為啥放着好用的volatile不用,偏偏要加鎖呢?一個很重要的原因是:不了解什麼是volatile。加鎖簡單粗暴,幾乎每個開發者都會用(但不一定可以正確地用),而volatile,可能壓根就不知道有這個東西(包括之前的筆者=_=)。那volatile是什麼東西?他有什麼作用?在什麼場景下適用?他的底層原理是什麼?他真的可以保證線程安全嗎?這一系列問題,是面試常見相關題目,也正是這篇文章要啊解決的問題。
那麼,我們開始吧。
認識volatile
volatile關鍵字的作用有兩個:變量修改對其他線程立即可見、禁止指令重排。
第二個作用我們後面再講,先主要講一下第一個作用。通俗點來說,就是我在一個線程對一個變量進行了修改,那麼其他線程馬上就可以知道我修改了他。嗯?難道我修改了數值其他線程不知道?我們先從實例代碼中來感受volatile關鍵字的第一個作用。
private boolean stopSignal = false;
public void fun(){
// 創建10個線程
for (int i=0;i<=10;i--){
new Thread(() -> {
while(!stopSignal){
// 循環等待
}
System.out.println(Thread.currentThread().toString()+"我停下來了");
}).start();
}
new Thread(() -> {
stopSignal = true;
System.out.println("給我停下來");
}).start();
}
這個代碼很簡單,創建10個線程循環等待stopSignal
,當stopSignal
變為true之後,則跳出循環,打印日誌。然後我們再開另外一個線程,把stopSignal
改成true。如果按照正常的情況下,應該是先打印「給我停下來」,然後再打印10個「我停下來了」,最後結束進程。我們看看具體情況如何。來,運行:
嗯嗯?為什麼只打印兩個我停下來了?而且看左邊的停止符號,表示這個進程還沒結束。也就是說在剩下的線程中,他們拿到的stopSignal
數據依舊是false
,而不是最新的true
。所以問題就是:線程中變量的修改,對於其他線程並不是立即可見。導致這個問題的原因我們後面講,現在是怎麼解決這個問題。加鎖是個好辦法,只要我們在循環判斷與修改數值的時候加個鎖,就可以拿到最新的數據了。但是前面講到,鎖是個重量級操作,然後再加上循環,這性能估計直接掉下水道里了。最好的解決方法就是:給變量加上volatile關鍵字,如下:
private volatile boolean stopSignal = false;
我們再運行一下:
誒,可以了,全部停下來了。使用了volatile關鍵修飾的變量,只要被修改,那麼其他的線程均會立即可見。這就是volatile關鍵字的第一個重要作用:變量修改對其他線程立即可見。關於指令重排我們後面再講。
那麼為什麼變量的修改是對其他線程不是立即可見呢?volatile為何能實現這個效果?那這樣我們可不可以每個變量都給他加上volatile關鍵字修飾?要解決這些問題,我們得先從Java內存模型說起。系好安全帶,我們的車準備開進底層原理了。
Java內存模型
Java內存模型不是堆區、棧區、方法區那些,而是線程之間如何共享數據的模型,也可以說是線程對共享數據讀寫的規範。內存模型是理解Java並發問題的基礎。限於篇幅這裡不深入講述,只簡單介紹一下。不然又是萬字長文了。先看個圖:
JVM把內存總體分為兩個部分:線程私有以及線程共享,線程共享區域也稱為主內存。線程私有部分不會發生並發問題,所以主要是關注線程共享區域。這個圖大致上可以這麼理解:
- 所有共享變量存儲在主內存
- 每條線程擁有自己的工作內存
- 工作內存保留了被該線程使用的變量的主內存副本
- 變量操作必須在工作內存進行
- 不同線程之間無法訪問對方的工作內存
簡單總結一下,所有數據都要放在主內存中,線程要操作這些數據必須要先拷貝到自己的工作內存,然後只能對自己工作內存中的數據進行修改;修改完成之後,再寫回主內存。線程無法訪問另一個線程的數據,這也就是為什麼線程私有的數據不存在並發問題。
那為什麼不直接從主內存修改數據,而要先在工作內存修改後再寫回主內存呢?這就涉及到了高速緩衝區的設計。簡單來說,處理器的速度非常快,但是執行的過程中需要頻繁在內存中讀寫數據,而內存訪問的速度遠遠跟不上cpu的速度,導致降低了cpu的效率。因而設計出高速緩衝區,處理器可以直接操作高速緩衝區的數據,等到空閑時間,再把數據寫回主內存,提高了性能。而JVM為了屏蔽不同的平台對於高速緩衝區的設計,就設計出了Java內存模型,來讓開發者可以面向統一的內存模型進行編程。可以看到,這裡的工作內存就對應硬件層面的高速緩衝區。
指令重排
前面我們一直沒講指令重排,是因為這是屬於JVM在後端編譯階段進行的優化,而在代碼中隱藏的問題很難去復現,也很難通過代碼執行來看出差別。指令重排是即時編譯器對於位元組碼編譯過程中的一種優化,受到運行時環境的影響。限於能力,筆者只能通過理論分析來講這個問題。
我們知道JVM執行位元組碼一般有兩種方式:解釋器執行和即時編譯器執行。解釋器這個比較容易理解,就是一行行代碼解釋執行,所以也不存在指令重排的問題。但是解釋執行存在很大的問題:解釋代碼需要耗費一定的處理時間、無法對編譯結果進行優化,所以解釋執行一般在應用剛啟動時或者即時編譯遇到異常才使用解釋執行。而即時編譯則是在運行過程中,把熱點代碼先編譯成機器碼,等到運行到該代碼的時候就可以直接執行機器碼,不需要進行解釋,提高了性能。而在編譯過程中,我們可以對編譯後的機器碼進行優化,只要保證運行結果一致,我們可以按照計算機世界的特性對機器碼進行優化,如方法內聯、公共子表達式消除等等,指令重排就是其中一種。
計算機的思維跟我們人的思維是不一樣的,我們按照面向對象的思維來編程,但計算機卻必須按照「面向過程」的思維來執行。所以,在不影響執行結果的情況下,JVM會更改代碼的執行順序。注意,這裡的不影響執行結果,是指在當前線程下。如我們可能會在一個線程中初始化一個組件,等到初始化完成則設置標誌位為true,然後其他線程只需要監聽該標誌位即可監聽初始化是否完成,如下:
// 初始化操作
isFinish = true;
在當前線程看起來,注意,是看起來,會先執行初始化操作,再執行賦值操作,因為結果是符合預期的。但是!!!在其他線程看來,這整個執行順序都是亂的。JVM可能先執行isFinish
賦值操作,再執行初始化操作;而如果你在別的線程監聽isFinish
變化,就可能出現還未初始化完成isFinish
卻是true的問題。而volatile可以禁止指令重排,保證在isFinish
被賦值之前,所有的初始化動作都已經完成。
volatile的具體定義
上面講了兩個看起來跟我們的主角volatile關係不大的知識點,但其實是非常重要的知識點。
首先,通過Java內存模型的理解,現在知道為什麼會出現線程對變量的修改其他線程未立即可知的原因了吧?線程修改變量之後,可能並不會立即寫回主內存,而其他線程,在主內存數據更新後,也並不會立即去主內存獲取最新的數據。這也是問題所在。
被volatile關鍵字修飾的變量規定:每次使用數據都必須去主內存中獲取;每次修改完數據都必須馬上同步到主內存。這樣就實現了每個線程都可以立即收到該變量的修改信息。不會出現讀取臟數據舊數據的情況。
第二個作用是禁止指令重排。這裡是使用到了JVM的一個規定:同步內存操作前的所有操作必須已經完成。而我們知道每次給volatile賦值的時候,他會同步到主內存中。所以,在同步之前,保證所有操作都必須完成了。所以當其他線程監測到變量的變化時,賦值前的操作就肯定都已經完成了。
既然volatile關鍵字這麼好,那可不可以每個地方都使用好了?當然不行!在前面講Java內存模型的時候有提到,為了提高cpu效率,才分出了高速緩衝區。如果一個變量並不需要保證線程安全,那麼頻繁地寫入和讀取內存,是很大的性能消耗。因而,只有必須使用volatile的地方,才使用他。
volatile修飾的變量一定是線程安全嗎
首先明確一下,怎麼樣才算是線程安全?
- 一個操作要符合原子性,要麼不執行,要麼一次性執行完成,在執行過程中不會受其他操作的影響。
- 對於變量的修改相對於其他線程必須立即可見。
- 代碼的執行在其他線程看來要滿足有序性。
從上面分析我們講了:代碼的有序性、修改的可見性,但是,缺少了原子性。在JVM中,在主內存進行讀寫都是滿足原子性的,這是JVM保證的原子性,那麼volatile豈不是線程安全的?並不是。
JVM的計算過程並不是原子性的。我們舉個例子,看一下代碼:
private volatile int num = 0;
public void fun(){
for (int k=0;k<=10;k++){
new Thread(() -> {
int a = 10000;
while (a > 0) {
a--;
num++;
}
System.out.println(num);
}).start();
}
}
按照正常的情況,最後的輸出應該是100000才對,我們看看運行結果:
怎麼才五萬多,不應該是10萬嗎?這是因為,volatile僅僅只是保證了修改後的數據對其他線程立即可見,但是並不保證運算的過程的原子性。
這裡的num++
在編譯之後是分為三步:1.在工作區中取出變量數據到處理器 2.對處理器中的數據進行加一操作 3.把數據寫回工作內存。如果,在變量數據取到處理器運算的過程中,變量已經被修改了,所以這時候進行自增操作得到的結果就是錯誤的。舉個例子:
變量a=5
,當前線程取5到處理器,這時a
被其他線程改成了8,但是處理器繼續工作,把5自增得到6,然後把6寫回主內存,覆蓋數據8,從而導致了錯誤。因此,由於Java運算的非原子性,volatile並不是絕對線程安全。
那什麼時候他是安全的:
- 計算的結果不依賴原來的狀態。
- 不需要與其他的狀態變量共同參與不變約束。
通俗點來講,就是運算不需要依賴於任何狀態的運算。因為依賴的狀態,可能在運算的過程中就已經發生了變化了,而處理器並不知道。如果涉及到需要依賴狀態的運算,則必須使用其他的線程安全方案,如加鎖,來保證操作的原子性。
適用場景
狀態標誌/多線程通知
狀態標誌是很適合使用volatile關鍵字,正如我們在第一部分舉的例子,通過設置標誌來通知其他所有的線程執行邏輯。或者是如上一部分的例子,當初始化完成之後設置一個標誌通知其他線程。
而更加常用的一個類似場景是線程通知。我們可以設置一個變量,然後多個線程觀察他。這樣只需要在一個線程更改這個數值,那麼其他的觀察這個變量的線程均可以收到通知。非常輕量級、邏輯也更加簡單。
保證初始化完整:雙重鎖檢查問題
單例模式是我們經常使用的,其中一種比較常見的寫法就是雙重鎖檢查,如下:
public class JavaClass {
// 靜態內部變量
private static JavaClass javaClass;
// 構造器私有
private JavaClass(){}
public static JavaClass getInstance(){
// 第一重判空
if (javaClass==null){
// 加鎖,第二重判空
synchronized(JavaClass.class){
if (javaClass==null){
javaClass = new JavaClass();
}
}
}
return javaClass;
}
}
這種代碼很熟悉,對吧,具體的設計邏輯我就不展開了。那麼這樣的代碼是否絕對是線程安全的呢?並不是的,在某些極端情況下,仍然會出現問題。而問題就出在javaClass = new JavaClass();
這句代碼上。
新建對象並不是一個原子操作,他主要有三個子操作:
- 分配內存空間
- 初始化Singleton實例
- 賦值 instance 實例引用
正常情況下,也是按照這個順序執行。但是JVM是會進行指令重排優化的。就可能變成:
- 分配內存空間
- 賦值 instance 實例引用
- 初始化Singleton實例
賦值引用在初始化之前,那麼外部拿到的引用,可能就是一個未完全初始化的對象,這樣就造成問題了。所以這裡可以給單例對象進行volatile修飾,限制指令重排,就不會出現這種情況了。當然,關於單例模式,更加建議的寫法還是利用類加載來保證全局單例以及線程安全,當然,前提是你要保證只有一個類加載器。限於篇幅這裡就不展開了。
其他類型的初始化標誌,也是可以利用volatile關鍵字來達到限制指令重排的作用。
總結
關於Java並發編程的知識很多,而volatile僅僅只是冰山一角。並發編程的難點在於,他的bug隱藏很深,可能經過幾輪測試都不能找到問題,但是一上線就崩潰了,且極難復現和查找原因。因而學習並發原理與並發編程思想非常重要。同時,更要注重原理。掌握了原理和本質,那麼其他的相關知識也是手到擒來。
希望文章對你有幫助。
全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
筆者才疏學淺,有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信交流。另外歡迎光臨筆者的個人博客:傳送門