PHP新的垃圾回收机制:Zend GC详解

Posted Orlion

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了PHP新的垃圾回收机制:Zend GC详解相关的知识,希望对你有一定的参考价值。

概述

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

    目前很少有详细的资料介绍新的GC,本文将是目前国内最为详细的从源码角度介绍PHP5.3中GC原理的文章。其中关于垃圾产生以及算法简介部分由笔者根据手册翻译而来,当然其中融入了本人的一些看法。手册中相关内容:Garbage Collection

    在介绍这个新的GC之前,读者必须先了解PHP中变量的内部存储相关知识,请先阅读 变量的内部存储:引用和计数 

 

什么算垃圾

    首先我们需要定义一下“垃圾”的概念,新的GC负责清理的垃圾是指变量的容器zval还存在,但是又没有任何变量名指向此zval。因此GC判断是否为垃圾的一个重要标准是有没有变量名指向变量容器zval。

    假设我们有一段PHP代码,使用了一个临时变量$tmp存储了一个字符串,在处理完字符串之后,就不需要这个$tmp变量了,$tmp变量对于我们来说可以算是一个“垃圾”了,但是对于GC来说,$tmp其实并不是一个垃圾,$tmp变量对我们没有意义,但是这个变量实际还存在,$tmp符号依然指向它所对应的zval,GC会认为PHP代码中可能还会使用到此变量,所以不会将其定义为垃圾。

    那么如果我们在PHP代码中使用完$tmp后,调用unset删除这个变量,那么$tmp是不是就成为一个垃圾了呢。很可惜,GC仍然不认为$tmp是一个垃圾,因为$tmp在unset之后,refcount减少1变成了0(这里假设没有别的变量和$tmp指向相同的zval),这个时候GC会直接将$tmp对应的zval的内存空间释放,$tmp和其对应的zval就根本不存在了。此时的$tmp也不是新的GC所要对付的那种“垃圾”。那么新的GC究竟要对付什么样的垃圾呢,下面我们将生产一个这样的垃圾。  

 

顽固垃圾的产生过程

    如果读者已经阅读了变量内部存储相关的内容,想必对refcount和isref这些变量内部的信息有了一定的了解。这里我们将结合手册中的一个例子来介绍垃圾的产生过程:

 

<?php

$a = "new string";

?>

在这么简单的一个代码中,$a变量内部存储信息为

a: (refcount=1, is_ref=0)=‘new string‘

 

当把$a赋值给另外一个变量的时候,$a对应的zval的refcount会加1

<?php

$a = "new string";

$b = $a;

?>
此时$a和$b变量对应的内部存储信息为

a,b: (refcount=2, is_ref=0)=‘new string‘

当我们用unset删除$b变量的时候,$b对应的zval的refcount会减少1

<?php

$a = "new string"; //a: (refcount=1, is_ref=0)=‘new string‘

$b = $a;                 //a,b: (refcount=2, is_ref=0)=‘new string‘

unset($b);              //a: (refcount=1, is_ref=0)=‘new string‘

?>

 

对于普通的变量来说,这一切似乎很正常,但是在复合类型变量(数组和对象)中,会发生比较有意思的事情:

<?php

$a = array(‘meaning‘ => ‘life‘, ‘number‘ => 42);

?>

a的内部存储信息为:

a: (refcount=1, is_ref=0)=array (
   ‘meaning‘ => (refcount=1, is_ref=0)=‘life‘,
   ‘number‘ => (refcount=1, is_ref=0)=42
)

数组变量本身($a)在引擎内部实际上是一个哈希表,这张表中有两个zval项 meaning和number,

所以实际上那一行代码中一共生成了3个zval,这3个zval都遵循变量的引用和计数原则,用图来表示:

 

技术分享

 

 

 

 下面在$a中添加一个元素,并将现有的一个元素的值赋给新的元素:

<?php

$a = array(‘meaning‘ => ‘life‘, ‘number‘ => 42);

$a[‘life‘] = $a[‘meaning‘];

?>

那么$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‘
)
其中的meaning元素和life元素之指向同一个zval的:

技术分享

 

 

现在,如果我们试一下,将数组的引用赋值给数组中的一个元素,有意思的事情就发生了:

<?php

$a = array(‘one‘);

$a[] = &$a;

?>

这样$a数组就有两个元素,一个索引为0,值为字符one,另外一个索引为1,为$a自身的引用,内部存储如下:

a: (refcount=2, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)=‘one‘,
   1 => (refcount=2, is_ref=1)=…
)

