前言

PHP是一门具有垃圾回收机制的高级编程语言,在PHP的简单好用主旨的指导下,PHP程序员往往不用关心底层的原理即可写出符合需求的业务代码。出活快,程序员工作效率高,但是我们并不清楚我们写的代码在运行的过程中真正涉及到了哪些过程,我们写的看起来的符合要求的代码实际又会有哪些可能的bug。抱着这种心态,我近期了解了下PHP的垃圾回收机制,初涉PHP的底层原理,在此记录下。

PHP的zval变量结构体(PHP5)

在了解垃圾回收机制之前,有必要了解下PHP的所有变量的基础:zval结构体。PHP是C语言实现的,PHP的基本数据类型分为两大类:标量类型和复杂类型。标量类型包括 整型、浮点型、字符串、布尔型;复杂类型包括数组、对象和资源;还有一个特殊类型:NULL。我们看下C中的zval结构具体形式:

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;
};

_zval_struct 结构体中的zvalue_value表示PHP中的变量值。_zval_struct是一个联合体,其中有5中数据类型。每个PHP的变量都可以用这5种类型来实现。那么是怎么用C语言中的5种数据类型来实现PHP中的8种数据类型呢?

  • 整型:用zvalue_value中的long类型 lval来存储
  • 浮点型:用zvalue_value中的double类型 dval来存储
  • 字符串:用zvalue_value中的结构体str来存储,可以看到字符串的长度是个计算好的值
  • 布尔型:用zvalue_value中的long类型 lval来存储
  • 数组:用zvalue_value中的*ht 类型来存储,所以说PHP中的数组其实就是哈希表,在zval结构体中存储的引用类型
  • 对象:用zvalue_value中的obj类型的 zend_object_value来存储
  • 资源:用zvalue_value中的long类型 lval来存储 (实际存储是资源的标志符,用来找到资源本身)
  • NULL: 如果所有字段全部置为0或NULL则表示PHP中的NULL
    通过这种复用的方式,C语言实现了PHP的八种基本类型。

再看_zval_struct 结构体本身,我们发现有4种属性。value存储变量值;refcount__gc存储计数值;type存储变量是哪种PHP数据类型,根据type的值去取value的值;is_ref__gc表示这个变量是否属于引用类型。

PHP5.2的垃圾回收机制

每当我们命名一个变量并赋值的时候。就会生成一个zval结构体。

$a = "new string";
xdebug_debug_zval('a');

//输出:a: (refcount=1, is_ref=0)='new string'

这个变量的计数值为1,因为他本身的存在所以至少计数值为1。如果将变量赋值给另一个变量就会增加计数值。

$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );

//输出:a: (refcount=2, is_ref=0)='new string'

从这我们也可以知道,PHP中变量赋值给另一个变量其实并没有立即产生复制的动作,PHP中采用的COW(Copy On Write)机制,这是一个不限制于PHP语言的机制,这里不做说明。变量容器在”refcount“变成0时就被销毁. 当任何关联到某个变量容器的变量离开它的作用域(比如:函数执行结束),或者对变量调用了函数 unset()时,”refcount“就会减1,下面的例子就能说明:

$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
unset( $b, $c );
xdebug_debug_zval( 'a' );

//输出:a: (refcount=3, is_ref=0)='new string'
//输出:a: (refcount=1, is_ref=0)='new string'

如果我们现在执行 unset($a);,包含类型和值的这个变量容器就会从内存中删除。
与标量(scalar)类型的值不同,array和 object类型的变量把它们的成员或属性存在自己的符号表中比如数组:

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

//输出:a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=1, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42
)

这个过程中生成了3个zval结构体,分别是'a','meaning'和'number'。
请输入图片描述

删除数组中的一个元素,就是类似于从作用域中删除一个变量. 删除后,数组中的这个元素所在的容器的“refcount”值减少,同样,当“refcount”为0时,这个变量容器就从内存中被删除,下面又一个例子可以说明:

$a = array( 'meaning' => 'life', 'number' => 42 );
$a['life'] = $a['meaning'];
unset( $a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );

//输出:a: (refcount=1, is_ref=0)=array (
   'life' => (refcount=1, is_ref=0)='life'
)

这种机制就是Reference Counting,这个算法中文翻译叫做“引用计数”,其思想非常直观和简洁:为每个内存对象分配一个计数器,当一个内存对象建立时计数器初始化为1(因此此时总是有一个变量引用此对象),以后每有一个新变量引用此内存对象,则计数器加1,而每当减少一个引用此内存对象的变量则计数器减1,当垃圾回收机制运作的时候,将所有计数器为0的内存对象销毁并回收其占用的内存。

这个算法有一个会导致内存泄露的漏洞:

$a = array( 'one' );
$a[] =& $a;
xdebug_debug_zval( 'a' );

//输出:a: (refcount=2, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=2, is_ref=1)=...
)

能看到数组变量 (a) 同时也是这个数组的第二个元素(1) 指向的变量容器中“refcount”为 2。上面的输出结果中的"..."说明发生了递归操作, 显然在这种情况下意味着"..."指向原始数组。

跟刚刚一样,对一个变量调用unset,将删除这个符号,且它指向的变量容器中的引用次数也减1。所以,如果我们在执行完上面的代码后,对变量$a调用unset, 那么变量 $a 和数组元素 "1" 所指向的变量容器的引用次数减1, 从"2"变成"1".

a: (refcount=1, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=1, is_ref=1)=...
)

