PHP二十一问:PHP的垃圾回收机制

本文转自:PHP二十一问:PHP的垃圾回收机制

PHP 是一门托管型语言,在 PHP 编程中,程序员不需要手工处理内存资源的分配与释放(使用 C 编写 PHP 或 Zend 扩展除外),这就意味着 PHP 本身实现了垃圾回收机制(Garbage Collection)。在 PHP 官方网站可以看到对垃圾回收机制的介绍。

PHP的引用计数

PHP在内核中是通过zval这个结构体来存储变量的,在Zend/zend.h文件中找到了其定义:

PHP5 中定义如下:

struct _zval_struct {
        /* Variable information */
        zvalue_value value;             /* value */
        zend_uint refcount;
        zend_uchar type;        /* active type */
        zend_uchar is_ref;
};

而到了PHP7中定义如下:

struct _zval_struct {
    union {
        zend_long         lval;             /* long value */
        double            dval;             /* double value */
        zend_refcounted  *counted;
        zend_string      *str;
        zend_array       *arr;
        zend_object      *obj;
        zend_resource    *res;
        zend_reference   *ref;
        zend_ast_ref     *ast;
        zval             *zv;
        void             *ptr;
        zend_class_entry *ce;
        zend_function    *func;
        struct {
            uint32_t w1;
            uint32_t w2;
        } ww;
    } value;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    type,         /* active type */
                zend_uchar    type_flags,
                zend_uchar    const_flags,
                zend_uchar    reserved)     /* call info for EX(This) */
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t     var_flags;
        uint32_t     next;                 /* hash collision chain */
        uint32_t     cache_slot;           /* literal cache slot */
        uint32_t     lineno;               /* line number (for ast nodes) */
        uint32_t     num_args;             /* arguments number for EX(This) */
        uint32_t     fe_pos;               /* foreach position */
        uint32_t     fe_iter_idx;          /* foreach iterator index */
    } u2;
};

我们定义一个PHP变量如下:

 $var = "mindoc";
 $var_dup = $var;
 unset($var);
  • 第一行代码创建了一个字符串变量,申请了一个大小为9字节的内存,保存了字符串”laruence”和一个NULL(\0)的结尾。
  • 第二行定义了一个新的字符串变量,并将变量var的值”复制”给这个新的变量。
  • 第三行unset了变量var

这样的代码在我们平时的脚本中是很常见的,如果PHP对于每一个变量赋值都重新分配内存,copy数据的话,那么上面的这段代码公要申请18个字节的内存空间,而我们也很容易的看出来,上面的代码其实根本没有必要申请俩份空间,PHP的开发者也看出来了:

PHP中的变量是用一个存储在symbol_table中的符号名,对应一个zval来实现的,比如对于上面的第一行代码,会在symbol_table中存储一个值”var”, 对应的有一个指针指向一个zval结构,变量值”laruence”保存在这个zval中,所以不难想象,对于上面的代码来说,我们完全可以让”var”和”var_dup”对应的指针都指向同一个zval就可以了。

PHP也是这样做的,这个时候就需要介绍过zval结构中的refcount字段了。

refcount,顾名思义,记录了当前的zval被引用的计数。

不准确但却通俗的说:
refcount:多少个变量是一样的用了相同的值,这个数值就是多少。
is_ref:bool类型,当refcount大于2的时候,其中一个变量用了地址&的形式进行赋值,好了,它就变成1了。

在 PHP 中可以通过 xdebug 扩展中提供的方法来查看变量的计数变化:

1.第一步:查看内部结构

$name = "咖啡色的羊驼";
 xdebug_debug_zval('name');

会得到:

name:(refcount=1, is_ref=0),string '咖啡色的羊驼' (length=18)

2.第二步:增加一个计数

$name = "咖啡色的羊驼";
$temp_name = $name;
xdebug_debug_zval('name');

会得到:

name:(refcount=2, is_ref=0),string '咖啡色的羊驼' (length=18)

看到了吧,refcount+1了。

