大雄的門線傳感器
大雄的公司最近在給國際足球協會研制一款門線傳感器。這可是一個大單,組織特地安排了大雄作為首席程序員,來開發這款軟件。
需求很簡單,傳感器需要在皮球越過門線的時候,給裁判身上的耳麥發送消息,告訴裁判球進了。
“So easy”,大雄四兩撥千斤地寫了一個進球通知線程:
public class GoalNotifier implements Runnable {
public boolean goal = false;
public boolean isGoal() {
return goal;
}
public void setGoal(boolean goal) {
this.goal = goal;
}
@Override
public void run() {
while (true) {
if (isGoal()) {
System.out.println("Goal !!!!!!");
// Tell the referee the ball is in.
// ...
// reset goal flag
setGoal(false);
}
}
}
}
“只要在比賽一開始就啟動這個線程,然后當球越過球門線的時候,調用我的setGoal()方法,把進球標志goal設置成true就OK了”,大雄對著投影里的代碼,跟產品經理胖虎講解著自己偉大的設計。
“很棒!代碼寫的非常簡潔,設計非常優雅,連我都看不出有什么Bug。靜香,不用浪費時間測試了,直接上線吧,時間就是金錢,我們要敢在別的競爭對手之前,推出這款產品!”,胖虎激動的說,唾沫橫飛。
“好的,我也相信大雄的能力!”,靜香含情脈脈的看著大雄,眼里都是崇拜。
Oop! Bug!
很快,大雄的門線傳感器上線了。英格蘭足協老總約翰是個很喜歡新科技的人,他迫不及待地想把這項技術推廣到他的國家。
這天,有一場讓世界矚目的友誼賽——曼聯傳奇隊 vs 阿森納傳奇隊。“把我們剛剛買過來的門線技術用上去,讓這群老家伙見識一下什么是高科技!”,約翰說。
比賽開始,剛開場,只見魯尼把球往旁邊一撥,貝克漢姆就順勢一腳圓月彎刀,皮球劃出一道美麗的弧線,飛過大半個足球場,阿森納門將始料不及,只能目送皮球應聲入網!
這個過程之迅猛,只能用下面這段代碼來描述了:
public class Game {
public static void main(String[] args) throws InterruptedException {
// Game begun! Init goalNotifier thread
GoalNotifier goalNotifier = new GoalNotifier();
Thread goalNotifierThread = new Thread(goalNotifier);
goalNotifierThread.start();
// After 3s
Thread.sleep(3000);
// Goal !!!
goalNotifier.setGoal(true);
}
}
就在曼聯隊隊員抱在一起慶祝的時候,裁判跑了過來,宣布進球無效,原因是門線傳感器沒有提示他進球了。。。
“What ???”,貝克漢姆一臉懵逼。。。
“大雄,怎么回事???”,約翰氣沖沖的對旁邊的大雄說。
“啊,難道是線程沒起來嗎?”,大雄也是一臉懵逼,“我加了日志的,看一下后臺就知道了!”
于是大雄登錄了后臺服務器,查看了日志信息:
“啊,一行日志都沒有。。。”,大雄很慌,“看來只能求助哆啦了。。。”
大雄趕緊視頻了正在日本度假的哆啦,視頻里,哆啦一邊喝著大阪清酒,一邊看著大雄的代碼,大概過了十秒鐘,突然掛斷了視頻。
“難道連哆啦也沒有辦法了。。”,就在大雄絕望的時候,他突然收到哆啦發來的信息,打開一看,里面就一個詞:
volatile
“啊,難道是它。。。”,來不及想太多了,貝克漢姆隨時都會再進球,“不能讓我貝失望啊”,大雄趕緊改了一行代碼:
public class GoalNotifier implements Runnable {
// public boolean goal = false;
public volatile boolean goal = false;
...
剛改完代碼,這邊曼聯隊就得到一個禁區外任意球的機會,貝克漢姆一記招牌的圓月彎刀,皮球直掛死角!不過這次,大家都沒慶祝,而是一致看向了裁判,全場鴉雀無聲。。。
突然,主席臺那里,有一個像逗比一樣的青年,大聲的吼著,“Yeah!!! 日志打印出來了!!!”,聲音之大,響徹全場。
過了大概兩秒鐘,人們才看到裁判把手指向了中圈,示意進球有效。。。
volatile和Java內存模型
“為什么把goal變量加上volatile修飾符,問題就解決了呢?”,帶著這個疑問,大雄開始研究了起來。漸漸的,他認識到,看Java代碼,不能只看表象,還要透過Java虛擬機,去看透本質。從JavaSE到JVM,這是一場認知的躍遷。
首先要解決的問題是,不加volatile之前,main函數明明調用了setGoal()方法,把goal改成了true,可為什么GoalNotifier線程里的goal還是false?
答案是,主線程里調用setGoal()方法修改的goal,和GoalNotifier線程里的goal,是兩個副本。
What??? 變量還有副本?
單看代碼,自然是看不出“副本”的,我們必須剝開代碼這層皮,到Java虛擬機里頭去看看。
在介紹JVM中的“副本”之前,我們先來簡單聊聊物理機的“副本”,因為JVM的副本和物理機的副本很像。
計算機,相比于處理器的運算速度,IO操作的速度往往有幾個數量級的差距,因此像下面這段常見的++運算:
int count = 0;
...
count ++;
如果計算機把count的值存儲在內存中,那么每次++操作,就有一次從內存中讀取i的值的操作,以及一次把i的值加1的操作,別忘了,還有一次把i的值寫進去內存的操作:
T(一次循環) = T(讀IO) + T(+1運算) + T(寫IO)
而IO操作的速度往往比運算速度多幾個數量級,所以:
T(一次循環) ≈ T(讀IO) + T(寫IO)
顯然,IO操作的速度嚴重拖后腿了,不管運算速度再快,只要IO操作還在,這個++操作的速度就永遠由IO操作的速度決定。
我們人類自然不允許這樣的情況發生,因此我們在處理器和內存之間,引入了讀寫速度接近處理器運算速度的一層高速緩存:
這樣,在上面的++操作里面,count變量只有在初始化的時候,需要寫入主內存,接著,count就被從主內存拷貝到處理器的高速緩存中,下次再想對它執行++操作時,直接從高速緩存中讀取就可以了,++操作執行完之后,也不需要馬上同步到主內存。
雖然各種平臺都會有高速緩存和主內存,但是不同平臺的內存模型并不完全相同。這也就導致了像C/C++這種直接使用物理機內存模型的編程語言,有時候一份代碼在一個平臺上可以正常運行,去到另一個平臺就掛了,所以需要“面向平臺”編程。而Java,正如廣告語說的,“Write once, run anywhere”,相同的一份代碼,去到哪個平臺都可以直接拿過去用。
為什么Java這么神奇呢?這自然是JVM的功勞,你下載JDK的時候,會讓你選擇是Windows還是Linux的。使用不同平臺的JDK,最大的差異就是JVM了,相同的一份代碼,Windows版的JVM幫你把代碼翻譯成Windows系統能識別的機器語言,Linux版的JVM則翻譯成Linux的語言。
JVM幫你屏蔽了不同平臺直接的差異。
自然的,對于物理機的內存模型,JVM也要進行“介入”,我們編寫的Java代碼,是不會直接去操作物理機的內存的,而是去操作JVM定義的Java內存模型(Java Memory Model, JMM),再通過JMM去操作物理機的內存。
Java的內存模型和上面講的物理機的內存模型非常類似:
現在再回過頭來看大雄碰到的問題:main函數明明調用了setGoal()方法,把goal改成了true,可為什么GoalNotifier線程里的goal還是false?
答案已經很明確了,這里面有兩個線程,main函數所在的是主線程和GoalNotifier線程,這兩個線程都分別從主內存從拷貝了一個goal變量的副本,所以當main函數調用setGoal()方法修改goal時,修改的其實是自己線程工作空間上的那個副本goal,對主內存的goal沒有影響,對GoalNotifier線程的goal副本更加沒有影響,GoalNotifier線程自然就感知不到goal變成true了。
那么,要怎樣才能讓GoalNotifier線程,能夠感知到main函數修改了goal呢?
很簡單嘛,讓main函數修改了goal之后主動同步到主內存,并且讓GoalNotifier線程在讀取goal的之前,主動從主內存去取goal,事實上,這就是volatile的原理。
volatile的內幕
那么volatile是如何讓修改的變量立刻同步到主內存的呢?
同樣,單看代碼是看不出來的,volatile只是我們告訴JVM的一個標志,那么JVM對于有volatile和沒有volatile的代碼,在翻譯成機器指令時,會有什么不同呢?
有同學會建議用javap命令反匯編查看一下,如果你也這么想,那現在我直接告訴你,不可以,至于為什么,你可以先自行研究,我將在后面單獨用一篇文章討論。
在這里我們要使用JIT級別的反匯編命令,原因同樣不在這里贅述。下面簡單介紹一下方法。
加入如下虛擬機參數:
-XX:+UnlockDiagnosticVMOptions -Xcomp -XX:+PrintAssembly -XX:CompileCommand=compileonly,*GoalNotifier.setGoal
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly:開啟JIT反匯編
-Xcomp:讓虛擬機以編譯模式執行代碼,使得JIT編譯可以立即觸發
-XX:CompileCommand=compileonly,*GoalNotifier.setGoal:只反匯編GoalNotifier的setGoal方法
然后執行兩次代碼,一次加入volatile修飾符,一次不加,把兩次控制臺打印的匯編語言,放到文件對比工具上對比一下,打印的信息很多,但是通過文件對比工具,我們可以很清楚的看到,加了volatile的代碼中,多了一行代碼:
這行“lock add dword ptr”的代碼是干什么用的呢?關鍵在于lock,這個lock不是指令,而是指令前綴,我對匯編語言不熟悉,這里借用《深入學習Java虛擬機》里的解釋:“lock的作用是使得本CPU的Cache寫入內存,同時使其他CPU的Cache無效”,其實也就是我們上面講的,將修改后的變量主動同步到主內存。
加了虛擬機參數后,運行的時候你可能會看到錯誤提示,別慌,很容易解決。另外,我把我做實驗生成的兩份匯編語言以及其他代碼上傳到Github了,有興趣的同學可以下載下來研究。
總結
對于volatile這個關鍵字,可能大家都聽過很多遍,但是由于實際中很少用到,所以大多不太了解其背后的原理。這次通過對volatile的介紹,順帶講解了Java內存模型,同時也看到了Java虛擬機在Java中的扮演的地位,還是那句話,看Java代碼,不能只看表象,還要透過Java虛擬機,去看透本質。從JavaSE到JVM,這是一場認知的躍遷。
這篇文章與其說是講volatile,不如說是講JVM。對volatile的介紹也只提到了它在可見性上的作用,volatile的另一個作用——禁止指令重排,并沒有提及,畢竟指令重排是個很高深的家伙,我也將在后面的文章中和大家一起探討。
祝大家春節快樂!