“…”表示1指向a自身,是一个环形引用:

技术分享

 

这个时候我们对$a进行unset,那么$a会从符号表中删除,同时$a指向的zval的refcount减少1

<?php

$a = array(‘one‘);

$a[] = &$a;

unset($a);

?>

那么问题也就产生了,$a已经不在符号表中了,用户无法再访问此变量,但是$a之前指向的zval的refcount变为1而不是0,因此不能被回收,这样产生了内存泄露:

技术分享

 

这样,这么一个zval就成为了一个真是意义的垃圾了,新的GC要做的工作就是清理这种垃圾。

 

为解决这种垃圾,产生了新的GC

    在PHP5.3版本中,使用了专门GC机制清理垃圾,在之前的版本中是没有专门的GC,那么垃圾产生的时候,没有办法清理,内存就白白浪费掉了。在PHP5.3源代码中多了以下文件:{PHPSRC}/Zend/zend_gc.h {PHPSRC}/Zend/zend_gc.c, 这里就是新的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是否为垃圾。那么如何判断这么一个变量是否为真正的垃圾呢?

简单的说,就是对此zval中的每个元素进行一次refcount减1操作,操作完成之后,如果zval的refcount=0,那么这个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释放掉。

 

这ABCD四个过程是手册中对这个算法的介绍,这还不是那么容易理解其中的原理,这个算法到底是个什么意思呢?我自己的理解是这样的:

比如还是前面那个变成垃圾的数组$a对应的zval,命名为zval_a,  如果没有执行unset, zval_a的refcount为2,分别由$a和$a中的索引1指向这个zval。  用算法对这个数组中的所有元素(索引0和索引1)的zval的refcount进行减1操作,由于索引1对应的就是zval_a,所以这个时候zval_a的refcount应该变成了1,这样zval_a就不是一个垃圾。如果执行了unset操作,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,那么可以判断这个数组是一个垃圾。

这个道理其实很简单,假设数组a的refcount等于m, a中有n个元素又指向a,如果m等于n,那么算法的结果是m减n,m-n=0,那么a就是垃圾,如果m>n,那么算法的结果m-n>0,所以a就不是垃圾了

 

m=n代表什么?  代表a的refcount都来自数组a自身包含的zval元素,代表a之外没有任何变量指向它,代表用户代码空间中无法再访问到a所对应的zval,代表a是泄漏的内存,因此GC将a这个垃圾回收了。

 

PHP中运用新的GC的算法

    在PHP中,GC默认是开启的,你可以通过ini文件中的 zend.enable_gc 项来开启或则关闭GC。当GC开启的时候,垃圾分析算法将在节点缓冲区(roots buffer)满了之后启动。缓冲区默认可以放10,000个节点,当然你也可以通过修改Zend/zend_gc.c中的GC_ROOT_BUFFER_MAX_ENTRIES 来改变这个数值,需要重新编译链接PHP。当GC关闭的时候,垃圾分析算法就不会运行,但是相关节点还会被放入节点缓冲区,这个时候如果缓冲区节点已经放满,那么新的节点就不会被记录下来,这些没有被记录下来的节点就永远也不会被垃圾分析算法分析。如果这些节点中有循环引用,那么有可能产生内存泄漏。之所以在GC关闭的时候还要记录这些节点,是因为简单的记录这些节点比在每次产生节点的时候判断GC是否开启更快,另外GC是可以在脚本运行中开启的,所以记录下这些节点,在代码运行的某个时候如果又开启了GC,这些节点就能被分析算法分析。当然垃圾分析算法是一个比较耗时的操作。

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

   

新的GC算法的性能

1.防止泄漏节省内存

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

 

 

<?php
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";
    }
}
?>

技术分享

这段代码的循环体中,新建了一个对象变量,并且用对象的一个成员指向了自己,这样就形成了一个循环引用,当进入下一次循环的时候,又一次给对象变量重新赋值,这样会导致之前的对象变量内存泄漏,在这个例子里面有两个变量泄漏了,一个是对象本身,另外一个是对象中的成员self,但是这两个变量只有对象会作为垃圾收集器的节点被放入缓冲区(因为重新赋值相当于对它进行了unset操作,满足前面的准则3)。在这里我们进行了100,000次循环,而GC在缓冲区中有10,000节点的时候会启动垃圾分析算法,所以这里一共会进行10次的垃圾分析算法。从图中可以清晰的看到,在5.3版本PHP中,每次GC的垃圾分析算法被触发后,内存会有一个明显的减少。而在5.2版本的PHP中,内存使用量会一直增加。

 

 

