Go並不需要Java風格的GC

Robert_Lu發表於2021-12-06

本文首發於 https://robberphex.com/go-does-not-need-a-java-style-gc/

像Go、Julia和Rust這樣的現代語言不需要像Java c#所使用的那樣複雜的垃圾收集器。但這是為什麼呢?

我們首先要了解垃圾收集器是如何工作的,以及各種語言分配記憶體的方式有什麼不同。首先,我們看看為什麼Java需要如此複雜的垃圾收集器。

本文將涵蓋許多不同的垃圾收集器話題:

  • 為什麼Java依賴快速GC?我將介紹Java語言本身中的一些設計選擇,它們會給GC帶來很大壓力。
  • 記憶體碎片及其對GC設計的影響。為什麼這對Java很重要,但對Go就不那麼重要。
  • 值型別以及它們如何改變GC。
  • 分代垃圾收集器,以及Go為什麼不需要它。
  • 逃逸分析 —— Go用來減少GC壓力的一個技巧。
  • 壓縮垃圾收集器 —— 這在Java中很重要,但是Go卻不需要它。為什麼?
  • 併發垃圾收集 —— Go通過使用多執行緒執行併發垃圾收集器來解決許多GC挑戰。為什麼用Java更難做到這一點。
  • 對Go GC的常見批評,以及為什麼這種批評背後的許多假設往往是有缺陷的或完全錯誤的。

為什麼Java比其他語言更需要快速的GC

基本上,Java將記憶體管理完全外包給它的垃圾收集器。事實證明,這是一個巨大的錯誤。然而,為了能夠解釋這一點,我需要介紹更多的細節。

讓我們從頭說起。現在是1991年,Java的工作已經開始。垃圾收集器現在很流行。相關的研究看起來很有前途,Java的設計者們把賭注押在高階垃圾收集器上,它能夠解決記憶體管理中的所有挑戰。

由於這個原因,Java中的所有物件——除了整數和浮點值等基本型別——都被設計為在堆上分配。在討論記憶體分配時,我們通常會區分所謂的堆和棧。

棧使用起來非常快,但空間有限,只能用於那些在函式呼叫的生命週期之內的物件。棧只適用於區域性變數。

堆可用於所有物件。Java基本上忽略了棧,選擇在堆上分配所有東西,除了整數和浮點等基本型別。無論何時,在Java中寫下 new Something()消耗的都是堆上的記憶體。

然而,就記憶體使用而言,這種記憶體管理實際上相當昂貴。你可能認為建立一個32位整數的物件只需要4位元組的記憶體。

class Knight {
   int health;
}

然而,為了讓垃圾收集器能夠工作,Java儲存了一個頭部資訊,包含:

  • 型別/Type — 標識物件屬於的類或它的型別。
  • 鎖/Lock — 用於同步語句。
  • 標記/Mark — 標記和清除(mark and sweep)垃圾收集器使用。

這些資料通常為16位元組。因此,頭部資訊與實際資料的比例是4:1。Java物件的c++原始碼定義為:OpenJDK基類

class oopDesc {
    volatile markOop  _mark;   // for mark and sweep
    Klass*           _klass;   // the type
}

記憶體碎片

接下來的問題是記憶體碎片。當Java分配一個物件陣列時,它實際上是建立一個引用陣列,這些引用指向記憶體中的其他物件。這些物件最終可能分散在堆記憶體中。這對效能非常不利,因為現代微處理器不讀取單個位元組的資料。因為開始傳輸記憶體資料是比較慢的,每次CPU嘗試訪問一個記憶體地址時,CPU會讀取一塊連續的記憶體。

這塊連續的記憶體塊被稱為cache line 。CPU有自己的快取,它的大小比記憶體小得多。CPU快取用於儲存最近訪問的物件,因為這些物件很可能再次被訪問。如果記憶體是碎片化的,這意味著cache line也會被碎片化,CPU快取將被大量無用的資料填滿。CPU快取的命中率就會降低。

Java如何克服記憶體碎片

