探討php的垃圾回收機制

2020-07-16 10:05:59

在平時php-fpm的時候,可能很少人注意php的變數回收,但是到swoole常駐記憶體開發後,就不得不重視這個了,因為在常駐記憶體下,如果不了解變數回收機制,可能就會出現記憶體洩露的問題,本文將一步步帶你了解php的垃圾回收機制,讓你寫出的程式碼不再記憶體漏失

寫時複製

首先,php的變數複製用的是寫時複製方式,舉個例子.

$a='仙士可'.time();
$b=$a;
$c=$a;
//這個時候記憶體占用相同,$b,$c都將指向$a的記憶體,無需額外占用
 
$b='仙士可1號';
//這個時候$b的資料已經改變了,無法再參照$a的記憶體,所以需要額外給$b開拓記憶體空間
 
$a='仙士可2號';
//$a的資料發生了變化,同樣的,$c也無法參照$a了,需要給$a額外開拓記憶體空間

詳細寫時複製可檢視:php寫時複製

參照計數

既然變數會參照記憶體,那麼刪除變數的時候,就會出現一個問題了:

$a='仙士可';
$b=$a;
$c=$a;
//這個時候記憶體占用相同,$b,$c都將指向$a的記憶體,無需額外占用
 
$b='仙士可1號';
//這個時候$b的資料已經改變了,無法再參照$a的記憶體,所以需要額外給$b開拓記憶體空間
 
unset($c);
//這個時候,刪除$c,由於$c的資料是參照$a的資料,那麼直接刪除$a?

很明顯,當$c參照$a的時候,刪除$c,不能把$a的資料直接給刪除,那麼該怎麼做呢?

這個時候,php底層就使用到了參照計數這個概念

參照計數,給變數參照的次數進行計算,當計數不等於0時,說明這個變數已經被參照,不能直接被回收,否則可以直接回收,例如:

$a = '仙士可'.time();
$b = $a;
$c = $a;
 
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');
 
$b='仙士可2號';
xdebug_debug_zval('a');
xdebug_debug_zval('b');
 
echo "指令碼結束n";

將輸出:

a: (refcount=3, is_ref=0)='仙士可1578154814'
b: (refcount=3, is_ref=0)='仙士可1578154814'
c: (refcount=3, is_ref=0)='仙士可1578154814'
a: (refcount=2, is_ref=0)='仙士可1578154814'
b: (refcount=1, is_ref=0)='仙士可2號'
指令碼結束

注意,xdebug_debug_zval函數是xdebug擴充套件的,使用前必須安裝xdebug擴充套件

參照計數特殊情況

當變數值為整型,浮點型時,在賦值變數時,php7底層將會直接把值儲存(php7的結構體將會直接儲存簡單資料型別),refcount將為0

$a = 1111;
$b = $a;
$c = 22.222;
$d = $c;
 
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');
xdebug_debug_zval('d');
echo "指令碼結束n";

輸出:

a: (refcount=0, is_ref=0)=1111
b: (refcount=0, is_ref=0)=1111
c: (refcount=0, is_ref=0)=22.222
d: (refcount=0, is_ref=0)=22.222
指令碼結束

當變數值為interned string字串型(變數名,函數名,靜態字串,類名等)時,變數值儲存在靜態區,記憶體回收被系統全域性接管,參照計數將一直為1(php7.3)

$str = '仙士可';    // 靜態字串

$str = '仙士可' . time();//普通字串

$a = 'aa';
$b = $a;
$c = $b;
 
$d = 'aa'.time();
$e = $d;
$f = $d;
 
xdebug_debug_zval('a');
xdebug_debug_zval('d');
echo "指令碼結束n";

輸出:

a: (refcount=1, is_ref=0)='aa'
d: (refcount=3, is_ref=0)='aa1578156506'
指令碼結束

當變數值為以上幾種時,複製變數將會直接拷貝變數值,所以將不存在多次參照的情況

參照時參照計數變化

如下程式碼:

$a = 'aa';
$b = &$a;
$c = $b;
 
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');
echo "指令碼結束n";

將輸出:

a: (refcount=2, is_ref=1)='aa'
b: (refcount=2, is_ref=1)='aa'
c: (refcount=1, is_ref=0)='aa'
指令碼結束

當參照時,被參照變數的value以及型別將會更改為參照型別,並將參照值指向原來的值記憶體地址中.

之後參照變數的型別也會更改為參照型別,並將值指向原來的值記憶體地址,這個時候,值記憶體地址被參照了2次,所以refcount=2.

而$c並非是參照變數,所以將值複製給了$c,$c參照還是為1

詳細參照計數知識,底層原理可檢視:https://www.cnblogs.com/sohuhome/p/9800977.html