3.第三步:引用赋值

$name = "咖啡色的羊驼";
$temp_name = &$name;
xdebug_debug_zval('name');

会得到:

name:(refcount=2, is_ref=1),string '咖啡色的羊驼' (length=18)

是的引用赋值会导致zval通过is_ref来标记是否存在引用的情况。

4.第四步:数组型的变量

$name = ['a'=>'咖啡色', 'b'=>'的羊驼'];
xdebug_debug_zval('name');

会得到:

name:
(refcount=1, is_ref=0),
array (size=2)
  'a' => (refcount=1, is_ref=0),string '咖啡色' (length=9)
  'b' => (refcount=1, is_ref=0),string '的羊驼' (length=9)

还挺好理解的,对于数组来看是一个整体,对于内部kv来看又是分别独立的整体,各自都维护着一套zval的refount和is_ref。

5.第五步:销毁变量

$name = "咖啡色的羊驼";
$temp_name = $name;
xdebug_debug_zval('name');
unset($temp_name);
xdebug_debug_zval('name');

会得到:

name:(refcount=2, is_ref=0),string '咖啡色的羊驼' (length=18)
name:(refcount=1, is_ref=0),string '咖啡色的羊驼' (length=18)

refcount计数减1,说明unset并非一定会释放内存,当有两个变量指向的时候,并非会释放变量占用的内存,只是refcount减1.

更多关于引用计数的请参考: http://www.laruence.com/2008/09/19/520.html

php的内存管理机制

知道了zval是怎么一回事,接下来看看如何通过php直观看到内存管理的机制是怎么样的。

外在的内存变化

先来一段代码:

//获取内存方法,加上true返回实际内存,不加则返回表现内存
var_dump(memory_get_usage());
$name = "咖啡色的羊驼";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());

会得到:

int 1593248
int 1593384
int 1593248

大致过程:定义变量->内存增加->清除变量->内存恢复

潜在的内存变化

当执行:

$name = "咖啡色的羊驼";

时候,内存的分配做了两件事情:

为变量名分配内存,存入符号表
为变量值分配内存
再来看代码:

var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
    $$a = "hello";    
}
var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
    unset($$a);    
}
var_dump(memory_get_usage());

会得到:

int 1596864
int 1612080
int 1597680

怎么和之前看的不一样?内存没有全部回收回来。

对于php的核心结构Hashtable来说,由于未知性,定义的时候不可能一次性分配足够多的内存块。所以初始化的时候只会分配一小块,等不够的时候在进行扩容,而Hashtable只扩容不减少,所以就出现了上述的情况:当存入100个变量的时候,符号表不够用了就进行一次扩容,当unset的时候只释放了”为变量值分配内存”,而“为变量名分配内存”是在符号表的,符号表并没有缩小,所以没收回来的内存是被符号表占去了。

潜在的内存申请与释放设计

php和c语言一样,也是需要进行申请内存的,只不过这些操作作者都封装到底层了,php使用者无感知而已。

首先我们要打破一个思维: PHP不像C语言那样, 只有你显示的调用内存分配相关API才会有内存的分配。也就是说, 在PHP中, 有很多我们看不到的内存分配过程。

比如对于:

$a = "laruence";

隐式的内存分配点就有:

  • 为变量名分配内存, 存入符号表
  • 为变量值分配内存

所以, 不能只看表象.

别怀疑,PHP的unset确实会释放内存(当然, 还要结合引用和计数), 但这个释放不是C编程意义上的释放, 不是交回给OS,对于PHP来说, 它自身提供了一套和C语言对内存分配相似的内存管理API:

emalloc(size_t size);
efree(void *ptr);
ecalloc(size_t nmemb, size_t size);
erealloc(void *ptr, size_t size);
estrdup(const char *s);
estrndup(const char *s, unsigned int length);

这些API和C的API意义对应, 在PHP内部都是通过这些API来管理内存的。

