我懷疑這是IDEA的BUG,但是我翻遍全網沒找到證據!

why技術發表於2022-05-16

你好呀,我是歪歪。

前幾天有朋友給我發來這樣的一個截圖:

他說他不理解,為什麼這樣不報錯。

我說我也不理解,把一個 boolean 型別賦值給 int 型別,怎麼會不報錯呢,並接著追問他:這個程式碼截圖是哪裡來的?

他說是 Lombok 的 @Data 註解自動生成的。

巧了,對於 Lombok 我之前有一點點了解,所以聽到這個的答案的那一瞬間,電光火石之間我彷彿明白了點什麼東西:因為 Lombok 是利用位元組碼增強的技術,直接操作位元組碼檔案的,難道它可以直接繞過變數型別不匹配的問題?

但是很快又轉念一想,不可能啊:這玩意要是都能繞過,Java 還玩個毛線啊。

於是我決定研究一下,最後發現這事兒其實很簡單:就是 idea 的一個 bug。

復現

Lombok 外掛我本來也再用,所以我很快就在本地復現了一波。

原始檔是這樣的,我只是加了 @Data 註解:

經過 Maven install 編譯之後的 class 檔案是這樣的:

可以看到 @Data 註解幫我們幹了非常多的事情:生成了無參建構函式、name 欄位的 get/set 方法、 equals 方法、toStrong 方法還有 hashCode 方法。

其實你點到 @Data 註解的原始碼裡面去,它也給你說明了,這就是一個複合註解:

因此,真正生成 hashCode 方法的註解,應該是 @EqualsAndHashCode 才對。

所以,為了排除干擾項,方便我聚焦到 hashCode 方法上,我把 @Data 註解替換為 @EqualsAndHashCode:

結果還是一樣的,只是預設生成的方法少了很多,而且我也不關心那些方法。

現在,也眼見為實了,為啥這裡的 hashCode 方法裡面的第一行程式碼是這樣的呢:

int PRIME = true;

直覺告訴我,這裡肯定有障眼法。

我首先想到了另一個反編譯的工具,jd-gui,就它:

果然,把 class 檔案拖到 jd-gui 裡面之後,hashCode 方法是這樣的:

是數字 59,而不是 true 了。

但是這個 PRIME 變數,看起來在 hashCode 方法裡面也沒有用呢,這個問題不著急,先丟擲來放在這裡,等下再說。

另外,我還想到了直接檢視位元組碼的方法:

可以看到這樣看到的 hashCode 方法的第一個命令用的整型入棧指令 bipush 數字 59。

經過 jd-gui 和位元組碼的驗證,我有理由懷疑在 idea 裡面顯示 int PRIME = true 絕!對!是!BUG!

開心,又發現 BUG 了,素材這不就來了嗎。

當時我開心極了,就和下面這個小朋友的表情是一樣一樣的。

線索

於是我在網上找了一圈,沒有找到任何這方面的資料,沒有一點點收穫。內心的 OS 是:“啊,一定是我的姿勢不對,再來一次。”

擴大了搜尋範圍,又找了一圈。

“怎麼還是沒有什麼線索呢,沒道理啊!不行,一定是有蛛絲馬跡的。”

於是又又找一圈。

“嗯,確實是沒有什麼線索。浪費我幾小時,垃圾,就這樣吧。”

我窮盡我的畢生所學,在網上翻了個底朝天,確實沒有找到關於 idea 為什麼會在這裡顯示 int PRIME = true 這樣的一行程式碼。

我找到的唯一有相關度的問題是這個:

https://stackoverflow.com/questions/70824467/lombok-hashcode-1-why-good-2-why-no-compile-error/70824612#70824612

在這個問題裡面,提問的哥們說,為什麼他看到了 int result = true 這樣的程式碼,且沒有編譯錯誤?

和我看到的有點相似,但是又不是完全一樣。我發現他的 Test 類是無參的,而我自己的做測試的 UserInfo 是有一個 name 引數的。

於是我也搞了個無參的看了一下:

我這裡是沒有問題的,顯示的是 int result = 1