為了解決這些主要的缺點,Java維護者在高階垃圾收集器上投入了大量的資源。他們提出了壓縮(compact)的概念,也就是說,把物件移動到記憶體中相鄰的塊中。這個操作非常昂貴,將記憶體資料從一個位置移動到另一個位置會消耗CPU週期,更新指向這些物件的引用也會消耗CPU週期。

這些引用被使用的時候,垃圾收集器沒法更新它們。所以更新這些引用需要暫停所有的執行緒。這通常會導致Java程式在移動物件、更新引用和回收未使用記憶體的過程中出現數百毫秒的完全暫停。

增加複雜性

為了減少這些長時間的暫停,Java使用了所謂的分代垃圾收集器(generational garbage collector)。這些都是基於以下前提:

在程式中分配的大多數物件很快就會被釋放。因此,如果GC花更多時間來處理最近分配的物件,那麼應該會減少GC的壓力。

這就是為什麼Java將它們分配的物件分成兩組:

  • 老年物件——在GC的多次標記和清除操作中倖存下來的物件。每次標記和掃描操作時,會更新一個分代計數器,以跟蹤物件的“年齡”。
  • 年輕物件——這些物件的“年齡”較小,也就是說他們是最近才分配出來的。

Java更積極地處理、掃描最近分配的物件,並檢查它們是否應該被回收或移動。隨著物件“年齡”的增長,它們會被移出年輕代區域。

所有這些優化會帶來更多的複雜度,它需要更多的開發工作量。它需要支付更多的錢來僱傭更優秀的開發者。

現代語言如何避免與Java相同的缺陷

現代語言不需要像Java和c#那樣複雜的垃圾收集器。這是在設計這些語言時,並沒有像Java一樣依賴垃圾回收器。

type Point struct {
    X, Y int
}
var points [15000]Point

在上面的Go程式碼示例中,我們分配了15000個Point物件。這僅僅分配了一次記憶體,產生了一個指標。在Java中,這需要15000次記憶體分配,每次分配產生一個引用,這些應用也要單獨管理起來。每個Point物件都會有前面提到的16位元組頭部資訊開銷。而不管是在Go語言、Julia還是Rust中,你都不會看到頭部資訊,物件通常是沒有這些頭部資訊的。

在Java中,GC追蹤和管理15000獨立的物件。Go只需要追蹤一個物件。

值型別

在除Java外的其他語言,基本上都支援值型別。下面的程式碼定義了一個矩形,用一個Min和Max點來定義它的範圍。

type Rect struct {
   Min, Max Point
}

這就變成了一個連續的記憶體塊。在Java中,這將變成一個Rect物件,它引用了兩個單獨的物件,MinMax物件。因此在Java中,一個Rect例項需要3次記憶體分配,但在Go、Rust、C/c++和Julia中只需要1次記憶體分配。

左邊是Java風格的記憶體碎片。在Go, C/C++, Julia等程式中,在右邊的連續記憶體塊上。

在將Git移植到Java時,缺少值型別造成了嚴重的問題。如果沒有值型別,就很難獲得良好的效能。正如Shawn O. Pearce在JGit開發者郵件列表上所說

JGit一直糾結於沒有一種有效的方式來表示SHA-1。C只需要輸入unsigned char[20]並將其內聯到容器的記憶體分配中。Java中的byte[20]將額外消耗16個位元組的記憶體,而且訪問速度較慢,因為這10個位元組和容器物件位於不相鄰的記憶體區域。我們試圖通過將一個byte[20]轉換為5個int來解決這個問題,但這需要耗費額外的CPU指令。

我們在說什麼?在Go語言中,我可以做和C/C++一樣的事情,並定義一個像這樣的結構:

type Sha1 struct {
   data [20]byte
}

這些位元組將位於一個完整的記憶體塊中。而Java將建立一個指向其他地方的指標。

Java開發人員意識到他們搞砸了,開發者確實需要值型別來獲得良好的效能。你可以說這種說法比較誇張,但你需要解釋一下Valhalla專案)。這是Oracle為Java值型別所做的努力,這樣做的原因正是我在這裡所談論的。

