天天看點

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

本文主要是描述輸出語句、sleep以及Integer對線程安全的影響。第一次碰到這個問題是122天之前,當時就覺得很奇怪。至于為什麼還有Integer?我也不知道,可能是玄學吧!這也是本文最後留下的一個問題,如果有知道的朋友還請指點一二。原創不易,感謝閱讀,感謝關注,感謝在看,感謝轉發。

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

本文主要是描述輸出語句、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://***.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://***.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技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。