请输入图片描述
这个时候变量a已经不存在了,变量符号表中没有$a了,但是这个数组数据本身还会一直存在内存中,成为了一个PHP无法清理的数据,这就是内存泄露了,这个例子中仅仅是一个简单变量,但是实际中可能是一个非常大的数组,对象等。久而久之就会导致内存占用异常的高。

庆幸的是,php将在脚本执行结束时清除这个数据结构,但是在php清除之前,将耗费不少内存。如果你要实现分析算法,或者要做其他像一个子元素指向它的父元素这样的事情,这种情况就会经常发生。当然,同样的情况也会发生在对象上,实际上对象更有可能出现这种情况,因为对象总是隐式的被引用。

如果上面的情况发生仅仅一两次倒没什么,但是如果出现几千次,甚至几十万次的内存泄漏,这显然是个大问题。这样的问题往往发生在长时间运行的脚本中,比如请求基本上不会结束的守护进程(deamons)或者单元测试中的大的套件(sets)中。后者的例子:在给巨大的eZ(一个知名的PHP Library) 组件库的模板组件做单元测试时,就可能会出现问题。有时测试可能需要耗用2GB的内存,而测试服务器很可能没有这么大的内存。
考虑到这个算法的缺陷,PHP开发组在PHP5.3改进了垃圾回收机制。

PHP5.3的垃圾回收机制

传统上,像以前的PHP用到的引用计数内存机制,无法处理循环的引用内存泄漏。然而 5.3.0 PHP 使用文章» 引用计数系统中的同步周期回收(Concurrent Cycle Collection in Reference Counted Systems)中的同步算法,来处理这个内存泄漏问题。

这个改进机制的大致思路:
如果一个变量引用计数增加,它将继续被使用,当然就不在垃圾中。如果引用计数减少到零,所在变量容器将被清除(free)。就是说,仅仅在引用计数减少到非零值时,才会产生垃圾周期(garbage cycle)。其次,在一个垃圾周期中,通过检查引用计数是否减1,并且检查哪些变量容器的引用次数是零,来发现哪部分是垃圾。
PHP管理着一个缓冲区,这个缓冲区默认的可以存储10000个数量的根zval(理解为最上层的zval,因为zval之间会有互相引用的关系),当缓冲区满了的时候就会触发回收机制,开始一个周期的数据清理工作:

  1. 对每个根缓冲区中的根zval按照深度优先遍历算法遍历所有能遍历到的zval,并将每个zval的refcount减1,同时为了避免对同一zval多次减1(因为可能不同的根能遍历到同一个zval),每次对某个zval减1后就对其标记为“已减”。
  2. 再次对每个缓冲区中的根zval深度优先遍历,如果某个zval的refcount不为0,则对其加1,否则保持其为0。
  3. 清空根缓冲区中的所有根(注意是把这些zval从缓冲区中清除而不是销毁它们),然后销毁所有refcount为0的zval,并收回其内存。

如果不能完全理解也没有关系,只需记住PHP5.3的垃圾回收算法有以下几点特性:

  1. 并不是每次refcount减少时都进入回收周期,只有根缓冲区满额后在开始垃圾回收。
  2. 可以解决循环引用问题。
  3. 可以总将内存泄露保持在一个阈值以下。

默认的,PHP的垃圾回收机制是打开的,然后有个 php.ini 设置允许你修改它:zend.enable_gc 。

当垃圾回收机制打开时,每当根缓存区存满时,就会执行上面描述的循环查找算法。根缓存区有固定的大小,可存10,000个可能根,当然你可以通过修改PHP源码文件Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然后重新编译PHP,来修改这个10,000值。当垃圾回收机制关闭时,循环查找算法永不执行,然而,可能根将一直存在根缓冲区中,不管在配置中垃圾回收机制是否激活。

当垃圾回收机制关闭时,如果根缓冲区存满了可能根,更多的可能根显然不会被记录。那些没被记录的可能根,将不会被这个算法来分析处理。如果他们是循环引用周期的一部分,将永不能被清除进而导致内存泄漏。

即使在垃圾回收机制不可用时,可能根也被记录的原因是,相对于每次找到可能根后检查垃圾回收机制是否打开而言,记录可能根的操作更快。不过垃圾回收和分析机制本身要耗不少时间。

除了修改配置zend.enable_gc ,也能通过分别调用gc_enable() 和 gc_disable()函数来打开和关闭垃圾回收机制。调用这些函数,与修改配置项来打开或关闭垃圾回收机制的效果是一样的。即使在可能根缓冲区还没满时,也能强制执行周期回收。你能调用gc_collect_cycles()函数达到这个目的。这个函数将返回使用这个算法回收的周期数。

允许打开和关闭垃圾回收机制并且允许自主的初始化的原因,是由于你的应用程序的某部分可能是高时效性的。在这种情况下,你可能不想使用垃圾回收机制。当然,对你的应用程序的某部分关闭垃圾回收机制,是在冒着可能内存泄漏的风险,因为一些可能根也许存不进有限的根缓冲区。因此,就在你调用gc_disable()函数释放内存之前,先调用gc_collect_cycles()函数可能比较明智。因为这将清除已存放在根缓冲区中的所有可能根,然后在垃圾回收机制被关闭时,可留下空缓冲区以有更多空间存储可能根。

参考文章

浅谈PHP5中垃圾回收算法(Garbage Collection)的演化
垃圾回收机制