衆所周知,編程是一門玄學。html
本文主要是描述輸出語句、sleep以及Integer對線程安全的影響。第一次碰到這個問題是122天以前,當時就以爲很奇怪。java
至於爲何還有Integer?我也不知道,多是玄學吧! 這也是本文最後留下的一個問題,若是有知道的朋友還請指點一二。面試
首先,仍是本號特點,先荒腔走板的聊聊生活。編程
上面這張圖是我 2017 年 12 月 9 日在北京西山國家森林公園拍的。緩存
拍照的地方有個頗有意思的名字:鬼笑石。安全
我在北京待了三年,這個地方一共只去了兩次,這是第一次去的時候拍的,我一我的從香山走到了西山,那個時候仍是一個充滿鬥志的北漂。併發
第二次去是由於我感受本身可能要離開北京了,若是說在離開以前還能去一個地方留戀一下,「鬼笑石」算得上其中之一。因而約了好幾個朋友一塊兒再爬了一次。oracle
在這個地方一眼望去,你能站在五環邊上,看到大半個北京,從夕陽西下,倦鳥歸林看到華燈初上,萬家燈火。app
你能夠感覺到在偌大的北京中本身的眇小,也能感覺到在這麼大的北京,必定要好好拼命努力才能不負北漂的時光。jvm
兩次我都在聽同一首歌趙雷的《理想》:
公車上我睡過了車站 一路上我望着霓虹的北京 個人理想把我丟在這個擁擠的人潮 車窗外已是一片白雪茫茫 ...... 理想今年你幾歲 你老是誘惑着年輕的朋友 你老是謝了又開 給我驚喜 又讓我沉入失望的生活裏 ...... 理想永遠都年輕 你讓我倔強地反抗着命運 你讓我變得蒼白 卻依然天真的相信花兒會再次的怒放
歌詞寫的真好,趙雷唱的真好,以致於我日後的每一次聽到這首歌的時候,我都會想起北漂的那些日子。
每次有讀者私聊我說,他要開始北漂啦。我都會說:必定要好好珍惜、把握、不虛度北漂的每一天。
此次,我再分享兩首歌給你吧。趙雷的《理想》和李志的《熱河》。
好了,說迴文章。
本文主要是描述輸出語句、sleep 以及 Integer 對線程安全的影響。
爲何還有 Integer ?我也不知道,多是玄學吧!
這個程序的意思就是定義一個 boolean 型的 flag 並設置爲 false。主線程一直循環,直到 flag 變爲 true。
而 flag 何時變爲 true 呢?
從程序裏看起來是在子線程休眠 100ms 後,把 flag 修改成 true。
來,你說這個程序會不會正常結束?
但凡是對 Java 併發編程有必定基礎的朋友都能看出來,這個程序是一個死循環。致使死循環的緣由是 flag 變量不是被 volatile 修飾的,因此子線程對 flag 的修改不必定能被主線程看到。
而這個地方,若是是在 HotSpot jvm 中用 Server 模式跑的程序,是必定不會被主線程看到,緣由後面會講。
若是你對於 Java 內存模型和 volatile 關鍵字的做用不清楚的話,我建議你先趕忙去搜一下相關的知識點,補充一下後再來看這篇文章。
因爲 Java 內存模型和 volatile 關鍵字是面試常見考題,出現的概率很是之高,因此已經有不少的文章寫過了,本文不會對這些基本概念進行解釋。
我默認你是瞭解 Java 內存模型和 volatile 關鍵字的做用的。
我第一次遇到這個問題,是在 2019 年 11 月 19 日,距今天已經122天了。我經常在夜裏想起這個題以及這個題的變種問題,爲何呢?究竟是爲何呢?
我再給你提供一個能夠直接複製粘貼運行的版本,我建議文中的代碼你都去執行一遍,你就會知道:MD,這事兒真是絕了!
public class VolatileExample { private static boolean flag = false; private static int i = 0; public static void main(String[] args) { new Thread(() -> { try { TimeUnit.MILLISECONDS.sleep(100); flag = true; System.out.println("flag 被修改爲 true"); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); while (!flag) { i++; } System.out.println("程序結束,i=" + i); }
}
還有,須要事先說明的是:要讓程序按照預期結束的正常操做是用 volatile 修飾 flag 變量。可是這題要是加上 volatile 就沒有意思了,也就失去了探索的意義。
因此下面的這些騷操做,僅作研究,真實場景中不能這樣去作。
另外,須要說明的是,根據不一樣的機器、不一樣的JVM、不一樣的CPU可能會產生不同的效果。
我會在這一小節基於上面展現的程序進行三次很是小的變化。
相信我,絕對讓你懵逼。甚至讓你以爲:不可能吧?我得親自操做一下。
操做以後你就會說:臥槽,還真是這樣?這是量子力學嗎?
那我把上面這題變一下,改變成下面這樣:
僅僅在程序的第 24 行加入了一個輸出語句,用於輸出每次循環時 flag 的值。其餘地方沒有任何變化。
能夠看到 idea 在 24 行還給了咱們一個友情提示:
它說:flag is always false。
來,你再猜一下。這個程序仍是不是死循環呢?
執行以後你會發現,這個程序竟然正常結束了,可是你不知道爲何,你只能大喊一聲:臥槽,絕了!
或者你說你知道,由於輸出語句裏面有 synchronized 關鍵字。
很好,彆着急,接着往下看。看看會不會被打臉。
先接着看下面的程序:
此次的變更點是在 while 循環裏面加了一個 10ms 的睡眠。
來,你再猜一下。這個程序仍是不是死循環呢?
執行以後你會發現,這個程序竟然正常結束了,可是你也不知道爲何,你只能再次大喊一聲:臥槽,這TM絕了!
sleep 語句裏面沒有 synchronized 了吧,你再給我解釋一波?
也許你會說,這我也知道,sleep 會致使內存的刷新操做。
來,等會把你的另一半臉伸過來捱打。
再看這一個改造程序:
此次的改動點是在第 9 行,用 volatile 修飾了變量 i。注意啊,flag 變量仍是沒有用 volatile 修飾的。
在 23 行,idea 又給了一個友情提示:
對於 volatile 修飾的字段 i 進行了非原子性的操做。
可是,沒有關係,朋友們,這個題的考點不在於此,好嗎?
你只須要知道對於 volatile 修飾的變量 i,進行 i++ 操做是不對的,由於 volatile 只保證可見性,不保證原子性,而 i++ 操做就不是原子操做的。
來,你再猜一下。上面這個程序仍是不是死循環呢?
執行以後你會發現,這個程序竟然正常結束了,可是你仍是不知道爲何,你只能再次大喊一聲:臥槽,真TM絕了!
再看最後一次的改造,也是致命一擊的改造:
此次的改動點仍是在第 9 行,把變量 i 從 基本類型 int 變成了包裝類型 Integer。
來,你再猜一下...
算了,別猜了,直接喊吧:
這個程序也會正常結束。
上面的四種狀況,你來品一品,你怎麼解釋。
其實在《Effective Java》這本 Java 聖典裏面也提到過一嘴這個問題。
在第 66 條(同步訪問共享的可變數據)這一小節中,有這麼一個程序:
你以爲這個程序會怎麼執行呢?
書裏面說:也許你可能指望這個程序運行大概一秒鐘左右,以後主線程將 stopRequested 設置爲 true,導致後臺線程的循環中止。可是在個人機器上,這個程序永遠不會終止:由於後臺線程永遠在循環!
問題在於,因爲沒有同步,就不能保證後臺線程什麼時候「看到」主線程對 stopRequested 的值所作的改變。
沒有同步,因此虛擬機會將這個代碼變成下面這個樣子:
書裏面是這樣說的:
書裏提到了一個活性失敗的概念:多線性併發時,若是 A 線程修改了共享變量,此時 B 線程感知不到此共享變量的變化,叫作活性失敗。
如何解決活性失敗呢?
讓兩個線程之間對共享變量有 happens-before 關係,最經常使用的操做就是volatile 或 加鎖。
活性失敗的知識點記下來就行,不是這裏的重點,重點是下面。
書裏說:這是能夠接受的,這種優化稱做提高(hoisting)。
提及提高這兩字,我聯想不出來啥,可是看到 hoisting 這個單詞,有點意思了。
電光火石之間,我想到了《深刻理解Java虛擬機》描述即時編譯(Just In Time,JIT)裏說到的一些東西了。
《深刻理解Java虛擬機》和《Effective Java》,呼應上了!
雖然《Effective Java》裏面沒有詳細描述這個提高是什麼,可是咱們有理由相信,它指的就是《深刻理解Java虛擬機》裏面描述的循環表達式外提(Loop Expression Hoisting)。
而這個提高是 JIT 幫咱們作的。
咱們還能怎麼驗證一下這個結論呢?
運行的時候配置下面的參數,其含義是禁止 JIT 編譯器的加載:
-Djava.compiler=NONE
仍是同樣的代碼,禁用了 JIT 的優化。程序正常運行結束了。
結合上面的描述,再加上這個「循環表達式外提」。如今,你應該就能品出點味道來了。
並且,這裏還有一個很是很是重要的信息我能夠品出來。
一個沒有被 volatile 修飾的變量 stopRequested ,在子線程和主線程中都有用到的時候,Java 內存模型只是不能保證後臺線程什麼時候「看到」主線程對 stopRequested 的值所作的改變,而不是永遠看不見。
加了 volatile,jvm 必定會保證 stopRequested 的可見性。
不加 volatile,jvm 會盡可能保證 stopRequested 的可見性。
也許你會問了,從左邊到右邊的提高究竟是怎麼回事,能細緻一點,底層一點嗎?
固然能夠啊。能夠深刻到彙編語言去。具體怎麼操做,你看R大的這兩個連接,很是之硬核,雖然可能看不懂,可是看着看着就是想磕頭,不讀三遍以上,你可能根本不知道他在說什麼:
https://hllvm-group.iteye.com/group/topic/34932 https://www.iteye.com/blog/rednaxelafx-644038
我直接說個R大的結論:
因此,這裏再次回到文章開始的時候說的點:根據不一樣的機器、不一樣的JVM、不一樣的CPU可能會產生不同的效果。
可是因爲咱們絕大部分同窗都使用的是 HotSpot 的 Server 模式,因此,運行結果都同樣。
在這一小節的最後,咱們回到本文[先出個題]環節拋出的那個程序:
這個地方的 while 循環和上面的一模一樣。因此你知道爲何這個程序爲何不會正常結束了嗎?
你不只知道了,並且你還能夠回答的比 volatile 更深刻一點。
因爲變量 flag 沒有被 volatile 修飾,並且在子線程休眠的 100ms 中, while 循環的 flag 一直爲 false,循環到必定次數後,觸發了 jvm 的即時編譯功能,進行循環表達式外提(Loop Expression Hoisting),致使造成死循環。而若是加了 volatile 去修飾 flag 變量,保證了 flag 的可見性,則不會進行提高。
好比下面的程序,註釋了 14 行和 16 行,while 循環,循環了3359次(該次數視機器狀況而定)後,就讀到了 flag 爲 true,尚未觸發即時編譯,因此程序正常結束。
接下來,咱們看輸出語句對這個程序的影響:
首先,咱們知道了,在第 24 行加入輸出語句後,這個程序是會正常結束的。
通過咱們上面的分析,咱們也能夠推導出。加了輸出語句後 JVM 並無作 JIT。
點進 println 方法,能夠看到該方法內部是調用了 synchronized 的。
關於這個問題,我須要分三個角度去討論:
在 stack overflow 中找到了這個地址:
https://stackoverflow.com/questions/25425130/loop-doesnt-see-value-changed-by-other-thread-without-a-print-statement?noredirect=1&lq=1
和咱們這裏的問題,一模一樣。該問題下面有一個回答,很是的好,獲得了你們的一致好評:
該回答從現象到原理,再到解決方案都說的頭頭是道。建議你去閱讀一下。
我這裏只解析和本文相關的輸出語句部分的回答:
我結合本身的理解和這個回答來解釋一下:
同步方法能夠防止在循環期間緩存 pizzaArrived(就是咱們的stop)。
嚴格的說,爲了保證變量的可見性,兩個線程必須在同一個對象上進行同步。若是某個對象上只有一個線程同步操做,經過 JIT 技術,JVM 能夠忽略它(逃逸分析、鎖消除)。
可是,JVM 不夠聰明,它沒法證實其餘線程在設置 pizzaArrived 以後不會調用 println,所以它只能假定其餘線程可能會調用 println。(因此有同步操做)
所以,若是使用 System.out.println, JVM 將沒法在循環期間緩存變量。
這就是爲何,當有 print 語句時,循環能夠正常結束,儘管這不是一個正確的操做。
這個角度其實和角度一基本上一致。可是因爲有了 Doug Lea 的加持,因此得單獨的再提一下,大佬,必須值得這樣的待遇。
在 Doug Lea 寫的這本書裏:
有一小節專門講可見性的:
他先說了一句:寫線程釋放同步鎖,讀線程隨後獲取相同的同步鎖。
這是咱們常規的認知。可是他緊接着說了個 In essence(本質上)。
從本質上來講,線程釋放鎖的操做,會強制性的將工做內存中涉及的,在釋放鎖以前的,全部寫操做都刷新到主內存中去。
而獲取鎖的操做,則會強制新的從新加載可訪問的值到該線程的工做內存中去。
第三個角度,和前面說的 synchronized 關係就不大了。
在這個角度裏面,解釋是這樣的:前面咱們已經知道了,即便一個變量沒有加 volatile 關鍵字,JVM 會盡力保證內存的可見性。可是若是 CPU 一直處於繁忙狀態,JVM 不能強制要求它去刷新內存,因此 CPU 有沒辦法去保證內存的可見性了。
而加了 System.out.println 以後,因爲 synchronized 的存在,致使 CPU 並非那麼的繁忙(相對於以前的死循環而言)。這時候 CPU 就可能有時間去保證內存的可見性,因而 while 循環能夠被終止。
(別說鎖粗化了,我以爲這個回答確定是不對的。)
經過上面三個角度的分析,咱們能獲得兩個結論
1.輸出語句的 synchronized 的影響。
2.輸出語句讓 CPU 有時間去作內存刷新的事兒。好比在個人示例中,把輸出語句換成new File()的操做也是能夠正常結束的。
可是說真的,我也不知道哪一個結論是對的,諸君判斷吧。
sleep 語句對程序的影響,我給出的例子是這樣的:
一樣,我在 stack overflow 上也找到了相關問題:
https://stackoverflow.com/questions/42676751/thread-sleep-makes-compiler-read-value-every-time
下面有個回答是這樣的:
根據這個回答,我解釋一下爲何咱們的測試程序沒有死循環。
關於 sleep 咱們能夠看官方文檔:
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3
文檔中的 while 循環中的 done 也是沒有被 volatile 修飾的。
裏面有兩句話特別重要(上面紅框圈起來的部分):
1.Thread.sleep 沒有任何同步語義(Thread.yield也是)。編譯器沒必要在調用 Thread.sleep 以前將緩存在寄存器中的寫刷新到共享內存,也沒必要在調用 Thread.sleep 以後從新加載緩存在寄存器中的值。
2.編譯器能夠**自由(free)**讀取 done 這個字段僅一次。
特別是第二點,注意文檔中的這個 free。簡直用的是一發入魂。
自由,意味着編譯器能夠選擇只讀取一次,也能夠選擇每次都去讀取,這纔是自由的含義。這是編譯器本身的選擇。
接着咱們看第三個改造點:
改動點是在第 9 行,用 volatile 修飾了變量 i。
若是咱們用下面的 jvm 參數運行:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=dontinline,*VolatileExample.main -XX:CompileCommand=compileonly,*VolatileExample.main
能夠看到以下輸出:
在操做程序的第 23 行,有個 lock 前綴。而這個 lock 指令,就至關於一個內存屏障。會觸發 Java 內存模式中的「store」和「write」操做。
這裏屬於 volatile 的知識點,就不詳細說明了。
有的人可能會往 happens-before 的方面去想。很不幸,這個想法是不對的。
爲何呢?
主線程讀的是非 volatile 類型的 flag,寫的是 volatile類型的 i。可是子線程中只有對非 volatile 類型的 flag 的寫入。
來,你怎麼去創建起子線程對 flag 的寫入 happens-before 於主線程對 flag 的讀的關係?
我我的理解這個地方致使程序正常結束的緣由是:巧合!
巧合在於,可能因爲某個時刻變量 i 和 flag 處於同一 CPU 的 cacheline 中。由於 lock 操做保證變量 i 的可見性的同時把 flag 也刷出去了。
須要特別說明的是:這個地方純屬我的理解,我沒有找到相應的資料進行結論的支撐。不具有權威性和引用性。
再看最後一次的改造,也是致命一擊的改造:
改動點仍是在第 9 行,把變量 i 從 基本類型 int 變成了包裝類型 Integer。
這個程序在個人機器上正常結束了。我真不知道爲何,寫出來的目的是萬一有讀者朋友知道的緣由的話,請多多指教。
若是要讓我強行給個解釋的話,我想會不會是 i++ 操做涉及到的拆箱裝箱操做,致使 CPU 有時間去刷了工做內存。
這個程序我再稍稍一變:
註釋掉了第九行,在第21行加入 Integer i=0。
是的,它也運行結束了。只是須要一點時間。在i = -2147483648 的時候。
而 -2147483648 就是 Integer.MIN_VALUE:
也許是溢出操做帶來的影響。我也不知道。
別問,問就是玄學。
留個坑在這裏,但願之後本身能把它填上。也但願知道緣由的朋友能給我指點一二,不勝感謝。
回到文章最開始說的,其實要讓程序按照預期結束的正確操做是用 volatile 修飾 flag 變量。可是這題要是加上 volatile 就沒有意思了,也就失去了探索的意義。
再次申明:上面的這些騷操做,僅作研究,真實場景中不能這樣去作。
上面的問題關於輸出語句和 sleep 對線程安全的影響,其實困擾我很長時間了,從第一次碰見到如今有122天了,這兩個問題我如今是比較清楚了。
可是,我在寫這篇文章的時候又遇到了上面說的最後一個關於 Integer 的問題。實在是不知道怎麼回事。
也許,我能夠把這個坑填上吧。
也許,編程的盡頭,是玄學吧。
才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。(我每篇技術文章都有這句話,我是認真的說的。)
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是why技術,一個不是大佬,可是喜歡分享,又暖又有料的四川好男人。
歡迎關注公衆號【why技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。