然後有人問是不是因為你這個 Test 類沒有欄位呀,搞幾個欄位看看。

當他加了兩個欄位之後,編譯後的 class 檔案就和我看到的是一樣的了:

但是這個問題下面只有這一個有效回答:

這個回答的哥們說:你看到 hashCode 方法是這樣的,可能是因為你用的生成位元組碼的工具的一個問題。

在你用的工具的內部,布林值 true 和 false 分別用 1 和 0 表示。

一些位元組碼反編譯器盲目地將 0 翻譯成 false,將 1 翻譯成 true,這可能就是你遇到的情況。

這個哥們想表達的意思也是:這是工具的 BUG。

雖然我總是覺得差點意思,先不說差在哪兒了吧,按下不表,我們先接著看。

在這個回答裡面,還提到了 lombok 的一個特性 delombok,我想先說說這個:

delombok

這是個啥東西呢?

給你說個場景,假設你喜歡用 Lombok 的註解,於是你在你對外提供的 api 包裡面使用了相關的註解。

但是引用你 api 包的同學,他並不喜歡 Lombok 註解,也沒有做過相關依賴和配置,那你提供過去 api 包別人肯定用不了。

那麼怎麼辦呢?

delombok 就派上用場了。

可以直接生成已經解析過 lombok 註解的 java 原始碼。

官網上關於這塊的描述是這樣的:

https://projectlombok.org/features/delombok

換句話說,也就是你可以利用它看到 lombok 給你生成的 java 檔案是長什麼樣的。

我帶你瞅一眼是啥樣的。

從官網上的描述可以看到 delombok 有很多不同的開啟方式:

對我們而言,最簡單的方案就是直接用 maven plugin 了。

https://github.com/awhitford/lombok.maven

直接把這一坨配置貼到專案的 pom.xml 裡面就行了。

但是需要注意的是,這個配置下面還有一段話,開頭第一句就很重要:

Place the java source code with lombok annotations in src/main/lombok (instead of src/main/java).

將帶有 lombok 註解的 java 原始碼放在 src/main/lombok 路徑下,而不是 src/main/java 裡面。

所以,我建立了一個 lombok 資料夾,並且把這 UserInfo.java 檔案移動到了裡面。

然後執行 maven 的 install 操作,可以看到 target/generated-sources/delombok 路徑下多了一個 UserInfo.java 檔案:

這個檔案就是經過 delombok 外掛處理之後的 java 檔案,可以在遇到對方沒有使用 lombok 外掛的情況下,直接放到 api 裡面提供出去。

然後我們瞅一眼這個檔案。我拿到這個檔案主要還是想看看它 hashCode 方法到底是怎麼樣的:

看到沒有,hashCode 方法裡面的 int PRIME = true 沒有了,取而代之的是 final int PRIME = 59

這已經是 java 檔案了,要是這地方還是 true 的話,那麼妥妥的編譯錯誤:

而且通過 delombok 生成的原始碼,也解答了我之前的一個疑問:

看 class 檔案的時候,感覺 PRIME 這個變數沒有使用過呢,那麼它的意義是什麼呢?

但是看 delombok 編譯後得到的 java 檔案,我知道了,PRIME 其實是用到了的:

那麼為啥 PRIME 變成了 true 呢?

望著 delombok 生成的原始碼,我突然眼前一亮,好傢伙,你看這是什麼:

這是 final 型別的區域性變數。

注意:是!final!類!型!

為了更好的引出下面我想說的概率,我先給你寫一個非常簡單的東西:

看到了嗎,why 和 mx 都變成 true 了,相當於把 test 方法直接修改為這樣了:

public int test() {
    return 3;
}

給你看看位元組碼可能更加直觀一點:

左邊是不加 final,右邊是加了 final。

可以看到,加了 final 之後完全都沒有訪問區域性變數的 iload 操作了。

這東西叫什麼?

這就是“常量摺疊”。

有幸很久之前看到過 JVM 大佬R大對於這個現象的解讀,當時覺得很有趣,所以有點印象。

