1. 程式人生 > 實用技巧 >簡析Go與Java記憶體管理的差異

簡析Go與Java記憶體管理的差異

前言
從實踐中看,Golang(以下簡稱Go)應用程式比Java佔用更少的記憶體,這與它們的執行時環境有關,其執行時自帶了記憶體動態分配和自動垃圾回收的管理機制,本文通過分析Go與Java在記憶體管理機制上的差異,以期對兩者在執行時記憶體方面有更進一步的認識。本文以Go(1.12)和當前使用較多的JDK8 HotSpot VM為例進行說明。

本篇文章包含以下內容:

介紹Go與Java的執行時記憶體結構差異

介紹Go與Java的記憶體資源佔用差異

介紹Go與Java如何為物件分配記憶體

介紹Go與Java的記憶體回收策略差異

在這裡插入圖片描述

記憶體結構差異

應用程式要能在linux系統上執行(其他平臺類似),其可執行檔案要求符合ELF規範(Executable and Linkable Format,可執行和可連結格式)。作業系統載入目標可執行檔案到記憶體中並以獨立程序方式執行程式。

作業系統為每個程序分配一個連續的虛擬記憶體地址空間,並將該程序記憶體空間劃分成多個不同用途的邏輯區域。JVM程序以及Go程序的記憶體結構如下圖所示:

在這裡插入圖片描述圖1

Java使用者程式是執行在Java虛擬機器(以下簡稱“JVM”)之上的,從上圖我們看到使用者程式的位元組碼指令並沒有儲存到JVM程序的堆空間或者text段中。實際上,虛擬機器將使用者程式位元組碼放在了使用本地直接記憶體實現的方法區中,並不佔用虛擬機器的堆記憶體。

JVM的堆空間儲存Java使用者程式執行時建立的物件和字串常量池等資料,棧空間則為Java執行緒提供了私有的記憶體區域。在HotSpot VM的實現中,Java執行緒棧使用作業系統棧和執行緒模型表示,且Java方法與本地方法共享同一個棧區。因此虛擬機器棧與本地方法棧其實是同一個區域。

Go與Java有較大不同,Go程序空間的text段不但儲存了內建的執行時機器指令,而且還有使用者程式的機器指令(Go在編譯時就已確定)。堆記憶體區則為使用者程式建立物件提供了儲存空間。Go天然支援併發程式設計模型,採用了系統執行緒與使用者執行緒(goroutine)相結合的實現機制,程序棧空間為系統執行緒提供了棧記憶體,而使用者執行緒棧的記憶體預設從堆中分配。

Java記憶體結構

  1. 執行時記憶體

Java程式的執行時記憶體被劃分為元資料區(方法區)、堆、虛擬機器棧、本地方法棧、程式計數器5個部分,這可以看作是JVM對程序可用堆、棧空間進行的二次分配,以滿足執行Java使用者程式的記憶體需求。其記憶體結構如下圖所示:

在這裡插入圖片描述
圖2

上圖中堆記憶體對應了圖1中JVM的堆記憶體,虛擬機器棧、本地方法棧、程式計數器則對應了圖1中JVM的棧區,元資料區則是JVM另外開闢的記憶體塊。

· 元資料區是JVM向作業系統申請的堆外記憶體,用於實現“方法區”,主要儲存虛擬機器載入class的類資訊、JIT編譯的程式碼、執行時常量池等資料,其預設大小由系統的可用實體記憶體上限限制。

· 堆記憶體是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。它存放了包括幾乎所有的Java物件以及陣列,這也是垃圾收集器關注的主要記憶體區。由於逃逸分析等優化技術,物件也有可能被分配到棧上。

· 虛擬機器棧即我們常說的棧空間,其生命週期與執行緒相同。執行緒的棧空間儲存了方法呼叫的棧幀,每個棧幀則儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。

· 本地方法棧為JVM使用到的Native方法提供記憶體空間,而虛擬機器棧為JVM執行Java方法提供記憶體空間。HotSpot將虛擬機器棧與本地方法棧合併管理。

· 程式計數器是一塊較小的記憶體空間,用來指向當前執行緒需要執行的下一條位元組碼指令。

元資料區、堆記憶體為所有執行緒共享,多執行緒訪問時需要進行同步控制。虛擬機器棧、本地方法棧、程式計數器都是執行緒私有的記憶體空間(堆的TLAB空間也是執行緒私有的空間),訪問時無需加鎖,速度很快。

  1. 分代的堆記憶體

