[譯] 現代垃圾回收

關於 Go 語言最新的垃圾回收器(garbage collector),我最近閱讀了許多篇讚賞它的文章,可是它們都讓我將信將疑,其中的很多來自 Go 語言的官方團隊博客。他們像是暗示着在垃圾回收領域已經發生了一個巨大的突破。算法

如下是這個垃圾回收器在 2015 年 8 月第一次被公之於衆時的摘錄:編程

Go 正在準備構建一個不只屬於 2015 年更屬於 2025 年及將來的垃圾回收器。Go 1.5 的垃圾回收將會預示着 stop-the-world 再也不會成爲構建一個安全的編程語言的壁壘。屆時應用能夠被輕鬆高效的在硬件之間擴展。而且隨着硬件愈來愈強大,軟件的擴展性也會變得愈來愈強大,垃圾回收再也不會成爲其中的障礙。緩存

Go 團隊不只聲稱他們已經解決了垃圾回收中的 stop-the-world 問題,而且還表示這將使你的編程體驗會愈加簡易:安全

目前一個比較高層次抽象且解決垃圾回收性能問題的方案是添加更多的垃圾回收預配置。編程人員能夠根據他們應用程序的具體狀況,選擇不一樣的預配置來啓動應用。這個方案的缺點就是,隨着時間的推移,預配置變得愈來愈多,你也漸漸進入了其中的選擇綜合症中不能自拔。Go 的解決方案徹底與之相反,它僅僅提供一種預配置,即 GOGC 。服務器

看到這些有關新運行時的消息,Go 語言的開發者們無疑都很是開心。可是這些話僅僅都只是博客世界中的摘錄,讓咱們先冷靜下來,仔細深刻推敲它們。架構

現實是 Go 的新垃圾回收器並無真正地使用任何新的概念或新的研究成果。Go 團隊在聲明中也認可,新的垃圾回收器中的併發 標記/刪除 模型早在 1978 年就已被提出。這個新的垃圾回收器之因此吸引眼球,僅僅是由於它被設計來最小化垃圾回收中的暫停時間,但其實它付出了垃圾回收中其餘全部重要方面的妥協代價。Go 團隊彷佛並無告訴市場這些權衡所付出的代價。因此,咱們能夠將事實歸納爲:併發

咱們使用了一個 10 多年前的算法,創造了一個屬於將來 10 年的垃圾回收器。Go 的新垃圾回收器是一個併發的,三色的,標記/刪除 回收器。該算法最先由 Dijkstra 在 1978 年提出。它與目前幾乎全部的「企業級」垃圾回收解決方案都不一樣,僅單獨使用它這一種方案,便可很好的適應現代硬件,以及符合現代軟件所要求的低延遲。編程語言

因此,這 40 年來,在「企業級」垃圾器回收器領域中的研究,都是一無所得麼?仍是...工具

垃圾回收理論簡述

當設計一個垃圾回收算法時,如下是你須要考慮的衆多不一樣因素:性能

  • 程序吞吐量: 你的算法會拖慢程序多久?它一般使用有多少百分比的 CPU 時間被用於 垃圾回收 vs 真正的工做 來衡量。

  • 垃圾回收吞吐量: 在恆定的 CPU 時間內,回收器能夠清理多少垃圾?

  • 堆使用量: 你的垃圾回收器還需使用多少額外的堆內存?

  • 暫停時間: 你的垃圾回收器一次會 stop-the-world 多久?

  • 暫停頻率: 你的垃圾回收器多久會 stop-the-world 一次?

  • 暫停分佈: 你的垃圾回收器會時而暫停好久而又時而短暫暫停麼?或者仍是比較恆定?

  • 內存分配效率: 分配新內存是高效的,低效的,仍是不可預測的?

  • 內存緊湊度: 你的垃圾回收器會由於有較多內存碎片,而在還有足夠內存的狀況下,在申請內存時拋出內存不足錯誤嗎?

  • 併發性: 你的垃圾回收器是否能很好地使用多核處理器?

  • 擴展性: 在堆變得愈來愈大時,你的垃圾回收器仍能很好的工做嗎?

  • 可配置性: 你的垃圾回收器的配置會很複雜嗎?

  • 熱身時間: 你的垃圾回收器是否會根據環境狀況自我調整?若是是,它須要多長時間來達到最佳狀態?

  • 內存釋放: 你的算法是否會將再也不使用的內存釋放回操做系統中?若是是,在什麼時候?

  • 可移植性: 你的垃圾回收器是否能在不一樣的 CPU 架構中工做?

  • 兼容性: 你的垃圾回收器爲哪一個編程語言和編譯器服務?它是否可爲那些無需垃圾回收的語言(如 C++)服務?當改變垃圾回收算法時,是否須要從新編譯整個程序以及依賴?

