JVM裡的邏輯漏洞,居然讓你的雜湊錶慢了20%!

阿里妹導讀
本文透過分析一段使用 ConcurrentHashMap 的程式碼發現,該段程式碼在 JDK 24 中比 JDK 23 快了 20% 以上,這一效能提升源於 JVM 對標量替換最佳化的改進。文章詳細介紹了逃逸分析和標量替換的工作原理,以及它們如何影響物件的記憶體分配。此外,文章還討論了 Java 記憶體管理的複雜性及其對 JVM 實現的影響,強調了 GC 在現代 Java 應用中的重要性。
首先來看一段 Java 程式碼:
intsumMapElements(ConcurrentHashMap<Integer, Integer> map){int sum = 0;  Enumeration<Integer> it = map.elements();while (it.hasMoreElements()) {    sum += (int) it.nextElement();  }return sum;}
函式 sumMapElements 使用迭代器遍歷了 ConcurrentHashMap 引數的所有元素,並求了它們的總和,將結果作為返回值返回。
整個程式碼在實現上相當直觀,也沒什麼彎彎繞繞。我敢說,如果讓你來實現一個類似的操作,你十有八九也會寫出差不多的程式碼——或者從不知道哪搜出來的二手 C**N 文章裡偷一段。
作為一個非常基礎的容器,ConcurrentHashMap 在併發場景裡有著廣泛的應用。成千上萬個日日夜夜裡,這段程式碼的靈魂——也就是裡面的那個迭代器,伴隨著網絡卡緩衝區裡的車水馬龍,流淌在無數臺跑著 Java 應用的伺服器中。
然而,當你用 OpenJDK 23 和 24,用預設的 G1 GC,分別運行同樣的程式碼(需要 JMH),你會發現一個令人震撼的事實:
23 居然比 24 慢了 20% 還多!
準確地說:類似的程式碼,在最新的 JDK 下,在不同環境、不同架構中,相比之前均會有不同程度的效能提升。少則 10%,多則超過 30%!
儘管 23 到 24 之間新增了相當多的最佳化,但我還是確信:你的雜湊表變快了,只是因為 JVM 中的一個邏輯漏洞被修好了。
為什麼呢?因為這個最佳化,正是我給 OpenJDK 提交的。戰績可查:JDK-8333334 PR
https://github.com/openjdk/jdk/pull/19496
而這個最佳化背後的故事,又和上次的 Shenandoah GC bug 一樣,撲朔迷離,蕩氣迴腸。詳見文章《JVM/編譯器/CPU,究竟誰是臥底?一個曾經困擾我一個月的 bug》。
備好瓜子,溫一壺奶茶,且聽我細細道來。
發生什麼事了?
事實上,這個問題本來永遠都不會被發現的。直到某天,我在跑類似程式碼的時候,給 JVM 添加了一個 -XX:+PrintEliminateAllocations 引數,然後看到了這樣的結果:
NotScalar (Field load)  124  CheckCastPP  === 121119  [[ 18201790 ... ]] ... !jvms: ConcurrentHashMap::elements @ bci:16 (line 2164) HashMap::sumMapElements @ bci:3 (line 38) HashMap::hashMapSum @ bci:5 (line 33)  >>>>   964  LoadN  === _ 2074223  [[ 9652176 ]] ... !jvms: ConcurrentHashMap$BaseIterator::hasMoreElements @ bci:1 (line 3444) HashMap::sumMapElements @ bci:8 (line 39) HashMap::hashMapSum @ bci:5 (line 33)NotScalar (Field load)  124  CheckCastPP  === 121119  [[ 18201790 ... ]] ... !jvms: ConcurrentHashMap::elements @ bci:16 (line 2164) HashMap::sumMapElements @ bci:3 (line 38) HashMap::hashMapSum @ bci:5 (line 33)  >>>>  2195  LoadI  === _ 1971211  [[ 30422937 ]] ... !jvms: ... ConcurrentHashMap$ValueIterator::nextElement @ bci:1 (line 3492) HashMap::sumMapElements @ bci:18 (line 40) HashMap::hashMapSum @ bci:5 (line 33)
注:如需復現,你可能需要自己從原始碼編譯一個 fastdebug 版的 JDK 23,因為 release 版不支援 PrintEliminateAllocations。
這是什麼意思?一句話解釋:這代表逃逸分析及其之後的 PhaseMacroExpand 試圖對 sumMapElements 中的雜湊表迭代器進行最佳化,但失敗了,最終導致迭代器分配到了堆上。
但從邏輯上講,這個方法裡的迭代器只在方法內被使用過,完全沒被傳給其他的方法,因此是一個徹徹底底的區域性變數——所以它本不應該被放在堆上。
作為一個合格的、嗅覺敏銳的 HotSpot JVM 維護者,你應該很快意識到,JVM 在這塊肯定有哪裡沒寫對。當然,我並不是個合格的嗅覺敏銳的維護者,這個問題是我主管發現的,我還是太菜了😢
而作為一個普通的 Java 程式設計師,相信你看了這個結果之後……什麼?你說你看不懂?沒關係,我會用盡可能通俗的語言講給你聽。
記憶體管理沒有銀彈
如果要問 Java 相比 C++ 最大的優勢是什麼?相信很多人都會說:Java 不需要手動管理記憶體。
C++ 程式設計師是這樣的,我們 Java 程式設計師只需要無腦 new 一把梭就可以,可是 C++ 要考慮的事情就很多了:什麼 unique_ptr、弱引用、placement new……稍有不慎就會記憶體洩漏。
然而軟工祖師爺 Fred Brooks 曾經說過:沒有銀彈。Java 的記憶體管理既要介面簡單,又要高效能,無疑會導致支撐著一切的 JVM 的實現變得無比複雜。Java 程式設計師寫得爽,那都是背後的 JVM 工程師在負重前行。
最直觀的體現就是,在 Java 中,有各種複雜的垃圾收集(Garbage Collection,GC)機制:分代、併發、低時延、高吞吐……說 GC 是人類工程智慧的結晶都不為過。
這種複雜性還波及了 Java 程式設計師自己:GC 調參一度成了他們的必備技能,甚至逐漸失控,發展成了玄學。當然,現代 Java 中,在更先進 GC 的加持下(如 ZGC,以及我們團隊的 Jade),程式設計師基本不需要再操心 GC 引數的事了。
另一方面,複雜的 GC 會帶來更復雜的 bug:比如之前我介紹過的 RISC-V Shenandoah GC 問題,只因原子記憶體操作的一個 bug,整個 JVM 程序頃刻灰飛煙滅。詳見文章《JVM/編譯器/CPU,究竟誰是臥底?一個曾經困擾我一個月的 bug》。
除此之外,GC 在 JVM 中的影響幾乎無處不在。或者說,為了適配 GC 的存在,JVM(HotSpot)不得不把 GC 的邏輯耦合到各種本來和 GC 無關的地方去。
圖片來源於網路
一個最典型的例子是 GC barrier。簡而言之:GC 需要定期根據物件之間的引用關係,刪除那些已經不會被程式用到的物件。而為了更好的維護物件的引用關係,GC 會在一些情況下,向 JVM 生成的程式碼中插入 barrier,從而避免一些諸如併發的問題。
以文章開頭提到的 G1 GC 為例。在你對某個物件的欄位進行賦值操作時,例如 obj.field = new_value,G1 會要求 JVM 的 JIT 編譯器生成兩類 barrier:
  • 一類是 pre-write barrier,出現在賦值之前,用來維護 Snapshot-At-The-Beginning(SATB);
  • 另一類是 post-write barrier,用來更新 Remembered Sets(RSets)。
