千萬不要在方法上打斷點!有坑!
- 2022 年 8 月 15 日
- 筆記
你好呀,我是歪歪。
我上周遇到了一個莫名其妙的搞心態的問題,浪費了我好幾個小時。
氣死我了,拿這幾個小時來敲(摸)代(摸)碼(魚)不香嗎?
主要是最後問題的解決方式也讓我特別的無語,越想越氣,寫篇文章吐槽一下。
先說結論,也就是標題:
在本地以 Debug 模式啟動項目的時候,千萬不要在方法上打斷點!千萬不要!

首先什麼是方法斷點呢?
比如這樣的,打在方法名這一行的斷點:

你點擊 IDEA 裡面的下面這個圖標,View Breakpoints,它會給你彈出一個框。
這個彈框裡面展示的就是當前項目裡面所有的斷點,其中有一個複選框,Java Method Breakpoints,就是當前項目裡面所有的「方法斷點」:

那麼這個玩意到底有什麼坑呢?
當項目以 Debug 模式啟動的時候,非常非常非常嚴重的拖慢啟動速度。
給你看兩個截圖。
下面這個是我本地的一個非常簡單的項目,沒有方法斷點的時候,只要 1.753 秒就啟動完成了:

但是當我加上一個方法斷點的時候,啟動時間直接來到了 35.035 秒:

從 1.7 秒直接飆升到 35 秒,啟動時間漲幅 2000%。
你說遭不遭得住?
遭不住,對不對。
那麼我是怎麼踩到這個坑的呢?

一個同事說他項目裡面遇到一個匪夷所思的 BUG,想讓我幫忙一起看看。
於是我先把項目拉了下來,然後簡單的看了一下程式碼,準備把項目先在本地跑起來調試一下。
然而半個小時過去了,項目還沒起來。我問他:這個項目本地啟動時間怎麼這麼長呢?
他答:正常來說半分鐘應該就啟動起來了呀。
接著他還給我演示了一下,在他那邊確實 30 多秒就啟動成功了。
很明顯,一樣的程式碼,一個地方啟動慢,一個地方啟動快,首先懷疑環境問題。
於是我準備按照下面的流程走一次。
檢查設置 -> 清空快取 -> 換workspace -> 重啟 -> 換電腦 -> 辭職
我檢查了所有的配置、啟動項、網路連接什麼的,確保和他本地的環境是一模一樣的。
這一套操作下來,差不多一小時過去了,並沒有找到什麼頭緒。
但是那個時候我一點都不慌,我還有終極絕招:重啟。
畢竟我的電腦已經好幾個月沒有關閉過了,重啟一下也挺好的。
果然,重啟了電腦之後,還是沒有任何改變。
正在焦頭爛額之際,同事過來問我啥進度了。
我能怎麼說?
我只能說:從時間上來說應該解決了,但是實際上我連項目都還沒啟動成功。
聽到這話,他坐在我的工位,準備幫我看一下。
半分鐘之後,一個神奇的場景出現了,他在我的電腦上直接就把項目啟動起來了。
一盤問,他並沒有以 Debug 的模式啟動,而是直接運行的。
用腳趾頭想也知道,肯定是 Debug 模式在搞事情。
然後基於面向瀏覽器編程的原則,我現在有了幾個關鍵詞:IDEA debug 啟動緩慢。
然後發現有很多人遇到了類似的問題,解決方法就是啟動的時候取消項目裡面的「方法斷點」。
但是,遺憾的是,沒有大多數文章都是說這樣做就好了。但是並沒有告訴我為什麼這樣做就好了。
我很想知道為什麼會有這個坑,因為我用方法斷點用的還是很多的,關鍵是以前在使用的過程中完全沒有注意到還有這個坑。
「方法斷點」還是非常實用的,比如我隨便個例子。
之前寫事務相關的文章的時候,提到過這樣的一個方法:
java.sql.Connection#setAutoCommit
setAutoCommit 這個方法有好幾個實現類,我也不知道具體會走哪一個:

所以,調試的時候可以在下面這個介面打上一個斷點:

然後重啟程式,IDEA 會自動幫你判斷走那個實現類的:

但是需要特別說明的是,不是所有的方法斷點都會導致啟動緩慢的問題。至少在我本地看起來是這樣的。
當我把方法斷點加在 Mapper 的介面裡面的時候,能穩定復現這個問題:

