解密方舟的高效能記憶體回收技術——HPP GC

HarmonyOS開發者社群發表於2022-07-20

解密方舟的高效能記憶體回收技術 —— HPP GC

眾所周知,記憶體是作業系統的一項重要資源,直接影響系統效能。而在應用蓬勃發展的今天,系統中執行的應用越來越多,這讓記憶體資源變得越來越緊張。在此背景下,方舟JS執行時在記憶體回收方面發力,推出了高效能記憶體回收技術——HPP GC(High Performance Partial Garbage Collection)。本文我們將從GC的基礎入手,由淺入深地為大家介紹HPP GC。

一、什麼是GC?

GC(全稱Garbage Collection),字面意思是垃圾回收。在計算機領域,GC就是找到記憶體中的垃圾,釋放和回收記憶體空間。目前主流程式語言實現的GC演算法主要分為兩大類:引用計數和物件追蹤(即Tracing GC)。

1. 引用計數

我們透過一個示例來了解什麼是引用計數:

當一個物件A被另一個物件B指向時,A引用計數+1;反之當該指向斷開時,A引用計數-1。當A引用計數為0時,回收物件A。


● 優點:


引用計數演算法設計簡單,並且記憶體回收及時,在物件成為垃圾的第一時間就會被回收,所以沒有單獨的暫停業務程式碼(Stop The World,STW)階段。
● 缺點:
在對物件操作的過程中額外插入了計數環節,增加了記憶體分配和記憶體賦值的開銷,對程式效能必然會有影響。最致命的一點是存在迴圈引用問題。



function main() {


var parent = {};
var child = {};
parent.child = child;
child.parent = parent;
}

比如以上程式碼中,物件parent被另一個物件child持有,物件parent引用計數加1,同時child也被parent持有,物件child引用計數也加1,這就是迴圈引用。一直到main函式結束後,物件parent和child依然無法釋放,導致記憶體洩漏。

2. 物件追蹤

為了方便大家理解物件追蹤演算法,我們透過一個圖來進行介紹:

如圖1所示,從根物件開始遍歷物件以及物件的域,所有可達的物件打上標記(黑色),即為活物件,剩下的不可達物件(白色)即為垃圾。

               

圖1 物件追蹤


● 優點:


物件追蹤演算法可以解決迴圈引用的問題,且對記憶體的分配和賦值沒有額外的開銷。
● 缺點:
和引用計數演算法相反,物件追蹤演算法較為複雜,且有短暫的STW階段。此外,回收會有延遲,導致比較多的浮動垃圾。

引用計數和物件追蹤演算法各有優劣,但考慮到引用計數存在迴圈引用的致命效能問題,方舟JS執行時選擇基於物件追蹤(即Tracing GC)演算法來設計自己的GC演算法。

二、Tracing GC介紹

在介紹HPP GC之前,我們先來了解一下Tracing GC。從前面的介紹可知,Tracing GC演算法透過遍歷物件圖示記出垃圾。而根據垃圾回收方式的不同,Tracing GC可以分為三種基本型別:標記-清掃回收、標記-複製回收、標記-整理回收。

1. 標記-清掃回收

此演算法的回收方式是:完成物件圖遍歷後,將不可達物件內容擦除,並放入一個空閒佇列,用於下次物件的再分配。

該種回收方式不需要搬移物件,所以回收效率非常高。但由於回收的物件記憶體地址不一定連續,所以該回收方式最大的缺點是會導致記憶體空間碎片化,降低記憶體分配效率,極端情況下甚至會出現還有大量記憶體的情況下分配不出一個比較大的物件的情況。

               

圖2 標記-清掃回收

(注:灰色塊表示可達物件的記憶體空間,白色塊表示不可達物件的記憶體空間。)

2. 標記-複製回收

此演算法的回收方式是:在物件圖的遍歷過程中,將找到的可達物件直接複製到一個全新的記憶體空間中。遍歷完成後,一次將舊的記憶體空間全部回收。

顯然,這種方式可以解決記憶體碎片的問題,且透過一次遍歷便完成整個GC過程,效率較高。但同時在極端情況下,這種回收方式需要預留一半的記憶體空間,以確保所有活的物件能被複製,空間利用率較低。

               

圖3 標記-複製回收

3. 標記-整理回收

此演算法的回收方式是:完成物件圖遍歷後,將可達物件(紅色)往本區域(或指定區域)的頭部空閒位置複製,然後將已經完成複製的物件回收整理到空閒佇列中。

這種回收方式既解決了“標記-清掃回收”引入的大量記憶體空間碎片的問題,又不需要像“標記-複製回收”那樣浪費一半的記憶體空間,但是效能上開銷比“標記-複製回收”稍大一些。

               

圖4 標記-整理回收

綜上所述,Tracing GC的三種基本型別各有優缺點,那麼方舟JS執行時的HPP GC是基於哪種型別實現的呢?

