為什麼Go不再需要Java風格的GC?- itnext

banq發表於2021-11-30

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 是一種基本上將記憶體管理完全外包給其垃圾收集器的語言。結果證明這是一個很大的錯誤。
為了解決這些主要缺點,Java 維護人員已經在高階垃圾收集器上投入了大量資金。這些做一些稱為壓縮的事情。壓縮涉及在記憶體中移動物件並將它們收集到記憶體中的連續塊中。這並不便宜。將塊從一個記憶體位置移動到另一個記憶體位置不僅會消耗 CPU 週期,而且更新對這些物件的每個引用以指向新位置也會消耗 CPU 週期。
執行這些更新需要凍結所有執行緒。您無法在使用引用時更新引用。這通常會導致 Java 程式完全凍結數百毫秒,其中物件移動、引用更新和未使用的記憶體被回收。
 

現代語言如何避免與 Java 相同的陷阱?
現代語言不需要像 Java 和 C# 這樣複雜的垃圾收集器。這是因為它們沒有被設計為在相同程度上依賴它們。

// Go: Make an an array of 15 000 Point objects in
type Point struct {
    X, Y int
}
var points [15000]Point


在上面的 Go 程式碼示例中,我們分配了 15 000 個Point物件。這只是一個單一的分配,產生一個單一的指標。在 Java 中,這需要 15 000 個單獨的分配,每個分配產生一個必須被管理的單獨引用。每個Point物件都會獲得我之前寫過的 16 位元組標頭開銷。在 Go、Julia 或 Rust 中,您都不會獲得這種開銷。物件通常是無標識的。
在 Java 中,GC 獲得了 15000 個它必須跟蹤和管理的單獨物件。Go 只需要跟蹤 1 個物件。
  • 值型別

下面的程式碼定義了一個矩形,用 Min和Maxpoint 定義其範圍。

type Rect struct {
   Min, Max Point
}

這成為一個連續的記憶體塊。在 Java 中,這將變成一個Rect物件,引用兩個單獨的物件,Min和Maxpoint 物件。
因此,在 Java 中,一個 的例項Rect需要 3 次分配,但在 Go、Rust、C/C++ 和 Julia 中僅需要 1 次分配。
Java 開發人員意識到他們搞砸了,並且您確實需要值型別以獲得良好的效能。Project Valhalla是 Oracle 帶頭提供 Java 值型別的一項努力,
 
  • 值型別還不夠

Valhalla會解決 Java 的問題嗎?它只會讓 Java 與 C# 處於同等地位。C# 在 Java 出現多年之後才出現,並且從那時起就意識到垃圾收集器並不是每個人都認為的那樣神奇。因此,他們新增了值型別。
然而,在記憶體管理靈活性方面,這並沒有讓 C# 和 Java 與 Go 和 C/C++ 等語言處於同等地位。Java 不支援真正的指標。在 Go 中,我可以這樣寫:

// Go pointer usage
var ptr *Point = &rect.Min // Store pointer to Min in ptr
*ptr = Point(2, 4)         // replace rect.Min

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

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

然而,C# 指標支援帶有一些不適用於 Go 的警告:
  1. 使用點的程式碼必須標記為不安全。這會建立安全性較低且更容易崩潰的程式碼。
  2. 堆疊上分配的純值型別(所有結構欄位必須是值型別)。
  3. 在固定範圍內,垃圾收集已關閉,使用 fixed 關鍵字。

因此,在 C# 中使用值型別的正常且安全的方法是複製它們,因為這不需要定義不安全或固定的程式碼區域。但是對於較大的值型別,這可能會產生效能問題。Go 沒有這些問題。您可以在 Go 中建立指向垃圾收集器管理的物件的指標。你不需要像在 C# 中那樣在 Go 中使用指標來隔離程式碼。
  • 自定義二級分配器

使用適當的指標,您可以做很多在只有值型別時無法實現的事情。一個例子是建立二級分配器。Chandra Sekar S. 舉了一個例子:

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 分配器類似,您只需增加一個指標即可獲得下一個值。除非您不必自己構建它。這可能看起來更聰明。但這會導致在 Go 中避免的幾個問題:

  1. 遲早你需要進行壓縮,這涉及移動資料和固定指標。Arena 分配器不必這樣做。
  2. 在多執行緒程式中,碰撞分配器需要鎖(除非您使用執行緒本地儲存)。這會扼殺它們的效能優勢,要麼是因為鎖會降低效能,要麼是因為執行緒本地儲存會導致碎片,這需要稍後進行壓縮。

Go 的創造者之一伊恩·蘭斯·泰勒 (Ian Lance Taylor)闡明瞭凹凸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指向一個切片,它本質上與指向陣列的指標相同。因為它被返回return,屬於它逃脫了,這意味著values必須在堆heap上分配。
然而,在第二個例子中,沒有任何指標values逃脫nonEscapingPtr函式。因此values可以在棧stack上分配,這是非常快速和廉價的。
逃逸分析本身只是分析一個指標是否逃逸。
 
  • Java 轉義分析的侷限性

Java 也進行轉義分析,但在使用上有更多限制。 HotSpot VM 的Java SE 16 Oracle 文件
只有全域性逃逸物件才可以用棧替代堆
然而,Java 使用了一種稱為標量替換的替代技巧,它避免了將物件放在棧上的需要。
從本質上講,它反對並將其原始成員放在棧中。請記住,Java 已經可以將諸如int和 之類的原始值float放在棧上。然而,正如Piotr Kołaczkowski在 2021 年發現的那樣,在實踐中,即使在非常微不足道的情況下,標量替換也不起作用。
相反,主要優點是避免鎖定。如果您知道在函式外部不使用指標,您還可以確定它不需要鎖。
  • Go Escape 分析的優勢

