PHP 垃圾回收與記憶體管理指引

liuqing_hu發表於2018-08-09

圖片1

本文首發於 PHP 垃圾回收與記憶體管理指引,轉載請註明出處。

本文將要講述 PHP 發展歷程中的垃圾回收及記憶體管理相關內容,文末給出 PHP 發展在各個階段有關記憶體管理及垃圾回收(核心)參考資料值得閱讀。

引用計數

在 PHP 5.2 及以前的版本中,PHP 的垃圾回收採用的是 引用計數 演算法。

引用計數基礎知識

引用計數基礎知識

php 的變數儲存在「zval」變數容器(資料結構)中,「zval」屬性包含如下資訊:

  • 當前變數的資料型別;
  • 當前變數的值;
  • 用於標識變數是否為引用傳遞的 is_ref 布林型別標識;
  • 指向該「zval」變數容器的變數個數的 refcount 識別符號(即這個 zval 被引用的次數,注意這裡的引用不是指引用傳值,注意區分)。

當一個變數被賦值時,就會生成一個對應的「zavl」變數容器。

檢視變數 zval 容器資訊

要檢視變數的「zval」容器資訊(即檢視變數的 is_ref 和 refcount),可以使用 XDebug 除錯工具的 xdebug_debug_zval() 函式。

安裝 XDebug 擴充套件外掛的方法可以檢視 這個教程,有關XDebug 使用方法請閱讀 官方文件

假設,我們已經成功安裝好 XDebug 工具,現在就可以來對變數進行除錯了。

  • 檢視普通變數的 zval 資訊

如果我們的 PHP 語句只是對變數進行簡單賦值時,is_ref 標識值為 0,refcount 值為 1;若將這個變數作為值賦值給另一個變數時,則增加 zval 變數容器的 refcount 計數;同理,銷燬(unset)變數時,「refcount」相應的減去 1。

請看下面的示例:

<?php
// 變數賦值時,refcount 值等於 1
$name = 'liugongzi';
xdebug_debug_zval('name'); // (refcount=1, is_ref=0)string 'liugongzi' (length=9)

// $name 作為值賦值給另一個變數, refcount 值增加 1
$copy = $name;
xdebug_debug_zval('name'); // (refcount=2, is_ref=0)string 'liugongzi' (length=9)

// 銷燬變數,refcount 值減掉 1
unset($copy);
xdebug_debug_zval('name'); // (refcount=1, is_ref=0)string 'liugongzi' (length=9)
  • 寫時複製

寫時複製(Copy On Write:COW),簡單描述為:如果通過賦值的方式賦值給變數時不會申請新記憶體來存放新變數所儲存的值,而是簡單的通過一個計數器來共用記憶體,只有在其中的一個引用指向變數的值發生變化時,才申請新空間來儲存值內容以減少對記憶體的佔用。 - TPIP 寫時複製

通過前面的簡單變數的 zval 資訊我們知道 \$copy\$name 共用 zval 變數容器(記憶體),然後通過 refcount 來表示當前這個 zval 被多少個變數使用。

看個例項:

<?php
$name = 'liugongzi';
xdebug_debug_zval('name'); // name: (refcount=1, is_ref=0)string 'liugongzi' (length=9)

$copy = $name;
xdebug_debug_zval('name'); // name: (refcount=2, is_ref=0)string 'liugongzi' (length=9)

// 將新的值賦值給變數 $copy
$copy = 'liugongzi handsome';
xdebug_debug_zval('name'); // name: (refcount=1, is_ref=0)string 'liugongzi' (length=9)
xdebug_debug_zval('copy'); // copy: (refcount=1, is_ref=0)='liugongzi handsome'

注意到沒有,當將值 liugongzi handsome 賦值給變數 \$copy 時,name 和 copy 的 refcount 值都變成了 1,在這個過程中發生以下幾個操作:

  • 將 \$copy 從 \$name 的 zval(內從)中分離出來(即複製);
  • 將 \$name 的 refcount 減去 1;
  • 對 \$copy 的 zval 進行修改(重新賦值和修改 refcount);

這裡只是簡單對「寫時複製」進行介紹,感興趣的朋友可以閱讀文末給出的參考資料進行更加深入的研究。

  • 檢視引用傳遞變數的 zval 資訊

引用傳值(&)的「引用計數」規則同普通賦值語句一樣,只是 is_ref 標識的值為 1 表示該變數是引用傳值型別。

我們現在來看看引用傳值的示例:

