原文的文章链接:
https://www.iminho.me/wiki/blog-18.html
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 = “咖啡色的羊驼”;
KaTeX parse error: Expected 'EOF', got '&' at position 13: 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 = n a m e ; x d e b u g d e b u g z v a l ( ′ n a m e ′ ) ; u n s e t ( name; xdebug_debug_zval('name'); unset( name;xdebugdebugzval(′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());
n a m e = " 咖 啡 色 的 羊 驼 " ; v a r d u m p ( m e m o r y g e t u s a g e ( ) ) ; u n s e t ( name = "咖啡色的羊驼"; var_dump(memory_get_usage()); unset( name="咖啡色的羊驼";vardump(memorygetusage());unset(name);
var_dump(memory_get_usage());
会得到:
int 1593248
int 1593384
int 1593248
大致过程:定义变量->内存增加->清除变量->内存恢复
潜在的内存变化
当执行:
$name = “咖啡色的羊驼”;
时候,内存的分配做了两件事情:
为变量名分配内存,存入符号表
为变量值分配内存
再来看代码:
var_dump(memory_get_usage());
for( i = 0 ; i=0; i=0;i<100;$i++)
{
a = " t e s t " . a = "test". a="test".i;
KaTeX parse error: Expected 'EOF', got '}' at position 18: …= "hello"; }̲ var_dump(memor…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 = " l a r u e n c e " ; v a r d u m p ( m e m o r y g e t u s a g e ( T R U E ) ) ; u n s e t ( a = "laruence"; var_dump(memory_get_usage(TRUE)); unset( a="laruence";vardump(memorygetusage(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代码,使用了一个临时变量 t m p 存 储 了 一 个 字 符 串 , 在 处 理 完 字 符 串 之 后 , 就 不 需 要 这 个 tmp存储了一个字符串,在处理完字符串之后,就不需要这个 tmp存储了一个字符串,在处理完字符串之后,就不需要这个tmp变量了, t m p 变 量 对 于 我 们 来 说 可 以 算 是 一 个 “ 垃 圾 ” 了 , 但 是 对 于 G C 来 说 , tmp变量对于我们来说可以算是一个“垃圾”了,但是对于GC来说, tmp变量对于我们来说可以算是一个“垃圾”了,但是对于GC来说,tmp其实并不是一个垃圾, t m p 变 量 对 我 们 没 有 意 义 , 但 是 这 个 变 量 实 际 还 存 在 , tmp变量对我们没有意义,但是这个变量实际还存在, tmp变量对我们没有意义,但是这个变量实际还存在,tmp符号依然指向它所对应的zval,GC会认为PHP代码中可能还会使用到此变量,所以不会将其定义为垃圾。
那么如果我们在PHP代码中使用完 t m p 后 , 调 用 u n s e t 删 除 这 个 变 量 , 那 么 tmp后,调用unset删除这个变量,那么 tmp后,调用unset删除这个变量,那么tmp是不是就成为一个垃圾了呢。很可惜,GC仍然不认为 t m p 是 一 个 垃 圾 , 因 为 tmp是一个垃圾,因为 tmp是一个垃圾,因为tmp在unset之后,refcount减少1变成了0(这里假设没有别的变量和 t m p 指 向 相 同 的 z v a l ) , 这 个 时 候 G C 会 直 接 将 tmp指向相同的zval),这个时候GC会直接将 tmp指向相同的zval),这个时候GC会直接将tmp对应的zval的内存空间释放, t m p 和 其 对 应 的 z v a l 就 根 本 不 存 在 了 。 此 时 的 tmp和其对应的zval就根本不存在了。此时的 tmp和其对应的zval就根本不存在了。此时的tmp也不是新的GC所要对付的那种“垃圾”。那么新的GC究竟要对付什么样的垃圾呢,下面我们将生产一个这样的垃圾。
PHP5.3 之前的内存泄漏的垃圾回收
产生内存泄漏主要真凶:环形引用。 现在来造一个环形引用的场景:
$a = [‘one’];
KaTeX parse error: Expected 'EOF', got '&' at position 7: 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,为 a数组就有了两个元素,一个索引为0,值为one字符串,另一个索引为1,为a自身的引用。
此时删掉$a:
$a = [‘one’];
KaTeX parse error: Expected 'EOF', got '&' at position 7: 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,并收回其内存,是真实删除的过程。
这个道理其实很简单,假设数组 a 的 refcount 等于 m,a 中有 n 个元素又指向 a,如果 m == n,那么判断 m - n = 0,那么 a 就是垃圾,如果 m > n,那么算法的结果 m - n > 0,所以 a 就不是垃圾了。
m = n 代表什么? 代表 a 的 refcount 都来自数组 a 自身包含的 zval 元素,说明 a 之外没有任何变量指向它,说明 a 被 unset 掉了,用户代码空间中无法再访问到 a 所对应的 zval,也就是代表 a 是泄漏的内存,因此 GC 应该回收 a 所对应的 zval。
举例如下:
a = [ ′ o n e ′ ] ; − − − z v a l a ( 将 a = ['one']; --- zval_a(将 a=[′one′];−−−zvala(将a对应的zval,命名为zval_a)
KaTeX parse error: Expected 'EOF', got '&' at position 7: 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 的垃圾回收算法有以下几点特性:
并不是每次 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() 函数强制执行内存回收。