在Java
的面試當中,面試官最愛問的就是volatile
關鍵字相關的內容。經過多次面試之后,你是否思考過,為什么他們那么愛問volatile
關鍵字相關的問題?而對於你,如果作為面試官,是否也會考慮采用volatile
關鍵字作為切入點呢?
為什么愛問volatile關鍵字
愛問volatile
關鍵字的面試官,大多數情況都是有一定功底的,因為volatile
作為切入點,往底層走可以切入Java
內存模型(JMM
),往並發方向走又可切入Java
並發編程。當然,如果再深入追究,JVM
的底層操作、字節碼的操作、單例都可以牽扯出來。
所以說懂的人提問都是有門道的。那么,先整體來看看volatile
關鍵字都涉及到哪些點:內存可見性(JMM
特性)、原子性(JMM
特性)、禁止指令重排、線程並發、與synchronized
的區別.....再往深層挖,可能涉及到字節碼和JVM等。
面試官:說說volatile關鍵字的特性
被volatile
修飾的共享變量,就具有了以下兩點特性:
- 保證了不同線程對該變量操作的內存可見性
- 禁止指令重排序
基本上大家看過面試題都可以回答出這兩點,點出了volatile
關鍵字兩大特性。針對這兩大特性繼續深入。
面試官:什么是內存可見性?能否舉例說明?
該問題涉及到Java
內存模型(JVM
)和它的內存可見性。
內存模型:Java虛擬機規范試圖定義一種Java
內存模型(JMM
),來屏蔽掉各種硬件和操作系統的內存訪問差距,讓Java程序在各種平台上都能達到一致的內存訪問效果。
Java
內存模型是通過變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值,將主內存作為傳遞媒介。可以舉例說明內存可見性的過程。
本地內存A
和B
有主內存中共享變量x
的副本,初始值都為0。線程A
執行之后把x
更新為1
,存放在本地內存中A
中。當線程A
和線程B
需要通信時,線程A
首先會把本地內存中x=1
值刷新到主內存中,主內存的值變為1
。隨后,線程B
到主內存中去讀取更新后的x
值,線程B
的本地內存的x
值也變為了1
。
最后再說可見性:可見性是指一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。
無論普通變量還是volatile
變量都是如此,只不過volatile
變量保證新值能夠立馬同步到主內存,使用時也立即從主內存中刷新,保證了多線程操作時變量的可見性。而普通變量不能夠保證。
面試官:提到JMM和可見性,能說說JMM的其他特性嗎?
我們知道JMM
除了可見性,還有原子性和有序性。
原子性即一個操作或一系列操作是不可中斷的。即使是在多線程的情況下,操作一旦開始,就不會被其他線程干擾。
比如,對於一個靜態變量int x
兩條線程同時對其賦值,線程A
賦值為1
,而線程B
賦值為2
,不管線程如何運行,最終值要么為1
,要么是2
,線程A
和線程B
間的操作是沒有干擾的,這就是原子性操作,是不可被中斷的。
在Java
內存模型中有序性可歸納為這樣一句話:如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另外一個線程,所有操作都是無序的。
有序性是指對於單線程的執行代碼,執行是按順序依次進行的。但在多線程環境中,則可能出現亂序現象,因為在編譯過程中會出現“指令重排”,重排后的指令與原指令的順序未必一致。
因此,上面歸納的前半句指的是線程內保證串行語義執行,后半句則指“指令重排”現象和“工作內存與主內存同步延遲”現象。
面試官:你多次提到指令重排,能舉例說明嗎?
CPU
和編譯器為了提高程序執行的效率,會按照一定的規則允許進行指令優化。但代碼邏輯之間是存在一定的先后順序,並發執行時按照不同的執行邏輯會得到不同的結果。
舉例說明多線程中可能出現的重排現象:
public class ReOrderDemo{
int a = 0;
boolean flag = false;
public void write(){
a = 1; //1
flag = true; //2
}
public void read(){
if (flag){ //3
int i = a * a; //4
}
}
}
在上面的代碼中,單線程執行時,read
方法能夠獲取flag
的值進行判斷,獲得預期的結果。但在多線程的情況下就可能出現不同的結果。比如,當線程A
進行write
操作時,由於指令重排,write
中的代碼執行順序可能會變成下面這樣:
a = 1; //1
flag = true; //2
也就是說可能會先對flag
賦值,然后再對a
賦值。這在單線程並不影響最終輸出的結果。
但如果與此同時,B
線程在調用read
方法,那么就有可能出現flag
為true
但a
還是0
,這時進入第4
步操作的結果就為0
,而不是預期的1
了。
而volatile
關鍵字修飾的變量,會禁止指令重排的操作,從而在一定程度上避免了多線程中的問題。
面試官:volatile能保證原子性嗎?
volatile
保證了可見性和有序性(禁止指令重排),那么能否保證原子性呢?
volatile
不能保證原子性,它只是對單個volatile
變量的讀/寫具有原子性,但是對於類似i++
的復合操作就無法保證了。
如下代碼,從直觀上來講,感覺輸出結果為100
,但實際上並不能保證,就是因為inc++
操作屬於復合操作。
public class Test {
public volatile int inc = 0;
public void increase(){
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10; j++) {
test.increase();
}
}).start();
}
//保證前面的進程都執行完
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(test.inc);
}
}
假設線程A
,讀取了inc
的值為10
,然后被阻塞, 因未對變量進行修改,未觸發volatile
規則。線程B
此時也讀取inc
的值,主存里的值依舊是10
,做自增,然后立刻寫會主存,值為11
。此時線程A
執行,由於工作內存里保存的是10
,所以繼續做自增,再寫回主存,11
此時又被寫了一遍。所以雖然兩個線程執行了兩次increase()
,結果卻只加了一次。
有人說,volatile
不是會使緩存行無效的嗎?但是這里線程A
讀取之后並沒有修改inc
值,線程B
讀取時依舊會是10
。又有人說,線程B
將11
寫會內存,不會把線程A
的緩存行設為無效嗎?只有在做讀取操作時,發現自己緩存行無效,才會去讀主存的值,而線程A
的讀取操作在線程B
寫入之前已經做過了,所以這里線程A
只能繼續做自增了。
針對這種情況,只能使用synchronized
、Lock
或並發包下的atomic
的原子操作類。
面試官:剛提到synchronized,能說說他們之間的區別嗎?
volatile
本質是在告訴JVM
當前變量寄存器(工作內存)中的值是不確定的,需要從主存中讀取;synchronized
則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。volatile
僅能使用在變量級別;synchronized
則可以使用在變量、方法和類級別上volatile
僅能實現變量的修改可見性,不能保證原子性;而synchronized
則可以保證變量的修改可見性和原子性volatile
不會造成線程的阻塞;synchronized
可能會造成線程的阻塞volatile
標記的變量不會被編譯器優化;synchronized
標記的變量可以被編譯器優化
面試官:還能舉出其他例子說明volatile的作用嗎?
單例模式的實現,典型的雙重檢查鎖定(DCL
):
class Singleton{
private volatile static Singleton instance = null;
private Singleton(){
}
public static Singleton getInstance(){
if (instance == null){ //1
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton(); //2
}
}
}
return instance;
}
}
這是一種懶漢的單例模型,使用時才創建對象,而且為了避免初始化操作的指令重排序,給instance
加上了volatile
。
為什么用了synchronized
還要用volatile
?具體來說就是synchronized
雖然保證了原子性,但卻沒保證指令重排序的正確性,會出現A線程執行初始化,但可能因為構造函數里面的操作太多了,所以A
線程的instance
還沒有造出來,但已經被賦值了(即代碼中2
操作,先分配內存空間后構建對象)。
而B
線程這時過來了(代碼1
操作,發現instance
不為null
),錯以為instance
已經被實例化出來,一用才發現instance
尚未被初始化。要知道我們的線程雖然可以保證原子性,但程序可能是在多核CPU
上執行。
總結
當然,針對volatile
關鍵字還有其他方面的拓展,比如講到JMM
時可拓展到JMM
與Java
內存模型的區別,講到原子性時可拓展到如何如何查看class
字節碼,講到並發可拓展到線程並發。
其實,不僅面試如此,在學習知識時也可以參考這種面試思維,多問幾個為什么。將一個點,通過為什么展成面,這樣就可以形成自己的知識網絡。