當看到 final int PRIME = 59 的時候,一下就點燃了回憶。

於是去找到了之前看的連結:

https://www.zhihu.com/question/21762917/answer/19239387

在R大的回答中,有這麼一小段,我給你截圖看看:

同時,給你看看 constant variable 這個東西在 Java 語言規範裡面的定義:

A variable of primitive type or type String, that is final and initialized with a compile-time constant expression , is called a constant variable.

一個基本型別或 String 型別的變數,如果是被 final 修飾的,在編譯時的就完成了初始化,這就被稱為 constant variable(常量變數)。

所以 final int PRIME = 59 裡面的 PRIME 就是一個常量變數。

這裡既然提到了 String,那我也給你舉個例子:

你看 test2 方法,用了 final,最終的 class 檔案中,直接就是 return 了拼接完成後的字串。

為什麼呢?

別問,問就是規定。

https://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.28

我只是在這裡給你指個路,有興趣的可以自己去翻一翻。

另外,也再一次實錘了 class 檔案下面這樣的顯示,確實是 idea 的 BUG,和 lombok 完全沒有任何關係,因為我這裡根本就沒有用 lombok:

同時,關於上面這個問題在 lombok 的 github 裡面也有相關的討論:

https://github.com/projectlombok/lombok/issues/523

提問者說:這個 PRIME 變數看起來像是沒啥用的程式碼呢,因為在這個區域性方法中都沒有被使用過。

官方的回答是:老哥,我懷疑你看到的是 javac 的一個優化。如果你看一下 delombok 生成的程式碼,你會看到 PRIME 這個玩意是在被使用。應該是 javac 在對這個常量進行了內聯的操作。

為什麼是 59

我們再次把目光聚焦到 delombok 生成的 hashCode 方法:

為什麼這裡用了 59 呢,hashCode 裡面的因子不應該是無腦使用 31 嗎?

我覺得這裡是有故事的,於是我又淺挖了一下。

我挖線索的思路是這樣的。

首先我先找到 59 這個數是怎麼來的,它肯定是來自於 lombok 的某個檔案中。

然後我把 lombok 的原始碼拉下來,檢視對應檔案中針對這個值的提交或者說變化。正常情況下,這種魔法值不會是無緣無故的來的,提交程式碼的時候大概率會針對為什麼取這個值進行一個說明。

我只要找到那段說明即可。

首先,我根據 @EqualsAndHashCode 呼叫的地方,找到了這個類:

lombok.javac.handlers.HandleEqualsAndHashCode

然後在這個類裡面,可以看到我們熟悉的 “PRIME”:

接著,搜尋這個關鍵詞,我找到了這個地方:

這裡的這個方法,就是 59 的來源:

lombok.core.handlers.HandlerUtil#primeForHashcode

第一步就算完成了,接著就要去看看 lombok 裡面 HandlerUtil 這個類的提交記錄了:

結果很順利,這個類的第二次提交的 commit 資訊就在說為什麼沒有用 31。

從 commit 資訊看,之前應該用的就是 31,而用這個數的原因是因為《Effective Java》推薦使用。但是根據 issue#625 裡面的觀點來說,也許 277 是一個比較好的值。

從提交的程式碼也可以看出,之前確實是使用的 31,而且是直接寫死的:

在這次的提交裡面,修改為了 277 並提到了 HandlerUtil 的一個常量中:

但是,這樣不是我想要找的 59 呀,於是接著找。

很快,就找到了 277 到 59 的這一次變更:

同時也指向了 issue#625。

等我哼著小曲唱著歌,準備到 issue#625 裡面一探究竟的時候,傻眼了:

https://github.com/projectlombok/lombok/issues/625

issue#625 說的事兒根本和 hashCode 沒有任何關係呀。

而且這個問題是 2015 年 7 月 15 日才提出來的,但是程式碼可是在 2014 年 1 月就提交了。

所以 lombok 的 issues 肯定是丟失了很大一部分,導致現在我對不上號了。

這行為,屬於在程式碼裡面下毒了,我就是一箇中毒的人。

