愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

PHP垃圾回收机制学习总结

一、引用计数

PHP的垃圾回收机制,主要通过引用计数来实现。在PHP的实现中,每个PHP变量都会存在于一个叫做zval的变量容器中,如在PHP的源码中可以看到:

struct _zval_struct {
    zvalue_value value;
    zend_uint refcount__gc;
    zend_uchar type;
    zend_uchar is_ref__gc;
};
typedef struct _zval_struct zval;

在一个zval容器的结构体中,包含有变量的类型(type)和值(value),还有就是is_ref__gcrefcount__gc两个由于垃圾回收的结构体成员。is_ref是个bool值,由于区分普通变量和引用变量,而ref_count则记录了有多少个变量指向这个zval

1、zval容器的创建

当给一个变量赋予常量值的时候,一个新的zval容器就会被创建,如:

$a = "hello,world";

我们可以使用xdebug_debug_zval()来查看该变量对应的zval容器信息,对上例子,有:

a: (refcount=1, is_ref=0)='hello,world'

2、refcount的增加与减少

当把一个变量赋值给另一个变量的时候,将增加refcount(引用次数),如:

$a = "hello,world";
$a = $b;

xdebug_debug_zval('a'); // 得到 a: (refcount=2, is_ref=0)='hello,world'

当关联到某个变量容器的变量离开它的作用域,或者对变量使用unset()的时候,refcount就会减1,如:

$a = 5;
$b = $a;

xdebug_debug_zval('a');

unset($b);

xdebug_debug_zval('a');
/*
输出:
a: (refcount=2, is_ref=0)=5
a: (refcount=1, is_ref=0)=5
*/

refcount减到0的时候,该zval容器就会被从内存中清除

3、复合类型

对于array和object类型,和标量不同的是,这些类型的变量会把它们的成员、属性存储在自己的符号表中,因此,对于以下的例子,将生成三个zval容器

$a = array(
    'x' => 1,
    'y' => 2
);
xdebug_debug_zval('a');
/*
输出:
a: (refcount=1, is_ref=0)=array(
    'x' => (refcount=1, is_ref=0)=1,
    'y' => (refcount=1, is_ref=0)=2
)
*/

图示为:

如果添加一个已经存在的元素到数组中,如:

$a = array(
    'x' => 1,
    'y' => 2
);
$a['z'] = $a['x'];
xdebug_debug_zval('a');
/*
输出:
a: (refcount=1, is_ref=0)=array(
    'x' => (refcount=2, is_ref=0)=1,
    'y' => (refcount=1, is_ref=0)=2,
    'z' => (refcount=2, is_ref=0)=1
)
*/

虽然输出是'z' => (refcount=2, is_ref=0)=1,但是实际上zx是指向同一zval容器,如:

4、把数组作为一个元素添加到自己

如果把一个数组元素添加到自己,就会发生奇妙的现象,如:

$a = [];
$a[] =& $a;

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

其中,...表示递归,这种情况下,图示如:

如果这种情况下,执行unset($a),就会导致:

这种情况下,尽管不再有任何变量指向该zval容器,但是由于refcount=1(因为第0个元素仍然指向该容器),所以该zval容器不会被回收,用户也没有办法操作这个容器,所以就会导致内存泄漏,这种现象,称为循环引用问题

二、回收周期

在PHP5.3.0之前,无法处理循环引用问题,而在PHP5.3.0之后,采用了引用计数系统中的同步周期回收中的同步算法,来解决这个内存泄露的问题。

1、什么时候产生垃圾周期?

只有当refcount减少到非零值时,才会产生垃圾周期。在一个垃圾周期中,通过检查引用计数是否减1,并且哪些zval容器refcount是0,来发现哪部分是垃圾

2、算法简单描述

以下算法的描述基于以下的PHP代码:

$a = ['one'];
$a[] =& $a;
$b = $a[1];
/*
unset前:
a: (refcount=3, is_ref=1)=array(
    0 => (refcount=2, is_ref=0)='one',
    1 => (refcount=3, is_ref=1)=...
)
*/
unset($a);
/*
unset后:
(refcount=1, is_ref=0)=array(
    0 => (refcount=2, is_ref=0)='one',
    1 => (refcount=2, is_ref=1)=...
)
*/

算法中,将疑似垃圾(某个zval容器)称为可能根(possible root),然后将每个可能根,放在根缓冲区中(用紫色标记),每个可能根只能在缓冲区中出现一次,只有在根缓冲区满了的时候,才会执行垃圾回收操作。如:

接下来,执行模拟删除操作,步骤如:

  • 对根缓冲区中的可能根进行深度优先搜索(DFS)遍历得到每个zval(比如例子中,通过可能根可以搜索得到(string) one所在的zval)。将每个zvalrefcount减1,并标记为灰色(已减,保证对每个zval只1次),如:
  • 再次对每个缓冲区中的可能根进行DFS得到每个zval,如果某个zvalrefcount不为0,就对其加1,恢复为黑色。如果为0,则不变,并标记为白色(下图中用蓝色表色),如:
  • 清空根缓冲区,然后销毁那些refcount为0的zval,回收其内存:

三、小结

1、默认情况下,根缓冲区可以保存10000个可能根,如果要修改该大小,可以编辑PHP源码中的Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES的值,然后重新编译PHP
2、可以通过修改php.ini配置文件中的zend.enable_gc来开启和关闭垃圾回收机制。也可以调用gc_enable()gc_disable()来打开和关闭垃圾回收机制,还可以使用gc_collect_cycles()来强制执行回收周期
3、并不是refcount减少时就会进入回收周期,而是只有在根缓冲区满了的时候,才会开始回收垃圾
4、PHP5.3.0起引入的新算法,可以解决引用计数中的循环引用问题
5、PHP5.3.0的垃圾回收机制,可以把内存泄漏保持在一个阈值以下