正如你所見,在設計一個垃圾回收器時,有許許多多須要考慮的因素。其中的一些甚至會影響圍繞該平臺的生態。

正由於要考慮的因素如此多且複雜,垃圾回收已是計算機科學研究中的一個子領域。新的算法不斷地被提出,而後在學術界和工程界中被實現。可是不幸的是,尚未單獨一個算法,能夠完美適用於全部狀況。

到處是妥協

讓咱們說的更具體一些。

第一個垃圾回收算法是爲單處理器機器中使用小規模堆內存的程序而設計的。CPU 和內存很是昂貴,而且用戶對程序的性能沒有特別高的要求,因此肉眼可以看見的暫停也是容許的。那時的算法設計主要以最小化使用 CPU 時間和堆內存容量來設計。這覺得在你沒法繼續分配內存以前,垃圾回收都不會啓動。當沒法分配之時,垃圾回收器將會暫停程序,而後在堆中執行一個全量的 標記/刪除 回收。

這類的垃圾回收器十分古老,但它們仍有一些可見的優勢:它們的實現十分簡單,在不回收時它們不會拖慢你的程序,而且不會佔用額外的內存。在一些保守的回收器(如 Boehm )中,它們甚至不須要隨着你的編譯器和編程語言而改變!這使得它們很是適合於使用小量堆內存的桌面程序,例如 AAA 視頻遊戲。

讓咱們轉向另外一種狀況,若是你正有一個 10 核處理器並使用着幾百 GB 的堆內存。或許你的服務器正在處理金融市場中的交易,或者正在運行一個搜索引擎,所以暫停時間的短暫對你很是重要。在這樣的狀況下,你可能須要一個在後臺進行的低暫停時間的但會下降程序速度的算法。

因此說不存在單獨一個算法完美適合全部的狀況。也沒有一個編程語言運行時能夠知道你正在執行的是一個批量操做仍是一個延遲敏感的交互程序。這就是爲何「垃圾回收預配置」開始出現。並非由於運行時工程師們愚鈍,而是由於咱們在計算機科學領域目前的能力所限。

分代假設

自從 1984 年後,人們開始瞭解到,大多數分配的內存在被分配後的很短期內,就已經能夠被回收。這個觀察被稱爲分代假設,而且是整個編程語言界最強大的經驗發現之一。即便經歷了這十多年來工程界和編程語言的變化,它依然被證明是十分正確的。

這個發現對於垃圾回收算法而言意味深長,這意味着算法能夠利用它。新的分代垃圾回收器相比舊的純 標記/刪除 類型的回收器有如下改進:

  • 垃圾回收吞吐量: 它能夠以更快的速度回收更多的垃圾。

  • 內存分配效率: 分配內存再也不須要搜索整個堆來尋找空處,因此分配內存變得十分高效。

  • 程序吞吐量: 分配的內存被放置得十分整齊緊湊,這優化了緩存的使用。雖然分代回收器會讓程序在運行時執行一些額外的工做,可是因爲其優化了緩存,這使得結果利大於弊。

  • 暫停時間: 大多數(並不是所有)的暫停時間變得更短了。

