PHP-----淺談垃圾回收機制
前言
大多數程式語言都會有自身的垃圾回收機制,php也不例外。經常聽很多人說gc,也就是垃圾回收器,全程為Garbage Collection。
在php5.3之前,是不包括垃圾回收機制的,也沒有專門的垃圾回收器,實現垃圾回收就是簡單判斷一下變數的zval的refcount是否為0,是的話就釋放。
但是如果這麼簡單的判斷垃圾回收的話,很容易引起程式過程中記憶體溢位。如果存在"自身指向自身"的情況的話,那麼變數將無法回收早成記憶體洩露,所以從php5.3開始就出現了專門負責清理垃圾資料防止記憶體洩露的垃圾回收器。
引用計數的基本知識
我們要了解GC,那麼首先要了解引起垃圾回收的基數是什麼。
在php中,每個變數存在一個叫“zval”的變數容器中。一個zval變數容器,除了包含變數的型別和值,還包括另外兩個位元組的額外資訊。第一個是"is_ref"。第二個是"refcount"。
is_ref是一個布林型別的值,用來標示這個變數是否屬於引用集合。通過這個位元組,php引擎才能把普通變數和引用變數區分開來,由於php允許使用者通過"&"來使用自定義的引用,所以zval中還有一個內部引用計數機制,來進行優化記憶體。
refcount用來表示這個zval變數容器的變數的個數。所有符號存在一個符號表當中,每個符號都有作用域。
通俗的講:
- refcount就是多少個變數是一樣的用了相同的值,那麼refcount就是這個值
- is_ref就是當有變數用了&的形式進行賦值,那麼is_ref的值就會增加
<?php
$a = "new string";
?>
在上面的程式碼中,變數a是在當前作用於中生成的,並且生成了型別為String和值為"new string"的變數容器。這個時候is_ref被預設的設定成了false,因為現在沒有任何自定義的引用生成。refcount被設定成了1。我們可以用php來看到這些計數的變化,首先需要用到xdebug,所以php沒有裝上xdebug擴充套件的需要先裝一下。
<?php $a = "new string"; xdebug_debug_zval('a'); 輸出:a: (refcount=1, is_ref=0)='new string' ?>
增加zval的引用計數
<?php
$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );
輸出:a: (refcount=2, is_ref=0)='new string'
?>
這時的引用次數是2,因為同一個變數容器被變數 a 和變數 b關聯.當沒必要時,php不會去複製已生成的變數容器。變數容器在”refcount“變成0時就被銷燬. 當任何關聯到某個變數容器的變數離開它的作用域(比如:函式執行結束),或者對變數呼叫了函式 unset()時,”refcount“就會減1。
減少引用計數
<?php
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
unset( $b, $c );
xdebug_debug_zval( 'a' );
輸出:
a: (refcount=3, is_ref=0)='new string'
a: (refcount=1, is_ref=0)='new string'
?>
執行 unset($a);,包含型別和值的這個變數容器就會從記憶體中刪除。
複合型別
當變數的型別為array或object這樣的複合型別時,array和object型別的變數把他們的成員或屬性存在自己的符號表中。
<?php
$a = array( 'meaning' => 'life', 'number' => 42 );
xdebug_debug_zval( 'a' );
輸出:
a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=1, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42
)
?>
根據上面的程式碼,我們可以理解,對於陣列來看成一個整體,對於內部的值來看又是一個獨立的整體,各自都有著一套zval的refcount和is_ref。下面這張圖是從官網上扒下來的:
新增一個已經存在的元素到陣列中:
<?php
$a = array( 'meaning' => 'life', 'number' => 42 );
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );
輸出:
a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=2, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42,
'life' => (refcount=2, is_ref=0)='life'
)
?>
如下圖解釋:
從陣列中刪除一個元素:
<?php
$a = array( 'meaning' => 'life', 'number' => 42 );
$a['life'] = $a['meaning'];
unset( $a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );
輸出:
a: (refcount=1, is_ref=0)=array (
'life' => (refcount=1, is_ref=0)='life'
)
?>
刪除陣列中一個元素,就是類似從作用於中刪除一個變數,刪除後陣列中這個元素所在容器的refcount的值減少,當refcount為0時,這個變數容器就從記憶體中被刪除。
將陣列作為一個元素新增給自身:
<?php
$a = array( 'one' );
$a[] =& $a;
xdebug_debug_zval( 'a' );
輸出:
a: (refcount=2, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=2, is_ref=1)=...
)
?>
我們可以看到陣列a同時也是這個陣列第二個元素,指向的變數容器中refcount的值為2,上面輸出的“...”說明發生了遞迴操作,意味著"..."指向原始陣列。
儘管不再有某個作用域中的任何符號指向這個結構(就是變數容器),由於陣列元素“1”仍然指向陣列本身,所以這個容器不能被清除 。因為沒有另外的符號指向它,使用者沒有辦法清除這個結構,結果就會導致記憶體洩漏。
垃圾回收週期
在5.3之前的版本中,php無法處理迴圈的引用記憶體洩露。但是自5.3之後php使用引用計數系統中同步週期回收的同步演算法,僅處理這個記憶體洩露問題。
基本思想是如果一個引用計數增加那麼將繼續被使用,當然就不再是垃圾。如果引用計數減少到零,所在變數容器將被清除。那麼也就是說只有在引用計數減少到非零值時,才會產生垃圾週期。在一個垃圾週期中通過檢查引用計數是否減1,並且檢查哪些變數容器的引用次數為零,來發現哪些是垃圾。
我們就拿這張圖舉例(來自php官網)。為了避免不得不檢查所有引用計數可能減少的垃圾週期,同步演算法將所有可能根放在了根緩衝區(root buffer)中(在圖中用紫色來標記,稱為疑似垃圾),這樣可以同時確保每個可能的垃圾根在緩衝區中只出現一次。僅當根緩衝區滿了時,才對緩衝區中所有不同的變數容器執行垃圾回收操作,在圖中體現為步驟A。
在步驟B中,模擬刪除每個紫色的變數。模擬刪除時可能將不是紫色的不同變數引用數減1,如果某個普通變數引用計數變成0時,就對這個普通變數在做一次模擬刪除。每個變數只能被模擬刪除一次,模擬刪除後標記為灰色。
在步驟C中,模擬恢復每個紫色變數。當然這個恢復是有條件的,當變數的引用計數大於0時才對其做模擬恢復。同樣的每個變數只能恢復一次,恢復後標記為黑色,這樣生下一對沒能恢復的就是該刪除的藍色節點了,在步驟D中遍歷出來真正的刪除掉。
在php中垃圾回收機制預設是開啟的,在你的php.ini中可以手動設定,通過zend.enable_gc這個屬性進行開啟或關閉垃圾回收機制。當開啟了垃圾回收機制後,每當根快取區存滿時,就會執行上面描述的迴圈查詢演算法。根快取區具有固定的大小,當然你可以通過修改php原始碼檔案Zend/zend_gc.c中常量GC_ROOT_BUFFER_MAX_ENTRIES來修改根快取區的大小(注意修改後需要重新編譯php)。當關閉垃圾回收機制後,這個迴圈查詢演算法將不會執行,然而可能根會一直存在於根緩衝區中,不管在配置中是否激活了垃圾回收機制。
當然你也可以通過呼叫gc_enable()和gc_disable()函式來開啟和關閉垃圾回收機制,效果和修改配置項相同。即使根緩衝區還沒有滿,也能強制執行週期回收。
php的記憶體管理機制
現在我們已經知道了zval是怎麼回事了。那麼現在我們需要知道php的記憶體管理機制是怎麼一回事。
var_dump(memory_get_usage());
$test = "這是測試啊";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());
輸出(php5.6):
/var/www/html/node_test/phptest/phptest.php:51:
int(361896)
/var/www/html/node_test/phptest/phptest.php:53:
int(361928)
/var/www/html/node_test/phptest/phptest.php:55:
int(361896)
過程是:定義變數->記憶體增加->清除變數->記憶體恢復
var_dump(memory_get_usage());
$test = "這是測試啊";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());
輸出(php7.1):
/var/www/html/node_test/phptest/phptest.php:51:
int(361896)
/var/www/html/node_test/phptest/phptest.php:53:
int(361928)
/var/www/html/node_test/phptest/phptest.php:55:
int(361928)
而我在用php7時發現了這個問題,這就要說道php5和php7的記憶體管理機制和垃圾回收機制的不同了,這裡暫且不表。我們繼續往下走。
當在執行
$test = "這是測試啊";
記憶體的分配做了兩件事:
- 為變數名分配記憶體,並存入符號表
- 為變數值分配記憶體
我們再看程式碼:
var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
$a = "test".$i;
$$a = "hello";
}
var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
$a = "test".$i;
unset($$a);
}
var_dump(memory_get_usage());
輸出:
/var/www/html/node_test/phptest/phptest.php:57:
int(363520)
/var/www/html/node_test/phptest/phptest.php:63:
int(372384)
/var/www/html/node_test/phptest/phptest.php:69:
int(369216)
為什麼記憶體沒有全部收回來呢?
因為php的核心結構Hashtable,在定義的時候不可能一次性分配足夠多的記憶體塊,所以初始化的時候只會分配一小塊,等不夠的時候在進行擴容,而Hashtable只擴容不減少,所以當存入100個變數的時候符號表不夠用了就進行一次擴容,當unset()時只是放了為變數值分配的記憶體,但是為變數名分配的記憶體還是在符號表中的,符號表並沒有縮小,所以沒收回來的記憶體是被符號表佔去了。
php並不是只要記憶體不夠就去向OS申請記憶體,而是先申請一大塊記憶體,然後將其中一部分分給申請者,這樣再有邏輯需要申請記憶體的時候,就不需要再向OS申請記憶體了,避免了重複申請,只有當一大塊記憶體不夠用的時候再去申請。而當釋放記憶體時,php並非把記憶體還給了OS,而是把記憶體軌道自己維護的空閒記憶體列表,以便重複利用。
新版本的php(5.3版本之後)是如何處理垃圾記憶體的?
剛剛上面我們已經講了,針對在php中環形引用導致的垃圾,產生了新的同步演算法(GC演算法),對於官網上的理論,我進行了理解:
如果一個zval的refcount增加,那麼表明該變數的zval還在使用,不屬於垃圾
如果一個zval的refcount減少到0,那麼zval可以被釋放掉,可以清除,不是垃圾
如果在經過模擬刪除後一個zval的refcount減1,如果該zval的引用次數為是大於0,那麼此zval不能被釋放,可能是一個垃圾
關於垃圾回收的小知識點
unset():unset()只是斷開一個變數到一塊記憶體區域的連線,同時將該記憶體區域的引用計數減1,記憶體是否回收主要還是看refcount是否到0了。
null:將null賦值給一個變數是直接將該變數指向的資料結構置空,同時將其引用計數歸0。
指令碼執行結束:該指令碼中所有記憶體都會被釋放,無論是否有環引用。