看官,PHP的垃圾回收机制(Reference Counting(引用计数))了解下不

    这个垃圾回收啊,说到底是对变量及其所关联内存对象的操作,所以在讨论PHP的垃圾回收机制之前,咱们先来简单了解下PHP中变量及其内存对象的内部表示(其C源代码中的表示)。

    首先啊PHP中的变量划分为两类:标量类型和复杂类型。标量类型包括布尔型、整型、浮点型和字符串;复杂类型包括数组、对象和资源;还有一个NULL比较特殊,它不划分为任何类型,而是单独成为一类。然而所有这些类型,在PHP内部统一用一个叫做zval的结构表示,在PHP源代码中这个结构名称为“_zval_struct”。zval的具体定义在PHP源代码的“Zend/zend.h”文件中,下面是相关代码的摘录:

    typedef union _zvalue_value {  
        long lval;                  /* long value */
        double dval;                /* double value */
        struct {  
            char *val;  
            int len;  
        } str;  
        HashTable *ht;              /* hash table value */
        zend_object_value obj;  
    } zvalue_value;  
     
    struct _zval_struct {  
        /* Variable information */
        zvalue_value value;       
    /* value */
        zend_uint refcount__gc;  
        zend_uchar type;    /* active type */
        zend_uchar is_ref__gc;  
    };

    看来上面的源码啥感觉?我先说,我第一时间是懵的,看了之后的解释,才音乐的了解了一点。这个,其中的联合体“_zvalue_value”用于表示PHP中所有变量的值,这里之所以使用union,是因为一个zval在一个时刻只能表示一种类型的变量。我们可以看到_zvalue_value中只有5个字段,但是PHP中算上NULL有8种数据类型,那么PHP内部是如何用5个字段表示8种类型呢?这也算是PHP设计比较巧妙的一个地方,它通过复用字段达到了减少字段的目的。例如,在PHP内部布尔型、整型及资源(只要存储资源的标识符即可)都可以通过lval字段存储;dval用于存储浮点型;str存储字符串;ht存储数组(我们要知道PHP中的数组其实是哈希表);而obj存储对象类型;如果所有字段全部置为0或NULL则表示PHP中的NULL,这样就达到了用5个字段存储8种类型的值。神奇吧,嘿嘿,这段得仔细看,要不你就还是一脸懵。。。废话不多说,我们接着来看。

    然而当前zval中的value(value的类型即是_zvalue_value)到底表示那种类型,则由“_zval_struct”中的type确定。_zval_struct即是zval在C语言中的具体实现,每个zval表示一个变量的内存对象。除了value和type,可以看到_zval_struct中还有两个字段refcount__gc和is_ref__gc,从其后缀就可以断定这两个家伙与垃圾回收有关。没错,PHP的垃圾回收全靠这俩字段了。其中refcount__gc表示当前有几个变量引用此zval,而is_ref__gc表示当前zval是否被按引用引用,这话听起来很拗口,这和PHP中zval的“Write-On-Copy”机制有关。关于这个机制咱们之后的文章会有记录,先不管了哈,咱们接着看下。

    因为这个PHP啊,在某个版本之后引入了大量的宏来操作这个鬼,所以在5.*之后基本上都是有这个_gc这个东西了。那咱们也废话不多说哈,直接来看下这个垃圾回收的算法哈。我知道的是两个,一个是Reference Counting(引用计数),还有一个是Concurrent Cycle Collection in Reference Counted Systems(这是个论文标题哈)。说实话,这一长串,我也不太清楚,别着急哈,咱们接下来,分别看下哈。

    首先是Reference Counting(引用计数)。在5.2之中还在应用,不过之后貌似就优化了。然而它的思想非常直观和简洁:首先为每个内存对象分配一个计数器,当一个内存对象建立时这个计数器初始化为1(因此此时总是有一个变量引用此对象),以后每有一个新变量引用此内存对象,则计数器加1,而每当减少一个引用此内存对象的变量则计数器减1,当垃圾回收机制运作的时候,将所有计数器为0的内存对象销毁并回收其占用的内存。而PHP中内存对象就是zval,而计数器就是refcount__gc。嘿嘿,了解了不。

    咱们啊,通过一段代码来了解下(计数器值通过xdebug得到):

$val1 = 100; //zval(val1).refcount_gc = 1;  
$val2 = $val1; //zval(val1).refcount_gc = 2,zval(val2).refcount_gc = 2(因为是Write on copy,当前val2与val1共同引用一个zval)  
$val2 = 200; //zval(val1).refcount_gc = 1,zval(val2).refcount_gc = 1(此处val2新建了一个zval)  
unset($val1); //zval(val1).refcount_gc = 0($val1引用的zval再也不可用,会被GC回收)  

    不过,虽然这个Reference Counting(引用计数)简单直观,实现方便,但是嘞,它却存在一个致命的缺陷,就是容易造成内存泄露。很多朋友可能已经意识到了,如果存在循环引用,那么Reference Counting就可能导致内存泄露。咱们再来通过一段代码感受下:

$a = array();
$a[] = & $a;
unset($a);

    这段代码首先建立了数组a,然后让a的第一个元素按引用指向a,这时a的zval的refcount就变为2,然后我们销毁变量a,此时a最初指向的zval的refcount为1,但是我们再也没有办法对其进行操作,因为其形成了一个循环自引用,如下图所示:


    其中灰色部分表示已经不复存在。由于a之前指向的zval的refcount为1(被其HashTable的第一个元素引用),这个zval就不会被GC销毁,这部分内存就泄露了。懂了呗,这也就意味着这个a的refcount不会归0,在脚本生命周期结束前,这部分内存,就相当于废了。

    这里特别要提醒一下的是,PHP是通过符号表(Symbol Table)存储变量符号的,全局有一个符号表,而每个复杂类型如数组或对象有自己的符号表,因此上面代码中,a和a[0]是两个符号,但是a储存在全局符号表中,而a[0]储存在数组本身的符号表中,且这里a和a[0]引用同一个zval(当然符号a后来被销毁了)。希望各位注意分清符号(Symbol)的zval的关系哈。

    然而,在PHP只用于做动态页面脚本时,这种泄露不是很要紧,因为动态页面脚本的生命周期很短,PHP会保证当脚本执行完毕后,释放其所有资源。但是PHP发展到目前已经不仅仅用作动态页面脚本这么简单,如果将PHP用在生命周期较长的场景中,例如自动化测试脚本或deamon进程,那么经过多次循环后积累下来的内存泄露可能就会很严重。所以说,简单了解下这个垃圾回收机制,在某些情况下,还是蛮重要的。

    因为这个Reference Counting的这个容易造成内存泄漏的缺陷,PHP在之后的版本中改进了垃圾回收算法,也就是咱们上面提到的那一长串英文。这个优化后的垃圾回收算法呢,还是以引用计数为基础,但是不再是使用简单计数作为回收准则,而是使用了一种同步回收算法,这个算法由IBM的工程师在论文Concurrent Cycle Collection in Reference Counted Systems中提出。这个算法呢,我只能说,相当复杂,咱也没能力详细解释,所以只能简单的理解下原理了(有兴趣的可以在网上找找,不得不说,很精彩,有真材实料的)。

    接下来,咱们就来简单看下这个改良之后的算法。首先PHP会分配一个固定大小的“根缓冲区”,这个缓冲区用于存放固定数量的zval,这个数量默认是10,000,如果需要修改则需要修改源代码Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES然后重新编译。之后当根缓冲区满额时,PHP就会执行垃圾回收。

    回收时候的具体算法操作如下:

    1、它首先对每个根缓冲区中的根zval按照深度优先遍历算法遍历所有能遍历到的zval,并将每个zval的refcount减1,同时为了避免对同一zval多次减1(因为可能不同的根能遍历到同一个zval),每次对某个zval减1后就对其标记为“已减”。

    2、完事之后,它会再次对每个缓冲区中的根zval深度优先遍历,如果某个zval的refcount不为0,则对其加1,否则保持其为0。

    3、最后呢,就是清空根缓冲区中的所有根(注意是把这些zval从缓冲区中清除而不是销毁它们),然后销毁所有refcount为0的zval,并收回其内存。

    大概理解了不,如果感觉有点绕的话,直接记住它的特点就好了,其一,并不是每次refcount减少时都进入回收周期,只有根缓冲区满额后在开始垃圾回收,其二,可以解决循环引用问题,其三就是它可以总将内存泄露保持在一个阈值以下。记住这三点就差不多了。

    完事呢,咱们来看下内存泄露试验,我直接直接引用PHP Manual中的实验代码和试验结果图,大家来看下:

class Foo
{
    public $var = '3.1415962654';
}

$baseMemory = memory_get_usage();

for ( $i = 0; $i <= 100000; $i++ )
{
    $a = new Foo;
    $a->self = $a;
    if ( $i % 500 === 0 )
    {
        echo sprintf( '%8d: ', $i ), memory_get_usage() - $baseMemory, "\n";
    }
}

    

    我们可以看到在可能引发累积性内存泄露的场景下,PHP5.2发生持续累积性内存泄露,而PHP5.3则总能将内存泄露控制在一个阈值以下(与根缓冲区大小有关)。完事我们再来看下性能方面的对比:

class Foo
{
    public $var = '3.1415962654';
}

for ( $i = 0; $i <= 1000000; $i++ )
{
    $a = new Foo;
    $a->self = $a;
}

echo memory_get_peak_usage(), "\n";

    我们这个脚本执行1000000次循环,使得延迟时间足够进行对比。完事呢,使用CLI方式分别在打开内存回收和关闭内存回收的的情况下运行此脚本,下面是命令:

time php -dzend.enable_gc=0 -dmemory_limit=-1 -n example2.php  
# and
time php -dzend.enable_gc=1 -dmemory_limit=-1 -n example2.php

    至于结果,大家可以自己动手试下,看看效果,本人就不赘述了。如果我们要修改垃圾回收算法相关的PHP配置,我们可以通过修改php.ini中的zend.enable_gc来打开或关闭PHP的垃圾回收机制,也可以通过调用gc_enable()或gc_disable()打开或关闭PHP的垃圾回收机制。

    当然,我们在PHP5.3中即使关闭了垃圾回收机制,PHP仍然会记录可能根到根缓冲区,只是当根缓冲区满额时,PHP不会自动运行垃圾回收,当然,任何时候我们都可以通过手动调用gc_collect_cycles()函数强制执行内存回收。

    好啦,本次记录就到这里了。如果感觉不错的话,请多多点赞支持哦。。。

猜你喜欢

转载自blog.csdn.net/luyaran/article/details/80816763
今日推荐