当我们调用emalloc申请内存的时候,PHP并不是简单的向OS要内存, 而是会像OS要一个大块的内存, 然后把其中的一块分配给申请者,这样当再有逻辑来申请内存的时候, 就不再需要向OS申请内存了, 避免了频繁的系统调用。

比如如下的例子:

var_dump(memory_get_usage(TRUE)); //注意获取的是real_size
$a = "laruence";
var_dump(memory_get_usage(TRUE));
unset($a);
var_dump(memory_get_usage(TRUE));

输出:

int(262144)
int(262144)
int(262144)

也就是我们在定义变量$a的时候, PHP并没有向系统申请新内存.

同样的, 在我们调用efree释放内存的时候, PHP也不会把内存还给OS, 而会把这块内存, 归入自己维护的空闲内存列表. 而对于小块内存来说, 更可能的是, 把它放到内存缓存列表中去
(后记, 某些版本的PHP, 比如我验证过的PHP5.2.4, 5.2.6, 5.2.8, 在调用get_memory_usage()的时候, 不会减去内存缓存列表中的可用内存块大小, 导致看起来, unset以后内存不变).

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究竟要对付什么样的垃圾呢,下面我们将生产一个这样的垃圾。

PHP5.3 之前的内存泄漏的垃圾回收
产生内存泄漏主要真凶:环形引用。 现在来造一个环形引用的场景:

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

得到:

a:
(refcount=2, is_ref=1),
array (size=2)
  0 => (refcount=1, is_ref=0),string 'one' (length=3)
  1 => (refcount=2, is_ref=1),
        &array<

这样 a 数 组 就 有 了 两 个 元 素 , 一 个 索 引 为 0 , 值 为 o n e 字 符 串 , 另 一 个 索 引 为 1 , 为 a数组就有了两个元素,一个索引为0,值为one字符串,另一个索引为1,为 a0one1a自身的引用。

此时删掉$a:

$a = ['one'];
$a[] = &$a;
unset($a);

PHP 5.3之后的垃圾内存回收

PHP5.3 的垃圾回收算法仍然以引用计数为基础,但是不再是使用简单计数作为回收准则,而是使用了一种同步回收算法,这个算法由IBM的工程师在论文Concurrent Cycle Collection in Reference Counted Systems中提出。

这个算法比较复杂,在这里,只能大体描述一下此算法的基本思想:

首先 PHP 会分配一个固定大小的“根缓冲区”,这个缓冲区用于存放固定数量的 zval(默认是10,000),如果需要修改则需要修改源代码 Zend/zend_gc.c 中的常量 GC_ROOT_BUFFER_MAX_ENTRIES 然后重新编译。

这个根缓冲区中存放的是“可能根(possible roots)”,就是可能发生内存泄露的 zval。当根缓冲区满了的时候(或者调用 gc_collect_cycle() 函数时),PHP 就会执行垃圾回收。

可能根我个人理解就是循环引用的数组和对象,我觉得判决一个 zval 是不是可能根也是这个算法的关键,但是没有找到相应的资料。

回收算法步骤如下:

步骤 A 把所有可能根(possible roots 都是 zval 变量容器),放在根缓冲区(root buffer)中(称为疑似垃圾),并确保每个可能的垃圾根(possible garbage root)在缓冲区中只出现一次。只有在根缓冲区满了的时候,才对缓冲区内部所有不同的变量容器执行垃圾回收操作;

步骤 B 被称为模拟删除,对每个根缓冲区中的根 zval 按照深度优先遍历算法遍历所有能遍历到的 zval,并将对应的 refcount 减 1,同时为了避免对同一 zval 多次减 1(因为可能不同的根能遍历到同一个 zval),每次对某个 zval 减 1 后就对其标记为“已减”。需要强调的是,这个步骤中,起初节点 zval 本身不做减 1 操作,但是如果节点 zval 中包含的符号表中有节点又指向了初始的 zval(环形引用),那么这个时候需要对节点 zval 进行减 1 操作;