值型別是不夠的

那麼Valhalla專案能解決Java的問題嗎?不是的。它僅僅是將Java帶到了與c#同等的高度上。c#比Java晚幾年出現,並且意識到垃圾收集器並不像大家想象的那麼神奇。因此,他們增加了值型別。

然而,在記憶體管理靈活性方面,這並沒有使c#/Java與Go、C/C++等語言處於同等地位。Java不支援真正的指標。在Go中,我可以這樣寫:

var ptr *Point = &rect.Min // 把指向 Min 的指標儲存到 ptr 中
*ptr = Point(2, 4)         // 替換 rect.Min 物件

就像在C/C++中一樣,你可以在Go中獲取物件的地址或物件的欄位,並將其儲存在一個指標中。然後,您可以傳遞這個指標,並使用它來修改所指向的欄位。這意味著您可以在Go中建立大的值物件,並將其作為函式指標傳遞,來優化效能。在c#中情況要好一些,因為它對指標的支援有限。前面的Go例子可以用c#寫成:

unsafe void foo() {
   ref var ptr = ref rect.Min;
   ptr = new Point(2, 4);
}

然而c#的指標支援伴隨著一些不適用於Go的警告:

  • 使用指標的程式碼必須標記為unsafe。這會產生安全性較低且更容易崩潰的程式碼。
  • 必須是在堆疊上分配的純值型別(所有結構欄位也必須是值型別)。
  • fixed的範圍內,fixed關鍵字關閉了垃圾收集。

因此,在c#中使用值型別的正常和安全的方法是複製它們,因為這不需要定義unsafe或fixed的程式碼域。但對於較大的值型別,這可能會產生效能問題。Go就沒有這些問題了。您可以在Go中建立指向由垃圾收集器管理的物件的指標。Go語言中,不需要像在c#中那樣,將使用指標的程式碼單獨標記出來。

自定義二次分配器

使用正確的指標,你可以做很多值型別做不到的事情。一個例子就是建立二級分配器。Chandra Sekar S給出了一個例子:Go中的 Arena 分配

type Arena []Node

func (arena *Arena) Alloc() *Node {
    if len(*arena) == 0 {
        *arena = make([]Node, 10000)
    }

    n := &(*arena)[len(*arena)-1]
    *arena = (*arena)[:len(*arena)-1]
    return n
}

為什麼這些有用?如果你檢視一些微基準測試,比如構造二叉樹的演算法,通常會發現Java比Go有很大的優勢。這是因為構造二叉樹演算法通常用於測試垃圾收集器在分配物件時的速度。Java在這方面非常快,因為它使用了我們所說的bump指標。它只是增加一個指標值,而Go將在記憶體中尋找一個合適的位置來分配物件。然而,使用Arena分配器,你也可以在Go中快速構建二叉樹。

func buildTree(item, depth int, arena *Arena) *Node {
    n := arena.Alloc()
    if depth <= 0 {
        *n = Node{item, nil, nil}
    } else {
        *n = Node{
              item,
              buildTree(2*item-1, depth-1, arena),
              buildTree(2*item, depth-1, arena),
        }
    }
    return n
}

這就是為什麼真正的指標會有好處。你不能在一個連續的記憶體塊中建立一個指向元素的指標,如下所示:

n := &(*arena)[len(*arena)-1]

Java Bump分配器的問題

Java GC使用的bump分配器與Arena分配器類似,您只需移動一個指標就能獲取下一個值。但開發者不需要手動指定使用Bump分配器。這可能看起來更智慧。但它會導致一些在Go語言中沒有的問題:

  • 或早或晚,記憶體都需要進行壓縮(compact),這涉及到移動資料和修復指標。Arena分配器不需要這樣做。
  • 在多執行緒程式中,bump分配器需要鎖(除非你使用執行緒本地儲存)。這抹殺了它們的效能優勢,要麼是因為鎖降低了效能,要麼是因為執行緒本地儲存將導致碎片化,這需要稍後進行壓縮。

