天天看點

「後端」自動的記憶體管理系統實操手冊——Java和Golang對比篇

作者:架構思考
現代進階程式設計語言管理記憶體的方式分自動和手動兩種。手動管理記憶體的典型代表是C和C++,編寫代碼過程中需要主動申請或者釋放記憶體;而PHP、Java 和Go等語言使用自動的記憶體管理系統,由記憶體配置設定器和垃圾收集器來代為配置設定和回收記憶體,其中垃圾收集器就是我們常說的GC。在《「後端」自動的記憶體管理系統實操手冊——Java垃圾回收篇》和《自動的記憶體管理系統實操手冊——Golang垃圾回收篇》向大家分享了Java 和 Golang 垃圾回收算法之後,今天向大家總結和對比兩種算法。歡迎閱讀~

一、 垃圾回收區域

「後端」自動的記憶體管理系統實操手冊——Java和Golang對比篇

Java記憶體運作時區域的各個部分,其中程式計數器、虛拟機棧、本地方法棧3個區域随着線程而生,随着線程而滅;棧中的棧幀随着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每個棧幀中配置設定多少記憶體基本是在類結構确定下來時就已知的。而Java堆和方法區則不同,一個接口中的多個實作類需要的記憶體可能不同,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間時才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,是以,Java堆和方法區是Java垃圾收集器管理的主要區域。

「後端」自動的記憶體管理系統實操手冊——Java和Golang對比篇

Go記憶體會分成堆區(Heap)和棧區(Stack)兩個部分,程式在運作期間可以主動從堆區申請記憶體空間,這些記憶體由記憶體配置設定器配置設定并由垃圾收集器負責回收。棧區的記憶體由編譯器自動進行配置設定和釋放,棧區中存儲着函數的參數以及局部變量,它們會随着函數的建立而建立,函數的傳回而銷毀。如果隻申請和配置設定記憶體,記憶體終将枯竭。Go使用垃圾回收收集不再使用的span,把span釋放交給mheap,mheap對span進行span的合并,把合并後的span加入scav樹中,等待再配置設定記憶體時,由mheap進行記憶體再配置設定。是以,Go堆是Go垃圾收集器管理的主要區域。

二、 觸發垃圾回收的時機

Java當應用程式空閑時,即沒有應用線程在運作時,GC會被調用。因為GC在優先級最低的線程中進行,是以當應用忙時,GC線程就不會被調用,但以下條件除外。

Java堆記憶體不足時,GC會被調用。但是這種情況由于java是分代收集算法且垃圾收集器種類十分多,是以其觸發各種垃圾收集器的GC時機可能不完全一緻,這裡我們說的為一般情況。

1. 當Eden區空間不足時Minor GC;

2. 對象年齡增加到一定程度時Young GC;

3. 新生代對象轉入老年代及建立為大對象、大數組時會導緻老年代空間不足,觸發Old GC;

4. System.gc()調用觸發Full GC;

5. 各種區塊占用超過門檻值的情況。

Go則會根據以下條件進行觸發:

  • runtime.mallocgc申請記憶體時根據堆大小觸發GC;
  • runtime.GC使用者程式手動觸發GC;
  • runtime.forcegchelper背景運作定時檢查觸發GC。

三、收集算法

目前Java虛拟機的垃圾收集采用分代收集算法,根據對象存活周期的不同将記憶體分為幾塊。比如在新生代中,每次收集都會有大量對象死去,是以可以選擇“标記-複制”算法,隻需要付出少量對象的複制成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行配置設定擔保,是以我們必須選擇“标記-清除”或“标記-整理”算法進行垃圾收集。

目前Go的都是基于标記清除算法進行垃圾回收。

四、垃圾碎片處理

由于Java的記憶體管理劃分,是以容易産生垃圾對象,JVM這些年不斷的改進和更新GC算法,JVM在處理記憶體碎片問題上更多采用空間壓縮和分代收集的思想,例如在新生代使用“标記-複制”算法,G1收集器支援了對象移動以消減長時間運作的記憶體碎片問題,劃分region的設計更容易把空閑記憶體歸還給OS等設計。