Java使用者程式建立的物件主要存放在Java堆記憶體中,操作非常頻繁,而且大部分物件的生命週期都很短暫。根據物件存活的特點,Java堆空間進一步劃分為新生代和老年代,其中新生代又可以細分為eden區和兩塊相等大小的survivor區。JVM根據物件根據存活週期將其存放在不同的記憶體代中,不同的記憶體代可以採用不同的垃圾收集器回收記憶體。如下圖所示:
在這裡插入圖片描述
圖3

· 新生代將記憶體劃分為eden空間和兩塊較小的survivor空間,每次使用eden和其中一塊survivor。當回收時,將eden和survivor中還存活著的物件一次性地複製到另外一塊survivor空間上,最後清理掉eden和剛才用過的survivor空間,這為新生代“標記-複製”回收記憶體提供演算法實現便捷性。eden區與survivor區的大小比例是8:1:1。

· 老年代一般存放存活週期較長的物件以及大物件。老年代採用“標記-清除”或“標記-整理”演算法回收記憶體。

· 新建物件總是優先在新生代的eden區中分配,“熬過”多次GC的物件可以從新生代晉升到老年代。

Go記憶體結構

  1. 執行時記憶體

Go的執行時記憶體就是作業系統分配給程序空間的堆、棧記憶體,在堆記憶體的使用上不像JVM分代管理,而是採用分層級的記憶體管理模式。

  1. 分層級的堆記憶體

Go的堆記憶體直接採用了TCMalloc庫的記憶體管理模型。TCMalloc庫是Google開發的現代記憶體分配器,其基本特徵是記憶體分層級、對抗記憶體碎片以及快速分配等。Go語言根據自身需求對TCMalloc做了很多優化,但仍保留了其基本架構。

Go的記憶體分配器是分層級的,由mcache/mcentral/mheap 三個元件構成。因此整個堆記憶體結構可以看成是三層級的記憶體模型,其結構如下圖所示:
在這裡插入圖片描述

圖4

· 快取元件mcache與工作執行緒(goroutine)繫結,是goroutine私有的記憶體空間。在mcache中為物件分配記憶體時,無需競爭,效能很高。

· 中間元件mcentral 只負責一種規格(size class)的記憶體塊,為mcache快取元件提供備用的特定規格的可用空間。mcache的記憶體擴容請求會被分散到不同的mcentral 元件上,以減小共享記憶體的競爭鎖粒度。

· 堆元件mheap負責管理使用者程式的所有可用堆記憶體空間以及為大物件直接分配記憶體。它為上層元件提供擴容支援。當空間不足時,mheap元件向作業系統申請記憶體。

中間元件mcentral、堆元件mheap為所有工作執行緒(goroutine)所共享,所以在記憶體分配時通常存在同步競爭的情況。

“多出來”的方法區

Go程式指令被作業系統載入到記憶體並儲存在text段中,其大小在執行時基本是確定的。

而JVM的text段儲存的是虛擬機器本身的指令資料,Java使用者程式的位元組碼被載入並存儲在堆外記憶體的元資料區(方法區)中。Java位元組碼的載入過程如下圖所示:

在這裡插入圖片描述
圖5

· 在程式啟動、執行期間,JVM中的類裝載器子系統按需動態載入類檔案(包括Java API基礎類庫、使用者程式class檔案、第三方依賴庫等)、以及由位元組碼框架動態生成的類資訊,這些資料經載入、驗證、準備、解析、初始化等一系列過程最終都會儲存在方法區中。

· 程式執行時,執行引擎中的直譯器解釋執行方法區中的位元組碼指令。混合模式下的JIT編譯器將探測到的熱點程式碼編譯為本地可執行的機器指令,編譯的機器指令也儲存在方法區中。

隨著程式不斷執行,方法區所佔的記憶體空間可能會越來越大,儲存的資料有時候能達到數百兆;而對該記憶體區域中型別解除安裝的條件又比較苛刻,記憶體回收效率並不如堆記憶體理想。

這“多出來”的用於儲存Java使用者程式位元組碼指令的區域是Java程式比Go消耗更多記憶體的一個重要因素。

物件記憶體分配差異

不同的記憶體結構決定了物件記憶體分配方式的差異,也會給垃圾回收帶來影響。Go以及Java都支援變數的逃逸分析,逃逸到棧上的物件會隨著方法退出而自然回收,而分配到堆記憶體的物件則需要垃圾收集器回收才能釋放出記憶體空間。因而我們更多關注物件在堆上分配空間的過程。

Java物件

JVM會為新建立的執行緒在stack棧區分配一塊私有的執行緒棧空間。某一個執行緒中建立的Java物件可能被分配在新生代,也可能分配在老年代,其具體的分配方法如下圖所示:

在這裡插入圖片描述
圖6

