1. 程式人生 > >一個專家眼中的Go與Java垃圾回收演算法大對比

一個專家眼中的Go與Java垃圾回收演算法大對比

我最近看過一堆宣傳 Go 語言的最新垃圾收集器的文章。 其中一些文章來自 Go 專案本身。 他們聲稱 GC 技術發生了根本性的突破。

以下是 2015 年 8 月新垃圾收集器的公告:

Go 正在構建一個垃圾收集器(GC),不僅是為了了 2015 年,同時也為 2025 以及更遠的未來…… stw 停頓不再是使用 Go 語言的障礙。在將來,應用程式隨著硬體輕鬆地擴充套件,並且跟隨硬體一起變得更加強大,GC 不會成為軟體可擴充套件性的絆腳石。

Go 團隊不僅聲稱已經解決了 GC 暫停的問題,而且整個事情變得非常傻瓜:

解決效能問題更高級別的一種方法是新增 GC 選項,每個效能問題設定不同的選項。程式設計師搜尋適合其應用的合適 GC 設定。 缺點是,經過十年以後你會得到非常多配置選項(配置選項成為一門黑魔法)。Go 不會走這條路。 相反,我們提供一個單一的選項,稱為 GOGC。


此外,由於持續支援數十個選項,Go 團隊可以根據真實應用程式的執行情況的反饋來改進執行時的效果。

許多 Go 使用者都非常滿意於新的 runtime。 但是對我來說,它更像是一個誤導。 由於這些宣告在各種部落格重複出現,現在是時候更深入地審視它們了。

現實情況是,Go 的 GC 並沒有真正實現任何新的思想或研究。正如公告中表達的,它是一個併發標記/掃描收集器(基於 20 世紀 70 年代的想法)。 它被設計為以 GC 中其他因素為代價來優化暫停時間。 Go 的技術講座似乎沒有提到這些權衡:

為了在接下來的十年中建立一個垃圾收集器,我們轉向幾十年前的一個演算法。 Go 的新垃圾收集器是一個併發的,三色,標記掃描收集器,這是一個由 Dijkstra 在 1978 年首次提出的想法。和今天的大多數“企業級”垃圾收集器相比,這是一個經得起推敲的差異化選擇,我們認為該演算法非常適合現代硬體的屬性和現代軟體的延遲要求。

讀了上述宣告之後,你可能會非常困惑,過去 40 年間,所有“企業”級別的 GC 研究沒有任何成果?

GC 理論基礎

下面是在設計垃圾收集演算法時您想要考慮的不同因素:

  • 程式吞吐量:你的演算法在多大程度上減慢程式?這表示為花費在執行垃圾收集與工作時間的百分比。

  • GC吞吐量:在給定CPU時間內多少垃圾可以被收集器清除?

  • 堆開銷:你的收集器需要多少額外的記憶體?如果你的演算法在收集時分配臨時記憶體,是否會使你的程式的記憶體使用突然暴漲?

  • 暫停時間:你的垃圾收集器stop world多久了?

  • 暫停頻率:你的垃圾收集器多久暫停一次程式?

  • 暫停分佈:有時有非常短暫的停頓,但有時會有很長的停頓。

  • 記憶體分配效能:分配新記憶體的時候是快還是慢?或者效能不可預測?

  • 整理:因為記憶體碎片的原因,在有足夠的可用空間可滿足請求,垃圾收集器是否會報告記憶體不足(OOM)錯誤?

  • 併發:垃圾收集器如何使用多核?

  • 擴充套件性:你的垃圾收集器隨著堆增大工作情況如何?

  • 調優:垃圾收集器的配置有多複雜,可以開箱即用並獲得最佳效能嗎?

  • 預熱時間:垃圾收集演算法是否基於測量行為進行自適應調整?需要多長時間才能達到最佳?

  • 記憶體釋放:您的演算法是否釋放未使用的記憶體回到作業系統?如果是,什麼時候釋放?

  • 可移植性:您的垃圾收集器是否可以在提供比x86更弱的記憶體一致性保證的CPU體系結構上工作?

  • 相容性:您的垃圾收集器使用哪些語言和編譯器?它可以與設計時沒有考慮GC的語言(如 C++)一起工作嗎?它需要修改編譯器嗎?如果是這樣,更改GC演算法是否需要重新編譯所有程式和依賴關係?

