淺析 PHP7 的垃圾回收機制

JeffreyC發表於2019-09-02

垃圾回收機制

垃圾回收機制是一種動態儲存分配方案。它會自動釋放程式不再需要的已分配的記憶體塊。 自動回收記憶體的過程叫垃圾收集。垃圾回收機制可以讓程式設計師不必過分關心程式記憶體分配,從而將更多的精力投入到業務邏輯。 在現在的流行各種語言當中,垃圾回收機制是新一代語言所共有的特徵。

垃圾的產生

PHP7 中複雜型別,像字串、陣列、物件等的資料結構中,頭部都有一個 gc, 這個 gc 的作用就是用來對垃圾回收的支援。當變數賦值、傳遞時,會增加 value 的引用數, unset、return 等釋放變數時再減掉引用數,減掉後如果發現 refcount 變為 0 則直接釋放 value,這是變數的基本回收過程。

不過有一種問題是這個機制無法解決的,就是迴圈引用的問題。

什麼是迴圈引用呢? 簡單說就是變數的內部裡存的 value 又引用了變數自身。 這種比較經常發生在陣列和物件型別的變數上。

這裡先講一下引用,即 zend_reference 這個型別,這個是 PHP7 新增的變數型別,當對變數使用 “&” 操作時,會建立新的中間結構體 zend_reference,這個結構體會真正的指向對應的 value 結構。

舉個例子:

// 當進行如下賦值操作時
$a = 'hello'; // $a -> zend_string
$b = $a; // $b,$a -> zend_string
$c = &$b; // $c,$b -> zval(type = IS_REFERENCE, refcount = 2) -> zend_string

最終會變成如下這樣:

即 $b 和 $c 的 zval 是通過中間結構體 zend_reference 再指向最終的 zend_string。

回到迴圈引用的問題,舉個陣列迴圈引用例子:

$a = [1];
$a[] = &$a;

unset($a);

使用 & 操作之後,變數 a 就變成了引用型別且引用計數 refcount 為 2,而又賦值給自己裡面的元素,即變數 a 變成了自己引用自己。

具體如下如所示:

當 unset 之後就變成下圖這樣:

即 $a 所在的 zval 型別已經變成了 IS_UNDEF 了,zend_reference 結構體的引用計數減 1,但是仍然大於 0,這時候,這部分結構體就變成了垃圾,對此不處理的話,就可能會造成記憶體洩露。這裡就需要垃圾收集器將這部分收集到緩衝區,之後進行回收處理。

回收過程

如果當變數的 refcount 減小後大於 0,PHP 並不會立即對這個變數進行垃圾鑑定和回收,而是放入一個緩衝區中,等這個緩衝區滿了以後(10000 個值)再統一進行處理,加入緩衝區的是變數 zend_value 裡的 gc,目前垃圾只會出現在陣列和物件兩種型別中,陣列的情況上面已經介紹了,物件的情況則是成員屬性引用物件本身導致的,其它型別不會出現這種變數中的成員引用變數自身的情況,所以垃圾回收只會處理這兩種型別的變數。

gc 的結構 zend_refcounted_h 具體如下:


typedef struct _zend_refcounted_h {
    uint32_t         refcount; // 記錄 zend_value 的引用數
    union {
        struct {
            zend_uchar    type,  // zend_value的型別, 與zval.u1.type一致
            zend_uchar    flags, 
            uint16_t      gc_info // GC資訊,記錄在 gc 池中的位置和顏色,垃圾回收的過程會用到
        } v;
        uint32_t type_info;
    } u;
} zend_refcounted_h;

一個變數只能加入一次緩衝區,為了防止重複加入,變數加入後會把 zend_refcounted_h.gc_info 置為 GC_PURPLE,即標為紫色,後續不會重複插入。

垃圾緩衝區是一個雙向連結串列,等到快取區滿了以後則啟動垃圾檢查過程:遍歷緩衝區,對當前變數的所有成員進行遍歷,然後把成員的 refcount 減 1 (如果成員還包含子成員則也進行遞迴遍歷,即深度優先遍歷),最後再檢查當前變數的引用,如果減為了 0 則為垃圾。這個演算法的原理核心是:垃圾是由於成員引用自身導致的,那麼就對所有的成員減一遍引用,如果發現最後變數本身的 refcount 變為了 0 則就表明其引用全部來自自身成員,即其他任何地方都不再使用它,那麼它就是垃圾,需要被回收掉。反之說明不是垃圾,需要將其從緩衝區移出去。具體的過程如下:

(1) 從緩衝區連結串列的 roots 開始遍歷,把當前 value 標為灰色 (zend_refcounted_h.gc_info 置為 GC_GREY),然後對當前 value 的成員進行深度優先遍歷,把成員 value 的 refcount 減 1,並且也標為灰色;

(2) 重複遍歷緩衝區連結串列,檢查當前 value 引用是否為 0,為 0 則表示確實是垃圾,把它標為白色(GC_WHITE),如果不為 0 則排除了引用全部來自自身成員的可能,表示還有外部的引用,並不是垃圾,這時候因為步驟(1)對成員進行了 refcount 減 1 操作,需要再還原回去,對所有成員進行深度遍歷,把成員 refcount 加 1,同時標為黑色;

(3) 再次遍歷緩衝區連結串列,將非 GC_WHITE 的節點從 roots 連結串列中移出,最終 roots 連結串列中全部為真正的垃圾,最後將這些垃圾清除。

參考
《PHP7 底層設計與原始碼解析》

php7-internal

You can not connect the dots looking forward, you can only connect them looking backwards.

相關文章