今天朋友去面试,回来问了一下怎么样,结果他说一脸懵逼,看来我们平时还是学习的太少了啊。于是比较好奇,果断问了一下都有哪些问题,朋友说第一个问题就是“描述PHP的垃圾回收机制”,我当时听了也是一脸茫然,因为平时我们业务逻辑写的太多,很少去关注这些,但是没办法,既然有人问这个问题,看来还是很有必要了解一下的。于是马上搜了一下,网上资料文章很多,看了几篇后加上自己的一些理解记录一下。
首先看了一下官方手册,只有php5.3版本以后的才有了所谓的新的垃圾回收机制GC,那么以前是怎么干的呢?以前是基于引用计数的方式,这里就需要提一下引用计数的知识,官方手册里面说php的每个变量都是存在一个叫做zval的容器里面,这个容器不仅包含了这个变量的值和类型,还包含了另外两个重要的信息,“is_ref”和“refcount”,“is_ref”看名字就应该知道大概和引用相关,它是一个bool值,如果这个值是true那么代表这是一个引用变量,否则是普通变量。“refcount”指的是有多少个变量(符号)指向这个zval容器。
比如一个变量$a="test",如果我们php安装了xdebug插件并且开启了插件,就可以用xdebug_debug_zval(“a”)来显示zval里面的值。这里会输出a:(refcount=1,is_ref=0)=“test”,可以看到refcount=1,因为这里有一个变量(符号)$a指向了这个zval容器,is_ref=0说明这个存放的是一个普通变量。
如果我们进行一个操作$b=$a呢?按照常规的思路,应该是把$a的值复制一份给$b,然后$b也存放在另一个zval容器中,这个zval容器内容和$a那个一样。真的是这样吗?我们用xdebug_debug_zval(“a”)先输出$a对应的zval容器值,结果会输出a:(refcount=2,is_ref=0)="test",这里refcount变成了2 ,说明除了$a还有一个变量(符号)指向这个zval容器,那就是$b了啊,这么一来$a和$b指向的是同一个zval容器,那不是修改$b也会影响到$a了?其实不会的,因为当$b或者$a的值改变的时候,这个zval容器的refcount会减一,然后会复制一份让改变值的那个变量(符号)指向新的zval容器,这个时候就是我们刚才常规思路想的一样了,有了两个zval容器都是(refcount=1,is_ref=0)只是两个容器的值和类型分别是$a和$b的值和类型。
那如果是引用赋值$c=&$a呢?这时$a和$c同样也指向同一个zval,即a,c:(refcount=2,is_ref=1)="test",这时候不光refcount加一,is_ref也变成了1也就是true,说明这是引用变量,那么改变$a和$c任何一个都会影响另一个的值。我们如果使用unset($c)的话,$a指向的容器的refcount就会减一变成1。如果我们再unset($a)的话,指向的zval容器的refcount就是0了,这个时候说明已经没有变量(符号)指向这个容器了,那么php引擎就会从内存中销毁释放这个容器。
那如果$a是一个数组呢,它指向的zval容器会是怎样的?比如$a=array("1","2"),xdebug_debug_zval(“a”)会输出如下的信息:
a: (refcount=1, is_ref=0)=array (
0 => (refcount=1, is_ref=0)='1',
1 => (refcount=1, is_ref=0)='2'
)
可以看到除了$a本身指向一个zval容器存放外,它的每一个元素也都分别指向一个zval容器,如果我要这样往$a中添加元素会怎样?
$a = array( 'meaning' => 'life', 'number' => 42 );
$a['life'] = $a['meaning']; //这里直接拿官方示例
这个时候xdebug_debug_zval(“a”)会输出: key为'meaning'和'life'的值指向同一个zval容器,refcount=2
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'
)
如果我们在添加元素的时候,添加的是对数组本身的引用,又会变成什么样?
<?php
$a = array( '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数组本身指向的容器refcount变成了2,因为$a和$a[1]指向了这个容器,然而$a[1]又是$a的元素,这个元素又引用了$a本身,这就形成了一个闭环。
如果这个时候来一句unset($a)呢?$a指向的容器refcount减一就会变成1,这个时候对于我们程序员来说已经不存在有可操作的变量(符号)指向这个容器了,但是refcount=1那么php引擎就不会销毁这个容器。
那不是这个容器在内存中不就成了垃圾了吗?这种情况如果没有垃圾回收机制GC,那么就只有等到当前请求结束,脚本结束自动清除了。但是有时候我们会用到一些递归或者死循环这类的来做一些特殊的业务逻辑,这时候内存如果有上面的情况出现,就会导致内存泄漏,消耗很大的内存空间。
所以才有了5.3版本新的内存回收机制的出现。先说说机制的三个基本规则:
- 如果一个zval容器的refcount增加,说明有新的变量(符号)指向这个容器,那么这个容器当然不会是垃圾,它将被继续使用。
- 如果一个zval容器的refcount减少到0了,那么说明没有变量(符号)指向这个容器,它就会被php引擎销毁。
- 如果一个zval容易的refcount减少了,但是不是0,那么这个容器就有可能是垃圾,就会被垃圾回收机制所管理。
怎么管理这些容器并判断哪些是垃圾呢?当发现某个容器有可能是垃圾时,这个容器会被放进一个内存缓冲区,当缓冲区满了时,就会进行垃圾回收算法来找出垃圾并销毁。这里具体的算法可以看看官方文档,我就用一个网友的总结来描述:
对于一个包含环形引用的数组,对数组中包含的每个元素的zval进行减1操作,之后如果发现数组自身的zval的refcount变成了0,那么可以判断这个数组是一个垃圾。
这个道理其实很简单,假设数组a的refcount等于m, a中有n个元素又指向a,如果m等于n,那么算法的结果是m减n,m-n=0,那么a就是垃圾,如果m>n,那么算法的结果m-n>0,所以a就不是垃圾了。m=n代表什么? 代表a的refcount都来自数组a元素的指向,代表除了a中的元素没有任何变量(符号)指向根zval容器,代表用户代码空间中无法再访问到这个zval,代表a是泄漏的内存,因此GC将a这个垃圾回收了。
最后在哪里可以设置这个回收机制呢?默认的,PHP的垃圾回收机制是打开的,然后在配置文件 里允许你修改它:zend.enable_gc 。除了修改配置zend.enable_gc是否开启 ,也能通过分别调用gc_enable() 和 gc_disable()函数来打开和关闭垃圾回收机制。调用这些函数,与修改配置项来打开或关闭垃圾回收机制的效果是一样的。如果想在根缓冲区还没满时强制执行周期回收,可以调用gc_collect_cycles()函数,这个函数将返回使用这个算法回收的周期数。
当垃圾回收机制打开时,每当根缓存区存满时,就会执行查找算法。根缓存区有固定的大小,可存10,000个可能根,当然可以通过修改PHP源码文件Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然后重新编译PHP,来修改这个10,000值。当垃圾回收机制关闭时,循环查找算法永不执行,然而,可能根将一直存在根缓冲区中,不管在配置中垃圾回收机制是否激活。
当垃圾回收机制关闭时,如果根缓冲区存满了可能根,更多的可能根显然不会被记录。那些没被记录的可能根,将不会被这个算法来分析处理。如果他们是循环引用周期的一部分,将永不能被清除进而导致内存泄漏。
即使在垃圾回收机制不可用时,可能根也被记录的原因是,相对于每次找到可能根后检查垃圾回收机制是否打开而言,记录可能根的操作更快。不过垃圾回收和分析机制本身要耗不少时间。
也就是说关闭了回收机制也会往缓冲区丢疑似垃圾的容器,当缓冲区满了的时候不会执行回收算法,后面更多的疑似垃圾容器不会继续放进去,就可能导致内存泄漏。当开启回收机制后,就会从缓冲区中之前放入的容器中开始垃圾回收机制。