由于Go的記憶體管理的實作,很難實作分代,而移動對象也可能會導緻runtime更龐大複雜,是以Go在關于記憶體碎片的處理方案和Java并不太一樣。

1.Go語言span記憶體池的設計,減輕了很多記憶體碎片的問題。

Go記憶體釋放的過程如下:當mcache中存在較多空閑span時,會歸還給 mcentral;而mcentral中存在較多空閑span時,會歸還給mheap;mheap再歸還給作業系統。這種設計主要有以下幾個優勢:

  • 記憶體配置設定大多時候都是在使用者态完成的,不需要頻繁進入核心态。
  • 每個 P 都有獨立的 span cache,多個 CPU 不會并發讀寫同一塊記憶體,進而減少 CPU L1 cache 的 cacheline 出現 dirty 情況,增大 cpu cache 命中率。
  • 記憶體碎片的問題,Go是自己在使用者态管理的,在 OS 層面看是沒有碎片的,使得作業系統層面對碎片的管理壓力也會降低。
  • mcache 的存在使得記憶體配置設定不需要加鎖。

2.tcmalloc配置設定機制,Tiny對象和大對象配置設定優化,在某種程度上也導緻基本沒有記憶體碎片會出現。

比如正常上sizeclass=1的span,用來給<=8B 的對象使用,是以像 int32, byte, bool以及小字元串等常用的微小對象,都會使用sizeclass=1的span,但配置設定給他們8B的空間,大部分是用不上的。并且這些類型使用頻率非常高,就會導緻出現大量的内部碎片。

是以Go盡量不使用sizeclass=1的span,而是将<16B的對象為統一視為tiny對象。配置設定時,從sizeclass=2的span中擷取一個16B的object用以配置設定。如果存儲的對象小于16B,這個空間會被暫時儲存起來 (mcache.tiny字段),下次配置設定時會複用這個空間,直到這個object用完為止。

「後端」自動的記憶體管理系統實操手冊——Java和Golang對比篇

以上圖為例,這樣的方式空間使用率是(1+2+8)/16*100%= 68.75%,而如果按照原始的管理方式,使用率是(1+2+8)/(8*3)=45.83%。源碼中注釋描述,說是對tiny對象的特殊處理,平均會節省20%左右的記憶體。如果要存儲的資料裡有指針,即使<= 8B也不會作為tiny對象對待,而是正常使用sizeclass=1的span。

Go中,最大的sizeclass最大隻能存放32K的對象。如果一次性申請超過32K的記憶體,系統會直接繞過mcache和mcentral,直接從mheap上擷取,mheap中有一個freelarge字段管理着超大span。

3.Go的對象(即struct類型)是可以配置設定在棧上的。

Go會在編譯時做靜态逃逸分析(Escape Analysis), 如果發現某個對象并沒有逃出目前作用域,則會将對象配置設定在棧上而不是堆上,進而減輕了GC記憶體碎片回收壓力。

比如如下代碼:

func F() {
  temp := make([]int, 0, 20) //隻是内函數内部申請的臨時變量,并不會作為傳回值傳回,它就是被編譯器申請到棧裡面。
  temp = append(temp, 1)
}


func main() {
  F()
}           

運作代碼如下,結果顯示temp變量被配置設定在棧上并沒有配置設定在堆上:

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:4:6: can inline F
./new1.go:9:6: can inline main
./new1.go:10:3: inlining call to F
./new1.go:5:14: make([]int, 0, 20) does not escape
./new1.go:10:3: make([]int, 0, 20) does not escapeh           

當我們把上述代碼更改:

package main
import "fmt"


func F() {
  temp := make([]int, 0, 20)
  fmt.Print(temp)
}


func main() {
  F()
}           

