GC系列:如何優化標記-整理演算法
引言
標記-整理演算法有一個整理物件,避免產生記憶體碎片的過程,那麼回收器是怎麼整理物件的?整理演算法又是怎麼區分效能好壞的?整理過程大概需要哪幾個步驟?《The Garbage Collection Handbook》詳細地描述了這一演算法。
記憶體碎片化是非移動式回收器無法解決的問題之一,儘管堆中有可用空間,卻無法找到一塊連續的記憶體塊來滿足較大物件的分配需求或者需要花費較長時間才能找到。
堆整理的最大優勢在於,它允許極為快速的順序分配,簡單地進行堆上限判斷,然後根據所需要空間的大小階躍式地移動空閒指標。
標記-整理的步驟:
- 標記階段
- 整理階段:移動存活物件,同時更新存活物件中所有指向被移動物件的指標
整理的順序
不同演算法中,堆遍歷的次數,整理的順序,物件的遷移方式都有所不同。而整理順序又會影響到程式的區域性性。主要有以下3種順序:
1. 任意順序:物件的移動方式和它們初始的物件排列及引用關係無關
2. 線性順序:將具有關聯關係的物件排列在一起
3. 滑動順序:將物件“滑動”到堆的一端,從而“擠出”垃圾,可以保持物件在堆中原有的順序
任意順序整理實現簡單,且執行速度快,但任意順序可能會將原本相鄰的物件打亂到不同的快取記憶體行或者是虛擬記憶體頁中,會降低賦值器的區域性性。
所有現代的標記-整理回收器均使用滑動整理,它不會改變物件的相對順序,也就不會影響賦值器的空間區域性性。複製式回收器甚至可以通過改變物件佈局的方式,將物件與其父節點或者兄弟節點排列的更近以提高賦值器的空間區域性性。
整理演算法的限制,如任意順序演算法只能處理單一大小的物件,或者針對大小不同的物件需要分批處理;整理過程需要2次或者3次遍歷堆空間;物件頭部可能需要一個額外的槽來儲存遷移的資訊。
幾種不同型別的整理演算法
- 雙指標回收演算法:實現簡單且速度快,但會打亂物件的原有佈局
- Lisp2演算法(滑動回收演算法):需要在物件頭用一個額外的槽來儲存遷移完的地址
- 引線整理演算法:可以在不引入額外空間開銷的情況下實現滑動整理,但需要2次遍歷堆,且遍歷成本較高
- 單次遍歷演算法:滑動回收,實時計算出物件的轉發地址而不需要額外的開銷
雙指標整理演算法:
屬於任意順序整理演算法,需要2次遍歷堆空間,最佳適用場景為只包含固定大小的區域。原理:針對某一塊記憶體區域中的待整理的存活物件,先計算出如果所有存活物件都移動到一端之後的截止地址,然後將地址大於該截止地址的物件移動到截止地址以下的空間。
如圖,高水位閾值即為截止地址。在演算法的開始階段,指標free指向區域始端,指標scan指向區域末端。在第一次遍歷過程中,回收器不斷向前移動指標free,直到發現空隙。同時,指標scan由後往前移動,直到遇到存活物件。然後在該物件的物件頭部記錄下轉發地址。如果指標free和scan交錯,則該過程停止,否則變將指標scan遇到的物件移動到free所在的位置。在第二次遍歷中,回收器將指向存活物件邊界外的指標更新為目標物件頭部記錄的轉發地址,即物件的新地址。該演算法的整理質量取決於指標free所指向的空隙和指標scan所指向的物件大小的匹配度。
優點:
速度快,簡單,且每次遍歷的操作比較少。轉發地址是在物件移動之後才寫入的,不會存在資訊丟失。同時,由於指標scan是由高位向地位移動的,所以要求回收器能逆向進行堆解析。但是由於物件的移動是任意的,可能會將原先並列的物件順序打亂,降低賦值器的空間區域性性。
Lisp2整理演算法:
在標記結束的第一次遍歷堆時,回收器會計算出每個物件的最終轉發地址,並儲存在物件頭的某一個域中。計算轉發地址需要3個引數,分別是待整理區域的起始地址,最終地址,目標區域的起始地址。目標區域通常與待整理區域相同。指標scan掃描來源區域的所有(存活的和死亡的)物件,指標free指向目標區域的下一個空閒地址。如果scan掃描到的物件是存活的,那就意味著該物件最終會移動到指標free所在地址,然後將該地址寫入到物件頭域中,然後根據物件的大小向前移動指標free.
在第二次遍歷時,回收器將使用物件頭域中儲存的轉發地址來更新賦值器執行緒根以及被標記的物件,確保它們指向物件的新位置。第3次遍歷時,才將物件移動至轉發地址。
需要注意的是,遍歷的方向(從低地址到高地址)和物件移動的方向(從高地址到低地址)相反,這樣可以保證低地址的物件移動到高地址的時候,高地址區域已經騰空。
缺點:1.需要遍歷3次;2.需要額外的空間記錄轉發地址。滑動回收是一種具有破壞性的操作,存活物件的新副本會覆蓋其他存活物件的原有副本,所以在移動物件,更新引用之前,必須記錄下其轉發地址。
單次遍歷演算法:
首先是標記的過程,標記過程是基於點陣圖的,每個位對應堆中的一個顆粒(即一個字)。在標記過程中,如果發現存活物件,就設定該物件的第一個位元組和最後一個位元組在點陣圖中對應的位(稱為標記向量)。回收器會在整理階段時根據標記向量的分析計算出任意存活物件的大小。
回收器使用額外的一張表來記錄轉發地址,但是如果記錄每個物件的轉發地址,則開銷又過大。所以該演算法將堆分成大小相等的記憶體塊(256位元組和512位元組)。偏移向量記錄了每個記憶體塊中第一個存活物件的轉發地址,而其他物件的地址則可以根據偏移向量和前面提到的標記向量實時計算出來。
所以對於給定的任何一個物件,可以先計算出它所在的記憶體塊的索引,再根據該記憶體塊的偏移向量和該物件的標記向量計算出該物件的轉發地址。所以回收器不需要遍歷2次堆記憶體來移動物件和更新指標,而是先通過對標記位向量的一次遍歷來夠造偏移向量,然後通過一次堆遍歷過程同時完成物件的移動和指標的更新。減少堆的遍歷次數也可以提升回收器的區域性性。
在Lisp2演算法中,由於物件的遷移資訊記錄在物件頭中(堆)中,且堆中物件的移動會破壞原有物件的遷移資訊,所以回收器需要將更新引用和移動物件的過程分開。但在單次遍歷演算法中,遷移資訊是根據偏移向量和標記向量實時計算出來的,無須儲存在堆中,所以回收器可以在單次遍歷過程中同時完成物件的遷移和引用的更新。
需要考慮的問題:
1.整理的吞吐量開銷
在整理式堆中進行順序分配的速度很快。如果堆的可用記憶體相對較大,標記-整理演算法是一個合適的移動式回收策略。與標記-清掃和複製式演算法相比,標記-整理的回收速度較慢。許多整理演算法對空間有額外開銷,或者對賦值器有一定的要求,同時因為需要多次堆遍歷,所以和標記-清掃/複製式回收演算法相比,吞吐量較低。每次遍歷的開銷都很大,因為不僅需要多次訪問對型別資訊和物件的指標域,還有指標開銷也很大。一個通用的解決方案是儘量使用標記-清掃演算法,直到碎片化達到一定程度時才使用標記-整理演算法
2.長壽資料
複製式演算法在處理長壽物件(永生物件)時的效果很差,它只會重複得將這些物件從一個區複製到另外一個區。分代回收器可以將這些物件移動到一個很少進行回收的區域,從而較好的實現長壽物件的處理。但是分代回收器並不適用較小堆空間的情況。因為如果要對分代回收器中最老的一代進行回收,它仍要處理長壽物件。相比之下,標記-整理回收器則可以選擇不去整理這一個區域。
3.區域性性
標記-整理回收器可能會保留物件在堆中原有的分配順序。隨意打亂物件排列順序會影響賦值器的區域性性。
4.標記-整理演算法的侷限性
包括記錄轉發地址需要佔用多少額外的空間。某些整理演算法對賦值器有要求:雙指標等簡單的處理演算法只能處理固定大小的物件,將物件按照大小分級當然可以,但是既然已經如此又何須進行整理?引線演算法要求指標和暫存在指標域的非指標臨時值進行區分,由於引線演算法會(臨時性)破壞指標域,所以不適合併發回收器。