當把方法斷點加在項目的其他方法上的時候,不是必現的,偶爾才會出現這個問題。
另外,其實當你以 Debug 模式啟動且帶有方法斷點的時候,IDEA 是會彈出這個提醒,告訴你方法斷點會導致 Debug 緩慢的問題:

但是,真男人,從不看提醒。反正我是直接就忽略了,根本沒有關心彈窗的內容。
至於為什麼會在 Mapper 的介面上打方法斷點?
都怪我手賤,行了吧。

到底為什麼
在找答案的過程中,我發現了這個 idea 的官方社區的鏈接:
//intellij-support.jetbrains.com/hc/en-us/articles/206544799-Java-slow-performance-or-hangups-when-starting-debugger-and-stepping
這個貼子,是 JetBrains Team 發布的,關於 Debug 功能可能會導致的性能緩慢的問題。

在這個帖子中,第一個性能點,就是 Method breakpoints。
官方是怎麼解釋這個問題的呢?
我給你翻譯一波。
Method breakpoints will slow down debugger a lot because of the JVM design, they are expensive to evaluate.
他們說由於 JVM 的設計,方法斷點會大大降低調試器的速度,因為這玩意的 「evaluate」 成本很高。
evaluate,四級單詞,好好記一下,考試會考:

大概就是說你要用方法斷點的功能,在啟動過程中,就涉及到一個關於該斷點進行「評估」的成本。成本就是啟動緩慢。
怎麼解決這個「評估」帶來的成本呢?
官方給出的方案很簡單粗暴:
不要使用方法斷點,不就沒有成本了?

所以,Remove,完事:
Remove method breakpoints and consider using the regular line breakpoints.
刪除方法斷點並考慮使用常規的 line breakpoints。
官方還是很貼心的,怕你不知道怎麼 Remove 還專門補充了一句:
To verify that you don’t have any method breakpoints open .idea/workspace.xml file in the project root directory (or
.iws file if you are using the old project format) and look for any breakpoints inside the method_breakpoints node.
可以通過下面這個方法去驗證你是否打開了方法斷點。
就是去 .idea/workspace.xml 文件中,找到 method_breakpoints 這個 Node,如果有就 Remove 一下。
然後我看了一下我項目裡面對應的文件,沒有找到 method_breakpoints 關鍵字,但是找到了下面這個。
應該是文檔發生了變化,問題不大,反正是一個意思,

其實官方給出的這個方法,雖然逼格稍微高一點,但還是我前面給的這個操作更簡單:

針對「到底為什麼」這個問題。
在這裡,官方給的回答,特別的模糊:because of the JVM design。
別問,問就是由於 JVM 設計如此。
我覺得這不是我想要的答案,但是好在我在這個帖子下面找到了一個「好事之人」寫的回復:

這個好事之人叫做 Gabi 老鐵,我看到他回復的第一句話 「I made some research」,我就知道,這波穩了,找對地方了,答案肯定就藏在他附上的這個鏈接裡面。
Gabi 老鐵說:哥子們,我研究了一下這個方法斷點為啥會慢的原因,研究報告在這裡:
//www.smartik.net/2017/11/method-breakpoints-are-evil.html
他甚至還來了一個概要:To make the long story short,長話短時。
他真的很貼心,我哭死。
他首先指出了問題的根本原因:
it seems that the root issue is that Method Breakpoints are implemented by using JDPA’s Method Entry & Method Exit feature.
根本問題在於方法斷點是通過使用 JDPA 的 Method Entry & Method Exit 特性實現的。
有同學就要問了,JDPA,是啥?
是個寶貝:
//docs.oracle.com/javase/8/docs/technotes/guides/jpda/index.html

JPDA,全稱 Java Platform Debugger Architecture。
IDEA 裡面的各種 Debug 功能,就是基於這個玩意來實現的。
不懂也沒關係,這個東西面試又不考,在這裡知道有這個技術就行。
接著,他用了四個 any 來完成了跳句四押:
This implementation requires the JVM to fire an event each time any thread enters any method and when any thread exits any method.
這個實現,要求 JVM,每次,在任何(any)執行緒進入任何(any)方法時,以及在任何(any)執行緒退出任何(any)方法時觸發事件。
好傢夥,這不就是個 AOP 嗎?
這麼一說,我就明白為什麼方法斷點的性能這麼差了。要觸發這麼多進入方法和退出方法的事件,可不得耗費這麼多時間嗎?
具體的細節,他在前面說的研究報告裡面都寫清楚了,如果你對細節感興趣的話,可以諮詢閱讀一下他的那篇報告。
話說他這個報告的名字也起的挺唬人的:Method Breakpoints are Evil。
我帶你看兩個關鍵的地方。
第一個是關於 Method Entry & Method Exit 的:

-
IDE 將斷點添加到其內部方法斷點 list 中 -
IDE 告訴前端啟用 Method Entry & Method Exit 事件 -
前端(調試器)通過代理將請求傳遞給 VM -
在每個 Method Entry & Method Exit 事件中,通過整個鏈將通知轉發到 IDE -
IDE 檢查其方法斷點 list 是否包含當前的這個方法。 -
如果發現包含,說明這個方法上有一個方法斷點,則 IDE 將向 VM 發送一個 SetBreakpoint 請求,打上斷點。否則,VM 的執行緒將被釋放,不會發生任何事情
這裡是表明,前面我說的那個類似 AOP 的稍微具體一點的操作。
核心意思就一句話:觸發的事件太多,導致性能下降厲害。
第二個關鍵的地方是這樣的:

文章的最後給出了五個結論:
-
方法斷點 IDE 的特性,不是 JPDA 的特性 -
方法斷點是真的邪惡,evil 的一比 -
方法斷點將極大的影響調試程式 -
只有在真正需要時才使用它們 -
如果必須使用方法作為斷點,請考慮關閉方法退出事件
前面四個點沒啥說的了。
最後一個點:考慮關閉方法退出事件。
這個點驗證起來非常簡單,在方法斷點上右鍵可以看到這個選項,Method Entry & Method Exit 默認都是勾選上了:

所以我在本地隨便用一個項目驗證了一下。
打開 Method Exit 事件,啟動耗時:113.244 秒。
關閉 Method Exit 事件,啟動耗時:46.754 秒。
你別說,還真有用。
現在我大概是知道為什麼方法斷點這麼慢了。
這真不是 BUG,而是 feature。
而關於方法斷點的這個問題,我順便在社區搜索了一下,最早我追溯到了 2008 年:

這個老哥說他調試 Web 程式的速度慢到無法使用的程度。他的項目只啟用了一行斷點,沒有方法斷點。
請求大佬幫他看看。
然後大佬幫他一頓分析也沒找到原因。
他自己也特別的納悶,說:

我啥也沒動,太奇怪了。這玩意有時可以,有時不行。
像不像一句經典台詞:

但是問題最後還是解決了。怎麼解決的呢?
他自己說:

確實是有個方法斷點,他也不知道怎麼打上這個斷點的,可能和我一樣,是手抖了吧。
意外收穫
在前面出現的官方帖子的最下面,有這樣的兩個鏈接:

它指向了這個地方:
//www.jetbrains.com/help/idea/debugging-code.html

我把這部分鏈接都打開看了一遍,經過鑒定,這可真是好東西啊。
這是官方在手摸手教學,教你如何使用 Debug 模式。
我之前看過的一些調試小技巧相關的文章,原來就是翻譯自官方這裡啊。
我在這裡舉兩個例子,算是一個導讀,強烈推薦那些在 Debug 程式的時候,只知道不停的下一步、跳過當前斷點等這樣的基本操作的同學去仔細閱讀,動手實操一把。
首先是這個:

針對 Java 的 Streams 流的調試。
官方給了一個調試的程式碼示例,我做了一點點微調,你粘過去就能跑:
class PrimeFinder {
public static void main(String[] args) {
IntStream.iterate(1, n -> n + 1)
.limit(100)
.filter(PrimeTest::isPrime)
.filter(value -> value > 50)
.forEach(System.out::println);
}
}
class PrimeTest {
static boolean isPrime(int candidate) {
return candidate == 91 ||
IntStream.rangeClosed(2, (int) Math.sqrt(candidate))
.noneMatch(n -> (candidate % n == 0));
}
}
程式碼邏輯很簡單,就是找 100 以內的,大於 50 的素數。
很明顯,在 isPrime 方法裡面對 91 這個非素數做了特殊處理,導致程式最終會輸出 91,也就是出 BUG 了。
雖然這個 BUG 一目了然,但是不要笑,要忍住,要假裝不知道為什麼。
現在我們要通過調試的方式找到 BUG。
斷點打在這個位置:

以 Debug 的模式運行的時候,有這樣的一個圖標:

點擊之後會有這樣的一個彈窗出來:

上面框起來的是對應著程式的每一個方法調用順序,以及調用完成之後的輸出是什麼。
下面框起來的這個 「Flat Mode」 點擊之後是這樣的:

最右邊,也就是經過 filter 之後輸出的結果。
裡面就包含了 91 這個數:

點擊這個 「91」,發現在經過第一個 filter 之後,91 這個數據還在。
說明這個地方出問題了。
而這個地方就是前面提到的對 「91」 做了特殊處理的 isPrime 方法。
這樣就能有針對性的去分析這個方法,縮小問題排除範圍。
這個功能怎麼說呢,反正我的評論是:

總之,以上就是 IDEA 對於 Streams 流進行調試的一個簡單示例。
接著再演示一個並發相關的:

官方給了這樣的一個示例:
public class ConcurrencyTest {
static final List a = Collections.synchronizedList(new ArrayList());
public static void main(String[] args) {
Thread t = new Thread(() -> addIfAbsent(17));
t.start();
addIfAbsent(17);
t.join();
System.out.println(a);
}
private static void addIfAbsent(int x) {
if (!a.contains(x)) {
a.add(x);
}
}
}
程式碼裡面搞一個執行緒安全的 list 集合,然後主執行緒和一個非同步執行緒分別往這個 list 裡面塞同一個數據。
按照 addIfAbsent 方法的意思,如果要添加的元素在 list 裡面存在了,則不添加。
你說這個程式是執行緒安全的嗎?
肯定不是。
你想想,先判斷,再添加,經典的非原子性操作。
但是這個程式你拿去直接跑,又不太容易跑出執行緒不安全的場景:

怎麼辦?
Debug 就來幫你干這個事兒了。
在這裡打一個斷點,然後右鍵斷點,選擇 「Thread」:

這樣程式跑起來的時候主執行緒和非同步執行緒都會在這個地方停下來:

可以通過 「Frames」 中的下拉框分別選擇 Debug 主執行緒還是非同步執行緒。
由於兩個執行緒都執行到了 add 方法,所以最終的輸出是這樣的:

這不就出現執行緒不安全了嗎?
即使你知道這個地方是執行緒不安全的,但是如果沒有 Debug 來幫忙調試,要通過程式輸出來驗證還是比較困難的。
畢竟多執行緒問題,大多數情況下都不是每次都能必現的問題。
定位到問題之後,官方也給出了正確的程式碼片段:

好了,說好了是導讀,這都是基本操作。還是那句話,如果感興趣,自己去翻一下,跟著案例操作一下。
就算你看到有人把 Debug 源碼,玩出花來了,也無外乎不過是這樣的幾個基礎操作的組合而已。
回首往事
讓我們再次回到官方的「關於 Debug 功能可能會導致的性能緩慢的問題」這個帖子裡面:

當我看到方框裡面框起來的 「Collections classes」 和 「toString()」 方法的時候,眼淚都快下來了。
我最早開始寫文章的時候,曾經被這個玩意坑慘了。
三年前,2019 年,我寫了這篇文章《這道Java基礎題真的有坑!我也沒想到還有續集。》
當時 Debug 調試 ArrayList 的時候遇到一個問題,我一度以為我被質子干擾了:

一句話匯總就是在單執行緒的情況下,程式直接運行的結果和 Debug 輸出的結果是不一樣的。
當時我是百思不得其解。
直到 8 個月後,寫《JDK的BUG導致的記憶體溢出!反正我是沒想到還能有續集》這篇文章的時候才偶然間找到問題的答案。
根本原因就是在 Debug 模式下,IDEA 會自動觸發集合類的 toString 方法。而在某些集合類的 toString 方法裡面,會有諸如修改頭節點的邏輯,導致程式運行結果和預期的不匹配。
也就是對應這句話:

翻譯過來就是:老鐵請注意,如果 toString 方法中的程式碼更改了程式的狀態,則在 debug 狀態下運行時,這些方法也可以更改應用程式的運行結果。
最後的解決方案就是關閉 IDEA 的這兩個配置:

同時,我也在官方文檔中找到了這個兩個配置的解釋:
//www.jetbrains.com/help/idea/customizing-views.html#renderers

主要是為了在 Debug 的過程中用更加友好的形式顯示集合類。
啥意思?
給你看個例子。
這是沒有勾選前面說的配置的時候,map 集合在 Debug 模式下的樣子:

這是勾選之後,map 集合在 Debug 模式下的樣子:

很明顯,勾選了之後的樣子,更加友好。
最後,我的文章全網首發都在我的公眾號裡面哦。歡迎關註:



