你好呀,我是歪歪。
前幾天有朋友給我發來這樣的一個截圖:
他說他不理解,為什麼這樣不報錯。
我說我也不理解,把一個 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/que...
在這個問題裡面,提問的哥們說,為什麼他看到了 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/fea...
換句話說,也就是你可以利用它看到 lombok 給你生成的 java 檔案是長什麼樣的。
我帶你瞅一眼是啥樣的。
從官網上的描述可以看到 delombok 有很多不同的開啟方式:
對我們而言,最簡單的方案就是直接用 maven plugin 了。
https://github.com/awhitford/...
直接把這一坨配置貼到專案的 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/questio...
在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/javas...
我只是在這裡給你指個路,有興趣的可以自己去翻一翻。
另外,也再一次實錘了 class 檔案下面這樣的顯示,確實是 idea 的 BUG,和 lombok 完全沒有任何關係,因為我這裡根本就沒有用 lombok:
同時,關於上面這個問題在 lombok 的 github 裡面也有相關的討論:
https://github.com/projectlom...
提問者說:這個 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/projectlom...
issue#625 說的事兒根本和 hashCode 沒有任何關係呀。
而且這個問題是 2015 年 7 月 15 日才提出來的,但是程式碼可是在 2014 年 1 月就提交了。
所以 lombok 的 issues 肯定是丟失了很大一部分,導致現在我對不上號了。
這行為,屬於在程式碼裡面下毒了,我就是一箇中毒的人。
事情看起來就像是走進了死衚衕。
但是很快,就峰迴路轉了,因為我的小腦殼裡面閃過了另外一個可能有答案的地方,那就是 changelog:
https://projectlombok.org/cha...
果然,在 changelog 裡面,我發現了新的線索 issue#660:
開啟 issue#660 一看,嗯,這次應該是沒走錯路了:
https://github.com/projectlom...
在這個 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<E>(null):
所以在 add 方法被呼叫之後的連結串列結構裡面的 item 指向應該是這樣的:
我們在 offer 方法裡面加入幾個輸出語句:
執行之後的日誌是這樣的:
為什麼最後一行輸出,【offer之後】輸出的日誌不是 null->@723279cf 呢?
因為這個方法裡面會呼叫 first 方法,獲取真正的頭節點,即 item 不為 null 的節點:
到這裡都一切正常。但是,當你用 debug 模式操作的時候就不太一樣了:
頭節點的 item 不為 null 了!而頭節點的下一個節點為 null,所以丟擲空指標異常。
單執行緒的情況下程式碼直接執行的結果和 Debug 執行的結果不一致!這不是遇到鬼了嗎。
我在網上查了一圈,發現遇到鬼的網友還不少。
最終找到了這個地方:
https://stackoverflow.com/que...
這個哥們遇到的問題和我們一模一樣:
這個問題下面只有一個回答:
你知道回答這個問題的哥們是誰嗎?
IDEA 的產品經理,獻上我的 respect。
最後的解決方案就是關閉 IDEA 的這兩個配置:
因為 IDEA 在 Debug 模式下會主動的幫我們呼叫一次 toString 方法,而 toString 方法裡面,會去呼叫迭代器。
而 CLQ 的迭代器,會觸發 first 方法,這個裡面和之前說的,會修改 head 元素:
一切,都真相大白了。
而這篇文章裡面的問題:
我有理由確定就是 IDEA 的問題,但是也沒有找到像是這一小節裡面的問題的權威人士的認證。
所以我前面說的差點意思,就是這個意思。
--- 本文首發於公眾號why技術。