前 言?
從實踐中看,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用戶程序運行時創建的對象和字符串常量池等數據,??臻g則為Java線程提供了私有的內存區域。在HotSpot VM的實現中,Java線程棧使用操作系統棧和線程模型表示,且Java方法與本地方法共享同一個棧區。因此虛擬機棧與本地方法棧其實是同一個區域。
Go與Java有較大不同,Go進程空間的text段不但保存了內置的運行時機器指令,而且還有用戶程序的機器指令(Go在編譯時就已確定)。堆內存區則為用戶程序創建對象提供了存儲空間。Go天然支持并發編程模型,采用了系統線程與用戶線程(goroutine)相結合的實現機制,進程棧空間為系統線程提供了棧內存,而用戶線程棧的內存默認從堆中分配。
Java內存結構?
?1. 運行時內存?
Java程序的運行時內存被劃分為元數據區(方法區)、堆、虛擬機棧、本地方法棧、程序計數器5個部分,這可以看作是JVM對進程可用堆、??臻g進行的二次分配,以滿足運行Java用戶程序的內存需求。其內存結構如下圖所示:
圖2
上圖中堆內存對應了圖1中JVM的堆內存,虛擬機棧、本地方法棧、程序計數器則對應了圖1中JVM的棧區,元數據區則是JVM另外開辟的內存塊。
·?元數據區是JVM向操作系統申請的堆外內存,用于實現“方法區”,主要存儲虛擬機加載class的類信息、JIT編譯的代碼、運行時常量池等數據,其默認大小由系統的可用物理內存上限限制。
·?堆內存是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。它存放了包括幾乎所有的Java對象以及數組,這也是垃圾收集器關注的主要內存區。由于逃逸分析等優化技術,對象也有可能被分配到棧上。
·?虛擬機棧即我們常說的??臻g,其生命周期與線程相同。線程的??臻g存儲了方法調用的棧幀,每個棧幀則存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。
·?本地方法棧為JVM使用到的Native方法提供內存空間,而虛擬機棧為JVM執行Java方法提供內存空間。HotSpot將虛擬機棧與本地方法棧合并管理。
·?程序計數器是一塊較小的內存空間,用來指向當前線程需要執行的下一條字節碼指令。
元數據區、堆內存為所有線程共享,多線程訪問時需要進行同步控制。虛擬機棧、本地方法棧、程序計數器都是線程私有的內存空間(堆的TLAB空間也是線程私有的空間),訪問時無需加鎖,速度很快。
?2.? 分代的堆內存?
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分代管理,而是采用分層級的內存管理模式。
?2.? 分層級的堆內存?
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文件、第三方依賴庫等)、以及由字節碼框架動態生成的類信息,這些數據經加載、驗證、準備、解析、初始化等一系列過程最終都會保存在方法區中。
·?程序運行時,執行引擎中的解釋器解釋執行方法區中的字節碼指令?;旌夏J较碌腏IT編譯器將探測到的熱點代碼編譯為本地可執行的機器指令,編譯的機器指令也保存在方法區中。
隨著程序不斷運行,方法區所占的內存空間可能會越來越大,存儲的數據有時候能達到數百兆;而對該內存區域中類型卸載的條件又比較苛刻,內存回收效率并不如堆內存理想。
這“多出來”的用于存儲Java用戶程序字節碼指令的區域是Java程序比Go消耗更多內存的一個重要因素。
對象內存分配差異
不同的內存結構決定了對象內存分配方式的差異,也會給垃圾回收帶來影響。Go以及Java都支持變量的逃逸分析,逃逸到棧上的對象會隨著方法退出而自然回收,而分配到堆內存的對象則需要垃圾收集器回收才能釋放出內存空間。因而我們更多關注對象在堆上分配空間的過程。
? Java對象??
JVM會為新創建的線程在stack棧區分配一塊私有的線程??臻g。某一個線程中創建的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的分塊對象內存模式雖然在重用性上得到了改善,但是又造成了一些浪費??梢奊o與Java 在內存管理上各有特點。
Go語言社區活躍,生態也在逐漸繁榮。Go與Java各有優勢,兩者之間也有許多可以相互借鑒之處,因此它們未來的發展也非常值得期待。