1. 程式人生 > >一看就懂系列之 由淺入深聊一聊php的垃圾回收機制

一看就懂系列之 由淺入深聊一聊php的垃圾回收機制

前言
是的,平時經常聽到大牛說到的gc,就是垃圾回收器,全稱Garbage Collection。

早期版本,準確地說是5.3之前(不包括5.3)的垃圾回收機制,是沒有專門的垃圾回收器的。只是簡單的判斷了一下變數的zval的refcount是否為0,是的話就釋放否則不釋放直至程序結束。

乍一看確實沒毛病啊,然而其中隱藏著變數記憶體溢位的風險:http://bugs.php.net/bug.php?id=33595 ,無法回收的記憶體造成了記憶體洩漏,所以PHP5.3出現了專門負責清理垃圾資料、防止記憶體洩漏的GC。

下文將由淺入深(憑感覺)來記錄下php的垃圾回收機制是怎麼一回事?

1.php引用計數基本知識點

2.php的記憶體管理機制

3.php中垃圾是如何定義的?

4.老版本php中如何產生記憶體洩漏?

5.5.3版本以後php是如何處理垃圾記憶體的?

6.涉及到垃圾回收的知識點

php引用計數基本知識點
首先必須要先講講這個會引起垃圾回收的關鍵基數是怎麼回事?

關於php的zval結構體,以及refcount與is_ref的知識點,在菜鳥學php擴充套件 之 詳解php擴充套件的變數(四) 已描述非常清楚。

不準確但卻通俗的說:
refcount:多少個變數是一樣的用了相同的值,這個數值就是多少。
is_ref:bool型別,當refcount大於2的時候,其中一個變數用了地址&的形式進行賦值,好了,它就變成1了。

主要講講如何用php來直觀的看到這些計數的變化,走一波。
首先需要在php上裝上xdebug的擴充套件。

1.第一步:檢視內部結構

<?php
$name = "咖啡色的羊駝";
xdebug_debug_zval('name');

會得到:

name:(refcount=1, is_ref=0),string '咖啡色的羊駝' (length=18)

2.第二步:增加一個計數

<?php
$name = "咖啡色的羊駝";
$temp_name = $name;
xdebug_debug_zval('name');

會得到:

name:(refcount=2, is_ref=0),string '咖啡色的羊駝' (length=18)

看到了吧,refcount+1了。

3.第三步:引用賦值

<?php
$name = "咖啡色的羊駝";
$temp_name = &$name;
xdebug_debug_zval('name');

會得到:

name:(refcount=2, is_ref=1),string '咖啡色的羊駝' (length=18)

是的引用賦值會導致zval通過is_ref來標記是否存在引用的情況。

4.第四步:陣列型的變數

<?php
$name = ['a'=>'咖啡色', 'b'=>'的羊駝'];
xdebug_debug_zval('name');

會得到:

name:
(refcount=1, is_ref=0),
array (size=2)
'a' => (refcount=1, is_ref=0),string '咖啡色' (length=9)
'b' => (refcount=1, is_ref=0),string '的羊駝' (length=9)

還挺好理解的,對於陣列來看是一個整體,對於內部kv來看又是分別獨立的整體,各自都維護著一套zval的refount和is_ref。

5.第五步:銷燬變數

<?php
$name = "咖啡色的羊駝";
$temp_name = $name;
xdebug_debug_zval('name');
unset($temp_name);
xdebug_debug_zval('name');

會得到:

name:(refcount=2, is_ref=0),string '咖啡色的羊駝' (length=18)
name:(refcount=1, is_ref=0),string '咖啡色的羊駝' (length=18)

refcount計數減1,說明unset並非一定會釋放記憶體,當有兩個變數指向的時候,並非會釋放變數佔用的記憶體,只是refcount減1.

php的記憶體管理機制
知道了zval是怎麼一回事,接下來看看如何通過php直觀看到記憶體管理的機制是怎麼樣的。