步骤 C 被称为模拟恢复,基本就是步骤 B 的逆运算,但恢复是有条件的。再次对每个缓冲区中的 zval 做深度优先遍历,如果某个 zval 的 refcount 不为 0,则对其加 1,否则保持其为 0。同样每个变量只能恢复一次;

步骤 D 清空根缓冲区中的所有根(注意是把所有 zval 从缓冲区中清除而不是销毁它们),然后销毁所有 refcount 为 0 的 zval,并收回其内存,是真实删除的过程。

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

m = n 代表什么? 代表 arefcount 都来自数组 a 自身包含的 zval 元素,说明 a 之外没有任何变量指向它,说明 aunset 掉了,用户代码空间中无法再访问到 a 所对应的 zval,也就是代表 a 是泄漏的内存,因此 GC 应该回收 a 所对应的 zval

举例如下:

$a = ['one']; --- zval_a(将$a对应的zval,命名为zval_a)
$a[] = &$a; --- step1
unset($a);  --- step2

为进行unset之前(step1),进行算法计算,对这个数组中的所有元素(索引0和索引1)的zvalrefcount进行减1操作,由于索引1对应的就是zval_a,所以这个时候zval_arefcount应该变成了1,这样说明zval_a不是一个垃圾不进行回收。

当执行unset的时候(step2),进行算法计算,由于环形引用,上文得出会有垃圾的结构体,zval_arefcount是1(zval_a中的索引1指向zval_a),用算法对数组中的所有元素(索引0和索引1)的zvalrefcount进行减1操作,这样zval_arefcount就会变成0,于是就认为zval_a是一个需要回收的垃圾。

算法总的套路:对于一个包含环形引用的数组,对数组中包含的每个元素的zval进行减1操作,之后如果发现数组自身的zvalrefcount变成了0,那么可以判断这个数组是一个垃圾。

简言之,PHP5.3 的垃圾回收算法有以下几点特性:

并不是每次 refcount 减少时都进入回收周期,只有根缓冲区满额后在开始垃圾回收;
解决了循环引用导致的内存泄露问题;
整体上可以总将内存泄露保持在一个阈值以下(与缓冲区的大小有关)。

PHP5.3之前和之后垃圾回收算法的性能比较

内存占用空间
分别在 PHP5.2 和 PH5.3环境下执行下面的脚本,并记录内存占用情况(其中排除了脚本启动时 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";
    }
}

这是个经典的内存泄露例子,创建一个对象,这个对象中的一个属性被设置为对象本身。在下一个循环(iteration)中,当脚本中的变量被重新赋值时,就会发生内存泄漏。

比较结果如下:

从这个图表中,可以看出 PHP5.3 的最大内存占用大概是 9Mb,而 PHP5.2 的内存占用一直增加。在 5.3 中,每当循环 10,000 次后(共产生 10,000 个可能根),根缓冲区满了,就会执行垃圾回收机制,并且释放那些关联的可能根的内存。所以 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";

分别在打开/关闭垃圾回收机制(通过配置 zend.enable_gc实现)的情况下运行脚本,并记录时间。

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

第一个命令持续执行时间大概为 10.7 秒,而第二个命令耗费 11.4 秒。时间上增加了 7%。然而,内存的占用峰值降低了 98%,从 931Mb 降到了 10Mb。

这个测试并不能代表真实应用程序的情况,但是它的确显示了新的垃圾回收机制在内存占用方面的好处。而且在执行中出现更多的循环引用变量时,内存节省会更多,但时间增加的百分比都是 7% 左右。

PHP垃圾回收的相关配置
可以通过修改配置文件 php.ini 中的 zend.enable_gc 来打开或关闭 PHP 的垃圾回收机制,也可以通过调用 gc_enable()gc_disable() 打开或关闭 PHP 的垃圾回收机制。

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

参考:
一看就懂系列之 由浅入深聊一聊php的垃圾回收机制:
PHP的垃圾回收机制
深入理解PHP内存管理之谁动了我的内存
深入理解PHP原理之变量分离/引用(Variables Separation)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值