<?php
$age = 'liugongzi';
xdebug_debug_zval('age'); // (refcount=1, is_ref=0)string 'liugongzi' (length=9)

$copy = &$age;
xdebug_debug_zval('age'); // (refcount=2, is_ref=1)string 'liugongzi' (length=9)

unset($copy);
xdebug_debug_zval('age'); // (refcount=1, is_ref=1)string 'liugongzi' (length=9)
  • 複合型別的引用計數

與標量型別(整型、浮點型、布林型等)不同,陣列(array)和物件(object)這種符合型別的引用計數規則會稍複雜一些。

為了更好的說明,還是先看看陣列的引用計數示例:

$a = array( 'meaning' => 'life', 'number' => 42 );
xdebug_debug_zval( 'a' );

// a:
// (refcount=1, is_ref=0)
// array (size=2)
//  'meaning' => (refcount=1, is_ref=0)string 'life' (length=4)
//  'number' => (refcount=1, is_ref=0)int 42

上面的引用計數示意圖如下:

示意圖

從圖中我們發現複合型別的引用計數規則基本上同標量的計數規則一樣,就給出的示例來說,PHP 會建立 3 個 zval 變數容器,一個用於儲存陣列本身,另外兩個用於儲存陣列中的元素。

新增一個已經存在的元素到陣列中時,它的引用計數器 refcount 會增加 1。

$a = array( 'meaning' => 'life', 'number' => 42 );
xdebug_debug_zval( 'a' );
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );

// a:
// (refcount=1, is_ref=0)
// array (size=3)
//  'meaning' => (refcount=2, is_ref=0)string 'life' (length=4)
//  'number' => (refcount=0, is_ref=0)int 42
//  'life' => (refcount=2, is_ref=0)string 'life' (length=4)

大致示意圖如下:

示意圖

  • 記憶體洩露

雖然,複合型別的引用計數規則同標量型別大致相同,但是如果引用的值為變數自身(即迴圈應用),在處理不當時,就有可能會造成記憶體洩露的問題。

讓我們來看看下面這個對陣列進行引用傳值的示例:

<?php
// @link http://php.net/manual/zh/function.memory-get-usage.php#96280
function convert($size)
{
    $unit=array('b','kb','mb','gb','tb','pb');
    return @round($size/pow(1024,($i=floor(log($size,1024)))),2).' '.$unit[$i];
}

// 注意:有用的地方從這裡開始
$memory = memory_get_usage();

$a = array( 'one' );

// 引用自身(迴圈引用)
$a[] =&$a;

xdebug_debug_zval( 'a' );

var_dump(convert(memory_get_usage() - $memory)); // 296 b

unset($a); // 刪除變數 $a,由於 $a 中的元素引用了自身(迴圈引用)最終導致 $a 所使用的記憶體無法被回收

var_dump(convert(memory_get_usage() - $memory)); // 568 b

從記憶體佔用結果上看,雖然我們執行了 unset(\$a) 方法來銷燬 \$a 陣列,但記憶體並沒有被回收,整個處理過程的示意圖如下:

示意圖

可以看到對於這塊記憶體,再也沒有符合表(變數)指向了,所以 PHP 無法完成記憶體回收,官方給出的解釋如下:

儘管不再有某個作用域中的任何符號指向這個結構 (就是變數容器),由於陣列元素 “1” 仍然指向陣列本身,所以這個容器不能被清除 。因為沒有另外的符號指向它,使用者沒有辦法清除這個結構,結果就會導致記憶體洩漏。慶幸的是,php 將在指令碼執行結束時清除這個資料結構,但是在 php 清除之前,將耗費不少記憶體。如果你要實現分析演算法,或者要做其他像一個子元素指向它的父元素這樣的事情,這種情況就會經常發生。當然,同樣的情況也會發生在物件上,實際上物件更有可能出現這種情況,因為物件總是隱式的被引用。 - 摘自 官方文件 Cleanup Problems

簡單來說就是「引用計數」演算法無法檢測並釋放迴圈引用所使用的記憶體,最終導致記憶體洩露。

引用計數系統的同步週期回收