HPP GC同時實現了這三種Tracing GC演算法!沒想到吧?HPP GC綜合了這三種演算法的優點,且支援根據不同物件區域、採取不同的回收方式。下面就為大家詳細解析HPP GC。

三、HPP GC詳解

前面我們提到了,HPP GC支援根據不同物件區域,採取不同的回收方式。這是基於分代模型和混合演算法來實現的。另外,為了實現HPP GC(High Performance Partial Garbage Collection)中的“High Performance”(高效能),HPP GC對GC流程做了大量最佳化。所以下面我們就從分代模型、混合演算法和GC流程最佳化三個方面來為大家介紹HPP GC。

1. 分代模型

方舟JS執行時採用傳統的分代模型,將物件進行分類。考慮到大多數新分配的物件都會在一次GC之後被回收,而大多數經過多次GC之後依然存活的物件會繼續存活,方舟JS執行時將物件劃分為年輕代物件和老年代物件,並將物件分配到不同的空間。

如圖5所示,方舟JS執行時將新分配的物件直接分配到年輕代(Young Generation)的From空間。經過一次GC後依然存活的物件,會進入To空間。而經過再次GC後依然存活的物件,會被複制到老年代(Old Generation)。

               

圖5 分代模型

2. 混合演算法

透過前面的介紹,我們已經知道:HPP GC同時實現了“標記-清掃回收”、“標記-複製回收”和“標記-整理回收”這三種Tracing GC演算法。也就是說, HPP GC是一種“部分複製+部分整理+部分清掃”的混合演算法,支援根據年輕代物件和老年代物件的不同特點,分別採取不同的回收方式。(1) 部分複製

考慮到年輕代物件生命週期較短,回收較為頻繁,且年輕代物件大小有限的特點,方舟JS執行時對年輕代物件採用“標記-複製回收”演算法。

(2) 部分整理+部分清掃

方舟JS執行時根據老年代物件的特點,引入啟發式Collection Set(簡稱CSet)選擇演算法。此選擇演算法的基本原理是:在標記階段對每個區域的存活物件進行大小統計,然後在回收階段優先選出存活物件少、回收代價小的區域進行物件整理回收,再對剩下的區域進行清掃回收。

具體的回收策略如下:


(a) 根據設定的區域存活物件大小閾值,將滿足條件的區域納入初步的CSet佇列,並根據存活率進行從低到高的排序。


(注:存活率=存活物件大小/區域大小)
(b) 根據設定的釋放區域個數閾值,選出最終的CSet佇列,進行整理回收。
(c) 對未被選入CSet佇列的區域進行清掃回收。

由上可知,啟發式CSet選擇演算法同時兼顧了 “標記-整理回收”和“標記-清掃回收”這兩種演算法的優點,既避免了記憶體碎片問題,也兼顧了效能。

3. GC流程最佳化

在記憶體回收時,雖然釋放和回收了記憶體空間,讓系統有了更多可用的記憶體資源,但記憶體回收過程本身需要暫停應用業務程式碼執行,影響使用者使用應用的體驗。回收記憶體時,如何儘可能的減小對應用效能的影響呢?

為此,我們在HPP GC流程中引入了大量的併發和並行最佳化,以減少對應用效能的影響。如圖6所示,HPP GC流程中採用了併發+並行標記(Marking)、併發+並行清掃(Sweep)、並行複製/整理(Evacuation)、並行回改(Update)和併發清理(Clear)。

               

圖6 HPP GC流程

(1) 併發+並行標記

在JS執行緒執行業務程式碼的同時,另外啟動執行緒執行標記,即為“併發標記”。如果另外啟動多個執行緒執行標記,即為“併發+並行標記”。

在併發+並行標記過程中,為確保標記的正確性和高效能,HPP GC採取了兩項最佳化措施:

措施一:在新增引用關係時增加標記屏障(Marking Barrier),以確保標記結果的正確性。

併發標記過程中,JS執行緒有可能會更改物件之間的引用關係,從而導致物件標記結果出錯。如圖7所示,在marking執行緒完成物件1的標記、準備標記物件2的過程中,JS執行緒更改了物件3的引用關係。那麼marking執行緒完成物件2的標記後,物件3不會被標記,回收器會判定物件3為垃圾,進行回收。此後,如果JS執行緒讀取物件1的欄位,將會發生不可預知的錯誤。

               

圖7 物件標記出錯

為確保標記結果的正確性,HPP GC在新增引用關係時增加標記屏障。如圖8所示,在marking執行緒完成物件1的標記、準備標記物件2的過程中,JS執行緒更改了物件3的引用關係。此時,JS執行緒會將物件3加入等待標記佇列,等marking執行緒完成物件2的標記後,繼續物件3的標記,從而確保物件3不會被回收。

               

圖8 增加標記屏障