JVM執行執行緒的建立物件指令,會向堆記憶體申請物件空間。堆記憶體被所有執行緒共享,在為物件分配空間時需要同步鎖定,這會降低記憶體分配的效率。使用者可以設定-XX: +UseTLAB引數啟用TLAB功能,這樣JVM總是優先嚐試在當前執行緒的TALB空間為物件分配記憶體。TLAB(Thread-Local Allocation Buffer,執行緒本地分配緩衝區)是JVM預先為每一個執行緒在堆區中劃分的一小塊私有記憶體空間,執行緒分配的物件空間總是在自己的TLAB上分配,無需加鎖。如果TLAB記憶體用完了則重新申請一塊新的TLAB。如果在TLAB中分配失敗,則會嘗試在新生代中繼續分配操作。一般過程如下所示:

1)首先檢查該類是否已載入、解析、初始化。如果沒有,則執行類載入的過程。

2)分配物件記憶體時,檢查是否啟用-XX: +UseTLAB引數。如果啟用TLAB,則直接從當前執行緒的TLAB空間以lock free方式分配指定大小的記憶體塊,速度很快。

■ 如果TLAB剩餘空間大於其可浪費空間閾值,則直接在新生代中分配。

■ 否則,JVM會嘗試為當前執行緒重新開闢一塊TLAB空間。

3)如果未啟用UseTLAB或者TLAB分配失敗,JVM將繼續在eden區或老年代上為物件分配空間,這時需要做同步操作。

4)物件記憶體分配成功後,JVM初始化物件零值、設定物件頭等元資訊,執行物件初始化方法,最後將物件引用壓入執行緒的棧記憶體中。

新建物件總是分配在新生代的eden區,當空間不夠時,會存放在老年代中。如果剩餘空間還是不夠,JVM會申請擴容或觸發一次GC回收記憶體後繼續在各個記憶體代中嘗試分配。

Java大物件的分配過程稍有不同,JVM總是直接在老年代中為其分配儲存空間。我們可以通過-XX:PretenureSizeThreshold引數來設定大物件的閾值,該引數預設值為0,說明物件總是先在eden區分配,不管這個物件有多大。

Go物件

Go記憶體分配器管理span以及object兩種型別的記憶體塊。span是Go記憶體管理的基本單元,由多個地址連續的頁(8k大小的page記憶體塊)組成的⼤塊記憶體。object則是將span按照特定規格(size class)切分成的多個⼩塊,每個⼩塊都可以用來儲存⼀個物件。

Go的object記憶體塊大小為8位元組的整倍數,被劃分為67種規格。Go物件的記憶體分配就是從有限的67種規格中找出與物件大小最合適的一塊可用記憶體塊的過程。Go使用“空閒列表”方式管理可分配的記憶體空間,相同規格的記憶體塊連線成一個雙向連結串列。以下是Go object的size class對應表,其中包含66種規格,另外一種規格是大於32KB的大物件:

在這裡插入圖片描述

根據不同物件的規格大小,Go記憶體分配器有不同的記憶體分配邏輯。比如零長度物件由於沒有可讀寫內容,在分配時不同型別可能指向同一位置,如struct{}與[0]int。記憶體分配器會將小於16位元組且不包含指標(noscan)的微小物件組合起來,並嘗試用單個object記憶體塊儲存以減少記憶體浪費。32KB以內大小的小物件使用mcache元件進行分配,大於32KB的大物件直接在mheap元件管理的堆上分配。

使用者程式中建立的物件大部分是小物件,這也是記憶體分配器的重心所在,小物件分配在每個goroutine mcache中避免了競爭鎖提升了分配效率。如前所述,Go記憶體分配器採用分層級元件的方式來管理應用的堆記憶體,為小物件分配記憶體需要多級元件相互協作完成。分配過程如下圖所示:

在這裡插入圖片描述
圖7

· mcache快取是goroutine的私有記憶體空間,直接為當前goroutine無鎖分配小物件的記憶體塊,速度很快。記憶體分配器首先根據物件大小獲取mcache中對應size class的span連結串列,並從表頭span中提取object塊進行分配。

· 如果分配器發現mcache下沒有對應規格的可用span資源,則會嘗試從堆區相應class的mcentral區域中申請擴容(mcentral是所有執行緒共享,在為mcache擴容時總是會先lock)。分配器將申請到的span資源連結到mcache連結串列,繼續為物件分配object塊空間。

· 如果mcentral中沒有找到可用的記憶體塊,分配器會向mheap申請擴容,擴容成功後繼續為物件分配記憶體。