2:运行效率影响

    启用了新的GC后,垃圾分析算法将是一个比较耗时的操作,手册中给了一段测试代码:

 

 

 

<?php
class Foo
{
    public $var = ‘3.1415962654‘;
}

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

echo memory_get_peak_usage(), "/n";
?>

然后分别在GC开启和关闭的情况下执行这段代码:

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

最终在该机器上,第一次执行大概使用10.7秒,第二次执行大概使用11.4秒,性能大约降低7%,不过内存的使用量降低了98%,从931M降低到了10M。当然这并不是一个比较科学的测试方法,但是也能说明一定的问题。这种代码测试的是一种极端恶劣条件,实际代码中,特别是在WEB的应用中,很难出现大量循环引用,GC的分析算法的启动不会这么频繁,小规模的代码中甚至很少有机会启动GC分析算法。

总结:

当GC的垃圾分析算法执行的时候,PHP脚本的效率会受到一定的影响,但是小规模的代码一般不会有这个机会运行这个算法。如果一旦脚本中GC分析算法开始运行了,那么将花费少量的时间节省出来了大量的内存,是一件非常划算的事情。新的GC对一些长期运行的PHP脚本效果更好,比如PHP的DAEMON守护进程,或则PHP-GTK进程等等。

 

 

 

 

引擎内部GC的实现

   前面已经介绍了新的GC的基本原理以及性能相关的内容,其中一些都是在手册中有简单介绍了,那么这里我们将从源代码的角度来分析一下PHP如何实现新的GC。

1.zval的变化

    在文件Zend/zend_gc.h中,重新定义了分配一个zval结构的宏:

 

 

[cpp] view plain copy

  1. #undef  ALLOC_ZVAL  

  2. #define ALLOC_ZVAL(z)                                   /  

  3.     do {                                                /  

  4.         (z) = (zval*)emalloc(sizeof(zval_gc_info));     /  

  5.         GC_ZVAL_INIT(z);                                /  

  6.     } while (0)  

ALLOC_ZVAL的原始定义是在Zend/zend_alloc.h中,原始的定义只是分配一个zval结构的内存空间,然后在新的GC使用后,分配一个zval空间实际上是分配了一个zval_gc_info结构的空间,下面看看zval_gc_info结构定义:

 

[cpp] view plain copy

  1. typedef struct _zval_gc_info {  

  2.     zval z;  

  3.     union {  

  4.         gc_root_buffer       *buffered;  

  5.         struct _zval_gc_info *next;  

  6.     } u;  

  7. } zval_gc_info;  

zval_gc_info这个结构的第一个成员就是一个zval结构,第二个成员是一个联合体u,是一个指向gc_root_buffer的指针和一个指向_zval_gc_info的指针。  第一个成员为zval结构,这就保证了对zval_gc_info类型指针做类型转换后和zval等价。在ALLOC_ZVAL宏中,分配了一个zval_gc_info的空间后,是将空间的指针转换成了(zval *)。这样就相当于分配了一个zval的空间。然后GC_ZVAL_INIT宏会把zval_gc_info中的成员u的buffered字段设置成NULL:

 

[cpp] view plain copy

  1. #define GC_ZVAL_INIT(z) /  

  2.     ((zval_gc_info*)(z))->u.buffered = NULL  

这个u.buffered指针就是用来表示这个zval对应的节点信息指针。

新的GC会为所有的zval分配一个空间存放节点信息指针,只有当zval被GC放入节点缓冲区的时候,节点信息指针才会被指向一个节点信息结构,否则节点信息指针一直是NULL。

具体方式是通过分配一个zval_gc_info结构来实现,这个结构包含了zval和节点信息指针buffered。

 

 

2.节点信息

  zval的节点信息指针buffered指向一个gc_root_buffer类型,这个类型的定义如下:

 

[cpp] view plain copy

  1. typedef struct _gc_root_buffer {  

  2.     struct _gc_root_buffer   *prev;     /* double-linked list               */  

  3.     struct _gc_root_buffer   *next;  

  4.     zend_object_handle        handle;   /* must be 0 for zval               */  

  5.     union {  

  6.         zval                 *pz;  

  7.         zend_object_handlers *handlers;  

  8.     } u;  

  9. } gc_root_buffer;  