措施二:在共享全域性工作佇列的基礎上,增加了本地工作佇列(Local Work List),以提高讀取物件的效能。

如圖9所示,並行標記時,每個Marking執行緒都要執行以下操作:從全域性工作佇列(Global Work List)獲取一個物件,然後設定標記位,最後遍歷該物件的每個域,將子物件放入全域性工作佇列中。考慮到執行緒之間讀取資料安全,必須在每個物件的Push/Pop操作時增加原子化或者鎖,這對物件的讀取效能有較大的影響。

               

圖9 全域性工作佇列

為提高讀取物件的效能,HPP GC增加了本地工作佇列。每個執行緒持有一個獨立的本地工作佇列,優先從本地工作佇列獲取/放入物件。當本地工作佇列滿時,將本地工作佇列的部分佇列一次釋出到全域性工作佇列中;當本地工作佇列空時,一次從全域性工作佇列獲取若干物件到本地工作佇列。這樣,只有從全域性工作佇列釋出/獲取物件那一次需要增加原子化或者鎖,兼顧了多執行緒的併發效率和任務均衡,大大提高了併發標記的效率。

               

圖10 增加本地工作佇列

(2) 併發+並行清掃

在JS執行緒執行業務程式碼的同時,另外啟動執行緒執行清掃,即為“併發清掃”。如果另外啟動多個執行緒執行清掃,即為“併發+並行清掃”。

在併發+並行清掃過程中,HPP GC採取增加區域空閒佇列(Region Free List)的最佳化措施,用於提高多執行緒併發清掃的效率。

併發+並行清掃過程中,Sweeping執行緒發現不可達物件後,需要將物件放入全域性的空閒佇列,同時JS執行緒執行的業務程式碼可能需要從空閒佇列分配物件。為了確保資料安全,這個過程需要增加原子化或者鎖,但會影響到分配和清掃的效率。

為了提升效率,HPP GC增加區域空閒佇列。將所有需要清掃的記憶體按記憶體地址分成若干個區域,每個區域擁有獨立的空閒佇列,且每個區域同時只有一個執行緒進行清掃。在併發清掃過程中,Sweeping執行緒會首先將不可達物件放入本區域的空閒佇列。當JS執行緒需要從空閒佇列分配物件時,先獲取已經完成清掃的區域,再將這些區域的空閒佇列釋出到全域性空閒佇列中,用於物件分配,如圖11所示。由於釋出的任務由JS執行緒單獨完成,所以整個並行清掃的過程都不需要加鎖,大大提高了併發+並行清掃的效率。                        

圖11 增加區域空閒佇列

(3) 並行複製/整理

在JS執行緒暫停業務程式碼(STW)對可達物件進行復制/整理時,另外啟動多個執行緒一起進行復制/整理,即為“並行複製/整理”。

和併發+並行清掃相似,並行複製/整理的瓶頸點在於多個執行緒同時從全域性空閒佇列/全域性線性分配器分配物件時,需要增加原子化或者鎖。 為了提高多執行緒分配效能,在並行複製/整理過程中引入了TLAB Allocator。

TLAB英文全稱為Thread Local Allocation Buffer。顧名思義,TLAB Allocator就是每個執行緒擁有一個獨立的本地分配器,該分配器會從全域性記憶體分配器一次分配一塊較大的記憶體,然後線上程內部再分配。這樣只需從全域性分配器分配時保證多執行緒安全,即可完成高效能且安全的並行複製/整理功能。

(4) 並行回改

在GC完成標記和複製/整理後,需要將可達物件中指向舊物件地址的域改成新物件地址,這個過程就是“地址回改”,如圖12所示。為了提升地址回改的效率,HPP GC引入了“並行回改”,同時啟動多個執行緒進行地址回改,每個執行緒負責其中一塊記憶體區域物件地址的回改。

               

圖12 地址回改

(5) 併發清理

在GC複製/整理結束後,JS執行緒將不再讀寫遍歷出來的不可達物件和已經完成複製的可達物件,因此需要清理和回收對應的實體記憶體。為了減少STW時間,HPP GC引入“併發清理”,另外啟動一個工作執行緒進行併發的實體記憶體回收,這樣JS執行緒就可以繼續執行業務程式碼。

四、結束語

本期就為大家介紹到這裡了,最後我們總結一下:


●  HPP GC基於分代模型將物件分為年輕代和老年代物件。


●  HPP GC基於Tracing GC的三種基本型別,實現了“部分複製+部分整理+部分清掃”的混合演算法,從而實現根據不同物件區域、採取不同的回收方式。
●  HPP GC透過在GC流程中引入了大量的併發和並行最佳化,實現高效能。

HPP GC仍有很大的探索空間,我們還將繼續努力,為大家提供更高效能的記憶體回收能力!

               



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70009402/viewspace-2906662/,如需轉載,請註明出處,否則將追究法律責任。

相關文章