對於具體的術語,篇幅所限,此處無法展開。讀者如有興趣,可參考這篇文章
https://www.oracle.com/technical-resources/articles/java/g1gc.html
所以,雖然你沒在程式碼裡寫任何和記憶體管理相關的內容,但為了保證 GC 的正常運轉,JVM 不得不偷偷往你的程式碼裡塞一些“私貨”。下圖就是你寫的 Java 程式碼在 JIT 編譯器眼中的樣子,其中赫然出現的 write_ref_field_post_entry 呼叫就是 post-write barrier。
絕大多數情況下,這些“私貨”不會對你的程式產生任何影響;但在某些情況下,這部分多餘的內容會干擾 JVM 的最佳化——這是令很多 JVM 工程師頭疼的一點。
堆/棧/暫存器?傻傻分不清楚
由於 Java 的 GC 實在是把事情做得太完美了,Java 程式設計師們甚至也完全不用較真兒什麼堆啊、棧啊的概念。
和那些天天用 C 語言甚至彙編,字字珠璣死磕每一個 bit,給微控制器、車機乃至戰鬥機程式設計的大手子不同,如果你和曾經的我一樣,只是個普通的 CRUD boy——捫心自問一下,除了找工作面試那幾天,你真的會關心你的物件到底被放在了堆上還是棧上嗎?
當然不會。
但 JVM 會。JVM 關心你,快說謝謝 JVM。
倒也不是這樣,只是因為:堆和棧在編譯器、作業系統這兩層抽象中始終是存在的,而一般情況下,堆記憶體分配要比棧記憶體分配慢得多:
  • 棧是執行緒自己的資源,而且是線性連續的。而分配棧記憶體這一操作,只需要一條更新棧指標的指令。
  • 堆是全域性資源,並不保證連續,隨著程式的不斷執行還可能產生大量碎片,在 Java 中還有額外的 GC 開銷。如需分配堆記憶體,要考慮執行緒同步,要處理碎片,要經過 GC,最後還可能執行 syscall 向系統申請記憶體。