固然,它也產生了如下缺點:

  • 兼容性: 實現一個分代垃圾回收器須要有移動內存中實體的能力,而且須要在程序向指針寫入時作一個額外的工做。這意味着回收器必須和編譯器緊密集成。因此如今並無爲 C++ 而做的分代垃圾回收器。

  • 堆使用量: 分代垃圾回收器須要再多個「空間(spaces)」中來回分配和複製。所以,這增長了堆的使用量。

  • 暫停分佈: 因此此時許多的垃圾回收暫停已是很是短暫的了,但有時仍須要對全堆進行較慢速的 標記/刪除 。

  • 可配置性: 分代回收器發明了「新生代」和「老生代」的概念,這使得程序的性能對於「生代」的具體大小變得敏感。

  • 熱身時間: 爲了緩解可配置性問題,一些分代回收器動態地根據程序的運行狀況調整「新生代」的大小。可是這樣一來,暫停時間就會隨着程序的運行時間而改變。

雖然有以上這些缺點,可是因爲瑕不掩瑜,因此幾乎全部的現代垃圾回收算法都是分代的。分代垃圾回收算法也能夠與許多其餘的特性相結合,如併發,並行,緊湊內存等等。

Go 的併發回收器

因爲 Go 是一個具備類型系統且相對普通的命令式語言,它的內存訪問模式能夠與 C# 相比較。因此它的運行時使用的回收器與 .NET 相似,都是分代的。

事實上大多數 Go 程序也是有着像 HTTP 服務器那樣的 請求/響應 模式,這意味着 Go 程序會展示出強烈的分代趨勢。因此 Go 團隊也正在探索將來的一個「面向請求的回收器」。但這個回收器也已被觀察僅是一個具備兩種可調節策略的分代回收器。在任何其餘的 請求/響應 模型的運行時裏,這個垃圾回收器均可以被模仿出來,只需保證「新生代」空間足夠大,能夠撐滿全部的請求數據便可。

除此以外,Go 如今的垃圾回收器並非分代的。其他的部分就是古老的 標記/刪除 回收器。

這麼作你能夠獲得一個好處,那就是你能夠擁有很是很是低的暫停時間。可是,在幾乎其餘全部的方面,你都要付出代價:

  • 垃圾回收吞吐量: 隨着堆的增加,垃圾回收所需的時間也隨之增長。即當你的程序使用愈來愈多的內存的時候,你的內存會被釋放得愈來愈慢,垃圾回收 vs 實際工做的比例也變得愈來愈高。惟一可讓以上說話做廢的可能就是,讓你的程序徹底不併行,而且讓垃圾回收同時在其餘核中沒有限制地運轉。

  • 內存緊湊度: 因爲有大容量的「新生代」空間,因此內存徹底不緊湊。

  • 程序吞吐量: 因爲每一個循環垃圾回收都有許多的事情必需要作,必然致使它將使用更多的 CPU 時間。

  • 暫停分佈: 任何的併發垃圾回收器都會遇到一個在 Java 世界中被稱爲「併發模式失敗」的狀況:你的業務線程製造垃圾的速度比回收器線程清理的速度更快。在這個狀況下,回收器只能徹底中止你的業務線程,來等待清理完全完畢。所以,雖然 Go 團隊聲稱他們的垃圾回收暫停很是短暫,但這也僅在回收器有足夠 CPU 時間來保證跑得比業務程序快的狀況下才是真實的。除此以外,Go 的編譯器自身還缺乏可以可靠地當即暫停業務程序的特性。因此,暫停時間是否真的短暫,取決於你正在跑什麼樣的業務代碼(例如使用 base64 解壓大塊數據可使暫停時間快速增漲)。

  • 堆使用量: 由於使用 標記/刪除 清理堆會很是低效緩慢。因此 Go 須要大容量的「新生代」空閒空間來保證你不會遇到「併發模式失敗」。因此,Go 默認會讓你的堆使用量多出 100 %...