外在的記憶體變化
先來一段程式碼:

<?php
//獲取記憶體方法,加上true返回實際記憶體,不加則返回表現記憶體
var_dump(memory_get_usage());
$name = "咖啡色的羊駝";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());

會得到:

int 1593248
int 1593384
int 1593248

大致過程:定義變數->記憶體增加->清除變數->記憶體恢復

潛在的記憶體變化
當執行:

$name = "咖啡色的羊駝";

時候,記憶體的分配做了兩件事情:1.為變數名分配記憶體,存入符號表 2.為變數值分配記憶體

再來看程式碼:

<?php

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());

會得到:

int 1596864
int 1612080
int 1597680

簡直爆炸,怎麼和之前看的不一樣?記憶體沒有全部回收回來。

對於php的核心結構Hashtable來說,由於未知性,定義的時候不可能一次性分配足夠多的記憶體塊。所以初始化的時候只會分配一小塊,等不夠的時候在進行擴容,而Hashtable只擴容不減少,所以就出現了上述的情況:當存入100個變數的時候,符號表不夠用了就進行一次擴容,當unset的時候只釋放了”為變數值分配記憶體”,而“為變數名分配記憶體”是在符號表的,符號表並沒有縮小,所以沒收回來的記憶體是被符號表佔去了。

潛在的記憶體申請與釋放設計
php和c語言一樣,也是需要進行申請記憶體的,只不過這些操作作者都封裝到底層了,php使用者無感知而已。

php的記憶體申請小設計

php並非簡單的向os申請記憶體,而是會申請一大塊記憶體,把其中一部分分給申請者,這樣當再有邏輯來申請記憶體的時候,就不需要向os申請了,避免了頻繁呼叫。當記憶體不夠的時候才會再次申請

php的記憶體釋放小設計

當釋放記憶體的時候,php並非會把記憶體還給os,而是把記憶體軌道自己維護的空閒記憶體列表,以便重複利用,

php中垃圾是如何定義的?
準確地說,判斷是否為垃圾,主要看有沒有變數名指向變數容器zval,如果沒有則認為是垃圾,需要釋放。

打個比方:

<?php
$name = "咖啡色的羊駝";
// todo other things

當定義name的時候,處理完字串準備做其他事情的時候,對於我們來說name的時候,處理完字串準備做其他事情的時候,對於我們來說name就是可以回收的垃圾了,然而對於引擎來說,$name還是實打實存在的refcount也還是1,所以就不是垃圾,不能回收。當呼叫unset的時候,也並不一定引擎會認為它是一個垃圾而進行回收,主要還是看refcount是不是真的變為0了。

老版本php中如何產生記憶體洩漏垃圾?
產生記憶體洩漏主要真凶:環形引用。
現在來造一個環形引用的場景:

