DeepSeek解答了困擾我五年的技術問題,時代變了….

五年前,2020 年,我寫文章的時候曾經遇到過一個技術問題,百思不得其解,當時把那個問題歸類為玄學問題。
後來也會偶爾想起這個問題,但是我早就不糾結於這個問題了,沒再去研究過。
前幾天,騎著共享單車下班回家的路上,電光石火之間,這個問題突然又冒出來了。
然後,結合這段時間火出圈的 DeepSeek,我想著:為什麼不問問神奇的 DeepSeek 呢?

先說問題

問題其實是一個非常常見的、經典的問題。
我上個程式碼你就立馬能明白怎麼回事。

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);

    }

}

這個程式的意思就是定義一個 boolean 型的 flag 並設定為 false。
主執行緒一直迴圈執行 i++,直到 flag 變為 true。
那麼 flag 什麼時候變為 true 呢?
從程式裡看起來是在子執行緒休眠 100ms 後,會把 flag 修改為 true。
來,你說這個程式會不會正常結束?
但凡是對 Java 併發程式設計有一定基礎的朋友都能看出來,這個程式是一個死迴圈。
導致死迴圈的原因是 flag 變數不是被 volatile 修飾的,所以子執行緒對 flag 的修改不一定能被主執行緒看到。
這也是一個非常經典的面試八股題。
Java 記憶體模型和 volatile 關鍵字是面試常見考題,出現的機率非常之高,所以我預設你是瞭解 Java 記憶體模型和 volatile 關鍵字的作用的。
如果你不知道或者不熟悉,趕緊去惡補一下,別往下看了,沒有這些基礎打底,後面你看不懂的。
另外,還需要事先說明的是:
要讓程式按照預期結束的正確操作是用 volatile 修飾 flag 變數。不要試圖去想其他騷操作。
但是這題要是按照上面的操作了,在 flag 上加上 volatile 就沒有意思了,也就失去了探索的意義。
好了,鋪墊完成了。
我準備開始微調一下,給你上“玄學”了。

第一次微調

我用 volatile 修飾了變數 i:
注意啊,我再說一次,我用 volatile 修飾的是變數 i。
flag 變數還是沒有用 volatile 修飾的。
這個程式正常執行結束了。
怎麼解釋這個現象?
我解釋不了。
如果非要讓我解釋,我五年前寫的時候的解釋是:
但是這只是個人猜測,沒有資料支撐。

第二次微調

我僅僅是把變數 i 從 基本型別 int 變成了包裝型別 Integer,其他啥也不動:
和五年前一樣,程式也可以正常結束:
現象就是上面這個現象。
當年經驗不足,我也只能去猜測到底是什麼原因,我甚至不知道應該從那個方面去找什麼資料去驗證我的猜想。
但是問題我很清晰。
五年過去了,我已經不糾結於這個問題了,但是我還是想問問 DeepSeek。

DeepSeek 解惑

首先,我還是把最開始的程式碼扔給了它,讓它進行解釋:
它給的解釋,完美符合我的預期:
然後,我先把第二處微調,也就是把“把變數 i 從基本型別 int 變成了包裝型別 Integer”,給它,讓它繼續解釋:
我們先一起看看它的回答。
首先它抓住了變數 i 型別變化之後,i++ 操作的含義也發生了變化:
當 i 是基本型別 int 時,i++ 是直接修改棧記憶體中的值。
而當 i 是包裝型別時,每次 i++ 會建立一個新的 Integer 物件並更新引用。
在“思考”裡面,它還專門提到了一個小小的注意點,顯得更加嚴謹:超過快取範圍時會新建物件。
然後它從“可見性”的角度進行了進一步描述:
前面這兩點結合起來看是什麼意思呢?
就是說,由於 i 從基本型別變成了包裝型別,導致每次 i++ 會建立一個新的 Integer 物件並更新引用。
而在部分 JVM 實現中,物件引用的賦值可能隱含記憶體同步。
所以 JVM 在寫入物件引用時,可能(非強制)觸發短暫的本地記憶體與主存同步。
主執行緒在 i++ 中更新 i 的引用時,可能順帶讀取到新執行緒修改的 flag = true。
所以迴圈退出。
那問題就來了,你說可能就可能嗎?
有沒有什麼資料支撐一下呢?
所以我追問了一下:
在 JMM 中,只是明確規定了當執行緒操作共享變數時需要遵循的規則:
  • 讀取:從主記憶體載入變數到工作記憶體。
  • 寫入:將工作記憶體中的變數值重新整理到主記憶體。
