PHP内存管理机制与垃圾回收机制

        在平时php-fpm的时候,可能很少人注意php的变量回收,但是到swoole常驻内存开发后,就不得不重视这个了,因为在常驻内存下,如果不了解变量回收机制,可能就会出现内存泄露的问题,本文将一步步带你了解php的垃圾回收机制,让你写出的代码不再内存泄漏。


      

先了解一下PHP的内存机制:

var_dump(memory_get_usage());   //获取内存

$a = "laruence";                //定义一个变量

var_dump(memory_get_usage());   //定义变量之后获取内存

unset($a);                      //删除该变量

var_dump(memory_get_usage());   //删除变量后获取内存

结果:
int(415408)
int(415408)
int(415408)

从上面可以看出php的内存管理机制是:预先给出一块空间,用来存储变量,当空间不够时,再申请一块新的空间

  1. 存储变量名,存在符号表。

  2. 变量值存储在内存空间。

  3. 在删除变量的时候,会将变量值存储的空间释放,而变量名所在的符号表不会减小。

var_dump(memory_get_usage());  //获取内存
//定义100个变量
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
    $$a = "hello";
}
//获取定义100个变量之后的内存
var_dump(memory_get_usage());

//定义100个变量并删除
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
    unset($$a);
}

//获取删除之后的内存
var_dump(memory_get_usage());

结果:
int(502688)
int(515616)
int(512448)

        从上面可以看出,虽然删除后内存变小了,但还是比没定义变量之前时大,这是因为虽然删除了变量的值,但变量名没有被删除。




        php垃圾回收机制

        PHP变量存储是存储在一个zval容器里面的。结构包含 :1.类型 2.值 3.is_ref 代表是否有地址引用 4.refcount 指向该值的变量数量

        zval中,除了存储变量的类型和值之外,还有is_ref字段和refcount字段。

  •                 is_ref:是个bool值,用来区分变量是否属于引用集合。什么意思呢,你可以这么认为:表示变量是否有一个以上的别名。

  •                 refcount:计数器,表示指向这个zval变量容器的变量个数。

        两者之间有这么一个默认关系:当refcount值为1时,is_ref的值为false。因为refcount为1,此变量不可能有多个别名,也就不存在引用了。

        安装xdebug拓展之后,可以利用xdebug_debug_zval打印出zval容器详情。

        这里有一点需要注意,将一个变量 = 赋值给另一个变量时,不会立即为新变量分配内存空间,而是在原变量的zval中给refcount加1。 只有当原变量或者发生改变时,才会为新变量分配内存空间,同时原变量的refcount减 1 。当然,如果unset原变量,新变量直接就使用原变量的zval而不是重新分配。

        &引用赋值时,原变量的is_ref 变为1,refcount 加1. 


        在5.2及更早版本的PHP中,没有专门的垃圾回收器GC(Garbage Collection),引擎在判断一个变量空间是否能够被释放的时候是依据这个变量的zval的refcount的值,如果refcount为0,那么变量的空间可以被释放,否则就不释放,这是一种非常简单的GC实现。然而在这种简单的GC实现方案中,出现了意想不到的变量内存泄漏情况。于是在PHP5.3中出现了新的GC,新的GC有专门的机制负责清理垃圾数据,防止内存泄漏。

        新的GC算法

        在较新的PHP手册中有简单的介绍新的GC使用的垃圾清理算法,这个算法名为 Concurrent Cycle Collection in Reference Counted Systems , 这里不详细介绍此算法,根据手册中的内容来先简单的介绍一下思路:

        首先我们有几个基本的准则:

        1:如果一个zval的refcount增加,那么此zval还在使用,不属于垃圾

        2:如果一个zval的refcount减少到0, 那么zval可以被释放掉,不属于垃圾

        3:如果一个zval的refcount减少之后大于0,那么此zval还不能被释放,此zval可能成为一个垃圾

        只有在准则3下,GC才会把zval收集起来,然后通过新的算法来判断此zval是否为垃圾。那么如何判断这么一个变量是否为真正的垃圾呢?

        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释放掉。



 新的GC算法的性能:

        1.防止泄漏节省内存

                新的GC算法的目的就是为了防止循环引用的变量引起的内存泄漏问题,在PHP中GC算法,当节点缓冲区满了之后,垃圾分析算法会启动,并且会释放掉发现的垃圾,从而回收内存

        2:运行效率影响

                启用了新的GC后,垃圾分析算法将是一个比较耗时的操作。