對於大物件,Go的記憶體分配器直接在mheap分配記憶體,如果沒有找到合適的span記憶體塊,分配器將向作業系統申請擴容後繼續分配。提取的span記憶體塊如果超過了物件規格所需的頁數,分配器將嘗試分割該span合適大小分配給物件,併合並剩餘的空間歸還給mheap管理,以減少堆記憶體碎片。

由上可知,小物件記憶體分配,Go mcache方式與Java TLAB方式相似,都是從堆記憶體中為執行緒劃分私有空間以便進行快速分配,但是仍然會有所差異:

在這裡插入圖片描述

垃圾收集差異

記憶體結構劃分、物件分配方式與垃圾收集策略密切相關,而且自動GC很大程度上會成為影響系統性能的瓶頸。Go與Java的GC策略是判斷物件是否存活,並對其進行標記,或採用“複製”演算法、“清除”演算法、“整理”演算法等完成不可引用物件的記憶體回收操作。目前垃圾收集過程中總是會遇到STW(stop the world)的問題。

Java GC

·垃圾收集器一覽

JVM的垃圾收集是分代的收集。比如新生代採用“標記-複製”演算法進行回收,老年代通常使用“標記-清理”或“標記-整理”演算法。其中在G1收集器下,記憶體的劃分又稍有不同了。

以下是JVM中主要的垃圾收集器實現,其中JDK8預設GC組合是Parallel Scavenge(新生代)和Parallel Old(老年代):

在這裡插入圖片描述

· GC觸發時機一覽

JVM的GC策略根據記憶體分代可以分為Minor GC(新生代垃圾收集)和Full GC(Major GC,老年代垃圾收集)兩類。它們的觸發時機一般如下表所示:

在這裡插入圖片描述

Go GC

Go只有一種垃圾收集器,其基於優化改進的“標記-清除”演算法,特徵為“非分代、非緊縮、寫屏障、三色標記、併發標記清理”。Go的“非分代”記憶體管理使得Go並不需要實現多種GC演算法策略,“非緊縮”的特徵使得回收的記憶體塊非常容易的複用,較少產生記憶體碎片,基本上不需要壓縮整理。Go的垃圾收集器與JVM中的CMS垃圾收集器原理上是非常相似的。

· GC中的STW問題

垃圾收集器在回收物件記憶體的過程中,總是需要掛起所有的使用者執行緒(即STW,stop the world),以避免GC執行緒在回收時物件的引用關係還在不斷變化導致回收結果不準確。但是STW可能會因GC時間過長而使得使用者執行緒長時間的停頓,這對追求響應速度的程式來說將是令人難以接收的。Go GC的目標就是儘量減小STW的時間,以使得程式能夠獲取最大限度的響應速度。

Go GC執行緒與使用者執行緒是併發的,其過程可以分為如下四個階段:

■ Sweep termination:清理掉意外遺留的span記憶體塊,只有上一次的GC清除工作完成了才能開始下一次GC。

■ Mark:

1)初始標記,需要STW。準備GC Roots物件的掃描、開啟寫屏障等。

2)併發標記,GC Roots到所有的物件的可達性分析,採用三色標記法。

■ Mark termination:

重新標記,需要STW。重新掃描部分GC Roots物件,修正併發標記期間因使用者執行緒繼續執行而導致標記產生變動的那一部分物件的標記記錄。

■ Sweep:

根據標記結果併發清除,回收記憶體。

可見,在初始標記以及重新標記期間仍然存在STW問題。Go GC雖然沒有完全消除STW,但在整個GC回收週期中,已將STW侷限在有限的2個階段,這讓程式實時性有了很大改善。

·GC觸發時機一覽

Go的堆記憶體沒有分代,每次GC時都要回收整個堆中的物件。

在這裡插入圖片描述

上文我們只對Go與Java在記憶體管理方面做了一個簡要的比較分析,其中還有很多方面以及細節並未展開或涉及。我們看到了Go與Java在記憶體管理上採用的不同策略所帶來的一些影響:Java的分代記憶體管理支援各個記憶體代選擇合適的GC演算法實現,通常GC只需要對某一個記憶體代進行回收;Go對整塊堆記憶體分層級管理,GC時就不得不掃描整塊區域。Java與Go為了實現物件空間的快速分配,都為執行緒分配了一塊私有堆記憶體。Java分配確定的大小物件記憶體,回收時更容易造成堆記憶體碎片問題。Go按size class的分塊物件記憶體模式雖然在重用性上得到了改善,但是又造成了一些浪費。可見Go與Java 在記憶體管理上各有特點。

Go語言社群活躍,生態也在逐漸繁榮。Go與Java各有優勢,兩者之間也有許多可以相互借鑑之處,因此它們未來的發展也非常值得期待。​​​​