事情看起來就像是走進了死衚衕。

但是很快,就峰迴路轉了,因為我的小腦殼裡面閃過了另外一個可能有答案的地方,那就是 changelog:

https://projectlombok.org/changelog

果然,在 changelog 裡面,我發現了新的線索 issue#660:

開啟 issue#660 一看,嗯,這次應該是沒走錯路了:

https://github.com/projectlombok/lombok/issues/660

在這個 issues 裡面首先 Maaartinus 老哥給出了一段程式碼,然後他解釋說:

在我的例子中,如果 lombok 生成的 hashCode 方法使用 31 這個因子,對於 256 個生成的物件,只有 64 個唯一的雜湊值,也就是說會產生非常多的碰撞。

但是如果 lombok 使用一個更好的因子,這個數字會增加到 144,相對好一點。

而且幾乎任何奇數都可以。使用 31 是少數糟糕的選擇之一。

官方看到後,很快就給了回覆:

看了老哥的程式,我覺得老哥說的有道理啊。之前我用 31 也完全是因為《Effective Java》裡面是這樣建議的,沒有考慮太多。

另外,我決定使用 277 這個數字來替代 31,作為新的因子。

為什麼是 277 呢?

別問,問就是它很 lucky!

277 is the lucky winner

那麼最後為什麼又從 277 修改為 59 呢?

因為使用 227 這樣一個“巨大 ”的因子,會有大概 1-2% 的效能損失。所以需要換一個數字。

最終決定就選 59 了,雖然也沒有說具體原因:

但是結合 changelog 來看,我有理由猜測原因之一是要選一個小於 127 的數,因為 -128 到 127 在 Integer 的快取範圍內:

IDEA

說起 IDEA 的 BUG,我早年間可是踩過一次印象深刻的 “BUG”。

以前在除錯 ConcurrentLinkedQueue 這個東西的,直接把心態給玩崩了。

你有可能會碰到的一個巨坑,比如我們的測試程式碼是這樣的:

public class Test {

    public static void main(String[] args) {
        ConcurrentLinkedQueue<Object> queue = new ConcurrentLinkedQueue<>();
        queue.offer(new Object());
    }
}

非常簡單,在佇列裡面新增一個元素。

由於初始化的情況下 head=tail=new Node(null):

所以在 add 方法被呼叫之後的連結串列結構裡面的 item 指向應該是這樣的:

我們在 offer 方法裡面加入幾個輸出語句:

執行之後的日誌是這樣的:

為什麼最後一行輸出,【offer之後】輸出的日誌不是 null->@723279cf 呢?

因為這個方法裡面會呼叫 first 方法,獲取真正的頭節點,即 item 不為 null 的節點:

到這裡都一切正常。但是,當你用 debug 模式操作的時候就不太一樣了:

頭節點的 item 不為 null 了!而頭節點的下一個節點為 null,所以丟擲空指標異常。

單執行緒的情況下程式碼直接執行的結果和 Debug 執行的結果不一致!這不是遇到鬼了嗎。

我在網上查了一圈,發現遇到鬼的網友還不少。

最終找到了這個地方:

https://stackoverflow.com/questions/55889152/why-my-object-has-been-changed-by-intellij-ideas-debugger-soundlessly

這個哥們遇到的問題和我們一模一樣:

這個問題下面只有一個回答:

你知道回答這個問題的哥們是誰嗎?

IDEA 的產品經理,獻上我的 respect。

最後的解決方案就是關閉 IDEA 的這兩個配置:

因為 IDEA 在 Debug 模式下會主動的幫我們呼叫一次 toString 方法,而 toString 方法裡面,會去呼叫迭代器。

而 CLQ 的迭代器,會觸發 first 方法,這個裡面和之前說的,會修改 head 元素:

一切,都真相大白了。

而這篇文章裡面的問題:

我有理由確定就是 IDEA 的問題,但是也沒有找到像是這一小節裡面的問題的權威人士的認證。

所以我前面說的差點意思,就是這個意思。

--- 本文首發於公眾號why技術。

相關文章