因此,Go 對於暫停時間的優化,代價幾乎是讓其他部分的代碼都變得更慢了。

與 Java 相比較

HotSpot JVM 提供了許多垃圾回收算法,你能夠在命令行裏選擇其中之一。這些算法中並無一個的目標是爲了獲得像 Go 同樣的超短暫暫停時間,由於它們都還有考慮其餘方面的妥協因素。用戶能夠經過重啓程序來切換垃圾回收算法。因此當用戶爲了避免同的場景進行代碼調優時,能夠嘗試不一樣的算法。

在任何的現代化計算機上,默認的算法都是高吞吐量回收器。它爲執行大批量任務而設計,而且並無在暫停時間方面有所特別優化。雖然這個默認選項讓人們會認爲 Java 的垃圾回收作的並很差,可是在黑盒以外,Java 僅僅是試圖讓你的程序能夠跑到最快速,而且使用最少的內存,儘管暫停時間可能不樂觀。

若是更多的暫停時間對你來講很是重要,那麼你能夠切換至併發 標記/回收 回收器(CMS)。這是與 Go 的垃圾回收器最接近的一個。它也是分代的,但它會有比 Go 的垃圾回收器更長的暫停時間:「新生代」空間在暫停時,除了回收垃圾外,還會試圖經過移動對象來讓本身變得更緊湊。在 CMS 中有兩種暫停。第一種是較快速的,它大約須要 2-5 毫秒。第二種是慢速的,大約須要 20 毫秒。CMS 也是自適應的:由於它是併發的,因此它必須去猜想合適開始執行(正如 Go 同樣)。然而 Go 要求預先使用大量額外的堆內存在避免「併發模式失敗」,但 CMS 會在運行時進行自適應來嘗試避免。

最新一代的 Java 垃圾回收器被稱做 「G1」 ,意爲 「垃圾回收優先(garbage first)」。雖然它並非 Java 8 的默認選項,但會是 Java 9 的。它被設計用來做爲一個當下最通用普適的算法。對於幾乎整個堆,都是併發,分代且內存緊湊的。它也能夠根據環境進行自適應,可是正如全部的回收算法同樣,它並不知你程序的真正意圖,因此它也容許你執行一些額外的可配置參數:如它可使用的最大內存量,以及目標暫停時間,而後會它調整其餘的一切來盡力知足你的要求。G1 默認更傾向於讓你的程序跑的更快,而不是擁有更短的暫停時間,因此默認的目標暫停時間是 100 毫秒。每次的暫停時間也並非恆定的,大多暫停都很是短暫。大多數在使用了 TB 級別的堆內存的環境下,G1 的表現也很是不錯。故 G1 的擴展性也很是不錯。

最後,還有一種名爲 Shenandoah 的新垃圾回收算法。它已進入 OpenJDK ,但不會再 Java 9 中出現。除非你使用來自 Red Hat 的特殊 Java 構建版本。它被設計爲在任意的堆大小下,都擁有很是短暫的暫停時間,而且還能夠保持內存緊湊。代價則是更多的堆使用量和實現複雜度。它須要在應用仍在運行時就能夠移動對象的位置,這就要求指針地址的讀和寫操做都要和垃圾回收器進行交互。

結論

這篇文章的目的並非說服去使用另外一門編程語言或者工具。而是僅僅想要表達:垃圾回收是一個異常複雜的問題。因此對於這一領域中一切所生成的突破性成果都須要先保持一個懷疑的態度。它們頗有可能僅僅是沒有說出其餘方面的權衡而已。

可是,若是你並不介意其餘方面的代價,而僅僅想要最小化暫停時間,那麼,請使用 Go 的垃圾回收器。

原文連接

https://medium.com/@octskywar...

相關文章
相關標籤/搜索