1. 程式人生 > >【程式設計玄學】一個困擾我122天的技術問題,我好像知道答案了。

【程式設計玄學】一個困擾我122天的技術問題,我好像知道答案了。

眾所周知,程式設計是一門玄學。

本文主要是描述輸出語句、sleep以及Integer對執行緒安全的影響。第一次碰到這個問題是122天之前,當時就覺得很奇怪。

至於為什麼還有Integer?我也不知道,可能是玄學吧! 這也是本文最後留下的一個問題,如果有知道的朋友還請指點一二。

荒腔走板聊生活

首先,還是本號特色,先荒腔走板的聊聊生活。

上面這張圖是我 2017 年 12 月 9 日在北京西山國家森林公園拍的。

拍照的地方有個很有意思的名字:鬼笑石。

我在北京待了三年,這個地方一共只去了兩次,這是第一次去的時候拍的,我一個人從香山走到了西山,那個時候還是一個充滿鬥志的北漂。

第二次去是因為我感覺自己可能要離開北京了,如果說在離開之前還能去一個地方留戀一下,“鬼笑石”算得上其中之一。於是約了好幾個朋友一起再爬了一次。

在這個地方一眼望去,你能站在五環邊上,看到大半個北京,從夕陽西下,倦鳥歸林看到華燈初上,萬家燈火。

你可以感受到在偌大的北京中自己的渺小,也能感受到在這麼大的北京,一定要好好拼命努力才能不負北漂的時光。

兩次我都在聽同一首歌趙雷的《理想》:

公車上我睡過了車站
一路上我望著霓虹的北京
我的理想把我丟在這個擁擠的人潮
車窗外已經是一片白雪茫茫
......
理想今年你幾歲
你總是誘惑著年輕的朋友
你總是謝了又開 給我驚喜
又讓我沉入失望的生活裡
......
理想永遠都年輕
你讓我倔強地反抗著命運
你讓我變得蒼白
卻依然天真的相信花兒會再次的盛開

歌詞寫的真好,趙雷唱的真好,以至於我往後的每一次聽到這首歌的時候,我都會想起北漂的那些日子。

每次有讀者私聊我說,他要開始北漂啦。我都會說:一定要好好珍惜、把握、不虛度北漂的每一天。

這次,我再分享兩首歌給你吧。趙雷的《理想》和李志的《熱河》。

好了,說迴文章。

本文主要是描述輸出語句、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

其實在《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

在 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 的加持,所以得單獨的再提一下,大佬,必須值得這樣的待遇。

在 Doug Lea 寫的這本書裡:

有一小節專門講可見性的:

他先說了一句:寫執行緒釋放同步鎖,讀執行緒隨後獲取相同的同步鎖。

這是我們常規的認知。但是他緊接著說了個 In essence(本質上)。

從本質上來說,執行緒釋放鎖的操作,會強制性的將工作記憶體中涉及的,在釋放鎖之前的,所有寫操作都重新整理到主記憶體中去。

而獲取鎖的操作,則會強制新的重新載入可訪問的值到該執行緒的工作記憶體中去。

角度三 - IO操作

第三個角度,和前面說的 synchronized 關係就不大了。

在這個角度裡面,解釋是這樣的:前面我們已經知道了,即使一個變數沒有加 volatile 關鍵字,JVM 會盡力保證記憶體的可見性。但是如果 CPU 一直處於繁忙狀態,JVM 不能強制要求它去重新整理記憶體,所以 CPU 有沒辦法去保證記憶體的可見性了。

而加了 System.out.println 之後,由於 synchronized 的存在,導致 CPU 並不是那麼的繁忙(相對於之前的死迴圈而言)。這時候 CPU 就可能有時間去保證記憶體的可見性,於是 while 迴圈可以被終止。

(別說鎖粗化了,我覺得這個回答肯定是不對的。)

通過上面三個角度的分析,我們能得到兩個結論

1.輸出語句的 synchronized 的影響。

2.輸出語句讓 CPU 有時間去做記憶體重新整理的事兒。比如在我的示例中,把輸出語句換成new File()的操作也是可以正常結束的。

但是說真的,我也不知道哪個結論是對的,諸君判斷吧。

sleep語句

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。簡直用的是一發入魂。

自由,意味著編譯器可以選擇只讀取一次,也可以選擇每次都去讀取,這才是自由的含義。這是編譯器自己的選擇。

volatile -- 巧合

接著我們看第三個改造點:

改動點是在第 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 也刷出去了。

需要特別說明的是:這個地方純屬個人理解,我沒有找到相應的資料進行結論的支撐。不具備權威性和引用性。

Integer -- 玄學

再看最後一次的改造,也是致命一擊的改造:

改動點還是在第 9 行,把變數 i 從 基本型別 int 變成了包裝型別 Integer。

這個程式在我的機器上正常結束了。我真不知道為什麼,寫出來的目的是萬一有讀者朋友知道的原因的話,請多多指教。

如果要讓我強行給個解釋的話,我想會不會是 i++ 操作涉及到的拆箱裝箱操作,導致 CPU 有時間去刷了工作記憶體。

這個程式我再稍稍一變:

註釋掉了第九行,在第21行加入 Integer i=0。

是的,它也執行結束了。只是需要一點時間。在i = -2147483648 的時候。

而 -2147483648 就是 Integer.MIN_VALUE:

也許是溢位操作帶來的影響。我也不知道。

別問,問就是玄學。

留個坑在這裡,希望以後自己能把它填上。也希望知道原因的朋友能給我指點一二,不勝感謝。

最後說一句(求關注)

回到文章最開始說的,其實要讓程式按照預期結束的正確操作是用 volatile 修飾 flag 變數。但是這題要是加上 volatile 就沒有意思了,也就失去了探索的意義。

再次申明:上面的這些騷操作,僅做研究,真實場景中不能這樣去做。

上面的問題關於輸出語句和 sleep 對執行緒安全的影響,其實困擾我很長時間了,從第一次遇見到現在有122天了,這兩個問題我現在是比較清楚了。

但是,我在寫這篇文章的時候又遇到了上面說的最後一個關於 Integer 的問題。實在是不知道怎麼回事。

也許,我可以把這個坑填上吧。

也許,程式設計的盡頭,是玄學吧。

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。(我每篇技術文章都有這句話,我是認真的說的。)

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是why技術,一個不是大佬,但是喜歡分享,又暖又有料的四川好男人。

歡迎關注公眾號【why技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。