php生命週期

php將每個執行域作為一次生命週期,每次執行完一個域,將回收域內所有相關變數:

<?php
/**
 * Created by PhpStorm.
 * User: Tioncico
 * Date: 2020/1/6 0006
 * Time: 14:22
 */
 
echo "php檔案的全域性開始n";
 
class A{
    protected $a;
    function __construct($a)
    {
        $this->a = $a;
        echo "類A{$this->a}生命週期開始n";
    }
    function test(){
        echo "類test方法域開始n";
        echo "類test方法域結束n";
    }
//通過類解構函式的特性,當類初始化或回收時,會呼叫相應的方法
    function __destruct()
    {
        echo "類A{$this->a}生命週期結束n";
        // TODO: Implement __destruct() method.
    }
}
 
function a1(){
    echo "a1函數域開始n";
    $a = new A(1);
    echo "a1函數域結束n";
    //函數結束,將回收所有在函數a1的變數$a
}
a1();
 
$a = new A(2);
 
echo "php檔案的全域性結束n";
//全域性結束後,會回收全域性的變數$a

可看出,每個方法/函數都作為一個作用域,當執行完該作用域時,將會回收這裡面的所有變數.

再看看這個例子:

echo "php檔案的全域性開始n";
 
class A
{
    protected $a;
 
    function __construct($a)
    {
        $this->a = $a;
        echo "類{$this->a}生命週期開始n";
    }
 
    function test()
    {
        echo "類test方法域開始n";
        echo "類test方法域結束n";
    }
 
//通過類解構函式的特性,當類初始化或回收時,會呼叫相應的方法
    function __destruct()
    {
        echo "類{$this->a}生命週期結束n";
        // TODO: Implement __destruct() method.
    }
}
 
$arr = [];
$i = 0;
while (1) {
    $arr[] = new A('arr_' . $i);
    $obj = new A('obj_' . $i);
    $i++;
    echo "陣列大小:". count($arr).'n';
    sleep(1);
//$arr 會隨著迴圈,慢慢的變大,直到記憶體溢位
 
}
 
echo "php檔案的全域性結束n";
//全域性結束後,會回收全域性的變數$a

全域性變數只有在指令碼結束後才會回收,而在這份程式碼中,指令碼永遠不會被結束,也就說明變數永遠不會回收,$arr還在不斷的增加變數,直到記憶體溢位.

記憶體漏失

請看程式碼:

function a(){
    class A {
        public $ref;
        public $name;
 
        public function __construct($name) {
            $this->name = $name;
            echo($this->name.'->__construct();'.PHP_EOL);
        }
 
        public function __destruct() {
            echo($this->name.'->__destruct();'.PHP_EOL);
        }
    }
 
    $a1 = new A('$a1');
    $a2 = new A('$a2');
    $a3 = new A('$3');
 
    $a1->ref = $a2;
    $a2->ref = $a1;
 
    unset($a1);
    unset($a2);
 
    echo('exit(1);'.PHP_EOL);
}
a();
echo('exit(2);'.PHP_EOL);

當$a1和$a2的屬性互相參照時,unset($a1,$a2) 只能刪除變數的參照,卻沒有真正的刪除類的變數,這是為什麼呢?

首先,類的範例化變數分為2個步驟,1:開闢類儲存空間,用於儲存類資料,2:範例化一個變數,型別為class,值指向類儲存空間.

當給變數賦值成功後,類的參照計數為1,同時,a1->ref指向了a2,導致a2類參照計數增加1,同時a1類被a2->ref參照,a1參照計數增加1

當unset時,只會刪除類的變數參照,也就是-1,但是該類其實還存在了一次參照(類的互相參照),

這將造成這2個類記憶體永遠無法釋放,直到被gc機制迴圈查詢回收,或指令碼終止回收(域結束無法回收).

手動回收機制

在上面,我們知道了指令碼回收,域結束回收2種php回收方式,那麼可以手動回收嗎?答案是可以的.

手動回收有以下幾種方式:

unset,賦值為null,變數賦值覆蓋,gc_collect_cycles函數回收

unset

unset為最常用的一種回收方式,例如:

class A
{
    public $ref;
    public $name;
 
    public function __construct($name)
    {
        $this->name = $name;
        echo($this->name . '->__construct();' . PHP_EOL);
    }
 
    public function __destruct()
    {
        echo($this->name . '->__destruct();' . PHP_EOL);
    }
}
 
$a = new A('$a');
$b = new A('$b');
unset($a);
//a將會先回收
echo('exit(1);' . PHP_EOL);
//b需要指令碼結束才會回收

輸出:

$a->__construct();
$b->__construct();
$a->__destruct();
exit(1);
$b->__destruct();

