前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >PHP垃圾回收机制

PHP垃圾回收机制

作者头像
Mandy的名字被占用了
发布2019-09-05 17:29:05
1.1K0
发布2019-09-05 17:29:05
举报

部分文章内容来源 https://www.iminho.me/wiki/blog-18.html

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

何为垃圾回收

在程序开发中,变量是我们经常用到的。那变量的定义是什么呢?变量是在内存中划分一片空间存储内容。既然存储在内存中,当变量多了,内存使用就会越来越多,我们肯定得去释放一些内存,不然会当内存使用量达到一定程序时,导致我们的计算机无法进行其他的工作。这时候就需要对我们计算机中没用的变量给清除掉(也即是是所谓的垃圾内存,我们平常用到的一些软件管家清楚内存,也是这样的原理),释放无效的内存。所做的这个操作就可以称之为垃圾回收。

容器

容器就是上面我们提到的存储变量的一个容器(姑且这么叫吧,更容易理解)。这里面我们需要关注四个元素,如下:

代码语言:javascript
复制
1.
refcount: 用以表示指向这个zval变量容器的变量(也称符号即symbol)个数。
所有的符号存在一个符号表中,其中每个符号都有作用域(scope),
那些主脚本(比如:通过浏览器请求的的脚本)和每个函数或者方法也都有作用域。
2.
is_ref:是否属于引用集合,通过这个字节,可以将普通变量和引用变量区分开,
例如我们在PHP使用到了&引用,就会影响到该值,下面的示例有讲.
3.4
即是变量的类型和变量的值

PHP的引用计数

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

PHP5 中定义如下:

代码语言:javascript
复制
struct _zval_struct {
        /* Variable information */
        zvalue_value value;             /* value */
        zend_uint refcount;
        zend_uchar type;        /* active type */
        zend_uchar is_ref;
};

而到了PHP7中定义如下:

代码语言:javascript
复制
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变量如下:

代码语言:javascript
复制
$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.第一步:查看内部结构

代码语言:javascript
复制
$name = "咖啡色的羊驼";
 xdebug_debug_zval('name');

会得到:

代码语言:javascript
复制
name:(refcount=1, is_ref=0),string '咖啡色的羊驼' (length=18)

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

代码语言:javascript
复制
$name = "咖啡色的羊驼";
$temp_name = $name;
xdebug_debug_zval('name');

会得到:

代码语言:javascript
复制
name:(refcount=2, is_ref=0),string '咖啡色的羊驼' (length=18)

看到了吧,refcount+1了。

3.第三步:引用赋值

代码语言:javascript
复制
$name = "咖啡色的羊驼";
$temp_name = &$name;
xdebug_debug_zval('name');

会得到:

代码语言:javascript
复制
name:(refcount=2, is_ref=1),string '咖啡色的羊驼' (length=18)

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

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

代码语言:javascript
复制
$name = ['a'=>'咖啡色', 'b'=>'的羊驼'];
    xdebug_debug_zval('name');

会得到:

代码语言:javascript
复制
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.第五步:销毁变量

代码语言:javascript
复制
$name = "咖啡色的羊驼";
$temp_name = $name;
xdebug_debug_zval('name');
unset($temp_name);
xdebug_debug_zval('name');

会得到:

代码语言:javascript
复制
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直观看到内存管理的机制是怎么样的。

外在的内存变化

先来一段代码:

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

会得到:

代码语言:javascript
复制
int 1593248
int 1593384
int 1593248

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

潜在的内存变化

当执行:

代码语言:javascript
复制
$name = "咖啡色的羊驼";

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

  1. 为变量名分配内存,存入符号表
  2. 为变量值分配内存

再来看代码:

代码语言:javascript
复制
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());

会得到:

代码语言:javascript
复制
int 1596864
int 1612080
int 1597680

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

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

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

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

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

比如对于:

代码语言:javascript
复制
$a = "laruence";

隐式的内存分配点就有:

  1. 为变量名分配内存, 存入符号表
  2. 为变量值分配内存

所以, 不能只看表象.

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

代码语言:javascript
复制
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申请内存了, 避免了频繁的系统调用。

比如如下的例子:

代码语言:javascript
复制
var_dump(memory_get_usage(TRUE)); //注意获取的是real_size
$a = "laruence";
var_dump(memory_get_usage(TRUE));
unset($a);
var_dump(memory_get_usage(TRUE));

输出:

代码语言:javascript
复制
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是一个垃圾,因为 $tmpunset之后, refcount减少1变成了0(这里假设没有别的变量和 $tmp指向相同的zval),这个时候GC会直接将 $tmp对应的 zval的内存空间释放, $tmp和其对应的 zval就根本不存在了。此时的 $tmp也不是新的GC所要对付的那种“垃圾”。那么新的GC究竟要对付什么样的垃圾呢,下面我们将生产一个这样的垃圾。

PHP5.3 之前的内存泄漏的垃圾回收

产生内存泄漏主要真凶:环形引用。现在来造一个环形引用的场景:

代码语言:javascript
复制
$a = ['one'];
$a[] = &$a;
xdebug_debug_zval('a');

得到:

代码语言:javascript
复制
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,值为one字符串,另一个索引为1,为$a自身的引用。

此时删掉$a:

代码语言:javascript
复制
$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 等于 m,a 中有 n 个元素又指向 a,如果 m==n,那么判断 m-n=0,那么 a 就是垃圾,如果 m>n,那么算法的结果 m-n>0,所以 a 就不是垃圾了。

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

举例如下:

代码语言:javascript
复制
$a = ['one']; --- zval_a(将$a对应的zval,命名为zval_a)
$a[] = &$a; --- step1
unset($a);  --- step2

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

当执行unset的时候(step2),进行算法计算,由于环形引用,上文得出会有垃圾的结构体,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,那么可以判断这个数组是一个垃圾。

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

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

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

内存占用空间

分别在 PHP5.2 和 PH5.3环境下执行下面的脚本,并记录内存占用情况(其中排除了脚本启动时 PHP 本身占用的基本内存):

代码语言:javascript
复制
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 的内存占用图是锯齿型的。

执行时间

为了检验执行时间,稍微修改上面的脚本,循环更多次并且删除了内存占用的计算,脚本代码如下:

代码语言:javascript
复制
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 实现)的情况下运行脚本,并记录时间。

代码语言:javascript
复制
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() 函数强制执行内存回收。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-09-04,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 卡二条的技术圈 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 何为垃圾回收
  • 容器
  • PHP的引用计数
    • 1.第一步:查看内部结构
      • 2.第二步:增加一个计数
        • 3.第三步:引用赋值
          • 4.第四步:数组型的变量
            • 5.第五步:销毁变量
            • php的内存管理机制
              • 外在的内存变化
                • 潜在的内存变化
                • 潜在的内存申请与释放设计
                • php中垃圾是如何定义的?
                  • PHP5.3 之前的内存泄漏的垃圾回收
                    • PHP 5.3之后的垃圾内存回收
                    • PHP5.3之前和之后垃圾回收算法的性能比较
                      • 内存占用空间
                        • 执行时间
                        • PHP垃圾回收的相关配置
                        相关产品与服务
                        容器服务
                        腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档