Tips:但凡是個像樣的公司面試官都得問對volatile關鍵字理解以及其實現原理。雖然多多少少知道一些,但是問深了,終究感覺還是差了那麼一點,所以這次我要把這個關鍵字來學個通透!
本文記錄個人學習volatile。主要包含以下內容,力求簡單明瞭:
1、一段代碼來演示問題背景
2、volatile解決內存可見性問題
3、Java內存模型原子操作
4、總線加鎖太慢?MESI緩存一致性協議(總線嗅探機制)
5、徹底掌握volatile底層原理
1、多線程環境下共享變量的線程安全問題
package volatil;
/**
* @author zty200329
* @date 2020/7/6 15:07
* @describe:
*/
public class Try {
/**
* 定義一個全局變量
*/
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
//線程1當flag==false的時候無限空轉,等待flag變爲true
new Thread(()->{
System.out.println("thread1 action");
while (!flag){
}
System.out.println("thread1 end");
}).start();
Thread.sleep(1000);
//線程2改變flag==true
new Thread(()->{
System.out.println("thread2 action");
flag = true;
System.out.println("thread2 end");
}).start();
}
}
點擊運行會有如下的輸出
thread1 action
thread2 action
thread2 end
可以知道的是,線程1在無限空轉,只有當flag=true纔會跳出,但是線程2對flag的改變,線程1卻感知不到。
使用top命令也可以看到有一個Java進程的CPU在100%上下:
在瞭解上面的代碼運行原理之前,我們需要知道現代計算機多核CPU併發緩存架構
那麼問題來了!CPU緩存是什麼東西?現代的CPU和主內存之間都會有CPU高速緩存來解決一個內存和CPU二者之間速度不匹配問題:主內存的速度跟不上CPU的高速運行。
上圖是計算機組成原理,大學肯定學過,現在的狀態就是:看到了來一句“我kao,這玩意我知道啊!”
那麼在Java線程內存模型中,其實和CPU緩存模型是一個意思,是基於CPU緩存模型來建立的:
結合上圖來看,文中的代碼段運行步驟應該是這樣的:
1、將靜態全局變量flag加載到主內存中;
2、線程1從主內存中讀取變量flag到自己的工內存中,即工作變量副本,值爲false;
3、線程2從主內存中讀取變量flag到自己的工作內存中,值爲false;
4、線程1無限空轉;
5、線程2將變量flag值置爲true,並刷新回主內存。
核心問題:線程2對變量flag的改變對線程1不可見!
volatile解決共享變量的線程可見性
問題已經很清晰,那麼怎麼能讓線程1能夠順利的跳出while循環呢?
private static volatile boolean flag = false;
只要對flag變量加volatile關鍵字修飾即可讓線程1跳出while循環
thread1 action...
thread2 action...
thread2 end
thread1 end
那麼volatile關鍵字到底在我們的多線程程序運行時候是怎麼來保證線程見的可見性呢?
幾個問題:
1、多線程程序底層是怎麼執行的?
2、主內存和工作內存是怎麼交互的?
3、爲什麼volatile關鍵字就可以保證各線程對變量的可見性?
Java內存模型定義瞭如下8種原子操作來實現主內存和工作內存之間的交互協議:
- read(讀取):從主內存讀取數據
- load(載入):將主內存讀取到的數據寫入工作內存
- use(使用):從工作內存讀取數據來計算
- assign(賦值):將計算好的值重新賦值到工作內存中去
- store(存儲):將工作內存數據寫入到主內存
- write(寫入):將store過去的變量賦值給主內存中的變量
- lock(鎖定):將主內存變量加鎖,標識爲線程獨佔狀態
- unlock(解鎖):將主內存變量解鎖,解鎖後其他線程可以鎖定該變量
這裏一定要注意的關鍵詞是原子操作,原子操作意味着,當我們對主內存的共享變量進行原子操作的時候,它一定是線程安全的!
將以上8個原子操作映射到上面的程序,來看看上面程序的線程內存模型是什麼樣子的,如下圖所示:
線程1的操作(紅色路線):
1、read——從主內存讀取到數據:flag=false;
2、load——將讀取到的flag=false數據寫入到線程1的工作內存;
3、use——使用工作內存中的flag=false數據:while(!flag)。
線程2的操作(橙色路線):
1、read——從主內存讀取到數據:flag=false;
2、load——將讀取到的flag=false數據寫入到線程2的工作內存;
3、use——使用工作內存中的flag=false數據;
4、assign——設置flag=true,並重新賦值到線程2的工作內存;
5、store——將工作內存中的flag=true寫入到主內存;
6、write——將寫入到主內存中的flag=true設置到主內存的變量。
根據這兩個線程的操作步驟,可以看到雖然線程2將主內存中的flag變量值變成true了,但是線程1根本就不知道,只有第一次read到的值纔有效,這就是本質原因。
所以我們可以思考一下,如何才能讓線程1感知到其他線程(線程2)對變量flag的更改呢?
1、加鎖:同一個時間點只有一個線程能read到flag這個變量,當其他線程去獲取這個變量時候,只能是等待;
2、事件響應模式:多個線程都可以read到flag這個變量,但是當有任何一個線程修改了這個變量的值,需要通知其他線程,使得自己工作內存中的緩存數據失效。
是不是這兩種思想?這兩種思想是不是有點熟悉?是不是感覺好多地方都是這種套路?比如I/O模型中的select和epoll,還有沒有其他場景也是這種套路呢?
早期在CPU層面爲了解決共享變量多線程之間副本不可見的問題,就是總線加鎖的方式:
和之前有變化的就是:
- read之前對該變量加鎖lock
- write之後對該變量釋放鎖unlock
這樣就能保證其他的CPU(線程)在讀取該變量的時候只能是等待狀態,但是問題也來了,在操作同一個變量時候,原來我們是一個多線程的程序,因爲加鎖的原因,使得我們的程序又是單線程串行執行了,也就是所謂的鎖粒度太粗。
上面的問題就是一棍子打死,只要一個線程獲取了主內存中的變量,其他線程都得在等待,這就錯殺了很多線程,有可能一個線程只是需要讀取一下,或者很多讀多寫少的場景都不滿足。
那麼怎麼優化呢?套路都是一致的:
- 降低鎖粒度:只在需要加鎖的時候加鎖
- 事件響應機制:當有線程對共享變量有更改後,讓其他線程能夠感知到從而讓自己本地工作內存中的緩存失效
這個思想就是CPU中的MESI緩存一致性協議:
多個CPU從主內存讀取同一個數據到各自的高速緩存,當其中某個CPU修改了緩存裏的數據,該數據會馬上同步回主內存,其他CPU通過總線嗅探機制可以感知到數據的變化從而將自己緩存裏的數據失效。
volatile底層實現
瞭解清楚上面的思想和原理後,其實volatile就是藉助了CPU的MESI緩存一致性協議的原理。
還是看上面的那段代碼,當對flag變量增加volatile修飾後,我們通過查詢該類的彙編碼指令,可以得到下面這部分彙編指令:
0x000000010d3f3203: lock addl $0x0,(%rsp) ;*putstatic flag
; - com.java.study.VolatileStudy::lambda$main$1@9 (line 31)
對應的就是上面程序的代碼:
flag = true
所以volatile關鍵字在彙編底層的實現原理就是通過彙編lock前綴指令。
IA-32架構軟件開發手冊對lock指令的解釋:
1、會將當前處理器緩存行的數據立即寫回到系統內存
2、這個寫回內存的操作會引起其他CPU裏緩存了該內存地址的數據無效(即MESI協議)
翻譯成人話就是(還是上面的程序代碼):
1、flag變量被volatile修飾了;
2、當線程2對flag做assign操作後需要立即寫回主內存;
3、在store之前,該lock指令會對內存中的變量flag加一把鎖;
4、當store操作將flag值寫回主內存時候,需要通過CPU總線,這個時候會觸發總線嗅探機制,通知其他CPU緩存失效;
5、執行write成功後,會執行unlock釋放鎖。
根據上述描述,可以發現鎖粒度小很多了,只在store時候加鎖,而不是像直接鎖總線那樣粗。