運作代碼如下,結果顯示temp變量被配置設定在堆上,這是由于temp傳入了print函數裡,編譯器會認為變量之後還會被使用。是以就申請到堆上,申請到堆上面的記憶體才會引起垃圾回收,如果這個過程(特指垃圾回收不斷被觸發)過于高頻就會導緻GC壓力過大,程式性能出問題。

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:9:11: inlining call to fmt.Print
./new1.go:12:6: can inline main
./new1.go:8:14: make([]int, 0, 20) escapes to heap
./new1.go:9:11: temp escapes to heap
./new1.go:9:11: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape           

五、“GC Roots” 的對象選擇

在Java中由于記憶體運作時區域的劃分,通常會選擇以下幾種作為“GC Roots” 的對象:

  • 虛拟機棧(棧幀中的本地變量表)中引用的對象;
  • 本地方法棧(Native 方法)中引用的對象;
  • 方法區中類靜态屬性引用的對象;
  • 方法區中常量引用的對象;
  • Java虛拟機内部引用;
  • 所有被同步鎖持有的對象。

而在Java中的不可達對象有可能會逃脫。即使在可達性分析法中不可達的對象,也并非是“非死不可”的,這時候它們暫時處于“緩刑階段”,要真正宣告一個對象死亡,至少要經曆兩次标記過程;此外Java中由于存在運作時常量池和類,是以也需要對運作時常量池和方法區的類進行清理。

而Go的選擇就相對簡單一點,即全局變量和G Stack中的引用指針,簡單來說就是全局量和go程中的引用指針。因為Go中沒有類的封裝概念,因而GC Root選擇也相對簡單一些。

六、寫屏障

為了解決并發三色可達性分析中的懸挂指針問題,出現了2種解決方案,分别是分别是“Dijkstra插入寫屏障”和“Yuasa删除寫屏障”。

在java中,對上述2種方法都有應用,比如CMS是基于“Dijkstra插入寫屏障”做并發标記的,G1、Shenandoah則是使用“Yuasa删除寫屏障”來實作的。

在Go語言v1.7版本之前,運作時會使用Dijkstra插入寫屏障保證強三色不變性,Go語言在v1.8組合Dijkstra插入寫屏障和Yuasa删除寫屏障構成了混合寫屏障,混合寫屏障結合兩者特點,通過以下方式實作并發穩定的GC:

1.将棧上的對象全部掃描并标記為黑色。

2.GC期間,任何在棧上建立的新對象,均為黑色。

3.被删除的對象标記為灰色。

4.被添加的對象标記為灰色。

由于要保證棧的運作效率,混合寫屏障是針對于堆區使用的。即棧區不會觸發寫屏障,隻有堆區觸發,由于棧區初始标記的可達節點均為黑色節點,因而也不需要第二次STW下的掃描。本質上是融合了插入屏障和删除屏障的特點,解決了插入屏障需要二次掃描的問題。同時針對于堆區和棧區采用不同的政策,保證棧的運作效率不受損。

總結

「後端」自動的記憶體管理系統實操手冊——Java和Golang對比篇

從垃圾回收的角度來說,經過多代發展,Java的垃圾回收機制較為完善,Java劃分新生代、老年代來存儲對象。對象通常會在新生代配置設定記憶體,多次存活的對象會被移到老年代,由于新生代存活率低,産生空間碎片的可能性高,通常選用“标記-複制”作為回收算法,而老年代存活率高,通常選用“标記-清除”或“标記-整理”作為回收算法,壓縮整理空間。

Go是非分代的、并發的、基于三色标記和清除的垃圾回收器,它的優勢要結合它tcmalloc記憶體配置設定政策才能展現出來,因為小微對象的配置設定均有自己的記憶體池,所有的碎片都能被完美複用,是以GC不用考慮空間碎片的問題。

文章來源:汪彙_騰訊背景開發工程師_https://mp.weixin.qq.com/s?__biz=MzI2NDU4OTExOQ==&mid=2247518208&idx=1&sn=1bcc216d13eb1bc123efc47e64dc5043&chksm=eaa89250dddf1b461b2b77fe5aac61e12423477ebf702c366c4e8a670179bf95aaecb39d5232&scene=178&cur_album_id=1985948015764750337#rd

繼續閱讀