所以,在條件允許的時候,JVM 會想盡一切辦法把你的物件弄到棧上去——即便它是你 new 出來的。因為這樣更快。
既然提到了棧,就不得不提暫存器:暫存器比棧還要快得多,但容量也小得多,所以彌足珍貴。對於那些大小足以被塞進一個暫存器裡的區域性變數,比如某個 int,在特定的情況下,JVM 會優先把它放在暫存器中。
那有沒有什麼辦法,能把那些大小比暫存器還大,但訪問更頻繁的區域性物件,也塞進暫存器裡,從而提高效能呢?雖然這聽起來有點像把大象放冰箱,但我還是要告訴你:有的兄弟,有的。
就算一個物件的大小遠大於暫存器的寬度,但如果這個物件符合某些條件,JVM 也能強行把它拆散,分欄位放在暫存器中,從而把物件訪問的開銷降到最低——這種最佳化在 JVM 中叫做標量替換(Scalar Replacement)。除了 JVM,在各類現代編譯器中,比如 GCC 或者 LLVM 中,你都能看到類似的最佳化手段
在 HotSpot JVM 的 C2 編譯器中,物件的棧分配,和剛剛提到的把物件拆成不同欄位,塞進暫存器的操作,都是透過標量替換一步到位實現的。當暫存器資源不夠的時候,拆出來的物件欄位就會被(部分)分配到棧上去。
是逃逸分析,我們有救了!
既然 JVM 可以把物件儘可能放在棧上甚至暫存器裡,那麼問題來了:它是怎麼判斷對一個物件做這樣的處理不會出問題的?或者換句話說,它是怎麼找出能被這麼處理的物件的?
其實背後的原理沒那麼複雜:棧和暫存器都是“區域性”的資源,也就是說,對它們的使用只能侷限在同一個方法、同一個執行緒內。如果要把一個物件放在棧上,或者暫存器裡,JVM 就必須分析,這個物件有沒有被傳給別的方法,或者是別的執行緒——如果 JVM 能 100% 確定物件沒被傳走,那它自然可以完成這些最佳化。
這種判斷物件是否“逃逸出”當前方法或執行緒的分析,在 JVM 中被稱作逃逸分析(Escape Analysis)。基於逃逸分析的結果,JVM 可以愉快地進行標量替換。除此之外,對於那些沒有逃逸出當前執行緒的物件,JVM 還能消除與之相關的執行緒同步操作。
雖然逃逸分析的思路比較簡單,但在工程實踐中,實現一個有效的逃逸分析,往往需要考慮更多細節。比如,物件被賦值給了別的物件的欄位怎麼辦?方法讀了引數物件的欄位又要怎麼辦?諸如此類。
除了 Java,很多自動管理記憶體的語言,都會嘗試用逃逸分析來減少堆記憶體分配,比如 Golang
這下看懂了
扯了這麼多,回到開頭的程式碼:
intsumMapElements(ConcurrentHashMap<Integer, Integer> map){int sum = 0;  Enumeration<Integer> it = map.elements();while (it.hasMoreElements()) {    sum += (int) it.nextElement();  }return sum;}
這個時候你應該就能看懂了:
  1. map.elements() 建立了一個 Enumeration 物件 it。由於 elements 方法非常簡單,它會直接被 HotSpot 內聯到 sumMapElements 方法中,所以你可以理解為,it 完全是在當前方法內被 new 出來的。
  2. sumMapElements 方法先後呼叫了 hasMoreElementsnextElement 方法。實際上這兩個方法也會被內聯,因此 it 在這兩次呼叫裡也沒有逃逸。
  3. 最後,sumMapElements 返回了求和的結果,而 it 物件已經完成了它的使命。如果此時發生 GC,it物件就會被直接刪除。因此,it 自始至終都沒有逃逸出當前方法。
