PHP的垃圾回收机制
前言
平时听到大神提到的GC ,就是垃圾回收器,全称Garbage Collection.
在介绍这个新的GC之前,读者必须先了解PHP中变量的内部存储相关知识,请先阅读变量的内部存储:引用和计数
变量的结构与类型
PHP在内核中是通过zval这个结构体来存储变量的,所以写扩展的时候当然也是一样。
在Zend/zend.h文件中找到了其定义:
1 | struct _zval_struct { |
快速理解:为方便起见可以直接把zval结构体理解成一个由
1 | value |
组成的对象就好了,*__gc是管理内存相关的时候会用到的,这里可以先不管。
什么算垃圾
首先我们需要定义一下“垃圾”的概念,新的GC负责清理的垃圾是指变量的容器zval还存在,但是又没有任何变量名指向此zval。因此GC判断是否为垃圾的一个重要标准是有没有变量名指向变量容器zval。
假设我们有一段PHP代码,使用了一个临时变量$tmp存储了一个字符串,在处理完字符串之后,就不需要这个$tmp变量了,$tmp变量对于我们来说可以算是一个“垃圾”了,但是对于GC来说,$tmp其实并不是一个垃圾,$tmp变量对我们没有意义,但是这个变量实际还存在,$tmp符号依然指向它所对应的zval,GC会认为PHP代码中可能还会使用到此变量,所以不会将其定义为垃圾。
那么如果我们在PHP代码中使用完$tmp后,调用unset删除这个变量,那么$tmp是不是就成为一个垃圾了呢。很可惜,GC仍然不认为$tmp是一个垃圾,因为$tmp在unset之后,refcount减少1变成了0(这里假设没有别的变量和$tmp指向相同的zval),这个时候GC会直接将$tmp对应的zval的内存空间释放,$tmp和其对应的zval就根本不存在了。此时的$tmp也不是新的GC所要对付的那种“垃圾”。那么新的GC究竟要对付什么样的垃圾呢,下面我们将生产一个这样的垃圾。
顽固垃圾的产生过程
如果读者已经阅读了变量内部存储相关的内容,想必对refcount和isref这些变量内部的信息有了一定的了解。这里我们将结合手册中的一个例子来介绍垃圾的产生过程:
1 | <?php |
在这么简单的一个代码中,$a变量内部存储信息为a: (refcount=1, is_ref=0)=’new string’
当把$a赋值给另外一个变量的时候,$a对应的zval的refcount会加1
1 | <?php |
此时$a和$b变量对应的内部存储信息为
a,b: (refcount=2, is_ref=0)=’new string’
当我们用unset删除$b变量的时候,$b对应的zval的refcount会减少1
1 |
|
对于普通的变量来说,这一切似乎很正常,但是在复合类型变量(数组和对象)中,会发生比较有意思的事情:
1 | <?php |
a的内部存储信息为:
1 | a: (refcount=1, is_ref=0)=array ( |
数组变量本身($a)在引擎内部实际上是一个哈希表,这张表中有两个zval项 meaning和number,
所以实际上那一行代码中一共生成了3个zval,这3个zval都遵循变量的引用和计数原则,用图来表示:
下面在$a中添加一个元素,并将现有的一个元素的值赋给新的元素:
1 | <?php |
那么$a的内部存储为:
1 | a: (refcount=1, is_ref=0)=array ( |
其中的meaning元素和life元素之指向同一个zval的:
现在,如果我们试一下,将数组的引用赋值给数组中的一个元素,有意思的事情就发生了:
1 | <?php |
这样$a数组就有两个元素,一个索引为0,值为字符one,另外一个索引为1,为$a自身的引用,内部存储如下:
1 | a: (refcount=2, is_ref=1)=array ( |
“…”表示1指向a自身,是一个环形引用:
这个时候我们对$a进行unset,那么$a会从符号表中删除,同时$a指向的zval的refcount减少1
1 | <?php |
那么问题也就产生了,$a已经不在符号表中了,用户无法再访问此变量,但是$a之前指向的zval的refcount变为1而不是0,因此不能被回收,这样产生了内存泄露:
这样,这么一个zval就成为了一个真是意义的垃圾了,新的GC要做的工作就是清理这种垃圾。
为解决这种垃圾,PHP5.3以后的GC
在PHP5.3版本中,使用了专门GC机制清理垃圾,在之前的版本中是没有专门的GC,那么垃圾产生的时候,没有办法清理,内存就白白浪费掉了。在PHP5.3源代码中多了以下文件:{PHPSRC}/Zend/zend_gc.h {PHPSRC}/Zend/zend_gc.c, 这里就是新的GC的实现。
GC算法
在较新的PHP手册中有简单的介绍新的GC使用的垃圾清理算法,这个算法名为 Concurrent Cycle Collection in Reference Counted Systems , 这里不详细介绍此算法,根据手册中的内容来先简单的介绍一下思路:
判断处理过程
1:如果一个zval的refcount增加,那么此zval还在使用,不属于垃圾
2:如果一个zval的refcount减少到0, 那么zval可以被释放掉,不属于垃圾
3:如果一个zval的refcount减少之后大于0,那么此zval还不能被释放,此zval可能成为一个垃圾
只有在准则3下,GC才会把zval收集起来,然后通过新的算法来判断此zval是否为垃圾。那么如何判断这么一个变量是否为真正的垃圾呢?
简单的说,就是对此zval中的每个元素进行一次refcount减1操作,操作完成之后,如果zval的refcount=0,那么这个zval就是一个垃圾。首先引用手册中的一张图:
A:为了避免每次变量的refcount减少的时候都调用GC的算法进行垃圾判断,此算法会先把所有前面准则3情况下的zval节点放入一个节点(root)缓冲区(root buffer),并且将这些zval节点标记成紫色,同时算法必须确保每一个zval节点在缓冲区中之出现一次。当缓冲区被节点塞满的时候,GC才开始开始对缓冲区中的zval节点进行垃圾判断。
B:当缓冲区满了之后,算法以深度优先对每一个节点所包含的zval进行减1操作,为了确保不会对同一个zval的refcount重复执行减1操作,一旦zval的refcount减1之后会将zval标记成灰色。需要强调的是,这个步骤中,起初节点zval本身不做减1操作,但是如果节点zval中包含的zval又指向了节点zval(环形引用),那么这个时候需要对节点zval进行减1操作。
C:算法再次以深度优先判断每一个节点包含的zval的值,如果zval的refcount等于0,那么将其标记成白色(代表垃圾),如果zval的refcount大于0,那么将对此zval以及其包含的zval进行refcount加1操作,这个是对非垃圾的还原操作,同时将这些zval的颜色变成黑色(zval的默认颜色属性)
D:遍历zval节点,将C中标记成白色的节点zval释放掉。
来个白话文版的:
例如:
1 | <?php |
为进行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,那么可以判断这个数组是一个垃圾。
算法优化配置
可能会发现,每次都进行这样的操作好像会影响性能,是的,php做事情套路都是走批量的原则。
申请内存也是申请一大块,仅使用当前的一小部分剩下的等下回再用,避免多次申请。
这个gc算法也是这样,会有一个缓冲区的概念,等缓冲区满了才会一次性去给清掉。
- 开关配置
1 |
|
缓冲区配置
缓冲区默认可以放10,000个节点,当缓冲区满了才会清理。可以通过修改Zend/zend_gc.c中的GC_ROOT_BUFFER_MAX_ENTRIES来改变这个数值,需要重新编译链接PHP关键函数
gc_enable() : 开启GC
gc_disable() : 关闭GC
gc_collect_cycles() : 在节点缓冲区未满的情况下强制执行垃圾分析算法
涉及到垃圾回收的知识点
- unset函数
unset只是断开一个变量到一块内存区域的连接,同时将该内存区域的引用计数-1;内存是否回收主要还是看refcount是否到0了,以及gc算法判断。
- = null 操作
a=null是直接将a 指向的数据结构置空,同时将其引用计数归0。
- 脚本执行结束
脚本执行结束,该脚本中使用的所有内存都会被释放,不论是否有引用环。