領域:Java 規則
基於昨天輸入最佳化的路徑,在今天的輸入到來時會失靈 [R:推測路徑在輸入改變時失靈]
源自 Aleksey Shipilev 的「JVM 解剖學」,關於輪廓汙染、內聯和去最佳化;HotSpot 的罕見陷阱機制。
JIT 編譯器編譯的不是你的程式碼,而是一個關於你程式碼的賭注,而這個賭注在它不再成立的那一刻就結算了。
HotSpot 編譯時依賴輪廓:這個呼叫點只看過一種接收者類型,所以將其內聯並加上守衛;這個分支從未被執行過,所以不產生它;這個欄位從未被設為 null,所以跳過檢查;這個類別從未被載入過,所以假設沒有子類別存在。每個假設都有一個守衛,而編譯後的路徑之所以快速,正是因為這些守衛成本低廉且主體不存在。當守衛失敗時,框架會被去最佳化:這是一個罕見陷阱,直譯器框架從編譯完成的框架重建,執行繼續在直譯模式下進行,並等待使用新的輪廓重新編譯。
時機點是這成為一個延遲規則而非吞吐量規則的原因。假設在輸入改變時被打破——不尋常的順序類型、當天的第一個例外、新場地的訊息格式、在第六小時載入的第二個介面實作。因此,路徑將產生的最差延遲,發生在最不尋常的事件上,而這通常就是最重要的事件。使用代表性平均流量的負載測試完全無法重現這種情況。
它預設也是靜默的。沒有任何日誌記錄,沒有指標變動,唯一的蹤跡是一個無法與 GC 暫停或排程延遲區分的延遲離群值。-XX:+PrintCompilation 和 -XX:+TraceDeoptimization 可以告訴你發生了什麼;JFR 能以更低開銷承載這些事件,是值得一直保持啟用的方式。
因此,熱身路徑時,也要包含不常見的案例以及常見的案例——這正是這個規則與 [R:測量結果與其所報告的環境相符] 的結合點:僅由典型流量建立的輪廓,也只能被典型流量所確認,並會被非典型流量摧毀。刻意保持熱呼叫點的單態性;在熱路徑上的一個多型介面呼叫是一個永久性的內聯屏障,而不是一次性陷阱。在路徑上線之前,載入路徑會用到的類別。並將添加到熱路徑的 lambda 或動態呼叫點視為一個必須重新賺取的新輪廓。
故障模式檢查:JIT 對這條路徑做了哪些假設,當其中一個假設第一次為假時會發生什麼? 如果誠實的回答是沒有人知道它假設了什麼,那這就是發現——假設是可以閱讀的,而閱讀它們就是工作。
集合檢視是通往他人資料的一扇窗 [R:檢視不是副本]
要知道你持有的是資料還是通往資料的視窗。靜態類型 List<T> 無法區分兩者,而且它也永遠不會告訴你。
標準函式庫回傳檢視的頻率遠高於大多數程式碼的預期,而且每一種都有其令人意外的方式:
Arrays.asList(a)是一個基於陣列的固定大小包裝器。set會寫入陣列;add和remove會拋出UnsupportedOperationException。List.of(...)是不可變且獨立的,通常才是你真正想要的。list.subList(from, to)是一個活躍的視窗。寫入會穿透進去,而且對父列表的任何結構性修改都會使其在下一次使用時拋出ConcurrentModificationException——這包括先取得子列表然後清除父列表這種常見的習慣用法。map.keySet(),values(),entrySet()都是檢視。keySet().remove(k)會從 map 中移除對應的條目。這有時是你想要的,但總是值得審慎考慮。Collections.unmodifiableList(l)在不複製的情況下進行包裝:包裝器拒絕寫入,但原始列表仍然可以寫入,因此它保護你免於呼叫者的影響,而不是保護你免於你自己的影響。它不是防禦性複本,這就是為什麼[R:不洩漏內部參考]會改用List.copyOf。Stream.toList()是不可修改的;collect(Collectors.toList())則沒有這種保證,且可能是可修改的也可能不是。不要依賴任一者是可變的。
所以,在你創建集合的時候就明確說明你想要哪一種。當接收者必須獨立時使用 List.copyOf,當分享是意圖且生命週期很短時使用檢視。儲存在欄位中的檢視是最需要仔細審視的形狀:它的壽命超過了創建它的表達式,而底層集合可以在之後的任何時間點在其底下更改。
同樣的區別也適用於迭代。在迭代集合時修改它會拋出 ConcurrentModificationException,即使在單執行緒環境下也是如此,因為迭代器本身也是一種檢視——請改用 removeIf,或者迭代一個副本,或者收集要移除的元素然後在迭代後應用它們。
故障模式檢查,對於任何接收或回傳的集合:如果有人對此進行寫入,還有什麼會改變? 如果你不打開工廠方法的 Javadoc 就無法回答,那就複製它。
在逃逸分析移除包裝類型之前,它會消耗一個物件 [R:包裝類型在此並非免費]
在這裡也為領域概念賦予它們自己的類型——一個不能傳給需要 AccountId 參數的 OrderId 在 Java 中和其他語言一樣有價值。但是,不要將成本論點和設計論點混為一談,因為只有其中一個能跨語言移植。
在 Rust 中,新型別是一個編譯時期的建構:struct OrderId(u64) 在執行時期就是一個 u64,編譯器可以證明這點。在 Java 中,record OrderId(long value) 是一個物件——一個標頭、一個欄位、一個需要追蹤的參考,以及一個與指向它的任何東西都分開的快取行。JIT 可以移除它:逃逸分析加上純量替換會扁平化一個可證明不會逃逸出其編譯單元的包裝器,這涵蓋了大量的一般程式碼。但它不能涵蓋的是你真正關心的情況——一個儲存在欄位中、放入集合中、傳遞給 JIT 拒絕內聯的方法、或存在於陣列中的包裝器,在陣列中 OrderId[] 是指標陣列,而 long[] 是數字陣列。
因此,規範根據值所在的位置而有所不同:
- 在 API 邊界,以及任何正確性是主要考量的地方,使用包裝器。它能防止的參數錯置 bug 值得一次 JIT 通常會移除的 allocation。
- 在熱門迴圈內部、大型陣列中、或每個訊息結構中,在假設包裝器已消失之前,先進行測量。這就是
[R:驗證抽象化已被編譯消除]在一個最佳化器決策是動態的語言中的體現:-XX:+PrintInlining、JFR allocation 輪廓或逃逸分析轉儲會告訴你實際發生的情況,而答案在同一個二進位檔的不同執行之間可能不同。 - 如果包裝器沒有消失且預算很緊張,保留原始型別並將安全性放在不會消耗成本的地方——命名參數、建構器、一個簽名不可能被錯誤呼叫的靜態工廠。
這個規則的未來版本值得瞭解而不是等待:Project Valhalla 的值類型正是設計用來讓這種包裝器根據規範而不是由最佳化器的裁量來扁平化。在程式碼運行在支援此功能的 JVM 之前,成本是動態的,必須經過測量而非假設。
故障模式檢查,在包裝一個有預算限制的路徑上的原始型別之前:這個物件在 JIT 完成後是否仍然存在?是什麼告訴我的?「它只是一個包裝器」是 Rust 的答案,而這正是那個無法移植的答案。
資源由開啟它的建構關閉,並且在任何路徑上都如此 [R:誰開啟誰關閉]
只在 try-with-resources 的標題中開啟資源,其他地方都不行。這個建構會在每一條退出路徑上以相反的順序關閉資源,而且——這是手寫 finally 做不到的部分——當關閉也失敗時,它會保留原始的例外。
try (var conn = pool.take(); var stmt = conn.prepare(sql)) {
return stmt.execute();
}
finally 區塊可能引入的失敗值得明確說明,因為它看起來是對的。如果主體拋出例外,而 close() 也拋出例外,那麼 finally 的例外會取代主體的例外:程式報告關閉連線失敗,但對於首先失敗的查詢卻隻字未提。相反地,Try-with-resources 會抑制關閉失敗,並將其附加到原始例外上,因此兩者都保留,而且根本原因正是你想要的。資源洩漏的代價是一個控制代碼;失去根本原因的代價是整個調查。
三種此建構不適用但規範仍然適用的情況:
- 生命週期是欄位而非區塊的資源——例如連線池、客戶端、執行器。擁有該資源的物件會變成
AutoCloseable,規範向上移動一層;應該要拒絕的是某個欄位被開啟,但沒有任何人特別負責關閉它。 ExecutorService不會僅由shutdown()關閉。shutdown()然後awaitTermination()然後shutdownNow()是標準順序,跳過它會讓非守護執行緒撐住 JVM 不結束。- 一個基於檔案的
Stream(Files.lines,Files.walk)持有一個控制代碼,必須被關閉。它是唯一會洩漏的 stream,而且它看起來就像那些不會洩漏的 stream。
不要依賴 finalizer 或 Cleaner 作為主要機制。它們在未指定的時間或從不執行,它們的存在是為了捕捉規範已經失敗的情況。
故障模式檢查,對每個資源:如果下一行程式碼拋出例外,哪個建構會關閉它? 如果答案是方法後面的某個 close() 呼叫,那麼沒有人會關閉它。
類別要嘛是 final,要嘛其繼承關係經過設計和文件化 [R:要為繼承而設計,否則就禁止它]
除非你已經為子類別設計好了,否則將類別設為 final;如果你已經設計好了,就明確說明子類別可以覆寫哪些方法,以及絕對不能覆寫哪些方法。
繼承並非它看起來的那種重用機制。子類別依賴於父類別中哪些方法呼叫了哪些其他方法——addAll 呼叫 add 是經典例子——而這正是父類別有權在修補程式版本中更改的實作細節。因此,一個可以運作的子類別會在父類別重構時損壞,而兩個作者都沒有做錯任何事。組合沒有這個特性:包裝器依賴於公開契約,僅此而已。
建構子的情況比脆弱更糟,它從一開始就是壞的。呼叫可覆寫方法的建構子,會在子類別自己的欄位被賦值之前運行子類別的覆寫版本,因此覆寫方法會看到它所宣告為 final 並已初始化的欄位為 null 和 0。沒有任何警告。這與 [R:不洩漏內部參考] 所提到的 this 逸出是同樣的發布風險,只是通過一條看起來像一般方法分派的路徑到達——而 [R:安全發布或完全不發布] 解釋了為什麼即使在只有一個執行緒時它也很重要。
因此:
- 預設為
final,適用於類別,以及任何子類別沒有理由取代的方法。Sealing ([R:密封替代方案]) 是更強大的形式,其中子類型集合是封閉且已知的。 - 永遠不要從建構子、初始化器、
clone()或readObject()中呼叫可覆寫的方法,因為這些方法本質上都是建構子。 - 如果一個類別是為了繼承而設計的,請文件化其自用模式:哪些方法呼叫哪些方法,哪些方法可以安全地覆寫,子類別必須呼叫哪些方法。那份文件是契約的一部分,而其成本正是大多數類別應該宣告為
final的真實原因。 - 偏好組合。 一個持有實例並進行轉發的包裝器寫起來更冗長,但不受父類別內部實作影響,並且當動機是重用而非可替代性時,應該優先使用它。
故障模式檢查,對每個非 final 類別:我的哪些方法會互相呼叫,並且如果一個子類別覆寫了其中一個,在我重新排列它們之後它仍然是正確的嗎? 如果這個問題無法回答,那麼這個類別是意外地可擴展的。
equals 和 hashCode 是同一個決策,而可變的鍵會破壞兩者 [R:等號是同一個契約]
一次性決定等號的實作,在同一次變更中實作其兩個部分,並從那些在物件可從集合中到達時不應該改變的欄位推導出來。
契約是機械式的:相等的物件必須有相等的 hash 碼。破壞它,HashMap 就會在錯誤的桶子中查找,因此 get 會為一個它包含的鍵回傳 null,而 remove 找不到可以移除的條目——這是一個大小會隨著流量成長的洩漏。沒有任何機制能偵測到這一點。集合的行為完全符合規格;只是規格被提供了一個說謊的物件。
可變鍵的部分是能夠通過程式碼審查的那一半,因為類別在被寫入時是正確的,並在有人添加 setter 時變得不正確。一個 hashCode 依賴於某個稍後被修改的欄位的物件,已經在不通知 map 的情況下移動了桶子,並且現在透過它最初歸檔的鍵是無法到達的。因此,用作鍵的類型是不可變的,否則就不要用作鍵。 record 為你提供了從元件產生且由建構子保證為 final 欄位的兩個方法,這就是為什麼它是值類型的預設形狀,以及為什麼手寫這兩個方法應該感覺像是一個決定而不是樣板程式碼。
還有兩個方法遵循同樣的契約,但值得說明因為它們各自有出錯的方式:
compareTo必須與equals一致,只要該類型用在TreeMap或排序集合中,它們使用排序並且根本不呼叫equals。如果比較器說兩個物件相等,而equals說它們不同,會產生一個靜默地只持有其中一個的集合。- 繼承與等號不相容。 使用
instanceof編寫的equals接受子類別,然後變得非對稱;使用getClass()編寫的equals是對稱的,並拒絕所有子類別。沒有第三種選項,這也是[R:要為繼承而設計,否則就禁止它]偏好final的原因之一。
故障模式檢查,在類型被用作鍵或放入集合之前:其等號取決於哪些欄位...(原內容被截斷)