这是一个双链表的节点结构类型,prev和next用来指向前一个节点和后一个节点,handel是和对象相关的,对象类型的变量比较特殊,我们这里不讨论,u是一个联合体,u.pz用来指向这个节点所对应的zval结构。 这样每一个zval结构和zval对应的节点信息互相被关联在一起了:

通过一个zval指针pz找到节点指针: pr = ((zval_gc_info *)pz)->u.buffered

通过一个节点指针pr找到zval指针: pz = pr->u.pz

 

3.为zval设置节点信息以及节点颜色信息

    这里GC应用了一些小技巧,先看看下面相关的宏:

 

[cpp] view plain copy

  1. #define GC_COLOR  0x03  

  2.   

  3. #define GC_BLACK  0x00  

  4. #define GC_WHITE  0x01  

  5. #define GC_GREY   0x02  

  6. #define GC_PURPLE 0x03  

  7.   

  8. #define GC_ADDRESS(v) /  

  9.     ((gc_root_buffer*)(((zend_uintptr_t)(v)) & ~GC_COLOR))  

  10. #define GC_SET_ADDRESS(v, a) /  

  11.     (v) = ((gc_root_buffer*)((((zend_uintptr_t)(v)) & GC_COLOR) | ((zend_uintptr_t)(a))))  

  12. #define GC_GET_COLOR(v) /  

  13.     (((zend_uintptr_t)(v)) & GC_COLOR)  

  14. #define GC_SET_COLOR(v, c) /  

  15.     (v) = ((gc_root_buffer*)((((zend_uintptr_t)(v)) & ~GC_COLOR) | (c)))  

  16. #define GC_SET_BLACK(v) /  

  17.     (v) = ((gc_root_buffer*)(((zend_uintptr_t)(v)) & ~GC_COLOR))  

  18. #define GC_SET_PURPLE(v) /  

  19.     (v) = ((gc_root_buffer*)(((zend_uintptr_t)(v)) | GC_PURPLE))  

  20.   

  21. #define GC_ZVAL_INIT(z) /  

  22.     ((zval_gc_info*)(z))->u.buffered = NULL  

  23. #define GC_ZVAL_ADDRESS(v) /  

  24.     GC_ADDRESS(((zval_gc_info*)(v))->u.buffered)  

  25. #define GC_ZVAL_SET_ADDRESS(v, a) /  

  26.     GC_SET_ADDRESS(((zval_gc_info*)(v))->u.buffered, (a))  

  27. #define GC_ZVAL_GET_COLOR(v) /  

  28.     GC_GET_COLOR(((zval_gc_info*)(v))->u.buffered)  

  29. #define GC_ZVAL_SET_COLOR(v, c) /  

  30.     GC_SET_COLOR(((zval_gc_info*)(v))->u.buffered, (c))  

  31. #define GC_ZVAL_SET_BLACK(v) /  

  32.     GC_SET_BLACK(((zval_gc_info*)(v))->u.buffered)  

  33. #define GC_ZVAL_SET_PURPLE(v) /  

  34.     GC_SET_PURPLE(((zval_gc_info*)(v))->u.buffered)  

 

其中宏GC_ZVAL_SET_ADDRESS(v, a)是为v这个zval设置节点信息的指针a,这个宏先得到v中的节点信息指针字段u.buffered,然后调用GC_ADDRESS(v,a)宏,将u.buffered字段设置成指针a。

GC_ADDRESS(v, a)宏的功能是将地址a赋给v,但是它的实现很奇怪:

(v) = ((gc_root_buffer*)((((zend_uintptr_t)(v)) & GC_COLOR) | ((zend_uintptr_t)(a))))

 

为什么需要这么一个复杂的过程,而且设置指针值为何还要牵扯到GC_COLOR颜色这个宏?

这里就得先说说节点的颜色信息保存方式。

在前面GC的算法简介中,提到了需要为节点上色,而实际在我们节点结构gc_root_buffer中并没有哪一个字段用来标识节点的颜色,这里GC运用了一个小的技巧:利用节点指针的低两位来标识颜色属性。可能读者会有疑问,用指针中的位来保存颜色属性,那么设置颜色后,指针不就变化了吗,那

以上是关于PHP新的垃圾回收机制:Zend GC详解的主要内容,如果未能解决你的问题,请参考以下文章

Java垃圾回收机制(GC)详解

Java垃圾回收(GC)机制详解

JVM的垃圾回收机制详解和调优

PHP垃圾回收机制(GC)

PHP 垃圾回收 GC 机制人工介入释放内存

PHP 垃圾回收 GC 机制人工介入释放内存