PHP新的垃圾回收机制

介绍

在阅读本篇博文之前,建议先了解一下: PHP内核之ZVAL
PHP是一门托管型语言,所以在写PHP代码时无需手动处理内存的分配和释放,也就是说PHP自身实现了垃圾自动回收(GC).
在PHP官方你可以看到关于GC的介绍: 传送

PHP5.2中的垃圾回收算法

在php5.3之前,PHP使用的内存回收算法是大名鼎鼎的Reference counting(引用计数)

Reference counting 的实现原理

为每个内存对象分配一个计数器,当一个内存对象建立时计数器初始化为 1(因为此时一定有一个变量引用了该对象)。以后每有一个新变量引用此内存对象(如赋值操作 $a = $b),计数器加 1,而每当减少一个引用此内存对象的变量(如 unset($a)),计数器减 1。当垃圾回收机制运作的时候,将所有计数器为 0 的内存对象销毁并回收其占用的内存。

在PHP5.2中内存对象就是zval, 计数器就是 zval 的 refcount (PHP5.3之后改成 refcount__gc), 使用 xdebug 拓展的xdebug_debug_zval() 方法可以看到 refcount 值。

1
2
3
4
5
6
$a = 'hello world';
xdebug_debug_zval('a');
$b = $a;
xdebug_debug_zval('a');
unset($b);
xdebug_debug_zval('a');

输出结果

1
2
3
a: (refcount=1, is_ref=0)='hello world'
a: (refcount=2, is_ref=0)='hello world'
b: (refcount=1, is_ref=0)='hello world'

由上而的输出结果到知,当把$a赋值给$b时,refcount 会加 1, 因为两个变量都指向同一个zval, 而不是新增一个zval, 也就是cow(写时复制)。

当对$b使用unset()时,refcount 会减 1, 因为现在只有$a指向这个zval。

Reference counting (引用计数) 非常的简单,但也有一个致命问题,那就是环状引用时会造成内存泄露。

如下是一个典型例子:

1
2
3
$a = array( 'one' );
$a[] = &$a;
xdebug_debug_zval( 'a' );

输出结果:

1
2
3
4
a: (refcount=2, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=2, is_ref=1)=...
)

图示:

上面的 … 是发生了递归调用, a 的变量容器中 refcount 的值由于有两次引用的存在所以为 2。

此时对 $a 调用 unset(),将从符号表中删除这个符号,同时 refcount 值也减 1。

1
2
3
4
(refcount=1, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=1, is_ref=1)=...
)

图示:


因为 refcount = 1 不等于 0,所以它不会被当作垃圾回收。但同时在符号表中找不到哪个符号指向这个 zval,导致也没有办法手动清除。最终的导致在这个脚本结束之前,这个zval结构体会一直占用着内存,导致内存泄漏。

由于 Reference Counting 的这个缺陷,PHP5.3 改进了垃圾回收算法。

PHP5.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 就会执行垃圾回收。

步骤 A

为避免不得不检查所有引用计数可能减少的垃圾周期,这个算法把所有可能根(possible roots 都是zval变量容器),放在根缓冲区(root buffer)中(用紫色来标记,称为疑似垃圾),这样可以同时确保每个可能的垃圾根(possible garbage root)在缓冲区中只出现一次。仅仅在根缓冲区满了时,才对缓冲区内部所有不同的变量容器执行垃圾回收操作。

步骤 B

模拟删除每个紫色变量。模拟删除时可能将不是紫色的普通变量引用数减”1”,如果某个普通变量引用计数变成0了,就对这个普通变量再做一次模拟删除。每个变量只能被模拟删除一次,模拟删除后标记为灰。

步骤 C

模拟恢复每个紫色变量。恢复是有条件的,当变量的引用计数大于0时才对其做模拟恢复。同样每个变量只能恢复一次,恢复后标记为黑,基本就是步骤 B 的逆运算。这样剩下的一堆没能恢复的就是该删除的蓝色节点了,

步骤 D

清空根缓冲区中的所有根,然后销毁所有 refcount 为 0 的 zval,并收回其内存,这才是真实删除的过程。

总结

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

新的垃圾回收机制总结

  • 如果发现一个zval容器中的refcount在增加,说明不是垃圾
  • 如果发现一个zval容器中的refcount在减少,如果减到了0,直接当做垃圾回收
  • 如果发现一个zval容器中的refcount在减少,并没有减到0,PHP会把该值放到缓冲区,当做有可能是垃圾的怀疑对象
  • 当缓冲区满时,PHP会自动调用一个方法取遍历每一个值,如果发现是垃圾就清理。
-------------本文结束感谢您的阅读-------------