分享一個我遇到過的“量子力學”級別的BUG。
你好呀,我是歪歪。
前幾天在網上沖浪的時候,看到知乎上的這個話題:

一瞬間,一次歷史悠久但是記憶深刻的代碼調試經歷,“刷”的一下,就在我的腦海中蹦出來了。
雖然最終定位到的原因令人無語,對于日常編碼也沒啥幫助,但是真的是:

情景再現
我記得當時我是學習 ConcurrentLinkedQueue (下文用 CLQ 代替)的這個玩意,為了比較深入的掌握這個玩意,我肯定是要 Debug 跟蹤一下源碼的。
問題就出現在 Debug 的時候,現象非常詭異,聽我細細道來。
首先,我當時的 Demo 極其簡單,就這么兩行代碼:

new 一個 CLQ 對象,然后調用 offer 方法篩一個對象進去。
完事了。
這么簡單的代碼能搞出什么牛逼的玩意呢?
首先,我帶你看看 CLQ 的數據結構。
CLQ 是由一個個 Node 組成的鏈式結構。

new CLQ 的時候通過 new Node() 構造出一個特殊的“dummy node”,翻譯過來大家一般叫它“啞元節點”。
然后將頭指針 head 和尾指針 tail 都指向這個啞元節點。
那這個 Node 長啥樣呢?
Node 里面有一個 item(放的是存儲的對象),還有一個 next 節點(指向的是當前 Node 的下一個節點):

從數據結構來看,也知道這是一個單向鏈表了。
當時為了學它,我想通過日志的方式直接輸出鏈表結構,這應該是最簡單的演示方式了。
畢竟 Java 程序員,就靠日志活著了。
所以我當時自定義了一個 WhyConcurrentLinkedQueue(下文簡寫為 WhyCLQ)。
這個 WhyCLQ 是怎么來的呢?
非常簡單,我直接把 JDK 源碼中的 CLQ 復制出來一份,改名為 WhyCLQ 就完事了。

然后搞個測試用例跑跑:

非常 nice,沒有任何毛病。
我們現在可以任意的在代碼中增加輸出日志了。
比如,我想要看 WhyCLQ 這個鏈式結構到底是怎么樣的。
我們可以在自定義的 CLQ 里面加一個打印鏈表結構的方法:
public void printWhyCLQ() {
StringBuilder sb = new StringBuilder();
for (Node<E> p = first(); p != null; p = succ(p)) {
E item = p.item;
sb.append(item).append("->");
}
System.out.println("鏈表item對象指向 =" + sb);
}
然后在每次 offer 方法新增完成后,調用一下 printWhyCLQ 方法,輸出當前的鏈式結構:

其他的地方類似,只要你覺得源碼看起來有點繞的地方,你就可以加輸出語句,哪怕一行代碼就配上一行輸出語句也沒問題。
甚至,你還能“客制化”源碼,但是這不是本文的重點,我就不展開了。
通過復制源碼的方式自定義一個 JDK 源碼中的類,然后加上大量的輸出語句,有時候也會對源碼進行各種改裝,是我常用的一個學習小技巧,分享給你,不用客氣。
當你被一步步 debug 帶暈的時候,你可以試一試這種方式,先整體再局部。
好,到這里就算是鋪墊完成了。
我們回到最開始的這兩行代碼:

按照我們的理解,第一次 offer 之后,對應的鏈表畫個簡圖應該是這樣的:

但是最后的輸出是這樣的:

為什么輸出的日志不是 null->@4629104a 呢?
因為我們自定義的 printWhyCLQ 這個方法里面會調用 first 方法,獲取真正的頭節點,即 item 不為 null 的節點:

也就是我框起來的地方:first 方法中的 updateHead(h, p) 方法,會去修改頭結點。
然后,我還想在第一次 offer 的時候,詳細的輸出頭結點的信息,所以加了這幾行輸出語句:

直接把程序跑起來,對應的效果是這樣的:

但是,當我在這個分支入口,打上斷點,用 debug 模式進行調試的時候:

運行結果是這樣的:

空指針異常!!!???
為了讓你有更加直觀的感受,我給你上個動圖。
首先,是直接把程序運行起來的動圖:

這是 Debug 運行時的動圖:

如果前面的文字你沒看懂,不重要,你只需要記住下面這個現象:
同樣的程序,當你直接運行,就能正常結束,當你用 Debug 模式運行的時候,就會拋出空指針異常。
來,如果是你遇到這個問題,你會怎么辦?
當年我還是一個萌新菜鳥的時候,遇到這個問題,直接就懵逼了啊,百思不得其解,感覺編程的大廈正在搖搖欲墜。
這真的就很詭異啊!