Ian Lance Taylor是Go的建立者之一,他解釋了bump分配器的問題

一般來說,使用一組每個執行緒快取來分配記憶體可能會更有效率,而在這一點上,你已經失去了bump分配器的優勢。因此,我要斷言,通常情況下,儘管有許多警告,但對多執行緒程式使用壓縮記憶體分配器並沒有真正的優勢。

分代GC和逃逸分析

Java垃圾收集器有更多的工作要做,因為它分配了更多的物件。為什麼?我們剛剛講過了。如果沒有值物件和真正的指標,在分配大型陣列或複雜的資料結構時,它將總是以大量的物件告終。因此,它需要分代GC。

分配更少物件的需求對Go語言有利。但Go語言還有另一個技巧。Go和Java在編譯函式時都進行了逃逸分析。

逃逸分析包括檢視在函式內部建立的指標,並確定該指標是否逃逸出了函式範圍。

func escapingPtr() []int {
   values := []int{4, 5, 10}
   return values
}

fun nonEscapingPtr() int {
    values = []int{4, 5, 10}
    var total int = addUp(values)
    return total
}

在第一個示例中,values指向一個切片,這在本質上與指向陣列的指標相同。它逃逸了是因為它被返回了。這意味著必須在堆上分配values

然而,在第二個例子中,指向values的指標並不會離開nonEscapingPtr函式。因此,可以在棧上分配values,這個動作非常快速,並且代價也很小。逃逸分析本身只分析指標是否逃逸。

Java逃逸分析的限制

Java也做轉義分析,但在使用上有更多的限制。從Java SE 16 Oracle文件覆蓋熱點虛擬機器:

對於不進行全域性轉義的物件,它不會將堆分配替換為堆疊分配。

然而,Java使用了另一種稱為標量替換的技巧,它避免了將物件放在棧上的需要。本質上,它分解物件,並將其基本成員放在棧上。請記住,Java已經可以在棧上放置諸如intfloat等基本值。然而,正如Piotr Kołaczkowski在2021年發現的那樣,在實踐中,標量替換即使在非常微不足道的情況下也不起作用。

相反,標量替換的主要的優點是避免了鎖。如果你知道一個指標不會在函式之外使用,你也可以確定它不需要鎖。

Go語言逃逸分析的優勢

但是,Go使用逃逸分析來確定哪些物件可以在堆疊上分配。這大大減少了壽命短的物件的數量,這些物件本來可以從分代GC中受益。但是要記住,分代GC的全部意義在於利用最近分配的物件生存時間很短這一事實。然而,Go語言中的大多數物件可能會活得很長,因為生存時間短的物件很可能會被逃逸分析捕獲。

與Java不同,在Go語言中,逃逸分析也適用於複雜物件。Java通常只能成功地對位元組陣列等簡單物件進行逃逸分析。即使是內建的ByteBuffer也不能使用標量替換在堆疊上進行分配。

現代語言不需要壓縮GC

您可以讀到許多垃圾收集器方面的專家聲稱,由於記憶體碎片,Go比Java更有可能耗盡記憶體。這個論點是這樣的:因為Go沒有壓縮垃圾收集器,記憶體會隨著時間的推移而碎片化。當記憶體被分割時,你將到達一個點,將一個新物件裝入記憶體將變得困難。

然而,由於兩個原因,這個問題大大減少了:

  1. Go不像Java那樣分配那麼多的小物件。它可以將大型物件陣列作為單個記憶體塊分配。
  2. 現代的記憶體分配器,如谷歌的 TCMalloc 或英特爾的 Scalable Malloc 不會對記憶體進行分段。

在設計Java的時候,記憶體碎片是記憶體分配器的一個大問題。人們不認為這個問題可以解決。但即使回到1998年,在Java問世後不久,研究人員就開始解決這個問題。下面是Mark S. Johnstone和Paul R. Wilson的一篇論文

