在Java相關的職位面試中,很多Java面試官都喜歡考察應聘者對Java並發的了解程度,以volatile關鍵字為切入點,往往會問到底,Java內存模型(JMM)和Java並發編程的一些特點都會被牽扯出來,再深入的話還會考察JVM底層實現以及操作系統的相關知識。
接下來讓我們在一個假想的面試過程中來學習一下volitile關鍵字吧。
1. Java並發這塊掌握的怎么樣?來談談你對volatile關鍵字的理解吧。
參考答案:
我的理解是,被volatile修飾的共享變量,就會具有以下兩個特性:
- 保證了不同線程對該變量操作的內存可見性。
- 禁止指令重排序。
2. 那你可不可以詳細的說一下究竟什么是內存可見性,什么又是重排序?
參考答案:
這個要是說起來可就多了,我就從Java內存模型開始說起吧。Java虛擬機規范試圖定義一個Java內存模型(JMM),以屏蔽所有類型的硬件和操作系統內存訪問差異,讓Java程序在不同的平台上能夠達到一致的內存訪問效果。簡單地說,由於CPU執行指令的速度很快,但是內存訪問速度很慢,差異不是一個量級,所以搞處理器的那群大佬們又在CPU里加了好幾層高速緩存。
在Java內存模型中,對上述優化進行了一波抽象。JMM規定所有的變量都在主內存中,類似於上面提到的普通內存,每個線程又包含自己的工作內存,為了便於理解可以看成CPU上的寄存器或者高速緩存。因此,線程的操作都是以工作內存為主,它們只能訪問自己的工作內存,並且在工作之前和之后,該值被同步回主內存。
說的我自己都有點暈了,用一張圖來幫助我們理解吧:

線程執行的時候,將首先從主內存讀值,再load到工作內存中的副本中,然后傳給處理器執行,執行完畢后再給工作內存中的副本賦值,隨后工作內存再把值傳回給主存,主存中的值才更新。
使用工作內存和主存,雖然加快了速度,但也帶來了一些問題。例如:
i = i + 1;
假設 i 的初始值為 0 ,當只有一個線程執行它的時候,結果肯定是 1 ,那么當兩個線程執行時,得到的結果會是 2 嗎?不一定。可能會存在這種情況:
線程1: load i from 主存 // i = 0
i + 1 // i = 1
線程2: load i from主存 // 因為線程1還沒將i的值寫回主存,所以i還是0
i + 1 //i = 1
線程1: save i to 主存
線程2: save i to 主存
如果兩個線程遵循上面的執行過程,那么 i 的最終值竟然是 1 。如果最后的寫回生效的慢,你再讀取 i 的值,都可能會是 0 ,這就是緩存不一致的問題。
接下來就要提到您剛才所問的問題了,JMM主要圍繞在並發過程中如何處理並發原子性、可見性和有序性這三個特征來建立的,通過解決這三個問題,就可以解決緩存不一致的問題。而volatile跟可見性和有序性都有關。
3. 那你具體說說這三個特性呢?
1 . 原子性(Atomicity):
在Java中,對基本數據類型的讀取和賦值操作是原子性操作,所謂原子性操作就是指這些操作是不可中斷的,要做一定做完,要么就沒有執行。 例如:
i = 2; j = i; i++; i = i + 1;
以上四個操作, i = 2 是一個讀取操作,肯定是原子性操作, j = i 你覺得是原子性操作,但事實上,可以分為兩個步驟,一個是讀取 i 的值,然后再把值賦給 j ,這已經是兩步操作了,不能稱為原子操作,i++ 和 i = i + 1 是等效的,讀的值,+ 1,然后寫回主存,這是三個步驟的操作了。在上面的例子中,最后一個值可能在各種情況下,因為它不會滿足原子性。
在本例中,只有一個簡單的讀取,賦值是一個原子操作,並且只能被分配給一個數字,使用變量來讀取變量的值的操作。一個例外是,在虛擬機規范中允許64位數據類型(long和double),它被划分為兩個32位操作,但是JDK的最新實現實現了原子操作。
JMM只實現基本的原子性,比如上面的i++操作,它必須依賴於同步和鎖定,以確保整個代碼塊的原子性。在釋放鎖之前,線程必須將I的值返回到主內存。
2 . 可見性(Visibility):
說到可見性,Java使用volatile來提供可見性。當一個變量被volatile修改時,它的變化會立即被刷新到主存,當其他線程需要讀取變量時,它會讀取內存中的新值。普通變量不能保證。
事實上,同步和鎖定也可以保證可見性。在釋放鎖之前,線程將把共享變量值刷回主內存,但是同步和鎖更昂貴。
3 . 有序性(Ordering)
JMM允許編譯器和處理器重新排序指令,但是指定了as-if-串行語義,也就是說,無論重新排序,程序的執行結果都不能更改。例如:
double pi = 3.14; //A double r = 1; //B double s= pi * r * r;//C
上面的語句中,可以按照C - > B - >,結果是3.14,但它也可以按照的順序B - > - > C,因為A和B是兩個單獨的語句,並依賴,B,C和A和B可以重新排序,但C不能行前面的A和B。JMM確保重新排序不會影響單線程的執行,但容易出現多線程問題。例如,這樣的代碼:
int a = 0;
bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
如果有兩個線程執行上面的代碼段,線程1首先執行寫,然后再乘以線程2。最后,ret的值必須是4?不一定:

