关于PHP的弱类型变量实现原理
关于 PHP的弱类型变量实现原理 其实已经有很多文章了,这里只是做个总结,方便以后快速回顾;毕竟
工作虽然拧螺丝,考试还得背八股。
简单来说,PHP是通过一个 zval 的结构体实现的,源代码如下:注:此源码是PHP5版本,7及以上版本,请参考官网
struct _zval_struct {
union {
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
zend_ast *ast;
} value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
};
这个zval结构体中的type字段, 代表变量当前的类型值, 常见的可能选项是IS_NULL, IS_LONG, IS_STRING, IS_ARRAY, IS_OBJECT
等;
弱类型,就是通过type字段的值, 取对应的value值来实现的;
这个value是联合体(union):
- 如果type是 IS_STRING, 就取
value.str
的值; - 如果type是 IS_LONG, 就用
value.lval
的值;
联合体的特点在于,允许在相同的内存位置存储不同的数据类型,也就是共享内存地址,其长度等于最长的子成员长度;
但同一时刻只能有一个成员带有有效值,比如设置 value.str='haha'
, 此时获取 value.lval
就不会得到 haha
;这一特点可有效节省内存占用。
而从源码中的_zval_struct
定义,我们也可以看出:
每次修改变量为不同类型的值,只需更改type
,并赋值给与其对应的value
联合体即可;
这就是 PHP的弱类型变量实现的基本原理,尽管源码是老旧的PHP5版本,但原理基本一致。
关于PHP的垃圾回收
PHP是通过引用计数来做基本的垃圾回收的, 就是上面zval结构体中的refcount__gc
字段, 其含义是变量的引用数目;
这个内存回收算法是大名鼎鼎的 Reference Counting
,一般译为 引用计数,其算法思想非常简洁:
为每个内存对象分配一个计数器,当一个内存对象建立时计数器初始化为1(自身引用自身),之后每有一个新变量引用此内存对象,则计数器加1;
每当减少一个对此内存对象的引用,则计数器减1,如果减少后值变为0,则销毁并回收其占用的内存;
在PHP中,内存对象就是zval,计数器就是refcount__gc。
关于字段名这里补充一下,5.3版本以前, 叫做 refcount
, 5.3及后续版本,引入新的垃圾回收算法来对付循环引用计数后, 改为现在的名字。
当然了,上面只是最基本的GC思想,具体实现过程要复杂的多,思想简洁不代表实现简单。
垃圾是如何产生的
垃圾产生的原理其实很简单,无限循环;
正常的代码逻辑应该是个有向无环的图,最终都会有一个退出标志,如果一个变量将引用指向自己,就会产生类似 while(true)
的无限循环逻辑,从而导致垃圾的产生,看如下代码:
$a = [1];
$a[] = &$a;
unset($a);
unset($a)
之后由于数组中有子元素指向$a
,所以refcount=1
,但$a
已经已经没有任何外部引用了,这种情况是无法通过正常的gc机制回收的,就会出现所谓的 内存泄漏;不过这类垃圾只会出现在array、object
两种复杂类型中;
这类代码如果只用于动态页面脚本,引起的泄露也许不是很要紧,因为动态页面脚本的生命周期很短,PHP会在脚本执行完毕后,释放其所有资源;
但如果将PHP用到自动化脚本任务或是deamon进程,那么经过长久循环后所积累下来的内存泄露就会很严重,因为泄漏是线性增长的;
当然这也都是老黄历了,在5.3版本以后更新了垃圾回收机制,可将泄漏控制在某个阈值以下。
垃圾回收的基本原理
垃圾回收的2种情况:
- 1、如果一个变量的refcount减少之后变为0, 那么此zval可被释放,属优质垃圾,无需再进行多余操作;
- 2、如果一个变量的refcount减少之后仍大于0,那么此zval还不能被释放,但它可能成为一个垃圾,需进一步操作;
针对第一种情况GC机制不会处理,只有第二种情况GC才会将变量收集起来,放入一个用于检测是否为垃圾的buffer链表,做模拟删除测试,测试逻辑如下:
- 1、从buffer链表的roots开始
深度优先遍历
,将遍历到的成员的refcount减1; - 2、重复遍历,检查当前变量的引用是否为0,为0则表示确实是可以销毁的垃圾,同时标记好以备删除; 如果
不为0则排除了引用全部来自自身成员的可能
,即还有来自外部的引用,不是垃圾; - 3、由于步骤(1)对成员进行了refcount减1操作,此时需要再还原回去,对所有refcount加1;
- 4、再次遍历buffer,将没有被标记删除的节点从链表中删除,最终剩下真正的垃圾,最后将这些垃圾清除。
这个算法的原理也很简介,如果垃圾是由成员引用自身导致的,那么对所有成员的引用次数执行 减一
操作后,变量本身的refcount
应该会变为0
。
关于垃圾回收原理的个人解析
1、如果如果一个变量的refcount
保持不变或仍在增长,它肯定不是一个垃圾,说明还在使用;
2、如果refcount
开始减少,则至少说明它是个准垃圾,如果减少后变为0,可放心删除;如果仍大于0,放到准垃圾回收池进行计算;
3、检测逻辑为何只做减1操作,如果 $a[] = &$a
这类代码被执行了2次,或者N次,那岂不是检测不出来了?比如:
$a = [1];
$a[] = &$a;
$a[] = &$a;
unset($a);
非也,非也,注意上面的遍历方式,是DFS-深度优先
,以$a[] = &$a
被执行2次为例:
如果$a
被判定为准垃圾,说明执行过unset($a)
操作,所以此时$a
的refcount = 2
;DFS
会按0,1,2
的下标顺序进行遍历:
对于 $a[0]
执行refcount-1
不会影响$a
的refcount
;
对于 $a[1]
执行refcount-1
后,$a
的refcount= 2-1 =1
;
对于 $a[2]
执行refcount-1
后,$a
的refcount= 1-1 =0
;
模拟删除结束,$a
变量的模拟结果refcount = 0
,可销毁。
4、为何是将泄漏控制在某个阈值以下?
因为这类操作无法避免,所以泄漏仍旧会存在;
添加了buffer待删除检测区后,可以一定程度上减少泄漏的持续增长,这个buffer的默认值是10000个变量,等凑满了才会进行处理,所以,这个机制会带来两个问题:
- 1、达到足够量级才会处理;
- 2、每次可处理的量级有上限;
因此,仍旧会有泄漏产生,但只要达到阈值,就会被处理掉:
但,如果有泄漏产生,那么内存的占用会是个折线图,忽高忽低,虽有泄漏,但可控;
而,旧版的垃圾回收机制,会导致泄漏是个线性增长的斜线图,泄漏没有上限,不可控。
7版本以后的一些变化
其实从上面我们也可以看到,这类垃圾只有复杂类型才会出现,若变量是int类型,是不会出现引用自身的,所以简单类型其实不需要做啥计数;
因此PHP7开始,对于在zval的value字段中能保存下的值, 比如IS_LONG、IS_DOUBLE
等简单类型,不再进行引用计数, 而是在拷贝时直接赋值, 这样就省掉了大量的引用计数相关的操作;
同样,对于那种根本没有值的类型, 比如,IS_NULL IS_FALSE IS_TRUE
, 也不再引用计数了;
同时,PHP7给变量添加了type_flag
,只有属于IS_TYPE_COLLECTABLE
的变量才会被GC收集,比如 array,object
等复杂类型。
关于PHP的 copy on write(写时复制) 和 change on write(写时改变)
再看一遍最开始贴的c源码中的 zval 结构体,还有个 is_ref__gc
字段没讲它的用途,这涉及PHP的 change on write
机制;is_ref__gc
是一个标志位,表示PHP中的一个变量是否是 &
引用类型;
先来看一段代码:
$var = "var_str";
$var_dup = $var;
$var = 1;
代码执行后,$var_dup 的值还是 var_str, 这就是通过 copy on write
机制实现的。
PHP在修改一个变量以前,会先查看这个变量的refcount,如果refcount大于1,PHP就会执行分离;
对于上面的代码,当执行到第三行时,由于 $var 指向的zval的refcount大于1,此时会复制一个新的zval出来,将原zval的refcount减1,并修改symbol_table
(全局符号表,存储了所有的变量符号);注:PHP通过此表存储变量符号,且每个复杂类型如数组都有自己的符号表,因此 $a和$a[0] 虽然是两个符号,但 $a 存在全局符号表,$a[0] 则存在数组本身的符号表
如果同时执行如下调试代码:
debug_zval_dump($var);
debug_zval_dump($var_dup);
会看到这俩变量的 refcount 都是2(debug_zval_dump
执行时也会导致 refcount+1),也就是这俩变量已经做了分离。注:本段测试代码需使用5版本,7版本以后由于计数方式变化,int等简单类型不再计数
因为PHP中,执行变量复制的时候 ,PHP内部并不是真正的复制,而是采用指向相同的结构来节约开销,类似 shallow copy
(浅拷贝),当源变量发生变化时,再执行深拷贝。
同理,如果一个变量是引用类型,判断方式就会发生变化,参考如下代码:
$var = "var_str";
$var_ref = &$var;
$var_ref = 1;
这段代码结束以后,$var 也会被间接的修改为1,这个过程唤作 change on write
(写时改变)。
当上面代码的第二行执行后,除了 $var 变量的 refcount+1变为2
外,其 is_ref__gc
也会被设置为 1,代表当前变量被引用了;
执行到第三行时,PHP会检查is_ref
字段,如果为 1,则不执行分离,而是直接将 源变量$var 的值修改为 1。
而如果将上面的代码改为:
$var = "var_str";
$var_dup = $var;
$var_ref = &$var;
同时存在 copy on write 和 change on write,执行逻辑如下:
第二行执行时,和前面讲的一样,$var_dup 和 $var 指向相同的zval, refcount为2;
第三行执行时,PHP发现refcount大于1,则先执行分离操作, 将 $var_dup 分离出去,再将 $var和$var_ref 做 change on write 关联,也就是,refcount=2, is_ref=1
;
下面来观察实际输出结果,代码改为如下:
$var = "var_str";
$var_dup = $var;
debug_zval_dump($var_dup);
$var_ref = &$var;
debug_zval_dump($var_dup);
debug_zval_dump($var);
在PHP5版本中,输出结果如下:
string(7) "var_str" refcount(3)
string(7) "var_str" refcount(2)
string(7) "var_str" refcount(1)
你会看到 debug_zval_dump($var_dup)
的执行结果,第一次输出refcount(3)
,第二次为 refcount(2)
, 说明此时$var_dup
是个单独的变量,已从$var
中分离出去;
那为什么 debug_zval_dump($var)
的执行结果是 refcount(1)
呢?
不是说 debug_zval_dump
执行时会导致变量自身的 refcount+1
吗,不应该输出 refcount(3)
吗?
我们做个测试,调整下代码顺序,先执行引用,如下:
$var = "var_str";
$var_ref = &$var;
$var_dup = $var;
debug_zval_dump($var_dup);
debug_zval_dump($var);
此时的输出结果为:
string(7) "var_str" refcount(2)
string(7) "var_str" refcount(1)
这就说明 $var_dup
在赋值时直接进行了分离,因为前一行的 $var_ref = &$var
已经让 $var
的 refcount=2, is_ref=1
了,同理,debug_zval_dump
输出$var
时,会直接执行分离操作,由于是在debug_zval_dump
内部分离的,输出时自然就只有被分离的变量自己,也就是 refcount(1)
了。
再次强调:
以上测试代码需使用5版本,7版本以后由于计数方式变化,都会输出(1)
PHP5中的这种设计结构,会带来一个经典的性能问题,参考如下代码:
$array = range(1, 100000);
function dummy($array) {
//do something..here
}
$b = &$array; //假设不小心把这个Array引用给了一个变量
$i = 0;
$start = microtime(true);
while($i++ < 100) {
dummy($array);
}
printf("Used %ss\n", microtime(true) - $start);
由于5版本的设计问题,上面循环体中的代码,在dummy
函数中调用时,其refcount>1, is_ref=1
,会命中 Change On Write
逻辑,导致每循环一次,就会执行一遍$array
的复制;
所以在PHP的7版本之后,修改 zval 结构的同时,增加了一个专门的引用类型 IS_REFERNCE
用来表示&
这种引用关系;对于如下代码:
$var = "var_str";
$var_ref = &$var;
$var_dup = $var;
其执行逻辑变为如下情况:
1、第二行执行时,发现产生了引用,就会创建一个 IS_REFERNCE
类型的结构,我们取个代号叫 zend_ref;
2、这个 zend_ref 会指向第一行代码中 $var
变量的 zval,同时将 $var、$var_ref
的指向修改为自身,并修改自身的 refcount=2
,也就是 $var、$var_ref
同时指向 zend_ref 这个 IS_REFERNCE
类型;
3、第三行执行时,发现 $var
是个IS_REFERNCE
类型,就会越过$var
,将$var_dup
指向 zend_ref 后面真实的 zval,同时对 zval 执行refcount+1
,并不会产生复制;
代码简单示意:
// $var -> zend_string(refcount=1)
$var = "var_str";
// [$var, $var_ref] -> zend_reference(refcount=2) -> zend_string(refcount=1)
$var_ref = &$var;
// [$var, $var_ref] -> zend_reference(refcount=2) -> zend_string(refcount=2)
// $var_dup -> zend_string(refcount=2)
$var_dup = $var;
这样就解决了上面提到的 经典性能问题
进阶可参考鸟哥的分析:https://www.laruence.com/2018/04/08/3179.html(深入理解PHP7内核之Reference)
本文内容参考并整理自如下文章:
官网:https://www.php.net/manual/zh/features.gc.refcounting-basics.php(引用计数基本知识)
鸟哥:https://www.laruence.com/2008/09/19/520.html(变量分离/引用)
以及:
//鸟哥:
//深入理解PHP7内核之zval: https://www.laruence.com/2018/04/08/3170.html
//深入理解PHP原理之变量: https://www.laruence.com/2008/08/22/412.html
//盘古大叔解析垃圾回收: https://github.com/pangudashu/php7-internal/blob/master/5/gc.md
//PHP5中的GC算法演化:https://www.cnblogs.com/leoo2sk/archive/2011/02/27/php-gc.html
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。