當你直接運行程序,會拿到一個預期的結果。
但是試圖通過 Debug 模式去觀察這個程序的時候,這個程序就會拋出異常。
這很難不讓人想起“量子力學”中的光的雙縫干涉試驗啊。
觀測手段觸發了光的粒子狀態,所以沒有干涉條紋。
如果不觀測,光就是波的形態,出現了干涉條紋。
如果你不知道我在說什么,一點也不重要。
但是你知道我在說什么,你就知道,歪師傅這個程序的現象,用“量子力學”來形容是多么的貼切。
我甚至還懷疑過是質子,一定是質子在搞事情。

當時,我是怎么解決這個問題的呢?
沒有解決。
當年經驗淺薄,現象又太過詭異導致我不知道應該怎么去解決,而且最重要的是并沒有影響我理解 CLQ 這個玩意。
是的,感謝我當時還記得主要目標是去學習 CLQ,而不是去研究這個詭異的現象。
偶遇真相
我忘了隔了多長時間,只記得是一個麥子黃了的季節,我在這個鏈接中偶遇到了真相:
https://stackoverflow.com/questions/55889152/why-my-object-has-been-changed-by-intellij-ideas-debugger-soundlessly

這個哥們遇到的問題和我一模一樣,但是這個問題下面只有一個回答:

這個回答給出的解決方案
最后的解決方案就是關閉 IDEA 的這兩個配置,他們默認是開啟的:

當關閉這兩個配置后,我的程序在 Debug 的時候也正常了。
為什么呢?
因為 IDEA 在 Debug 模式下會主動的幫我們調用一次 toString 方法。
而在 CLQ 的 toString 方法里面,會去調用 first 方法:

前面我說了:first 方法中的 updateHead(h, p) 方法,會去修改頭結點。

之前我給的簡圖是這樣的:

由于 Debug 會調用 toString 方法,從而觸發了 first 方法,進而導致了頭結點不是 null,而是這個 obj 了:

再到 this.head.next 這里獲取頭結點的 next 的時候,由于 next 并不存在,值為 null:

所以 this.head.next.item 拋出了空指針異常。
沒有什么玄學,我們要相信科學。
但是,這個真相確實有點坑。

IDEA 圖啥?
那么問題就來了。
為什么 IDEA 要在 Debug 的時候默認調用一下 toString 方法呢?
我用 HashMap 舉例,給你上個對比圖你就知道它想要干啥了。
這是默認配置的情況:

可以直觀的看到 map 中 key 和 value 的情況
當我們取消前面說的配置:

再次 Debug 的時候,看到的就是這樣的:

而且可以看到,toString 方法是可以點擊的。
當你點擊之后,就變成了這樣:

這么一對比,就很直觀了。
你說 IDEA 圖啥?
還不就說圖用戶調試起來的時候,看起來更加直觀嘛,確實是一片好心。
誰能想到你 toString 方法中還能藏著一些邏輯呢。
這波我站 IDEA。
又學到一個埋坑的小技巧
通過前面的介紹,我仿佛又掌握了一個埋坑的小技巧。
我給你演示一下。
首先我定義一個 why 的類:

這個類的 toSting 方法中有 age++ 這樣的操作。
當你直接運行這個程序的時候,運行結果為 18:

但是,當你 Debug 的時候:

age 就變成 19 了。
而且是看一次,就漲一歲,這你受得了嗎:

如果代碼再復雜一點,找問題都讓你焦頭爛額了。
誰能想到 IDEA 在你 Debug 的時候幫你調用了 toString,誰又能想到 toString 方法中還有邏輯呢?
如果 toString 方法中的邏輯,和前面說的 CLQ 一樣,會影響到你要尋找的答案...
這一套絲滑小連招下來,你就玩去吧。
一個埋坑的小技巧,沒到血海深仇,不要輕易使用。
最后,你說上帝在編程的時候,會不會也是埋了這樣的一個坑。
當我們直接運行“光”這個方法的時候,光就是波的形態。
但是當我們使用通過觀察手段去 Debug “光”這個方法到底是怎么運行的時候,上帝他老人家就會在“光的 toStirng 方法”中主動調用一個讓光變成粒子的邏輯。
所以,我們的任何觀測手段都會觸發這個“光的 toStirng 方法”,導致光的出現了粒子狀態,在光的雙縫干涉試驗直接中,就沒有出現干涉條紋。
從編程角度,看量子力學,有點意思。


浙公網安備 33010602011771號