PHP5底層原理之垃圾回收機制

  • 2019 年 10 月 29 日
  • 筆記

概念

垃圾回收機制 是一種記憶體動態分配的方案,它會自動釋放程式不再使用的已分配的記憶體塊。

垃圾回收機制 可以讓程式設計師不必過分關心程式記憶體分配,從而將更多的精力投入到業務邏輯。

與之相關的一個概念,記憶體泄露 指的是程式未能釋放那些已經不再使用的記憶體,造成記憶體的浪費。

那麼 PHP 是如何實現垃圾回收機制的呢?

PHP變數的內部存儲結構

首先還是需要了解下 基礎知識,便於對垃圾回收原理內容的理解。

PHP 所有類型的變數在底層都會以 zval 結構體 的形式實現 (源碼文件Zend/zend.h)

源碼根目錄搜索

grep -rin --color --include=*.h --include=*.c _zval_struct *

struct _zval_struct {      /* Variable information */      zvalue_value value;     /* 變數value值 */      zend_uint refcount__gc; /* 引用計數記憶體中使用次數,為0刪除該變數 */      zend_uchar type;    /* 變數類型 */      zend_uchar is_ref__gc; /* 區分是否是引用變數,是引用為1,否則為0 */  };  

註:上面 zval 結構體是 php5.3 版本之後的結構,php5.3 之前因為沒有引入新的垃圾回收機制,即 GC,所以命名也沒有_gc;而 php7 版本之後由於性能問題所以改寫了 zval 結構,這裡不再表述。

引用計數原理

變數容器

每個 PHP 變數存於一個叫 zval 的變數容器中。創建變數容器時,變數容器的 ref_count 初始值為 1, 每次被變數使用後,ref_count + 1 。當刪除變數時(unset( )),則它指向的變數容器的 ref_count – 1

非 array 和 object 變數

每次將常量賦值給一個變數時,都會產生 一個 變數容器

舉例:

$a = 'new string';  xdebug_debug_zval('a');  

結果會輸出:

a:(refcount=1, is_ref=0),string 'new string' (length=10)  

array 和 object 變數

每次將常量賦值給一個變數時,都會產生 元素個數 +1 個 變數容器

舉例:

$b = [      'name' => 'new string',      'number' => 12  ];  xdebug_debug_zval('b');  

結果會輸出:

b:  (refcount=1, is_ref=0),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'new string' (length=10)    'number' => (refcount=1, is_ref=0),int 12  

賦值原理

寫時複製原理

php 在設計的時候,為了節省記憶體,所以在變數之間賦值時,對於值相同的兩個變數,會共用一塊記憶體,也就是會在 全局符號表 內將變數 b 的變數指針指向變數 a 指向的同一個 zval 結構體,而只有當其中一個變數的 zval 結構發生變化時,才會發生變數容器複製的記憶體變化,也因此叫做 寫時複製原理

寫時複製原理 觸發時機:

php在修改一個變數時,如果發現變數的 refcount > 1,則會執行變數容器的記憶體複製

舉例:

// 創建一個變數容器,變數 a 指向給變數容器,a 的 ref_count 為 1  $a = ['name' => 'string','number' => 3];    // 變數 b 也指向變數 a 指向的變數容器,a 和 b 的 ref_count 為 2  $b = $a;  xdebug_debug_zval('a', 'b');  echo '<hr/>'  // 變數 b 的其中一個元素髮生改變,此時會複製出一個新的變數容器,變數 b 重新指向新的變數容器,a 和 b 的ref_count 變成 1  $b['name'] = 'new string';  xdebug_debug_zval('a', 'b');  

結果輸出:

a:(refcount=2, is_ref=0),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'string' (length=6)    'number' => (refcount=1, is_ref=0),int 3  b:(refcount=2, is_ref=0),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'string' (length=6)    'number' => (refcount=1, is_ref=0),int 3  ________________________________________________________________________________________  a:(refcount=1, is_ref=0),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'string' (length=6)    'number' => (refcount=2, is_ref=0),int 3  b:(refcount=1, is_ref=0),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'new string' (length=10)    'number' => (refcount=2, is_ref=0),int 3  

 

寫時改變原理

上面說了普通賦值的情況,那麼將引用賦值呢?

先通過舉例說明

$a = ['name' => 'string','number' => 3];  $b = &$a;  xdebug_debug_zval("a", "b");  

結果輸出

a:(refcount=2, is_ref=1),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'string' (length=6)    'number' => (refcount=1, is_ref=0),int 3  b:(refcount=2, is_ref=1),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'string' (length=6)    'number' => (refcount=1, is_ref=0),int 3  

此時,我們發現,變數 a 和 b 的 refcount 還是 2,只不過 is_ref 變成了 1,那是因為在將變數 a 引用賦值給變數b 時,在原變數容器上作了修改,將 is_ref 變成了 1,且 refcount + 1

那如果引用賦值的基礎上又發生了變數的改變了呢?

$a = ['name' => 'string','number' => 3];  $b = &$a;  $b['name'] = "new string";  xdebug_debug_zval("a", "b");  

結果輸出:

a:(refcount=2, is_ref=1),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'new string' (length=10)    'number' => (refcount=1, is_ref=0),int 3  b:(refcount=2, is_ref=1),  array (size=2)    'name' => (refcount=1, is_ref=0),string 'new string' (length=10)    'number' => (refcount=1, is_ref=0),int 3  