這實質上加強了我們之前的結果,這些結果表明,記憶體碎片問題通常被誤解了,好的分配器策略可以為大多數程式提供良好的記憶體使用。

因此,設計Java記憶體分配策略時的許多假設都不再正確。

分代GC vs 併發GC的暫停

使用分代GC的Java策略旨在使垃圾收集週期更短。要知道,為了移動資料和修復指標,Java必須停止所有操作。如果停頓太久,將會降低程式的效能和響應能力。使用分代GC,每次檢查的資料更少,從而減少了檢查時間。

然而,Go用一些替代策略解決了同樣的問題:

  1. 因為不需要移動記憶體,也不需要固定指標,所以在GC執行期間要做的工作會更少。Go GC只做一個標記和清理:它在物件圖中查詢應該被釋放的物件。
  2. 它併發執行。因此,單獨的GC執行緒可以在不停止其他執行緒的情況下尋找要釋放的物件。

為什麼Go可以併發執行GC而Java卻不行?因為Go不會修復任何指標或移動記憶體中的任何物件。因此,不存在嘗試訪問一個物件的指標,而這個物件剛剛被移動,但指標還沒有更新這種風險。不再有任何引用的物件不會因為某個併發執行緒的執行而突然獲得引用。因此,平行移動“已經死亡”的物件沒有任何危險。

這是怎麼回事?假設你有4個執行緒在一個Go程式中工作。其中一個執行緒在任意時間T秒內執行臨時GC工作,時間總計為4秒。

現在想象一下,一個Java程式的GC只做了2秒的GC工作。哪個程式擠出了最多的效能?誰在T秒內完成最多?聽起來像Java程式,對吧?錯了!

Java程式中的4個工作執行緒將停止所有執行緒2秒。這意味著 2×4 = 8秒的工作在T秒中丟失。因此,雖然Go的停止時間更長,但每次停止對程式工作的影響更小,因為所有執行緒都沒有停止。因此,緩慢的併發GC的效能可能優於依賴於停止所有執行緒來執行其工作的較快GC。

如果垃圾產生的速度比清理它的速度還快怎麼辦?

反對當前垃圾收集器的一個流行觀點是,活動工作執行緒產生垃圾的速度可能比垃圾收集器執行緒收集垃圾的速度快。在Java世界中,這被稱為“併發模式失敗”。

在這種情況下,執行時別無選擇,只能完全停止程式並等待GC週期完成。因此,當Go聲稱GC暫停時間非常低時,這種說法只適用於GC有足夠的CPU時間和空間超過主程式的情況。

但是Go語言有一個聰明的技巧來繞過Go GC大師Rick Hudson所描述的這個問題。Go使用的是所謂的“Pacer”。

如果需要的話,Pacer會在加速標記的同時降低分配速度。在一個較高的水平,Pacer停止了Goroutine,它做了大量的分配,並讓它做標記。工作量與Goroutine的分配成比例。這加快了垃圾收集器的速度,同時減慢了mutator的速度。

Goroutines有點像線上程池上覆用的綠色執行緒。基本上,Go接管正在執行產生大量垃圾的工作負載的執行緒,並讓它們幫助GC清理這些垃圾。它會一直接管執行緒,直到GC的執行速度超過產生垃圾的協程。

簡而言之

雖然高階垃圾收集器解決了Java中的實際問題,但現代語言,如Go和Julia,從一開始就避免了這些問題,因此不需要使用Rolls Royce垃圾收集器。當您有了值型別、轉義分析、指標、多核處理器和現代分配器時,Java設計背後的許多假設都被拋到了腦後。它們不再適用。

GC的Tradeoff不再適用

Mike Hearn在Medium上有一個非常受歡迎的故事,他批評了Go GC的說法:現代垃圾收集

Hearn的關鍵資訊是GC設計中總是存在權衡。他的觀點是,因為Go的目標是低延遲收集,他們將在許多其他指標上受到影響。這是一本有趣的讀物,因為它涵蓋了很多關於GC設計中的權衡的細節。