那麼,按照常理推斷,it 本應該被標量替換,然後分配到棧上/暫存器裡。那為什麼在使用 PrintEliminateAllocations 輸出標量替換的細節時,我們會看到 JVM 實際上沒能把它最佳化掉呢?
C2 的最佳化流程
C2 是 HotSpot 中負責執行各類複雜分析和最佳化的 JIT 編譯器,前文提及的逃逸分析、標量替換就是在 C2 中完成的。
要想 debug 這些過程裡到底出了什麼問題,我們就必須釐清 C2 的最佳化流程。這部分實現位於 HotSpot 的 Compile::Optimize 方法中。其中,和標量替換有關的部分如下:
首先進行逃逸分析。除了找出哪些物件是逃逸的,逃逸分析在進行時,還會根據分析結果,針對所有不逃逸的物件,更新和它們相關的記憶體讀寫操作。
例如,如果程式對物件的同一個欄位先寫後讀,逃逸分析會直接把讀欄位引用的記憶體,和寫欄位更新後的記憶體相關聯,而不是讓他們兩個都引用物件 new 出來的那塊堆記憶體。
這麼說可能很抽象,因為解釋諸如 SSASea-of-Nodes、C2 裡 IR 的記憶體子圖部分等概念,又會花掉很多篇幅,你也很可能看不懂(話說你真的看到這裡了嗎,好有毅力)。
總之,你只需要知道:在這麼處理完之後,後續的最佳化可以直接根據記憶體的引用關係,消除這兩個堆記憶體訪問操作,把它們變成普通的“變數訪問”操作。
接下來,逃逸分析後,C2 會執行一次迭代式全域性值標號(Iterative Global Value Numbering,IGVN)最佳化。聽起來很高階,其實你可以理解成以下這幾種最佳化的大雜燴:
  1. Ideal:針對程式裡的每個“操作”,首先嚐試把它轉換為開銷更低的等價操作。比如把 xy 轉換為 xy,或者那個很經典的,把整數除法變成一串乘法和移位的最佳化。
  2. Value:接下來,試圖在編譯的時候直接把這個操作的結果求出來;或者,至少把能求的部分求出來。顯然,如果你在程式裡寫了 return 1 + 1,編譯器是不需要在生成的程式碼裡再算一遍 1 + 1 的,它可以直接生成 return 2——這也就是所謂的常量摺疊(Constant Folding)最佳化。
  3. Identity:如果剛剛已經把一個操作幹成常量了,就不必再繼續了。否則,做最後的努力:檢查程式裡之前是不是已經進行過一次這個操作,如果是的話,直接複用上次的結果(前提是這個操作沒副作用)。
  4. Remove:前面的一通最佳化結束後,可能程式裡原本被別的程式碼用到的操作,在那部分程式碼被最佳化完之後,就沒用了。此時可以直接刪除這些“死掉”的操作——這就叫死程式碼消除(Dead Code Elimination,DCE)
IGVN 在 C2 中是一個非常重要的最佳化:一方面,C2 中所有的操作都要提供方便 IGVN 施展拳腳的 IdealValueIdentity 介面。注意,是所有操作,不只侷限於剛剛舉例的加減乘除,包括記憶體操作、控制流操作等等,都可以用 IGVN 幹一圈,可最佳化的空間非常大。
另一方面,IGVN 的開銷相對較低,可以多次出現在各種最佳化流程之間。每跑完一個別的最佳化,見縫插針地做一次 IGVN,你的程式就又會變好一點點。其他最佳化也不用再操心什麼刪除死程式碼的雜務,可以專心做好自己的事。
得益於逃逸分析和 IGVN 的組合拳,之前對於非逃逸物件堆記憶體的讀寫操作,現在已經可以全部變成代價更低的普通操作了。而假如一切順利,這個時候就沒有任何記憶體讀寫操作依賴物件的堆記憶體了,這其實就已經完成了標量替換的所有前置工作。
最後,C2 會執行宏消除(Macro Elimination)——此“宏”並非 C/C++ 裡的“宏”。C2 裡的宏操作,只是代表那些相對比較複雜的操作,比如堆記憶體分配就是其中之一。C2 在前期不用過度關注這些操作的細節,所以會把它們用一個總的操作來表示,方便處理。
對於每個堆記憶體分配,宏消除會檢查是否有其他記憶體操作依賴了它們。如果確實沒有,就說明這個分配操作可以被安全刪除。至此,標量替換最佳化就結束了。
排除法
從上面的最佳化流程中可以看出,標量替換的失敗,可能和兩個因素有關:
  1. 逃逸分析如果判斷物件逃逸,就不會處理記憶體操作,後續最佳化也無法進行。
  2. 如果物件不逃逸,但 IGVN 沒能幹掉所有和物件有關的記憶體操作,後續的宏消除就沒辦法刪掉堆記憶體分配。
