議的Java基石深度解析)
上一次在團(tuán)隊做JDK源碼分享我把排在第一位的主題給了Object類。有人嘀咕Object有什么好說的無非toString、equals、hashCode。我反問了一句如果讓你從零設(shè)計Java你會把wait方法放到哪個類這個問題不難但很少有人認(rèn)真想過——正因為每一個對象都可以被鎖定、被等待、被喚醒這些能力必須放在所有類的基類上所以才有了我們看到的JDK源碼之Object。Object.java方法不多放在IDE里一眼能掃完可它背后的對象頭、哈希策略、鎖模型、垃圾回收協(xié)作機(jī)制幾乎關(guān)系著整個Java運行時的基石。這篇文章是我把JDK8中Object.java完整讀了三遍再對照HotSpot實現(xiàn)和日常排障經(jīng)驗整理出來的適合剛開始讀JDK源碼的開發(fā)者也適合那些對Object停留在“背過”階段、想真正“讀懂”的老手。文中沒有只講API更多是講每個方法為什么這樣設(shè)計以及這些設(shè)計在真實項目里的回響。1. 為什么JDK源碼之旅從Object開始而不是從ArrayList開始很多人開始讀JDK源碼時第一選擇往往是HashMap、ArrayList這些“有東西可講”的集合類。結(jié)果翻開沒兩行就陷入紅黑樹、擴(kuò)容機(jī)制、迭代器快照這些細(xì)節(jié)里讀得暈頭轉(zhuǎn)向。我的建議反而不是這樣先讀JDK源碼之Object把根基打牢再去看集合和并發(fā)類不然很多問題你根本不知道要去哪里找答案。1.1 打開Object.java的第一印象方法很少契約很多JDK8里的Object.java算上靜態(tài)代碼塊和wait的重載版本表面上一共也沒幾個方法。核心成員就是下面這張表方法修飾符職責(zé)getClassfinal native返回運行時類hashCodenative返回對象哈希值equalspublic判斷邏輯相等cloneprotected native創(chuàng)建對象副本toStringpublic返回字符串表示notifyfinal native喚醒一個等待線程notifyAllfinal native喚醒全部等待線程waitfinal native釋放鎖并等待finalizeprotected垃圾回收前的回調(diào)看這張表你能直接感受到Object設(shè)計上的克制它不是工具類而是所有類的行為協(xié)議。Java沒有多繼承但又希望每個對象都擁有這些基本能力那就只能把它們?nèi)糠诺焦不?。注意這些方法不是拿來直接用的有一部分是需要你重寫的比如equals和toString有一部分是禁止你重寫的比如wait、notify和getClass。如果一開始不理解為什么這樣設(shè)計可以先記住結(jié)論后面一步步拆。1.2 一切要從對象頭說起為什么這些能力能放在Object里因為每個Java對象在JVM里都帶有一段公共的運行時結(jié)構(gòu)。HotSpot中對象在堆內(nèi)存里除了實例字段數(shù)據(jù)還包含一個mark word和一個類型指針。mark word里可能存放無鎖狀態(tài)下的identity hashCode、偏向鎖的線程ID、輕量級鎖的指針、重量級鎖的monitor指針以及GC分代年齡。也就是說所有對象的“身份哈希”“鎖狀態(tài)”“GC狀態(tài)”這些信息都在對象頭里統(tǒng)一定義。所以O(shè)bject.wait要釋放鎖Object.hashCode要取哈希都必須去操作這個頭部結(jié)構(gòu)自然更適合用native方法實現(xiàn)由JVM底層統(tǒng)一完成。這也解釋了一個常見疑問為什么wait和notify不是定義在Thread類里而是定義在Object里因為鎖的對象就是普通Java對象線程只是鎖的持有者真正被等待、被喚醒、被鎖定的是對象本身。放在Object里本質(zhì)上是在說每一個對象天生就是一把可以用作線程協(xié)作的鎖。1.3 從Object的“虛無”中讀出繼承設(shè)計的邊界Object的源碼第一行是public class Object沒有extends關(guān)鍵字意味著它在繼承樹的最頂端。但別把這種“虛無”理解成什么都沒有——它的靜態(tài)代碼塊和native方法定義了類加載階段的注冊邏輯運行層面還有與JVM綁定的整套機(jī)制。祖先關(guān)系不一定體現(xiàn)在繼承關(guān)鍵字里還有一層運行時層面的綁定。設(shè)計者的意圖很明顯Object是所有類的超類但它本身不承載業(yè)務(wù)只承載契約。我們寫業(yè)務(wù)代碼時常犯一個錯誤就是把公共基類越做越重什么通用方法都往BaseEntity里塞。Object給了個示范基類可以很小覆蓋面可以很大關(guān)鍵是每個方法都有明確邊界該鎖死的鎖死該開放的開放。這是讀Object源碼時第一個值得遷移到自身代碼的設(shè)計思路。2. native方法的特權(quán)與邊界hashCode、clone、getClass的真面目Object里標(biāo)記為native的方法有五個getClass、hashCode、clone、notify、notifyAll和wait(long)。native意味著Java層只留一個方法簽名真正的邏輯跑到JVM內(nèi)部。很多人讀到這里就放棄了覺得“反正實現(xiàn)不在Java層沒法深究”。但恰恰是這幾個native方法藏著Java對象模型最核心的秘密。2.1 hashCode的默認(rèn)策略比想象中復(fù)雜在IDE里點進(jìn)Object.hashCode看到的只有一行native。JVM為對象計算默認(rèn)hashCode時會采用一組可配置的散列策略有的版本基于對象地址做運算有的版本用線程相關(guān)的隨機(jī)數(shù)有的版本用全局自增序列。關(guān)鍵是兩件事。第一默認(rèn)hashCode不是必然等于對象地址。JDK官方注釋只說“盡可能讓不同對象產(chǎn)生不同值”沒說一定是地址。網(wǎng)上很多資料把hashCode解釋成“對象的內(nèi)存地址”那是某個歷史版本里的實現(xiàn)方式不能當(dāng)公理背。如果你在日志里看到兩個對象的hashCode一樣也不要太吃驚哈希碰撞在32位int空間里是完全可能發(fā)生的。第二這個值一旦算出來就會被緩存到對象頭的mark word里。之后哪怕對象發(fā)生GC移動、地址變了hashCode也保持不變。這正是hashCode契約要求的——同一個對象的hashCode在生命周期內(nèi)不能變。如果每次GC移動后地址變、哈希就跟著變HashMap就會出現(xiàn)同一個key前后定位不一致的嚴(yán)重問題。從源碼注釋里推導(dǎo)出這個結(jié)論比背一句“hashCode要穩(wěn)定”要扎實得多。2.2 clone()被設(shè)計成“你最好別直接用”O(jiān)bject.clone()是protected native。為什么是protected因為Object不能替子類決定clone的語義子類需要在覆蓋時決定是否對外可見。再看注釋里的描述創(chuàng)建并返回此對象的一個副本?!案北尽钡降资鞘裁磳ζ胀↗ava對象來說就是逐字段復(fù)制也就是淺拷貝。如果你的類里有引用類型的成員淺拷貝后兩個對象會共享同一個引用這時候修改子對象原對象也會跟著變。舉例說明public class Order implements Cloneable { private ListItem items; Override public Order clone() { try { Order copy (Order) super.clone(); copy.items new ArrayList(items); // 手工深拷貝 return copy; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } }這里真正跑的是兩條線super.clone()由JVM按字段復(fù)制完成然后自己補一刀把集合深拷貝。如果忘了實現(xiàn)Cloneablesuper.clone()會拋CloneNotSupportedException這也是源碼注釋里明確寫出的邊界條件。工程上現(xiàn)在很多人直接用拷貝構(gòu)造函數(shù)或copyOf替代clone因為可以顯式控制字段深淺也不容易碰clone里的坑。這種替代不是否定clone而是理解了clone的淺拷貝邊界后做出的更安全的選擇。每當(dāng)我看到有人直接調(diào)用super.clone()卻不處理引用字段就知道他大概率踩過淺拷貝的坑。2.3 getClass()返回的不是“感覺上的類”而是運行時類getClass()返回的是類加載器實際加載的運行時Class對象。一個典型場景父類引用指向子類實例時getClass()得到的是子類Class。比如Object obj new ArrayList(); System.out.println(obj.getClass()); // class java.util.ArrayList System.out.println(obj instanceof ArrayList); // true在類加載器不同的情況下兩個類的名字可以一樣但Class對象不是同一個。這就是為什么涉及類判定時光看名字不可靠最好直接比較class對象或者用泛型約束必要時還要考慮類加載器維度。這層理解對排查NoSuchMethodError、NoClassDefFoundError一類問題很有幫助因為這類錯誤背后往往就是“同一個類在不同ClassLoader里各加載了一份”的典型糾紛。3. equals與hashCode契約從Object源碼到HashMap、Spring的實戰(zhàn)equals和hashCode是Object里被重寫頻率最高的兩個方法也是面試必問的組合。但面試題往往停留在“重寫equals為什么要重寫hashCode”的標(biāo)準(zhǔn)答案上真正在項目里踩過坑的人才會明白這不是約定俗成的規(guī)矩而是集合類能正常工作的地基。3.1 默認(rèn)equals為什么只做引用相等Object里的equals實現(xiàn)簡單到一句話return (this obj);這個實現(xiàn)表達(dá)的是兩個引用指向同一個實例才算相等。Java里每個對象天然有唯一身份默認(rèn)equals比較的就是身份而不是內(nèi)容。那為什么String、Integer這些類要重寫因為它們在真實業(yè)務(wù)里代表的是“值”。比如兩個字符串內(nèi)容相同業(yè)務(wù)上就應(yīng)該看成同一個。重寫equals時常規(guī)套路是先比較引用如果this obj直接返回true 再判斷類型用getClass或者instanceof擋住不同類型的對象 最后比較關(guān)鍵字段。這里有一個很多人在繼承場景容易踩的點。如果父類用instanceof判斷子類重寫equals時很容易造成不對稱。比如父類Person的equals判斷obj instanceof Person子類Student重寫equals后認(rèn)為obj instanceof Student才算相等那就會出現(xiàn)person.equals(student)為true、student.equals(person)可能為false的情況直接違反對稱性。一個比較嚴(yán)謹(jǐn)?shù)淖龇ㄊ窃趀quals里用getClass() ! obj.getClass()先擋住不同類實例保證對稱性代價就是父類和子類之間永遠(yuǎn)不會相等這個取舍需要業(yè)務(wù)自己權(quán)衡。3.2 重寫equals不重寫hashCode等于給HashMap埋雷Object.hashCode與Object.equals在源碼注釋里立了一條硬契約如果兩個對象equals返回true那么它們的hashCode必須相同如果兩個對象hashCode不同那么equals結(jié)果一定是false。哈希相同并不代表一定equals但哈希不同可以直接判定不相等。為什么HashMap這么敏感因為HashMap插入和查找時先用hash定位到桶桶內(nèi)再用equals逐個比較。假設(shè)重寫了equals但沒重寫hashCode就會出現(xiàn)put時用對象Aget時用對象BA和B邏輯相等但hashCode不同于是get直接去了另一個桶永遠(yuǎn)也找不到。這個問題在緩存、去重、Set集合里同樣存在。之前有同事在一個用戶權(quán)益去重場景吃過這個虧他重寫了User的equals只比較userId但沒同步重寫hashCode結(jié)果線上用多個渠道寫入同一用戶時HashSet里出現(xiàn)了兩條重復(fù)權(quán)益記錄。問題查到后來把兩個對象的hashCode打印出來一對比才發(fā)現(xiàn)正是這個經(jīng)典陷阱。事后我常常跟團(tuán)隊說一句話凡是重寫equals的地方旁邊必須緊接著重寫hashCode這兩者不是一個選擇題而是一道捆在一起的必答題。3.3 從Object的注釋里可以讀到的完整等式規(guī)范你直接看Object.java的源碼注釋會發(fā)現(xiàn)JDK官方用大量篇幅定義了equals的五個性質(zhì)自反性、對稱性、傳遞性、一致性以及對非null對象比較null返回false。這些性質(zhì)不是面試八股而是任何集合類都能穩(wěn)定工作的前提。以傳遞性為例A.equals(B)為true、B.equals(C)為true那么A.equals(C)也必須為true。如果你在equals里比較的是可變字段對象中途被別的線程改了字段值equals結(jié)果就可能在一次請求內(nèi)跳變這違反一致性。Spring的Bean比較、各種框架里的去重邏輯本質(zhì)都在依賴這套契約。源碼讀到這里就不再是背方法而是在讀一份“對象行為契約說明書”。這也是為什么我建議入門者第一份源碼看Object——回望其他框架大量用到equals/hashCode的地方本質(zhì)上都在遵守這套契約。4. wait/notify/notifyAllObject如何定義并發(fā)協(xié)作的基礎(chǔ)協(xié)議如果說equals/hashCode是對象“身份與值”的協(xié)議那么wait/notify/notifyAll就是對象“線程協(xié)作”的協(xié)議。這三個方法算是JDK里最古老的并發(fā)原語日常寫業(yè)務(wù)代碼可能不常用但理解它們能幫你把synchronized、鎖、Condition這些東西串成一條線。4.1 源碼注釋里的“前提條件”不滿足會怎樣Object.wait/notify/notifyAll在所有對象上都可用但有一個硬性前提當(dāng)前線程必須持有該對象的監(jiān)視器鎖也就是處于synchronized同步塊或同步方法內(nèi)。如果直接調(diào)用wait會拋IllegalMonitorStateException。為什么會有這個限制因為wait的語義是“讓出鎖并進(jìn)入等待集”。假如一開始就不持有鎖JVM不知道該釋放什么等待集也無從登記。要注意wait釋放的是與這個監(jiān)視器相關(guān)的全部鎖所有權(quán)而不是讓出一部分。如果在synchronized方法里調(diào)用了wait方法的鎖會被完整釋放等線程被喚醒重新獲得鎖后再繼續(xù)執(zhí)行余下代碼。notify的語義是隨機(jī)喚醒一個在同一監(jiān)視器等待集里的線程但不保證公平性notifyAll喚醒全部等待線程讓它們?nèi)ジ偁庢i。從語義上看notifyAll在復(fù)雜場景下比notify更安全因為notify選錯線程時被漏掉的線程可能永遠(yuǎn)等下去。4.2 用while循環(huán)而不是if判斷條件來自wait源碼注釋的強烈暗示很多初學(xué)多線程的人寫過這樣的代碼synchronized (queue) { if (queue.isEmpty()) { queue.wait(); } // 取元素... }這其實是有問題的。因為wait在返回時只代表線程被喚醒并重新獲得了鎖并不代表等待條件已經(jīng)滿足??赡芡瑫r喚醒了多個線程隊列里的元素被第一個線程拿走了第二個線程醒來后queue依然是空的。更極端的情況還有虛假喚醒線程可能在沒有被notify的情況下醒來。JVM規(guī)范并沒有禁止虛假喚醒所以設(shè)計者必須防御它。所以標(biāo)準(zhǔn)的等待-通知模式必須長這樣synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } T item queue.poll(); }而另一側(cè)的生產(chǎn)者要記得在隊列發(fā)生變化后調(diào)用notifyAll。這個模式本身不是別人憑空想的Object源碼注釋的規(guī)范部分就明確提到線程應(yīng)該在一個條件謂詞上循環(huán)等待而不是假設(shè)醒來條件就成立。讀源碼時注意到這種注釋往往比記住某個API更有價值。4.3 從Object.wait到Condition源碼演進(jìn)背后的選型邏輯Object.wait/notify有個短板就是所有線程共享一個等待集。比如一個容量為N的阻塞隊列我們需要“隊列未滿”和“隊列非空”兩類條件但synchronizedwait只能把它們混在一起無法精準(zhǔn)地把生產(chǎn)者喚醒到生產(chǎn)者等待集、消費者喚醒到消費者等待集。于是JUC里ReentrantLock的Condition就是為解決這個問題出現(xiàn)的一個鎖上可以new出多個Condition每個條件對應(yīng)各自的等待隊列signal()可以選擇性地通知某個條件上等待的線程。ArrayBlockingQueue內(nèi)部就是用notEmpty、notFull兩個Condition實現(xiàn)的。我的工程選型經(jīng)驗是簡單的互斥加wait/notify就夠用代碼也直觀一旦出現(xiàn)多類線程依賴不同條件通信就果斷換ReentrantLock加Condition。這不是說Object的wait機(jī)制不行而是說源碼的演進(jìn)已經(jīng)指出了每種工具的適用邊界。5. toString、finalize、registerNatives逐漸被忽略但仍有味道的細(xì)節(jié)Object里除了那幾組明星方法還藏著三個容易被忽略的細(xì)節(jié)toString的默認(rèn)實現(xiàn)、finalize的興衰史、registerNatives的靜態(tài)初始化。它們不加注意時沒什么存在感但讀懂之后能避免很多誤用。5.1 toString里的符號其實展示的是哈希的十六進(jìn)制不是內(nèi)存地址Object.toString()的源碼是public String toString() { return getClass().getName() Integer.toHexString(hashCode()); }每次看到有人把日志里com.foo.User5bcab594解釋成“對象內(nèi)存地址”我都想糾正一下那是默認(rèn)hashCode()的十六進(jìn)制形式而hashCode由JVM的策略決定不一定代表地址。對象在GC后發(fā)生移動地址變了但hashCode只要計算過就被緩存下來toString打印的結(jié)果仍然穩(wěn)定。工程上沒人愿意靠默認(rèn)toString排障它太沒有辨識度。我更習(xí)慣在所有DTO和領(lǐng)域模型里重寫toString至少包含id和核心業(yè)務(wù)字段。排查線上問題時一行日志里能不能直接看到userId、orderId效率差異非常大。網(wǎng)上很多“打印對象看到一串亂碼”的求助本質(zhì)上就是沒有重寫toString。5.2 finalize的覆滅以及我們該從中得到什么Object里最后一個方法finalize在JDK8的源碼中是一個protected空方法注釋明確說它“可能在垃圾回收時被調(diào)用”——注意是可能不是必然。它曾經(jīng)被設(shè)計成在對象被GC回收前給一次清理機(jī)會但現(xiàn)實中問題很多JVM不保證finalize何時執(zhí)行、不保證一定執(zhí)行、不保證進(jìn)程中全部finalizable對象都被處理。JDK9開始它被標(biāo)記棄用JDK18已經(jīng)標(biāo)記為移除。如果你接手的老系統(tǒng)里還有finalize清理數(shù)據(jù)庫連接或文件句柄我的建議是盡早替換成try-with-resources或Cleaner。就算為了兼容老JDK暫時不能刪也要知道它只是兜底絕不能作為資源釋放的主鏈路。讀Object源碼時看到finalize最有價值的其實是這個教訓(xùn)一個看似優(yōu)雅的“回調(diào)鉤子”如果執(zhí)行時機(jī)不可控在工程里就是危險的。5.3 registerNativesObject里一段容易被忽略的靜態(tài)初始化在Object.java開頭還有一個很少被講的細(xì)節(jié)private static native void registerNatives(); static { registerNatives(); }它做的就是把當(dāng)前類里聲明的native方法與JVM內(nèi)部C/C函數(shù)做綁定注冊。沒有這一步native方法在首次使用時才去找綁定會有額外開銷和不確定性。JDK的模塊化基礎(chǔ)類里很多類都有類似的靜態(tài)初始化塊。讀源碼時看到這種模式可以推演出一個思路凡是需要跟底層運行時打交道的類最好在類加載階段就完成綁定和校驗而不是等到調(diào)用時才處理。這對我們寫一些涉及本地資源綁定的業(yè)務(wù)代碼也有借鑒意義——連接、密鑰、外部句柄這類東西能提前校驗就提前校驗不要拖到第一筆請求來了才暴雷。6. 讀完Object源碼之后我沉淀的閱讀方法與可遷移經(jīng)驗最后這部分不聊Object本身了聊聊我把它讀完之后沉淀下來的一些方法和習(xí)慣。這些東西是源碼閱讀的“副產(chǎn)品”但長期看比記住某個方法簽名有用得多。6.1 先把“契約清單”列出來再去看實現(xiàn)這里給準(zhǔn)備開始啃JDK源碼的朋友一個方法讀類之前先把官方文檔里關(guān)于這個類的契約列成清單。以O(shè)bject為例我一開始列的清單是equals滿足五種性質(zhì)hashCode兩次調(diào)用不變clone返回獨立淺拷貝wait必須持有鎖toString包含類名和哈希finalize不保證執(zhí)行。清單列完后再讀源碼你會發(fā)現(xiàn)每個方法的native實現(xiàn)只是回答“怎么做”而注釋和規(guī)范回答的是“什么條件下能做”。這兩者合起來才是一個完整的方法契約。后續(xù)讀HashMap、ThreadLocal、ConcurrentHashMap我都沿用這個思路先契約后實現(xiàn)再聯(lián)系實際場景。效果比直接一頭扎進(jìn)C代碼好得多。6.2 final與protected的搭配是Object留給API設(shè)計者的示范Object的方法修飾符組合很有意思getClass、wait、notify、notifyAll全部是final不允許子類覆蓋而clone和finalize是protected留給你按需擴(kuò)展。這說明一個道理對“基礎(chǔ)能力型方法”設(shè)計者要敢于鎖死行為避免子類把它破壞掉對“可擴(kuò)展型方法”則要明確定義擴(kuò)展點而不是讓所有人隨意修改入口。我后來在業(yè)務(wù)系統(tǒng)里設(shè)計基礎(chǔ)服務(wù)時也學(xué)著這么干核心狀態(tài)流轉(zhuǎn)方法一律final防止子類篡改流程模板方法里預(yù)留protected擴(kuò)展點讓業(yè)務(wù)方在受控位置上做定制。這是讀Object源碼順手得到的一筆設(shè)計饋贈也是很多框架源碼里可以反復(fù)看到的影子。6.3 把System.identityHashCode當(dāng)調(diào)試點肉眼排查“同一個對象”問題最后分享一個實際排查技巧。很多并發(fā)或緩存問題時核心要判斷的是“這個對象到底是不是同一個實例”。光看日志里的地址不可靠可以直接打印兩個值System.identityHashCode(obj)和obj.getClass()。前一個無論equals/hashCode怎么被重寫都代表默認(rèn)身份哈希同一個實例一定相同后一個能快速確認(rèn)運行時類型。之前排查一個連接池從回收隊列取到過期連接的問題就是比對兩個引用的identityHashCode不一致才發(fā)現(xiàn)原來是從不同緩存路徑各拿了一個對象實例。JDK源碼里Object.hashCode的native語義到了實戰(zhàn)中就成了定位對象身份的一把鑰匙。如果你也想開始讀JDK源碼我建議第一頁翻的就是Object.java不用選太新的JDK版本JDK8的源碼注釋已經(jīng)足夠豐富。第一遍只看方法清單和注釋第二遍對照HotSpot的實現(xiàn)理解native方法背后的對象頭、監(jiān)視器結(jié)構(gòu)第三遍再去Class、String、HashMap這些類里驗證契約怎么被具體實現(xiàn)。三遍下來你對“一個Java對象到底是什么”的理解會和只會背面試題的人完全在兩個層次。對我個人來說Object這段源碼最大的價值不是某個方法而是它讓我明白真正的基礎(chǔ)設(shè)施往往是那些看起來最簡單、最容易被跳過的類。