PHP垃圾回收机制

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

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

何为垃圾回收

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

容器

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

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

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 = "咖啡色的羊驼";

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

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

再来看代码:

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

隐式的内存分配点就有:

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

所以, 不能只看表象.

别怀疑,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是一个垃圾,因为 $tmpunset之后, 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,值为one字符串,另一个索引为1,为$a自身的引用。

此时删掉$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 等于 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。

举例如下:

$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 本身占用的基本内存):

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() 函数强制执行内存回收。

本文分享自微信公众号 - 浪子编程走四方(qq1005349393)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-09-04

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏fanzhh的技术笔记

javascript的“Uncaught SyntaxError: Unexpected token <”问题

使用Django Rest Framework + React 写一个应用,中间需要使用 jquery 读取api服务的json数据,反复出现Uncaught ...

82730
来自专栏大史住在大前端

Vue-Router中History模式

history模式是指使用HTML5的historyAPI实现客户端路由的模式,它的典型表现就是去除了hash模式中url路径中的#。对于前端路由基本原理还不了...

11640
来自专栏学院君的专栏

Go 语言错误及异常处理篇(三):panic 和 recover

前面学院君介绍了 Go 语言通过 error 接口统一进行错误处理,但这些错误都是我们在编写代码时就已经预见并返回的,对于某些运行时错误,比如数组越界、除数为0...

9120
来自专栏学院君的专栏

Go 语言并发编程系列(一)—— 多进程、多线程与协程的引入

在原生 PHP 中并没有并发的概念,所有的操作都是串行执行的、同步阻塞的,这也是很多人诟病 PHP 性能的原因,但是不支持并发编程的好处也是显而易见的:保证了 ...

23120
来自专栏FreeBuf

CORS-Vulnerable-Lab:与COSR配置错误相关的漏洞代码靶场

此存储库包含与CORS配置错误相关的易受攻击代码。你可以在本地机器上配置易受攻击的代码,以实际利用与CORS相关的错误配置问题。

10820
来自专栏landv

[php][thinkphp] 记一次Composer Linux版安装以及用它进行thinkphp项目初始化

php中开启exec,system等函数调用系统命令 修改php.ini文件 关掉安全模式 safe_mode = off 然后在看看 禁用函数列表 disab...

11220
来自专栏不为人知的前端技巧

如何把css'content的操作跟价值发挥到最大💢

content属性需要与before及after伪元素配合使用,作用是可以定义伪元素所显示的内容,本文主要列举content的可选值及实用的案例与技巧?

9730
来自专栏跨平台全栈俱乐部

React V16.9来了 无痛感升级 加入性能检测 【译-真香】

React 16.9不包含重大更改,旧版本名称在此版本中继续有效。但是,当您使用任何旧名称时,您将看到警告:

32830
来自专栏算法之名

配合OAuth2进行单设备登录拦截 顶

原理就在于要在登录时在redis中存储Session,进行操作时要进行Session的比对。

19630
来自专栏Web技术研发

PHP系列 | 依赖注入容器和服务定位器

依赖注入(Dependency Injection,DI)容器就是一个对象,它知道怎样初始化并配置对象及其依赖的所有对象。注册会用到一个依赖关系名称和一个依赖关...

13040

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励