之所以做这个翻译,是因为官网的中文文档和英文原始文档有些不一致,另外,把英文翻译过来看,好像可以有更好地理解。
若发现错误,请随时指出。
若有建议,请随时提出。
感谢支持!
引用计数器的基本概念
PHP 变量是存放于一个叫做 "zval" 的容器中。zval 容器包含了变量的类型和值以及附加的两位 (bit) 信息。第一个叫做 "is_ref" 是个 bool 值,表示变量是否为 "reference set" 的一部分。通过这个位信息,PHP 引擎即可区分该变量是普通变量还是引用。由于 PHP 允许用户 (user-land) 通过 & 操作符来创建引用,zval 容器也包含了一个内部的引用计数机制用来优化内存使用。第二个位信息叫做 "refcount",它包含指向这个 zval 容器的变量名称 (也叫符号 (symbols) ) 个数。所有的符号都存放在符号表 (symbol table) 中,其中,每个符号都有自己的作用域 (scope)。对于主脚本 (例: 被浏览器请求的脚本) 和每个函数或方法也都有作用域。
当一个变量被赋常量值时,就会生成一个 zval 变量容器,像这样:
$a = "new string";
这个例子中,一个新的符号名 a 在当前作用域被创建了,也有一个新的变量容器被创建了,其类型是 string,值是 "new string"。"is_ref" 位默认是 FALSE,因为还没有引用被用户创建。"refcount" 为 1 因为只有一个符号在使用这个变量容器。注意,如果 "refcount" 为 1,"is_ref" 只会为 FALSE。如果你安装了 Xdebug,亦可以调用 xdebug_debug_zval() 方法来展示信息。
$a = "new string";
xdebug_debug_zval('a');
上面的例子将输出:
a: (refcount=1, is_ref=0)='new string'
将这个变量赋值给另一个变量会增加 "refcount"。
$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );
上面的例子将输出:
a: (refcount=2, is_ref=0)='new string'
现在 "refcount" 是 2,因为同一个变量容器被关联 (linked) 到了 a 和 b。PHP 足够聪明,在非必要时不会去拷贝实际的变量容器。容器变量在 "refcount" 减至 0 时被销毁。当有关联的变量容器的符号离开了作用域 (如: 函数结束时) 或者被取消赋值 (如: 被 unset() 时) 时,"refcount" 会减 1。下面的例子就能说明:
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
$b = 42;
xdebug_debug_zval( 'a' );
unset( $c );
xdebug_debug_zval( 'a' );
上面的例子将输出:
a: (refcount=3, is_ref=0)='new string'
a: (refcount=2, is_ref=0)='new string' // when $b assigned by a new value
a: (refcount=1, is_ref=0)='new string' // when $c is unset()
如果我们现在调用 unset($a);,容器变量(包括类型和值)将从内存中被移除。
复合类型
对于复合类型而言,比如数组和对象,事情会变得稍微复杂些。和标量 (scalar) 值相反,数组和对象将自己的成员存放在自己的符号表中。这意味着下面的示例创建了 3 个 zval 容器:
$a = ['meaning' => 'life','number' => 42];
xdebug_debug_zval( 'a' );
上面的例子将输出类似这样的东西:
a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=1, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42
)
或者是这样的图示:
3 个 zval 容器分别是: a,meaning 和 number。相似的规则同样可用来减少 "recounts"。下面,我们添加另一个元素到数组中,并将值设置为一个已存在元素的内容:
$a = ['meaning' => 'life','number' => 42];
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );
上面的例子将输出类似这样的东西:
a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=2, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42,
'life' => (refcount=2, is_ref=0)='life'
)
或者是这样的图示:
从上述 Xdebug 输出中,我们可以看出新老数组元素现在指向了一个 "refcount" 为 2 的zval 容器。虽然 Xdebug 的输出中有两个值为 "life" 的 zval 容器,但是他们其实同一个。虽然 xdebug_debug_zval() 函数不会说明这个,但你可以通过查看内存指针来分辨。
从数组中移除元素就像是将符号从作用域中移除一样。移除后,数组元素所指向的容器的 "refcount" 会被减少。同样,当 "refcount" 减至 0 时,变量容器将会从内存中移除。下面的例子可以说明:
$a = ['meaning' => 'life', 'number' => 42];
$a['life'] = $a['meaning'];
unset( $a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );
上面的例子将输出类似这样的东西:
a: (refcount=1, is_ref=0)=array (
'life' => (refcount=1, is_ref=0)='life'
)
现在,如果我们将数组本身作为一个元素添加到数组中,事情就变得有趣了,我们将在下面的例子中这么做,而且会悄悄地添加一个引用操作符,不然 PHP 会创建一个拷贝:
$a = ['one'];
$a[] = &$a;
xdebug_debug_zval( 'a' );
上面的例子将输出类似这样的东西:
a: (refcount=2, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=2, is_ref=1)=...
)
或者是这样的图示:
可以看出数组变量 (a) 和 第二个元素 (1) 现在都指向了一个 "refcount" 为 2 的变量容器。上面的 "..." 表示发送了递归,当然,在这里意味着指回了起源数组。
和之前一样,对一个变量进行 unset() 会移除其符号,其指向的变量容器的引用数 (reference count) 将被减 1。所以如果我们在上述代码后面 unset 变量 $a,那么 $a 和 元素 (1) 所指向的变量容器的引用数就会减 1,由 "2" 变为 "1"。可以这样呈现:
(refcount=1, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=1, is_ref=1)=...
)
或者是这样的图示:
清理问题
虽然在任何作用域中都没有符号指向这个结构了,但是它无法被清理掉,因为数组元素 "1" 仍然指向自己本身。由于没有额外的符号指向它,所以对于用户来讲,是无法清理掉这个结构的,于是你就遇上了内存泄露。幸运的是,PHP 会在请求结束时清理掉这个数据结构,但在这之前,将会占去宝贵的内存空间。如果你在实现解析算法或者其他东西时将子元素指回了父元素,这种情况就会经常发生。当然,同样的情况也会发生在对象身上,而且可能性更高,因为对象总是隐式地被引用。
这样的情况发生一两次倒也不是问题,但如果发生上千次或者几十万次的内存流失,这明显就成问题了。这样的问题往往发生在长时间运行的脚本中,比如守护进程 (请求基本上永远不会结束) 或者大量的单元测试。后者,在对 eZ Components 库的 Template 组建做单元测试时,有时会需要使用超过 2GB 的内存,而测试服务也许无法满足,这便是问题。
回收周期
虽然对算法的完全说明有点超出这部分内容的范围,但基本的解释是有的。首先我们要建立一些基本原则。如果 "refcount" 增加了,zval 容器仍在被使用,所以这不是垃圾。如果 "refcount" 被减少了,并且被减至 0,则 zval 可以被释放。这意味着,只有当 "refcount" 被减少至非零时,垃圾周期 (garbage cycles) 才可以被产生。其次,在一次垃圾周期中,是有可能通过判断 "refcount" 是否可以被减 1,以及哪些 zval 的 "refcount" 是 0 的方式,来发现垃圾的。
为避免发生检查所有 refcount 可能减少的垃圾周期,该算法把所有可能的root (possible roots),即 zval 放进 "root buffer" (以紫色示意) 中。同时也确保每个可能是垃圾的 root 在 root buffer 中只出现一次。只有当 root buffer 达到饱和,回收机制才会对里面所有不同的 zval 启动。详见上图步骤 A。
在步骤 B 中,算法针对所有可能的 root 执行一次深度优先搜索,找到 zval 后对其 refcount 减 1,并确保不会在同一个 zval 上重复执行 (以灰色示意)。在步骤 C 中,算法再次对每个 root 节点进行深度优先搜索,再次检索每个 zval 的 refcount 值。如果发现 refcount 为 0,zval 则被标记成 "白色" (蓝色部分)。如果 refcount 大于 0,则算法将从此处执行深度优先搜索并回滚 refcount 减 1 操作,并将这些 zval 重新标记为 "黑色"。在最后的步骤 D 中,算法遍历整个root buffer,从中移除 zval root,同时检索出之前步骤中被标记为 "白色" 的 zval。每个被标记为 "白色" 的 zval 都将被释放。
现在你对算法是如何工作已经有了基本的认识,我们回过头来看它是如何与PHP集成的。默认情况下,PHP 的垃圾回收机制 (garbage collector) 是开启的。然而 php.ini 配置文件允许你做出修改: zend.enable_gc。
当 GC 开启时,一旦 root buffer 达到饱和,上述的循环查找算法就会被执行。root buffer 固定可存放 10,000 个 root (虽然你可以通过修改位于 PHP 源码 Zend/zend_gc.c 中的 GC_ROOT_BUFFER_MAX_ENTRIES 常量,然后重编译 PHP 来改变这个数值)。当 GC 关闭时,循环查找算法将不会启动。然而,可能的 root 将永远记录在 root buffer 里,不管是否在配置中开启了 GC。
如果在 GC 关闭的情况下, root buffer 达到饱和,后续的可能的 root 就不会被记录下来。那些无法被记录的可能的 root 将永远无法被算法分析。如果它们存在循环引用,他们讲永远无法被清理掉,并造成内存泄露。
为什么在GC关闭的情况下,还是会有 root 被记录呢?是因为记录这些 root 要比在每次找到root时判断GC是否开启更快。然而,垃圾回收与分析机制本身可能会消耗相当长的时间。
除了修改 zend.enable_gc 配置,同样也可以通过调用 gc_enable() 或 gc_disable() 来控制垃圾回收机制的开与关。调用这些函数和修改配置是等效的。这同样也可以用来强制控制垃圾回收即便 root buffer 尚未饱和。你可以使用 gc_collect_cycles() 函数实现。该函数将返回被算法收集的周期数量。
允许打开和关闭垃圾回收机制并且允许自主初始化的原因,是由于你的应用程序的某部分可能是高时效性的。在这种情况下,你可能不想使用垃圾回收机制。当然,对你的应用程序的某部分关闭垃圾回收机制,是在冒着可能发生内存泄漏的风险,因为一些可能 root 也许存不进有限的 root buffer。因此,就在你调用 gc_disable() 函数释放内存之前,先调用 gc_collect_cycles() 函数可能比较明智。因为这将清除已存放在 root buffer 中的所有可能 root,然后在垃圾回收机制被关闭时,可留下空 buffer 以有更多空间存储可能 root。
本作品采用《CC 协议》,转载必须注明作者和本文链接