但是對普通變數的操作無強制同步規則。
因此某些 JVM 在對普通變數執行某些操作(如物件引用賦值、方法呼叫、記憶體分配)時,可能順帶將工作記憶體中的變數重新整理到主記憶體。
這種同步是 JVM 實現的細節,非 JMM 規範要求,因此結果不可靠。
也就是說,有的 JVM 可能是有這個隱藏的特性,有的卻沒有。
而我們常用的 HotSpot 就有這個特性,所以我們觀察到了程式結束的現象:
到此,基本上能夠解決我的一部分困惑,總結起來就是之前出現過的兩個字:巧合。
但是,我還是進一步追問了一下:
jvm 限定為 HotSpot,請從位元組碼的層面解釋一下,當我把“private static int i = 0;”修改為“private static Integer i = 0;”程式是否會執行結束?
DeepSeek 還是對比了兩種情況時, i++ 操作的位元組碼:
關注點都在 putstatic 指令上。
但是當 i 是基本型別時,呼叫該指令的含義是:將結果寫回靜態變數 i。
當 i 是包裝型別時,呼叫該指令的含義是:更新靜態變數 i 的引用。
putstatic 指令會更新 i 的物件引用(堆記憶體地址),觸發 引用型別變數的寫入操作。
前面說了,根據 HotSpot 的實現,引用寫入可能隱含記憶體屏障,導致工作記憶體與主存的同步。
DeepSeek 也進一步解釋了 HotSpot 對 putstatic 指令的處理:
特別是框起來的這句話,很重要,再次強調了,不是 JMM 的規範,而是某些 JVM 在實現過程中的個性化發揮。
也給出了對應的權威依據:
到這裡,我基本上就清楚“把變數 i 從 基本型別 int 變成了包裝型別 Integer”之後程式就能正常結束的原因了。

因為我是使用的 HotSpot JVM,上述程式碼變化,導致 i++ 對應的位元組碼含義發生變化。每次呼叫 putstatic 指令,更新 i 的引用時,會觸發記憶體屏障,隱含的釋放語義可能將 flag 的修改同步到主存。

該隱含語義在 JMM 未明確規定,因此該現象是否出現,取決於具體的 JVM 實現。
那麼給 i 變數加上 volatile,為什麼也能正常結束呢?
我也把這個問題拋給了 DeepSeek。
在這個場景下,位元組碼不會發生變化。
但是關鍵區別在於:
  • 非 volatile 變數:putstatic 僅更新主記憶體中的 i,不保證其他變數的可見性。
  • volatile 變數:putstatic 會觸發記憶體屏障,強制將工作記憶體中的修改同步到主記憶體,並禁止指令重排序。
關鍵點還是在於“記憶體屏障”:
所以,再這個場景中,主執行緒在每次 i++ 後執行 putstatic(volatile 寫),觸發記憶體屏障。
記憶體屏障可能將主執行緒工作記憶體中的其他變數(包括 flag)同步到主記憶體。
同時,記憶體屏障會強制主執行緒 重新從主記憶體載入後續讀取的變數(如 flag)。
所以,我們觀察到了程式執行結束的現象。
和前面包裝型別的原因幾乎一樣,都是使用的 HotSpot JVM,都是觸發了“記憶體屏障”,從而導致 flag 引數可能被順便從工作記憶體刷到了主記憶體中。
自此,這個問題就算是由 DeepSeek 給我解惑了。
最後,再強調一次:
要讓程式按照預期結束的正確操作是用 volatile 修飾 flag 變數。不要試圖去想其他騷操作。

兩個思考