如圖1和2所示,在寫方法中進行重新排序,線程1對第一個賦值為true,然后執行到線程2,ret直接計算結果,然后再執行線程1,這一次的CaiFu值為2,顯然是較晚的步驟。
此時要標記加上volatile關鍵字,重新排序,可以確保程序的“順序”,也可以基於重量級的同步和鎖定來確保,他們可以確保在代碼執行的區域內一次性完成。
此外,JMM有一些內在的規律性,也就是說,沒有任何方法可以保證有序,這通常稱為發生在原則之前。<< jsr-133: Java內存模型和線程規范>>定義了以下事件:
- 程序順序規則: 一個線程中的每個操作,happens-before於該線程中的任意后續操作
- 監視器鎖規則:對一個線程的解鎖,happens-before於隨后對這個線程的加鎖
- volatile變量規則: 對一個volatile域的寫,happens-before於后續對這個volatile域的讀
- 傳遞性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C
- start()規則: 如果線程A執行操作
ThreadB_start()(啟動線程B) , 那么A線程的ThreadB_start()happens-before 於B中的任意操作 - join()原則: 如果A執行
ThreadB.join()並且成功返回,那么線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。 - interrupt()原則: 對線程
interrupt()方法的調用先行發生於被中斷線程代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否有中斷發生 - finalize()原則:一個對象的初始化完成先行發生於它的
finalize()方法的開始
第1條程序順序規則在一個線程中,所有的操作都是有序的,但實際上只要JMM的執行結果允許重新排序,這也是發生的重點——單線程執行結果是正確的,但是也不能保證多線程。
規則2,規則監視器的規則,非常好理解。在鎖被添加之前,鎖已經被釋放,然后它才能繼續被鎖定。
第三條規則適用於討論的不穩定性。如果一個行程序編寫一個變量,另一個線程讀取它,那么在操作之前必須讀取寫入操作。
第四個規則正在發生。
接下來的幾行不會重復。
4. volatile關鍵字如何滿足並發編程的三大特性的?
重新引入volatile變量規則是很重要的:對於一個不穩定域的寫,出現之前,然后是對這個volatile字段的讀取。本文進行再次說,如果一個變量聲明事實上是不穩定,所以當我讀了變量,最新的價值總是可以閱讀它,這個最新的值意味着無論什么其他線程寫操作,該變量將立即更新到主內存,我也可以從主內存讀取只寫值。也就是說,volatile關鍵字保證了可視性和有序度。
繼續上面的代碼示例:
int a = 0;
bool flag = false;
public void write() {
a = 2; //1
flag = true; //2
}
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
}
當編寫一個volatile變量時,JMM在本地內存中刷新與主內存對應的本地內存中的共享變量。
當您讀取一個volatile變量時,JMM將使線程對應的本地內存失效,然后線程將從主內存讀取共享變量。