由於引用計數演算法存在無法回收迴圈應用導致的記憶體洩露問題,在 PHP 5.3 之後對記憶體回收的實現做了優化,通過採用 引用計數系統的同步週期回收 演算法實現記憶體管理。引用計數系統的同步週期回收演算法是一個改良版本的引用計數演算法,它在引用基礎上做出瞭如下幾個方面的增強:

  • 引入了可能根(possible root)的概念:通過引用計數相關學習,我們知道如果一個變數(zval)被引用,要麼是被全域性符號表中的符號引用(即變數),要麼被複雜型別(如陣列)的 zval 中的符號(陣列的元素)引用,那麼這個 zval 變數容器就是「可能根」。
  • 引入根緩衝區(root buffer)的概念:根緩衝區用於存放所有「可能根」,它是固定大小的,預設可存 10000 個可能根,如需修改可以通過修改 PHP 原始碼檔案 Zend/zend_gc.c 中的常量 GC_ROOT_BUFFER_MAX_ENTRIES,再重新編譯。
  • 回收週期:當緩衝區滿時,對緩衝區中的所有可能根進行垃圾回收處理。

下圖(來自 PHP 手冊),展示了新的回收演算法執行過程:

回收週期

引用計數系統的同步週期回收過程

  1. 緩衝區(紫色框部分,稱為疑似垃圾),儲存所有可能根(步驟 A);
  2. 採用深度優先演算法遍歷「根緩衝區」中所有的「可能根(即 zval 遍歷容器)」,並對每個 zval 的 refcount 減 1,為了避免遍歷時對同一個 zval 多次減 1(因為不同的根可能遍歷到同一個 zval)將這個 zvel 標記為「已減」(步驟 B);
  3. 再次採用深度優先遍歷演算法遍歷「可能根 zval」。當 zval 的 refcount 值不為 0 時,對其加 1,否則保持為 0。並請已遍歷的 zval 變數容器標記為「已恢復」(即步驟 B 的逆運算)。那些 zval 的 refcount 值為 0 (藍色框標記)的就是應該被回收的變數(步驟 C);
  4. 刪除所有 refcount 為 0 的可能根(步驟 D)。

整個過程為:

採用深度優先演算法執行:預設刪除 > 模擬恢復 > 執行刪除 達到記憶體回收的目的。

優化後的引用計數演算法優勢

  • 將記憶體洩露控制在閥值內,這個由快取區實現,達到緩衝區大小執行新一輪垃圾回收;
  • 提升了垃圾回收效能,不是每次 refcount 減 1 都執行回收處理,而是等到根緩衝區滿時才開始執行垃圾回收。

你可以從 PHP 手冊 的回收週期 瞭解更多,也可以閱讀文末給出的參考資料。

PHP 7 的記憶體管理

PHP 5 中 zval 實現上的主要問題:

  • zval 總是單獨 從堆中分配記憶體;
  • zval 總是儲存引用計數和迴圈回收 的資訊,即使是整型(bool / null)這種可能並不需要此類資訊的資料;
  • 在使用物件或者資源時,直接引用會導致兩次計數;
  • 某些間接訪問需要一個更好的處理方式。比如現在訪問儲存在變數中的物件間接使用了四個指標(指標鏈的長度為四);
  • 直接計數也就意味著數值只能在 zval 之間共享。如果想在 zval 和 hashtable key 之間共享一個字串就不行(除非 hashtable key 也是 zval)。

PHP 7 中的 zval 資料結構實現的調整:

最基礎的變化就是 zval 需要的記憶體 不再是單獨從堆上分配,不再由 zval 儲存引用計數。
複雜資料型別(比如字串、陣列和物件)的引用計數由其自身來儲存。 - 摘自 Internal value representation in PHP 7 - Part 1

這種實現的優勢:

  • 簡單資料型別不需要單獨分配記憶體,也不需要計數;
  • 不會再有兩次計數的情況。在物件中,只有物件自身儲存的計數是有效的;
  • 由於現在計數由數值自身儲存(PHP 有 zval 變數容器儲存),所以也就可以和非 zval 結構的資料共享,比如 zval 和 hashtable key 之間;
  • 間接訪問需要的指標數減少了。

更具體的有關 PHP 7 zval 實現和記憶體優化細節可以閱讀 深入理解 PHP7 核心之 zvalInternal value representation in PHP 7 - Part 1

參考資料

深入理解 PHP7 核心之 zval

Internal value representation in PHP 7 - Part 1

Internal value representation in PHP 7 - Part 2

TPIP:第六節 寫時複製(Copy On Write)

TPIP:記憶體管理

PHP7 核心之 zval

淺談 PHP5 中垃圾回收演算法 (Garbage Collection) 的演化

Confusion about PHP 7 refcount

引用計數系統中的同步週期回收 (Concurrent Cycle Collection in Reference Counted Systems) 論文

PHP7 革新與效能優化

相關文章