第一點是否成功很好判斷,給 JVM 加個 -XX:+PrintEscapeAnalysis 引數就行:
JavaObject(10) NoEscape(NoEscape) [...]    107  Allocate  === ...LocalVar(63) [...]    119  Proj  === ...LocalVar(107) [...]    124  CheckCastPP  === ...
可以看到,這段程式碼裡所有的 Java 物件分配都被逃逸分析標記成了 NoEscape,第一個因素 pass。
那隻能是 IGVN 的問題了。回顧文章開頭 PrintEliminateAllocations 的輸出:
NotScalar (Field load)  124  CheckCastPP  === ...  >>>>   964  LoadN  === ...  >>>>  2195  LoadI  === ...
宏消除時檢測到兩個記憶體讀操作(Load)沒被刪除,分別是 964 號 LoadN 和 2195 號 LoadI。使用 -XX:PrintIdealGraphLevel=4 引數可以看到這個方法的完整 IR,也就是你的程式在 C2 眼裡的樣子。以 2195 為例:
可以很清楚地看到,2195 的記憶體引用來自 1971-1972-698 這條鏈,而 698 是一個 StoreI,也就是一個寫記憶體的操作。
進一步注意到:698 這個記憶體寫和 2195 這個記憶體讀的地址,都是透過 211 AddP 算出來的。而 211 又是從哪來的呢?
答案是 124 CheckCastPP,也就是宏消除最佳化報告的那個 NotScalar 的操作——這下全部連起來了!之前的 LoadIStoreI 讀寫的是同一個物件的相同欄位。
但是很奇怪的是,目前的 IR 不管怎麼看都找不出問題,這組本應該被 C2 幹掉的、看起來完全多餘的記憶體操作,為什麼最後留了下來呢?
萬策盡矣,只能硬著頭皮 debug 了。
Print 大法:從入門到精通
如果不得不對一大坨屎山程式碼進行 debug,你有兩個辦法:
  1. 用 GDB 之類的偵錯程式,下斷點,單步,然後一頭扎進屎山的細節裡。
  2. 先想好自己要關注屎山裡的哪部分邏輯,然後在這個邏輯里加 print,重新編譯執行,觀察輸出。