神奇的事情發生了,變數 b 和變數 a 的值一起發生改變了,其實這是因為觸發了寫時改變原理

image-20191029105237186

寫時改變原理 觸發時機:
is_ref 為 1 的變數容器在被賦值之前,優先檢查變數容器的 is_ref 是否等於 1 ,如果為 1,則不進行寫時複製,而是在原變數容器基礎上作內容修改;而如果將 is_ref 為 1 的變數容器賦值給其他變數時,則會立即觸發 寫時改變原理

現在將上面幾個例子結合起來,又會是怎樣的呢?

$a = ['name' => 'string','number' => 3];  $b = $a;  $c = &$a;  xdebug_debug_zval("a", "b", "c");  

結果輸出:

image-20191029111149169

執行過程:

執行第一行:變數容器的 refcount 為 1

執行第二行:變數容器的 refcount 為 2,變數 a 和 變數 b 共享同一個變數容器

執行第三行:要將變數 a 引用賦值 給 變數 c,此時變數容器的 refcount > 1,如果要發生改變,會觸發 寫時複製,將變數 a 和 變數 b 分離,之後將變數 a 引用賦值給變數 c,則變數容器的 is_rel 變成 1,且 refcount 變成 2。

引用計數清 0

當變數容器的 ref_count 計數清 0 時,表示該變數容器就會被銷毀,實現了記憶體回收。

這就是 PHP 5.3 版本之前的垃圾回收機制。

舉例:

$a = "new string";  $b = $a;  xdebug_debug_zval('a');  unset($b);      // 刪除了符號表中的變數名 b,同時它指向的變數容器 ref_count -1  xdebug_debug_zval('a');  xdebug_debug_zval('b');  

結果輸出:

a:(refcount=2, is_ref=0),string 'new string' (length=10)  a:(refcount=1, is_ref=0),string 'new string' (length=10)  b: no such symbol  

循環引用引發的記憶體泄露問題

當我們添加一個 數組或對象 作為這個 數組或對象 的元素時,而如果此時刪除了這個變數符號(unset),此變數容器並不會被刪除。因為其子元素還在指向該變數容器,但是由於所有作用域內沒有任何符號指向這個變數容器,所以用戶沒有辦法清除這個變數容器,結果就會導致記憶體泄露,直到該腳本執行結束被動清除這個變數容器。

舉例:把數組作為一個元素添加到自己

$a = array( 'one' );  $a[] = &$a;  xdebug_debug_zval( 'a' );  

會輸出:

a:  (refcount=2, is_ref=1),  array (size=2)    0 => (refcount=1, is_ref=0),string 'one' (length=3)    1 => (refcount=2, is_ref=1),&array<  

圖示:

12f37b1c6963c1c5c18f30495416a197-loop-array

能看到數組變數 a 同時也是這個數組的第二個元素「1」指向的變數容器中 refcount2。上面的輸出結果中的 &array< 意味著指向原始數組。

跟剛剛一樣,對一個變數調用 unset,將刪除這個符號,且它指向的變數容器中的引用次數也減 1。所以,如果我們在執行完上面的程式碼後,對變數 a 調用 unset , 那麼變數 ​ a 和數組元素 「1」所指向的變數容器的引用次數減 1, 從 2 變成了 1 . 下例可以說明:

unset($a);  

圖示:

12f37b1c6963c1c5c18f30495416a197-leak-array

如果上面的情況發生僅僅一兩次倒沒什麼,但是如果出現幾千次,甚至幾十萬次的記憶體泄漏,這顯然是個大問題。這樣的問題往往發生在長時間運行的腳本中,比如請求基本上不會結束的守護進程(deamons)或者單元測試中的大的套件(sets)中。

新的垃圾回收機制

PHP 5.3 版本之後引入 根緩衝機制,即 PHP 啟動時默認設置指定 zval 數量的根緩衝區(默認是10000),當 PHP發現有存在 循環引用 的 zval 時,就會把其投入到根緩衝區,當根緩衝區達到配置文件中的指定數量(默認是10000)後,就會進行垃圾回收,以此解決循環引用導致的記憶體泄漏問題。

垃圾回收演算法

每當根快取區存滿時,PHP 會對根緩衝區的所有變數容器遍歷進行 模擬刪除,然後進行 模擬恢復。但是 PHP 只會對進行模擬刪除後 refcount > 0 的變數容器進行恢復,那麼沒有進行恢復的也就是 refcount = 0 的就是垃圾了。

確認為垃圾的準則

1、如果引用計數減少到零,所在變數容器將被清除(free),不屬於垃圾
2、如果一個zval 的引用計數減少後還大於0,那麼它會進入垃圾周期。其次,在一個垃圾周期中,通過檢查引用計數是否減1,並且檢查哪些變數容器的引用次數是零,來發現哪部分是垃圾。

總結

垃圾回收機制:
1、以 php 的引用計數機製為基礎( php5.3 以前只有該機制)
2、同時使用根緩衝區機制,當 php 發現有存在循環引用的 zval 時,就會把其投入到根緩衝區,當根緩衝區達到配置文件中的指定數量後,就會進行垃圾回收,以此解決循環引用導致的記憶體泄漏問題( php5.3 開始引入該機制)

參考資料

PHP進階學習之垃圾回收機制詳解

php底層原理之垃圾回收機制

引用計數基本知識