如你所見,設計垃圾收集器有很多不同的因子需要考慮,其中一些會影響您平臺上更廣泛的生態系統的設計。 我自己甚至不確定以上列表是否包含所有因子。

因為設計空間如此複雜,所以垃圾收集是電腦科學的一個子領域。該領域有豐富的研究論文, 新的演算法由學術界和工業界以穩定的速率提出並實現。 然而沒有人發現單一的演算法在理論上滿足所有情況。

權衡(tradeoff)的藝術

讓我們討論得更具體一點。

第一個垃圾收集演算法是為具有較小堆的單處理器機器設計的。 當時CPU和RAM是非常昂貴的,使用者對程式暫停的要求並非很嚴苛,因此可見暫停是可以接受的。這個演算法優先考慮最小化垃圾收集器的CPU和堆開銷。這意味著在你分配記憶體失敗之前,垃圾收集器沒有做任何事情。垃圾收集器將暫停程式,並且完成堆的標記/掃描並回收記憶體。

該型別的收集器儘管有些年邁,但仍然有一些優勢 - 演算法簡單導致不會降低你的程式執行速度,當不進行垃圾收集時,不增加任何記憶體開銷。在保守垃圾收集器如Boehm GC的情況下,甚至不需要修改編譯器或換程式語言!這使它們適合於通常具有較小堆記憶體的桌面應用,包括AAA視訊遊戲(其中大量的RAM由不需要掃描的資料檔案佔用)。

Stop-the-world(STW)標記/掃描 (mark/sweep)是本科電腦科學類中最常見的 GC 演算法。在面試時,我會要求候選人談一談 GC,他們幾乎總是將 GC作為黑盒並對 GC 知之甚少。

簡單的STW 標記/掃描(mark/sweep)有非常嚴重的問題。隨著你新增處理器或者堆增長,該演算法無法良好工作。但是 -如果你的堆比較小,該演算法就能夠滿足對停頓時間的要求!在這種情況下,你應該使用該演算法,以保持你的GC開銷足夠低。

極端的情況下,也許你在一個擁有數十個核的機器上使用數百 GB 的堆。也許您的伺服器正在執行金融市場交易,或搜尋引擎,因此低暫停時間對您非常重要。這時候你可能願意使用雖然降低程式執行速度但是可以併發執行的收集演算法。

或者您也許有大批量作業。因為它們是非互動式,所以暫停時間根本不重要。在這種情況下,您最好使用吞吐量高於一切的演算法,可以提高工作時間與執行收集時間的比率。

問題是沒有單一的演算法在所有方面都是完美的。語言執行時也不可能知道您的程式是批處理作業還是互動式延遲敏感型程式。這就是為什麼“GC調優”存在的原因。它反映了我們電腦科學的基本限制。

代際(generation)假說

自1984年以來,我們發現大多數物件都很“年輕”(在分配之後很快就變成垃圾)。這個情況被稱為代際假說(generational hypothesis),是整個 PL 工程領域最強的經驗之一。它在不同種類的程式語言中,以及在軟體行業幾十年的變化中一直是正確的:函式語言,命令式語言,沒有值型別的語言和有值型別的語言都是如此。

發現這個事實是非常有用的,因為它意味著 GC 演算法可以在設計時利用它。這些新一代垃圾收集器對舊的 SWT 垃圾收集器有很多改進:

  • GC吞吐量:他們可以更多更快的收集垃圾。

  • 分配效能:分配新的記憶體不再需要搜尋通過堆尋找可用記憶體,因此記憶體分配器變得更有效。

  • 程式吞吐量:物件整齊地放在彼此相鄰的記憶體中,這大大提高了快取利用率。分代垃圾收集器確實需要程式在執行時做一些額外的工作,但是這種降低被改進的快取記憶體效果所抵消。

  • 暫停時間:大多數(但不是全部)暫停時間變得更低。

當然也引入一些缺點:

  • 相容性:實現一個分代垃圾收集器需要能夠在記憶體中移動物件,並且在某些情況下,當程式使用指標時需要執行額外的工作。這意味著GC必須與編譯器緊密整合。因此沒有用於 C++ 的分代垃圾收集器。

  • 堆開銷:這些收集器通過在各種“空間”之間來回複製記憶體來工作。因為必須有空間來進行復制,這些垃圾收集器增加了一些堆開銷。此外,它們需要維護各種指標對映,進一步增加記憶體開銷。

  • 暫停分配:雖然許多GC暫停現在非常短,但有些仍然需要對整個堆執行完全標記/掃描。

  • 調優:代數收集器引入了“年輕代”或“eden空間”的概念,程式效能對這個空間的大小非常敏感。

  • 預熱時間:為了響應調優問題,一些收集器通過觀測程式的執行以來動態地調整年輕代的大小,這種情況下暫停時間就取決於程式執行多長時間。

分代收集器的優勢是如此誘人,因此基本上所有現代 GC 演算法都是分代的。分代垃圾收集器可以通過各種其他功能進行增強,典型的現代 GC 將併發,並行,整理和分代整合在一起。

Go 併發垃圾收集器

由於 Go 是一種命令式語言,它的值型別,記憶體訪問模式和 C# (.NET 使用分代垃圾收集器)相當。

事實上,Go 程式通常是處理 request/response 任務(如 HTTP 伺服器),這意味著 Go 程式表現出強烈的代際行為,Go 團隊正在探索潛在的可以利用代際假說的演算法,他們稱之為“面向請求的垃圾收集器”。這本質上是一個可以策略調優的分代垃圾收集器。在處理請求/響應這種模式時,通過確保年輕代足夠大以使通過處理請求產生的所有垃圾都在其中來優化 GC。(高可用架構譯者注:指的是 Go下一代垃圾收集器 Transaction-Oriented Collector)

儘管如此,Go 的當前 GC 是不分代的。只是在後臺執行標記/掃描。(高可用架構譯者注:併發標記清除演算法)

這樣使暫停時間非常短 ,但使其他因素更糟糕。從我們的基本理論上面我們可以看到:

  • GC吞吐量:GC時間與堆大小同步增長。簡單來說,你的程式使用的記憶體越多,記憶體釋放速度就越慢,你的計算機花費的時間就越多。如果你的程式沒有並行化,你可以不用考慮這個問題。

  • 整理:因為沒有整理,GC 過程會產生記憶體碎片。程式也不會受益於在快取中整齊排列的內容。

  • 程式吞吐量:因為GC必須在每個週期做很多工作,所以會消耗不少CPU時間。

  • 暫停分佈:與程式併發執行的任何垃圾收集器都可能遇到Java中“併發模式失敗”的問題:您的程式建立垃圾的速度比GC執行緒可以清除它快。在這種情況下,runtime別無選擇只能完全停止程式,等待GC完成垃圾收集。因此當Go團隊宣告GC暫停非常低時,該宣告只能適用於GC具有足夠的CPU時間和空間以完成垃圾回收的情況。另外,由於Go編譯器缺乏確保執行緒可以被快速可靠暫停這一功能,會導致暫停時間是否很低取決於您執行的是什麼型別的程式碼(例如,base64 解碼單個 goroutine 中的大 blob 會導致暫停時間上升)。

  • 堆開銷:因為通過標記/掃描收集堆非常慢,您需要大量的空間以確保不會遇見“併發模式故障”。 Go預設使用100%的堆開銷會讓程式需要的記憶體量增加一倍。

我們可以看到這些權衡:

服務1分配記憶體多於服務2,因此STW暫停在服務1中較高。但STW暫停持續時間在兩個服務上都下降了一個數量級。我們看到切換後,兩個服務後在GC中花費的CPU使用率增加了約20%。

在這個特定的情況下,Go 以更慢的收集器為代價換取暫停時間的數量級下降。這是一個好的權衡嗎?暫停時間已經足夠低嗎?

付出更多的硬體成本以獲得較低的暫停時間,在一些情況下未必有意義。如果你的伺服器暫停時間從 10msec 降低到 1msec,你的使用者真的會注意到嗎?如果你必須加倍你的機器數量才能達成這一目的呢?

Go 將暫停時間優化作為首要目標,以至於它似乎願意將程式減慢至任何數量級,以獲得較短暫停。

與 Java 對比

HotSpot JVM 有幾個 GC 演算法,您可以在命令列中選擇。因為他需要平衡其他各種因素,因此沒有一個 GC 演算法的目標能將暫停時間降低到 Go 水平。可以通過重新啟動程式在 GC 之間切換,因為編譯是在程式執行時完成(高可用架構譯者注:這裡指 JIT 編譯器),所以不同演算法所需的不同記憶體屏障可以根據需要編譯和優化到程式碼中。