unset的回收原理其實就是參照計數-1,當參照計數-1之後為0時,將會直接回收該變數,否則不做操作(這就是上面記憶體漏失的原因,參照計數-1並沒有等於0)

=null回收

class A
{
    public $ref;
    public $name;
 
    public function __construct($name)
    {
        $this->name = $name;
        echo($this->name . '->__construct();' . PHP_EOL);
    }
 
    public function __destruct()
    {
        echo($this->name . '->__destruct();' . PHP_EOL);
    }
}
 
$a = new A('$a');
$b = new A('$b');
$c = new A('$c');
unset($a);
$c=null;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');
 
echo('exit(1);' . PHP_EOL);

=null和unset($a),作用其實都為一致,null將變數值賦值為null,原先的變數值參照計數-1,而unset是將變數名從php底層變數表中清理,並將變數值參照計數-1,唯一的區別在於,=null,變數名還存在,而unset之後,該變數就沒了:

$a->__construct();
$b->__construct();
$c->__construct();
$a->__destruct();
$c->__destruct();
a: no such symbol //$a已經不在符號表
b: (refcount=1, is_ref=0)=class A { public $ref = (refcount=0, is_ref=0)=NULL; public $name = (refcount=1, is_ref=0)='$b' }
c: (refcount=0, is_ref=0)=NULL  //c還存在,只是值為null
exit(1);
$b->__destruct();

變數覆蓋回收

通過給變數賦值其他值(例如null)進行回收:

class A
{
    public $ref;
    public $name;
 
    public function __construct($name)
    {
        $this->name = $name;
        echo($this->name . '->__construct();' . PHP_EOL);
    }
 
    public function __destruct()
    {
        echo($this->name . '->__destruct();' . PHP_EOL);
    }
}
 
$a = new A('$a');
$b = new A('$b');
$c = new A('$c');
$a=null;
$c= '練習時長兩年半的個人練習生';
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');
 
echo('exit(1);' . PHP_EOL);

將輸出:

$a->__construct();
$b->__construct();
$c->__construct();
$a->__destruct();
$c->__destruct();
a: (refcount=0, is_ref=0)=NULL
b: (refcount=1, is_ref=0)=class A { public $ref = (refcount=0, is_ref=0)=NULL; public $name = (refcount=1, is_ref=0)='$b' }
c: (refcount=1, is_ref=0)='練習時長兩年半的個人練習生'
exit(1);
$b->__destruct();

可以看出,c由於覆蓋賦值,將原先A類範例的參照計數-1,導致了$c的回收,但是從程式的記憶體占用來說,覆蓋變數並不是意義上的記憶體回收,只是將變數的記憶體修改為了其他值.記憶體不會直接清空.

gc_collect_cycles

回到之前的記憶體漏失章節,當寫程式不小心造成了記憶體漏失,記憶體越來越大,可是php預設只能指令碼結束後回收,那該怎麼辦呢?我們可以使用gc_collect_cycles 函數,進行手動回收

function a(){
    class A {
        public $ref;
        public $name;
 
        public function __construct($name) {
            $this->name = $name;
            echo($this->name.'->__construct();'.PHP_EOL);
        }
 
        public function __destruct() {
            echo($this->name.'->__destruct();'.PHP_EOL);
        }
    }
 
    $a1 = new A('$a1');
    $a2 = new A('$a2');
 
    $a1->ref = $a2;
    $a2->ref = $a1;
 
    $b = new A('$b');
    $b->ref = $a1;
 
    echo('$a1 = $a2 = $b = NULL;'.PHP_EOL);
    $a1 = $a2 = $b = NULL;
    echo('gc_collect_cycles();'.PHP_EOL);
    echo('// removed cycles: '.gc_collect_cycles().PHP_EOL);
    //這個時候,a1,a2已經被gc_collect_cycles手動回收了
    echo('exit(1);'.PHP_EOL);
 
}
a();
echo('exit(2);'.PHP_EOL);

輸出:

$a1->__construct();
$a2->__construct();
$b->__construct();
$a1 = $a2 = $b = NULL;
$b->__destruct();
gc_collect_cycles();
$a1->__destruct();
$a2->__destruct();
// removed cycles: 4
exit(1);
exit(2);

注意,gc_colect_cycles 函數會從php的符號表,遍歷所有變數,去實現參照計數的計算並清理記憶體,將消耗大量的cpu資源,不建議頻繁使用

另外,除去這些方法,php記憶體到達一定臨界值時,會自動呼叫記憶體清理(我猜的),每次呼叫都會消耗大量的資源,可通過gc_disable 函數,去關閉php的自動gc

以上就是探討php的垃圾回收機制的詳細內容,更多請關注TW511.COM其它相關文章!