但是,Go 使用轉義分析來確定可以在堆疊上分配哪些物件。這顯著減少了可以從分代 GC 中受益的短期物件的數量。請記住,分代 GC 的全部意義在於利用最近分配的物件存活時間很短的事實。然而,Go 中的大多數物件可能會存活很長時間,因為壽命很短的物件很可能會被逃逸分析捕獲。
與 Java 不同,這也適用於複雜物件。Java 通常只能成功地對像位元組陣列這樣的簡單物件進行轉義分析。即使內建ByteBuffer也不能使用標量替換在棧上分配。
 

現代語言不需要壓縮 GC
你可以讀到很多關於垃圾收集器的專家聲稱,由於記憶體碎片,Go 比 Java 更有可能耗盡記憶體。爭論是這樣的:因為 Go 沒有壓縮垃圾收集器,記憶體會隨著時間的推移變得碎片化。當記憶體碎片化時,您將達到將新物件放入記憶體變得困難的地步。
但是,由於兩個原因,這個問題大大減少了:

  1. Go 沒有像 Java 那樣分配那麼多的小物件。它可以將大型物件陣列分配為單個記憶體塊。
  2. 現代記憶體分配器(例如 Google 的 TCMalloc 或英特爾的 Scalable Malloc)不會對記憶體進行碎片化。

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

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

因此,為 Java 設計記憶體分配策略時的許多假設前提是不再正確了。
 
分代 GC 與併發 GC 暫停

使用分代 GC 的 Java 策略旨在縮短垃圾收集週期。請記住,Java 必須停止一切以移動資料並修復指標。如果持續時間過長,這會降低效能和響應能力。使用分代 GC,每次減少時間檢查的資料更少。
然而,Go 用許多替代策略解決了同樣的問題:

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

為什麼 Go 可以併發執行它的 GC 而 Java不能?
因為 Go 不固定任何指標或移動記憶體中的任何物件。因此,不存在嘗試訪問指向剛被移動但該指標尚未更新的物件的指標的風險。由於某些併發執行緒正在執行,不再有任何引用的物件不會突然獲得引用。因此,並行移除死物件沒有危險。
這是怎麼玩的?
假設您有 4 個執行緒在 Go 程式中工作。其中一個執行緒偶爾會在任意時間段T秒內執行總共 4 秒的 GC 工作。
現在想象一個帶有 GC 的 Java 程式只執行 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 等現代語言一開始就簡單地避免了這些問題的產生,從而消除了對勞斯萊斯垃圾收集器的需求。當您擁有值型別、轉義分析、指標、多核處理器和現代分配器時,Java 設計背後的許多假設就不再適用了。它們不再適用。
 

為什麼低延遲對於 Java 也很重要
我們生活在一個 docker 容器和微服務的世界中。這意味著許多較小的程式可以相互通訊和工作。想象一下工作要透過幾個服務。每當一個鏈中的這些服務中的一個出現明顯的停頓時,就會產生漣漪效應。它會導致所有其他程式停止工作。如果管道中的下一個服務正在等待忙於進行垃圾收集的服務,則它無法工作。
因此,延遲/吞吐量權衡不再是 GC 設計中的權衡。多個服務協同工作時,高延遲會導致吞吐量下降。Java 對高吞吐量和高延遲 GC 的偏好適用於單體世界。它不再適用於微服務世界。
 

Java假設的GC 權衡不再適用
Mike Hearn 在 Medium 上有一個非常受歡迎的故事,批評關於 Go GC:現代垃圾收集的說法。
Hearn 的關鍵資訊是在 GC 設計中總是要權衡利弊。他提出的觀點是,由於 Go 的目標是低延遲收集,因此他們將在許多其他指標上受到影響。這是一本有趣的讀物,因為它涵蓋了很多關於 GC 設計權衡的細節。
首先,低延遲是什麼意思?與可能花費數百毫秒的各種 Java 收集器相比,Go GC 平均僅暫停約 0.5 毫秒。
我在 Mike Hearn 的論點中看到的問題在於,它們基於這樣一個有缺陷的前提,即所有語言的記憶體訪問模式都相同。正如我在本文中所介紹的那樣,這根本不是真的。Go 將生成更少的物件以供 GC 管理,並且會使用逃逸分析及早清除其中的很多物件。
赫恩 (Hearn) 提出的論點表明,簡單的收集器本質上是不好的:

停止世界 (STW) 標記/掃描是本科電腦科學課程中最常教授的 GC 演算法。在面試時,我有時會要求應聘者談談 GC,而且幾乎總是這樣,他們要麼將 GC 視為一個黑匣子,對它一無所知,要麼認為它現在仍在使用這種非常古老的技術。
是的,它可能很舊,但是這種技術允許您同時執行 GC,這是“現代”技術所不允許的。在我們擁有多核的現代硬體世界中,這更重要。
 

Go 不是 C#
另一種說法:
由於 Go 是一種相對普通的具有值型別的命令式語言,它的記憶體訪問模式可能與 C# 相當,其中分代假設肯定成立,因此 .NET 使用分代收集器。
情況並非如此。C# 開發人員將盡量減少對較大值物件的使用,因為不能安全地使用指標相關程式碼。我們必須假設 C# 開發人員更喜歡複製值型別而不是使用指標,因為這可以在 CLR 中安全地完成。這自然會帶來更高的開銷。
據我所知,C# 也沒有利用逃逸分析來減少堆上短期物件的產生。其次,C# 不擅長併發執行大量任務。Go 可以利用它們的協程來同時加速收集,正如 Pacer 所提到的。
詳細點選標題
 

相關文章