預設演算法是吞吐量收集器(throughput collector)。這是為批處理作業設計的,預設情況下沒有任何暫停時間目標。這種預設選擇也是人們認為 Java GC 有點吸引力的一個原因:開箱即用,它試圖使您的應用程式儘可能快地執行,並儘可能少的記憶體開銷,而暫停時間不是該演算法首要考慮的。

如果暫停時間對您更重要,那麼您可能需要切換到併發標記/掃描收集器( CMS concurrent mark / sweep collector)。這是和 Go 使用的 GC 演算法最接近的垃圾收集器。但它也是分代的垃圾收集器,這也是為什麼它的暫停時間比 Go 的更長的原因:年輕代需要整理並移動物件,而導致應用程式暫停。 CMS 中有兩種型別的暫停。第一種,較為短暫可能持續大約 2-5 毫秒。第二種可能會持續 20 毫秒或者更久。 CMS 是自適應的:因為是併發的,所以它必須猜測什麼時候可以開始執行 GC(就像 Go)。 CMS 將在執行時調整自己並嘗試避免“併發模式故障”。因為堆的大部分是標記/掃描演算法(高可用架構譯者注:這裡說的是老年代,使用 CMS 演算法的時候,年輕代並非使用該演算法而是使用基於標記/整理的 ParNew,所以嚴格來說把整理並整理記憶體的好處算在 CMS 演算法頭上是有問題的),可能會因為堆碎片而導致問題。

最新一代 Java GC 被稱為“ G1”( garbage first 垃圾優先)。它將在 Java 9 中成為預設演算法。它旨在提供一個通用的演算法。該演算法是針對整個堆的併發的,分代的和整理的演算法。 G1 在很大程度上也是自適應的,因為(像所有的 GC 演算法)它不能知道你真正想要什麼,但它允許你指定首選權衡:只需要告訴它你允許使用的 RAM 最大值和暫停時間目標(以毫秒為單位),它就會盡力滿足暫停時間目標。除非你指定不同的目標,否則預設的暫停時間目標大約是 100 毫秒。 G1 會更傾向於讓你的應用程式執行的速度快而非暫停少。其每次暫停時間並不完全一致,但大多數都非常快(少於一毫秒),有些暫停因為堆被整理而稍慢( 50 毫秒)。 G1 的擴充套件性也非常好。有報告稱,人們在 TB 級別堆規模的程式上使用 G1 演算法。它還有一些其他功能,如重複資料刪除堆中的字串。

Red Hat 支援的一個專案組開發了一種新的 GC 演算法,稱為 Shenandoah。程式碼已經貢獻給 OpenJDK,但不會出現在 Java 9 中(除非你使用紅帽子的 Java 版本)。這一演算法被設計為無論堆多大的情況下,都可以在提供整理的同時保證非常低的暫停時間。其成本是額外的堆開銷和更多的記憶體屏障(高可用架構譯者注:同時使用了讀寫屏障,而上述其他演算法都只使用了寫屏障)。在這個意義上,它類似於 Azul 的“無暫停”垃圾收集器(ArchNotes 譯者注:指的是使用 C4 演算法的垃圾收集器,嚴格來說也並非完全無停頓,只是保證停頓時間在任何情況都小於 10ms, 由於在軟實時系統上 OS 帶來的誤差有可能超過 10ms,因此可以認為是無停頓垃圾收集器)。

結論

本文的重點不是說服你使用不同的程式語言或工具。 只是希望帶來對垃圾收集器的正確的理解。垃圾收集是一個非常挑戰的工作,很多電腦科學家在上面耗費了數十年,因此不太有可能一晚上就會有一個全新的別人沒用過的 GC 演算法問世,更有可能的是,聲稱的新的 GC 演算法只是對老的 GC 演算法做了一個不同的,而且成熟的 GC 演算法不太會考慮的偏門 tradeoff 而已。

但是如果你僅希望減少程式暫停時間,那麼請關注 Go GC。

參考閱讀(可點選開啟)

本文由高可用架構志願者翻譯,英文原文地址:

https://medium.com/@octskyward/modern-garbage-collection-911ef4f8bd8e#.5j56cki9w

技術原創及架構實踐文章,歡迎通過公眾號選單「聯絡我們」進行投稿。轉載請註明來自高可用架構「ArchNotes」微信公眾號及包含以下二維碼。

高可用架構

改變網際網路的構建方式

長按二維碼 關注「高可用架構」公眾號

本文為頭條號作者釋出,不代表今日頭條立場。