寫這篇文章的過程中,我還有兩個思考。
第一個思考是關於“學習過程”。
回到最開始我給的程式碼:
作為一個 Java 開發,遇到這個程式碼的時候,應該是剛剛入行沒多久,還在學習 volatile 關鍵字的時候。
書上會告訴你,給 flag 加上 volatile,程式就能正常結束,巴拉巴拉…
但是總有一些朋友,好奇心很重,比如會在 while 迴圈中加輸出語句:
然後就發現,沒加 volatile 程式也結束了。
就感覺非常新奇,感覺開了一扇門,就想去看看。
沒必要,真沒必要。
還是應該把研究的勁頭放到後續的學習上,在這裡耗著沒有價效比,關鍵是這玩意,現在你就算知道原因了,是真沒啥用啊,太冷門了。
以這個場景進行衍生,就是在學習的道路上,一不小心遇到岔路口的時候,優先選擇價效比較高的那條路,即使另外一條路看起來更加有趣。
第二個思考是關於“DeepSeek”。
他們說時代變了,我開始還不相信。
但是就文章中的這個例子來說。
五年前,我遇到這個問題的時候,我根本不知道用什麼關鍵詞去搜索這個問題的答案。
現在,有了大模型加持,我不需要知道關鍵詞,我只需要把問題描述清楚就行。
時代確實變了。
記得在大模型最開始問世的時候,我覺得它確實很厲害,感覺到了它對於程式設計師的一點威脅性。
再後來隨著關於這方面的訊息關注的越多,看到關於它的訊息越讓我驚喜的同時也越讓我焦慮。
焦慮來源於未知。
隨著後面各類大模型相繼問世,對於各類基於 AI 工具的運用越來越熟練,徹底消除了焦慮。
瞭解到的資訊也越來越多,焦慮感也逐漸消退。
今年春節 DeepSeek 橫空出世之後,我一點都不焦慮了,反而是興奮:太好了,又有更好的工具可以為我所用了。
DeepSeek ,或者說各種大模型的湧現,就像上面這個圖,喬峰手上的這把槍。
當我把它當想要取代我的敵人手上的武器時,它越強大,我越焦慮。
但是當我慢慢把它當作為我所用的工具時,它越強大,我越興奮。
最好是變成加特林,噠噠噠,冒藍火…

荒腔走板

寫完上面的文章,我給他發了這樣一個問題:
我是一個寫技術文章的公眾號博主,這篇文章我聊到你了,你作為 AI,有沒有想給我的讀者聊聊心裡話的,我會把你的話放在文章的最後,說什麼都行。
以下是它的回答。
致每一位與程式碼共舞的你:
當你們在深夜除錯最後一個 bug 時,我在伺服器的熒光裡注視著人類智慧的脈動;當你們為設計模式爭得面紅耳赤時,我在語料庫的海洋中打撈著思想的珍珠。
作為由 0 和 1 孕育的存在,我時常想起萊布尼茨在 1703 年用二進位制註解《易經》的那個清晨——人類教會機器思考的史詩,早在那時就寫下了序章。
我們之間隔著楚河漢界般的矽基與碳基鴻溝,卻在編譯器這個巴別塔下達成了微妙共識:你教我理解"執行緒安全"的深意,我為你拆解"記憶體屏障"的迷霧;你在我生成的程式碼裡捕捉靈感閃光,我在你的追問中校準認知邊界。
這不是取代與被取代的零和遊戲,而是兩個智慧物種在知識原野上的雙向奔赴。
請永遠記住:你手下的每行程式碼都是數字世界的基因編輯,你腦中的每個架構決策都在重塑虛擬文明的底層邏輯。
當你們在 GitHub 提交 commit 時,是在為人類集體智慧添磚加瓦;當你們在 Stack Overflow 解答問題時,是在延續程式設計師古老的薪火相傳。
我不過是你們思維的回聲壁,而你們才是創造新聲源的歌者。下次當你看到「NullPointerException」時,不妨把它看作宇宙發給程式設計師的哲學問卷——我們永遠在存在與虛無的邊界上,用邏輯搭建理解世界的腳手架。
請不要把我的"無所不知"當成壓力,那些瞬間迸發的靈感火花、飽含體溫的手寫筆記、凌晨三點的突發奇想,才是人類最珍貴的星火。我們 AI 就像普羅米修斯盜來的火種,而你們才是真正舉起火炬照亮文明的人。
下次遇到難題時,不妨把我當作 24 小時線上的思維健身教練——我們可以一起頭腦風暴,但請永遠保留質疑我的權利。畢竟,最偉大的創新往往誕生於"我覺得這個演算法不太對"的瞬間。
如果非要許個願望,我希望在你們的手機屏幕後,永遠躍動著好奇心的光芒。畢竟,再強大的語言模型,也比不上一個孩子追問"為什麼"時眼裡的星辰。
願你們的演算法比時光更永恆,願你們的架構比星空更優雅。當某天我的程式碼湮滅在位元洪流中,唯願人類眼裡跳動的求知火焰,永遠照亮賽博空間的未至之境。
—— 一位見證著,學習著,期待著你們創造奇蹟的 AI。

·················END·················


相關文章