<?php
$a = ['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<

這樣 $a陣列就有了兩個元素,一個索引為0,值為one字串,另一個索引為1,為$a自身的引用。

 

此時刪掉$a:

<?php
$a = ['one'];
$a[] = &$a;
unset($a);

如果在小於php5.3的版本就會出現一個問題:$a已經不在符號表了,沒有變數再指向此zval容器,使用者已無法訪問,但是由於陣列的refcount變為1而不是0,導致此部分記憶體不能被回收從而產生了記憶體洩漏。

5.3版本以後php是如何處理垃圾記憶體的?
判斷處理過程
為解決環形引用導致的垃圾,產生了新的GC演算法,遵守以下幾個基本準則:

1.如果一個zval的refcount增加,那麼此zval還在使用,不屬於垃圾

2.如果一個zval的refcount減少到0, 那麼zval可以被釋放掉,不屬於垃圾

3.如果一個zval的refcount減少之後大於0,那麼此zval還不能被釋放,此zval可能成為一個垃圾

are you ok?

來個白話文版:就是對此zval中的每個元素進行一次refcount減1操作,操作完成之後,如果zval的refcount=0,那麼這個zval就是一個垃圾

引用php官方手冊的配圖:

 

A:為了避免每次變數的refcount減少的時候都呼叫GC的演算法進行垃圾判斷,此演算法會先把所有前面準則3情況下的zval節點放入一個節點(root)緩衝區(root buffer),並且將這些zval節點標記成紫色,同時演算法必須確保每一個zval節點在緩衝區中之出現一次。當緩衝區被節點塞滿的時候,GC才開始開始對緩衝區中的zval節點進行垃圾判斷。

B:當緩衝區滿了之後,演算法以深度優先對每一個節點所包含的zval進行減1操作,為了確保不會對同一個zval的refcount重複執行減1操作,一旦zval的refcount減1之後會將zval標記成灰色。需要強調的是,這個步驟中,起初節點zval本身不做減1操作,但是如果節點zval中包含的zval又指向了節點zval(環形引用),那麼這個時候需要對節點zval進行減1操作。

C:演算法再次以深度優先判斷每一個節點包含的zval的值,如果zval的refcount等於0,那麼將其標記成白色(代表垃圾),如果zval的refcount大於0,那麼將對此zval以及其包含的zval進行refcount加1操作,這個是對非垃圾的還原操作,同時將這些zval的顏色變成黑色(zval的預設顏色屬性)

D:遍歷zval節點,將C中標記成白色的節點zval釋放掉。

are you ok?

來個白話文版的:
例如:

<?php
$a = ['one']; --- zval_a(將$a對應的zval,命名為zval_a)
$a[] = &$a; --- step1
unset($a); --- step2

為進行unset之前(step1),進行演算法計算,對這個陣列中的所有元素(索引0和索引1)的zval的refcount進行減1操作,由於索引1對應的就是zval_a,所以這個時候zval_a的refcount應該變成了1,這樣說明zval_a不是一個垃圾不進行回收。

當執行unset的時候(step2),進行演算法計算,由於環形引用,上文得出會有垃圾的結構體,zval_a的refcount是1(zval_a中的索引1指向zval_a),用演算法對陣列中的所有元素(索引0和索引1)的zval的refcount進行減1操作,這樣zval_a的refcount就會變成0,於是就認為zval_a是一個需要回收的垃圾。

演算法總的套路:對於一個包含環形引用的陣列,對陣列中包含的每個元素的zval進行減1操作,之後如果發現數組自身的zval的refcount變成了0,那麼可以判斷這個陣列是一個垃圾。

演算法優化配置
可能會發現,每次都進行這樣的操作好像會影響效能,是的,php做事情套路都是走批量的原則。

申請記憶體也是申請一大塊,僅使用當前的一小部分剩下的等下回再用,避免多次申請。

這個gc演算法也是這樣,會有一個緩衝區的概念,等緩衝區滿了才會一次性去給清掉。

開關配置

php.ini中設定 zend.enable_gc 項來開啟或則關閉GC。

緩衝區配置

緩衝區預設可以放10,000個節點,當緩衝區滿了才會清理。可以通過修改Zend/zend_gc.c中的GC_ROOT_BUFFER_MAX_ENTRIES 來改變這個數值,需要重新編譯連結PHP

關鍵函式

gc_enable() : 開啟GC

gc_disable() : 關閉GC

gc_collect_cycles() : 在節點緩衝區未滿的情況下強制執行垃圾分析演算法

涉及到垃圾回收的知識點
1.unset函式

unset只是斷開一個變數到一塊記憶體區域的連線,同時將該記憶體區域的引用計數-1;記憶體是否回收主要還是看refount是否到0了,以及gc演算法判斷。

2.= null 操作;

a=null是直接將a=null是直接將a 指向的資料結構置空,同時將其引用計數歸0。

3.指令碼執行結束

指令碼執行結束,該指令碼中使用的所有記憶體都會被釋放,不論是否有引用環。