写时复制


        首先,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还在不断的增加变量,直到内存溢出.


内存泄漏

        $a = array('one');

        $a[] = &$a;

        unset($a);

        那么问题也就产生了,$a已经不在符号表中了,用户无法再访问此变量,但是$a之前指向的zval的refcount变为1而不是0,因此不能被回收,这样产生了内存泄露。这样,这么一个zval就成为了一个真是意义的垃圾了,新的GC要做的工作就是清理这种垃圾。


请看代码:

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代码中我们可以通过gc_enable()和gc_disable()函数来开启和关闭GC,也可以通过调用gc_collect_cycles()在节点缓冲区未满的情况下强制执行垃圾分析算法。这样用户就可以在程序的某些部分关闭或则开启GC,也可强制进行垃圾分析算法。

冷暖自知一抹茶ck



垃圾回收:

1.在5.2版本或之前版本,PHP会根据refcount值来判断是不是垃圾

        如果refcount值为0,PHP会当做垃圾释放掉

        这种回收机制有缺陷,对于环状引用的变量无法回收

2.在5.3之后版本改进了垃圾回收机制

        如果发现一个zval容器中的refcount在增加,说明不是垃圾

        如果发现一个zval容器中的refcount在减少,如果减到了0,直接当做垃圾回收

        如果发现一个zval容器中的refcount在减少,并没有减到0,PHP会把该值放到缓冲区,当做有可能是垃圾的怀疑对象。

        当缓冲区达到了临界值,PHP会自动调用一个方法去遍历每一个值,如果发现是垃圾就清理


附1:PHP 的unset()到底会不会释放内存?

function aa()
{
    var_dump(memory_get_usage(false));  //int(22356464)
    echo "<br/>";
    for ($q=1;$q<1000;$q++){
        $a[] = $q;
    }
    var_dump(memory_get_usage(false));   //int(22393384)
    echo "<br/>";
    array_pop($a);
    var_dump(memory_get_usage(false));   //int(22393408)
    echo "<br/>";
    $b = $a;
    var_dump(memory_get_usage(false)); //int(22393408)
    echo "<br/>";
    unset($a);
    var_dump(memory_get_usage(false)); //int(22393384)
    echo "<br/>";
    unset($b);
    var_dump(memory_get_usage(false)); //int(22356464)
    echo "<br/>";
}
function dd()
{
    aa();
    var_dump('last:'.memory_get_usage(false)); // last:22356464
    echo "<br/>";
}
dd();

        可以看到这个实验是一个方法调用另一个方法来测试变量被unset后内存的变化。

        1、初始内存值为 22356464

        2、创建一个数组$a,填充1000个值,这时内存来到 22393384,内存增长了36860。

        3、我们去掉数组中的一个值,看下内存:22393408,内存涨了24。

        4、然后把$a赋值给$b,这时看下内存:22393408,内存没有变化。

        5、unset($a) 后,内存回落到 22393384,减少了24。

        6、然后又unset($b) ,这是内存回落到初始值 22356464

        7、返回dd() 函数内,内存还是 初始值 22356464

        从这个实验中可以看到,如果$a 有 赋值过给 其他变量以后,当我们unset掉 原来的变量$a,并不会释放掉他原本的内存。 其实在 我们把 $a赋值给$b 的时候就可以发现,内存并没有多加一倍$a 的内存,其实$a 和 $b 都相当于是一个个标签,指向了这堆数据,当我们unset掉$a 其实只是把这个"标签摘掉", 不指向这堆数据了,如果这堆数据没标签标记的话,则会被释放,如果有这样一个$b 也指向这堆数据的话则不会被释放。

        所以unset() 是可以释放内存的,前提是这个变量没有赋值给另一个变量。


参考:

        PHP: 垃圾回收机制

        PHP: 引用计数基本知识

        安装xdebug扩展 ,传送门


 

冷暖自知一抹茶ck
请先登录后发表评论
  • 最新评论
  • 总共0条评论