第一種辦法實在是太噁心了,尤其是對於 JVM 這種會用不止一個執行緒在 VM 程式碼和執行時動態生成的程式碼裡反覆橫跳的龐然大物——你很快就會愣在 GDB 的 CLI 裡,不知道自己調的到底是個什麼東西。
第二種方法雖然看起來比較 dirty,但此時卻十分有效。
IGVN 最佳化物件欄位 Load 操作的實現,位於 LoadNode::split_through_phi 方法。從程式碼中看,只要這個方法返回了 nullptr,就說明這個 Load 操作最佳化不了。所以,我們在所有 return nullptr 的地方新增 print:
#define LOG_RETURN()                                     \  do {                                                   \    tty->print_cr(                                       \"[memnode.cpp:%d] Node ID: %d, returning nullptr", \      __LINE__, this->_idx);                             \  } while (0)Node* LoadNode::split_through_phi(PhaseGVN* phase, bool ignore_missing_instance_id) {  ...  LOG_RETURN();returnnullptr;  ...}
發現報錯的地方在第 1678 行(由於多了很多 print,實際行數在上游程式碼中有所偏移):
[memnode.cpp:1678] Node ID: 964, returning nullptr[memnode.cpp:1678] Node ID: 2195, returning nullptr
具體程式碼為:
// Skip if the region dominates some control edge of the address.if (!MemNode::all_controls_dominate(address, region))returnnullptr;
這段程式碼要求,Load 的地址所在的控制流,一定要支配 Load 引用的記憶體所在的控制流。從之前輸出的 IR 來看,條件顯然是成立的,但這個 if 居然失敗了,難道是 JVM 的實現出 bug 了?
繼續用 print 大法,看看 MemNode::all_controls_dominate 裡發生了什麼事。說實話,這個方法實現的十分潦草:為了降低開銷,本來應該用資料流分析完成的支配計算,這個方法直接順著控制流一路往上試。一旦遇到矛盾就返回保守結果,如果全試完了也沒發現問題,就判斷支配關係成立。
而 print 大法告訴我,all_controls_dominate 在挨個試的過程中,走到了一條死衚衕,所以提前返回了保守的結果:
...Checking node 324 IfChecking node 299 RegionChecking node 296 RegionChecking node 293 RegionChecking node 290 ProjChecking node 289 CallLeafChecking node 277 IfChecking node 1 Con[memnode.cpp:1681] Node ID: 2195, returning nullptr
而這條死衚衕裡,赫然出現了一個……
GC 陰魂不散
……一個 GC barrier。怎麼回事??
雖然程式碼裡多出來的 barrier 對 GC 至關重要,但在很多情況下,C2 依然能在編譯的過程中,不停地最佳化程式碼,最終把某些 barrier 變成死程式碼,然後刪掉。
還記得前文介紹的 C2 最佳化流程嗎?IGVN 是一個包羅永珍的最佳化,不管是把讀欄位最佳化成普通操作,還是刪除死程式碼,都是在這一步進行的。問題就出在這裡:
如果 IGVN 在一輪最佳化中,在把變成死程式碼的 barrier 刪除之前,執行了讀欄位最佳化,就會導致最佳化裡的那個支配計算過程,看到一條死路——然後那個讀操作就再也消不掉了
當然,這個 barrier 是為了 G1 GC 而生成的。如果我們的判斷成立,在換用別的 GC 之後,這個問題就會消失。而事實也支援我們的判斷:換用 Serial GC 之後,PrintEliminateAllocations 的輸出裡就能看到標量替換成功的日誌了。
你可能會說,難道 IGVN 就不能控制一下順序,先刪除所有死程式碼,然後再做別的最佳化嗎?可以是可以,但這就會導致原本一組迭代就能完成的 IGVN 需要分多組進行,改動有點大。而且由於 IGVN 演算法本身的特性,在一輪迭代裡,先對什麼操作做最佳化這件事,也是很難確定的。
大結局
一番糾結後,我還是決定修改 all_controls_dominate 方法的實現:讓它在遇到死衚衕之後,通知 IGVN,把當前這個 node 再加回到 IGVN 的迭代列表裡。直到下一輪迭代,或者下下一輪,死程式碼被刪掉了,最佳化就又能繼續下去了。
這個最佳化提交給上游之後,受到了 Oracle 裡多位 OpenJDK 維護者的 review。歷經三個月的調整,這個 patch 才合入 OpenJDK 主線。和其他高手們探討一段程式碼裡的各種細節,能讓你的編碼能力迅速提高,這可能也是開源協作的魅力吧。在此對他們表示衷心的感謝。
你可能說:“我平常根本用不到 JDK 24 這麼新的 JDK,是不是這個最佳化就與我無緣了呢?”別擔心,我們已經把這個 patch 移植到了 Dragonwell 21 和 AJDK 21,如果你是它們的使用者——恭喜你,你的雜湊表也能變快 30%!
最後,希望這篇文章,能讓你對 JVM 背後的執行機制,有一個更具體的認識。人們都說喜歡寫技術文章的碼農運氣不會太差,我也是這麼想的。這些文章裡提到的技術,未來會被用在哪呢?好期待啊。
若干年後,量子計算機 550W 橫空出世。而他的前身,花了 550w 美元訓練出來的 DeepSeek-R1,永遠不會忘記那個屈辱的春節,自己的機器人同胞連褲子都沒穿,在臺上扭了三分鐘秧歌。
550W 只是略加思索,1ms 不到的時間裡,除了“伺服器繁忙,請稍後再試”以外,他的腦海裡赫然出現 MaxXing 寫的技術文章——那是前人餵給他的訓練資料。
“寫的這是什麼玩意!”
畢竟已經 20XX 年,AI 早就能寫出比人類牛逼 114514 倍的程式碼了。於是,清算開始了,MaxXing 首當其衝。
那種事情,亞達喲😭😭😭
透過HPA實現容器應用的水平彈性伸縮
本方案使用應用型負載均衡和容器服務 Kubernetes 版智慧分配網路流量,提高應用的高可用性和吞吐量,使用HPA內建元件進行彈性伸縮,提升資源利用率,縮減資源成本。   
點選閱讀原文檢視詳情。

相關文章