首先,低延遲是什麼意思?Go GC平均只暫停0.5毫秒,而各種Java收集器可能要花費數百毫秒。

我認為Mike Hearn的論點的問題在於,它們基於一個有缺陷的前提,即所有語言的記憶體訪問模式都是相同的。正如我在本文中所提到的,根本不是這樣的。Go生成的需要GC管理的物件會少得多,並且它會使用逃逸分析提前清理掉很多物件。

老技術本身就是壞的?

Hearn的論點宣告,簡單的收集在某種程度上是不好的:

Stop-the-world (STW)標記/清除是本科生電腦科學課程中最常用的GC演算法。在做工作面試時,我有時會讓應聘者談論一些關於GC的內容,但幾乎總是,他們要麼將GC視為一個黑盒子,對它一無所知,要麼認為它至今仍在使用這種非常古老的技術。

是的,它可能是舊的,但是這種技術允許併發地執行GC,這是“現代”的技術不允許的。在我們擁有多核的現代硬體世界中,這一點更重要。

Go 不是 C

另一個說法:

由於Go是一種具有值型別的相對普通的命令式語言,它的記憶體訪問模式可能可以與C#相比較,後者的分代假設當然成立,因此.NET使用分代收集器。

事實並非如此。C#開發人員會盡量減少大值物件的使用,因為不能安全地使用與指標相關的程式碼。我們必須假設c#開發人員更喜歡複製值型別而不是使用指標,因為這可以在CLR中安全地完成。這自然會帶來更高的開銷。

據我所知,C#也沒有利用逃逸分析來減少堆上的短生命週期物件的產生。其次,C#並不擅長同時執行大量任務。Go可以利用它們的協程來同時加速收集,就像Pacer提到的那樣。

記憶體壓縮整理

壓縮:因為沒有壓縮,你的程式最終會把堆碎片化。我將在下面進一步討論堆碎片。在快取中整齊地放置東西也不會給您帶來好處。

在這裡,Mike Hearn對分配器的描述並不是最新的。TCMalloc等現代分配器基本上消除了這個問題。

程式吞吐量:由於GC必須為每個週期做大量工作,這從程式本身竊取CPU時間,降低了它的速度。

當您有一個併發GC時,這並不適用。所有其他執行緒都可以在GC工作時繼續執行——不像Java,它必須停止整個世界。

堆的開銷

Hearn提出了“併發模式失敗”的問題,假設Go GC會有跟不上垃圾生成器的速度的風險。

堆開銷:因為通過標記/清除收集堆是非常慢的,你需要大量的空閒空間來確保你不會遭遇“併發模式失敗”。預設的堆開銷是100%,它會使你的程式需要的記憶體翻倍。

我對這種說法持懷疑態度,因為我看到的許多現實世界的例子似乎都建議圍棋程式使用更少的記憶體。更不用說,這忽略了Pacer的存在,它會抓住Goroutines,產生大量垃圾,讓他們清理。

為什麼低延遲對Java也很重要

我們生活在一個Docker和微服務的世界。這意味著許多較小的程式相互通訊和工作。想象一個請求要經過好幾個服務。在一個鏈條,這些服務中如果有一個出現重大停頓,就會產生連鎖反應。它會導致所有其他程式停止工作。如果管道中的下一個服務正在等待STW的垃圾收集,那麼它將無法工作。

因此,延遲/吞吐量的權衡不再是GC設計中的權衡。當多個服務一起工作時,高延遲將導致吞吐量下降。Java對高吞吐量和高延遲GC的偏好適用於單塊世界。它不再適用於微服務世界。

這是Mike Hearn觀點的一個根本問題,他認為沒有靈丹妙藥,只有權衡取捨。它試圖給人這樣一種印象:Java的權衡是同樣有效的。但權衡必須根據我們所生活的世界進行調整。

簡而言之,我認為Go語言已經做出了許多聰明的舉動和戰略選擇。如果這只是任何人都可以做的trade-off,那麼省去它是不可取的。

相關文章