PHP的变量是弱类型的,也实现了如整型、浮点型、字符串、数组和对象等类型。PHP中的变量是使用结构体zval来表示的,在介绍PHP 7的zval之前,先了解一下PHP 5的zval设计。
首先来看PHP 5中_zval_struct(zval)这个结构体:
PHP 5的zval核心由一个zvalue_value类型的联合体和zend_uchar类型的type组成。在PHP 5.3之后相继引入了refcount__gc字段通过引用计数进行垃圾回收,同时增加了新的字段is_ref__gc来标记是否为引用类型。默认在i386:x86-64架构下,上面的zvalue_value结构体中lval和dval大小为8字节,str结构体大小为12字节,ht和ast是指针类型,大小为8字节,obj结构体大小为12字节,所以在内存对齐的情况下_zval_struct中的value大小为16字节,加上refcount__gc大小为4字节和两个1字节的type、is_ref__gc, _zval_struct结构体本身大小为24字节(考虑到结构体对齐)。根据3.1.2节中讨论的结构体和联合体的知识,PHP 5中zval的示例如上图所示。
图:PHP5中 _zval_struct的大小
这是不是说,PHP 5中一个变量就占用一个zval呢?其实不是的。PHP 5中现有的zval结构里每个字段都有明确的定义,不可轻易修改,因此在PHP 5时代一些对zval的优化都是通过结构映射的方式,例如在PHP 5.3之后为了解决循环引用的问题,通过重写分配zval的宏,对zval进行扩充。新的分配方法如下所示:
这里申请的结构体为_zval_gc_info,是由一个zval结构体和一个u联合体组成的,其中u大小为8字节,_zval_gc_info结构体如下所示:
通过这种方式,PHP 5中一个变量实际分配了32字节,如下图所示。
图:PHP5中 _zval_struct的实际大小
扩充后的zval带来了新的问题,因为整型和浮点型不需要进行gc,所以对于整型和浮点型会有内存浪费。不仅如此,在开启Zend内存池的情况下,zval_gc_info在内存池中分配,内存池会为每个zval_gc_info额外申请一个大小为16字节的zend_mm_block结构体(限于篇幅,这里不展开讨论),用来存放内存相关信息。zend_mm_block结构如下(size_t占用8字节):
最终一个变量在PHP 5中实际占用的内存大小为48字节,内存占用情况如下图所示。
图:PHP 5中变量实际占用的内存大小
这48字节的大小其实有很多的浪费,而这点PHP开发者在PHP 7中做了重点优化,那么接下来看一下PHP 7中zval的实现。
PHP 7中zval结构体有了一些变化,zval依然保留了value字段,但跟PHP 5不同的是value里面支持更丰富的类型,且PHP 7的zval不再存储复杂类型的结构,复杂类型的数据都是通过指针操作的,新的联合体中value的内存占用只有8字节。zval结构体如下所示:
保留的value字段,它在PHP 7中的定义如下所示:
从上面的定义可以看出,value支持更多的类型。除了value字段之外,zval结构体中还有两个重要的字段u1和u2,它们都是联合体结构,却各有用途。
(1)u1中的字段含义
1)type:记录变量类型。
2)type_flag:对应变量类型特有的标记,不同类型的变量对应的flag也不同。所对应的标记如下:
3)const_flag:常量类型的标记,对应的属性有:
PHP 7的zval类型字段改为使用联合体结构中的u1.v.type表示,u1由type_info和结构体v组成,由3.1.2节中联合体的知识可知道,v和type_info共享一块内存,两个字段长度均为4字节,修改type_info等同于修改结构体v中的值,其实type_info就是v中的4个char的组合,如下图所示。
图:u1中v和type_info的关系
以字符串为例,字段u1.v.type值为6(IS_STRING,3.2.3节会详细讨论PHP 7中的变量类型),同时字符串又是可以引用和可拷贝的,因此u1.v.type_flag值为24(S_TYPE_COPYABLE | IS_TYPE_REFCOUNTED),这样u1.type_info值为6150。在PHP 7开始的版本通过value和u1已经可以表示任何类型,并记录一些类型的属性。另外还有一个u2,其实是后来增加的辅助字段,u2里面的字段均是uint32_t类型,占用4字节,所以u2的大小是4字节,这样zval总共占用的内存是16字节,但是如果没有u2字段,在内存对齐的情况下,zval同样占用16字节。与其浪费,不如用来记录一些特殊信息。那么u2都记录了哪些信息呢?
(2)u2中的字段信息
1)next:用来解决哈希冲突问题,记录冲突的下一个元素位置,具体会在第5章中详细说明。
2)cache_slot:运行时缓存。在执行函数时会优先去缓存中查找,若缓存中没有,会在全局的function表中查找。
3)lineno:文件执行的行号,应用在AST节点上。Zend引擎在词法和语法解析时会将当前文件的行号记录下来,记录在zend_ast中的lineno中,如果zend_ast这个节点的kind刚好是ZEND_AST_ZVAL(值为64),则会将该zend_ast强制转换成zend_ast_zval类型,而对应的lineno则记录在zend_ast_zval结构体中内嵌的zval里。这部分会在第11章中详细展开。
4)num_args:函数调用时传入参数的个数。
5)fe_pos:遍历数组时的当前位置。比如在对数组执行foreach时,fe_pos每执行一次都会加1。当再次调用foreach对数组遍历时,会首先对数组的fe_pos指针重置。这同样也会在第5章中详细说明。
6)fe_iter_idx:跟fe_pos用途类似,只是这个字段是针对对象的。对象的属性也是HashTable,传入的参数是对象时,会获取对象的属性表,因此遍历对象就是遍历对象的属性。
7)access_flags:对象类的访问标志,常用的标识有public、protected、private。这个会在第6章中阐述。
8)property_guard:防止类中魔术方法的循环调用,比如__get、__set等。通过上面的介绍发现,u2的辅助字段里面记录了很多类型的信息,这些信息对内部功能的实现都有很大好处,或提升了缓存友好性或减少了内存寻址的操作。同时相对于PHP 5时代的zval, PHP 7的zval节省了很大的内存。PHP 7的zval内存占用如下图所示。
图:PHP 7的zval内存占用
在PHP 5时代,所有的变量都在堆中申请,但是对于临时变量是没有必要的,而PHP 7对此做了优化,这种临时变量直接在栈中申请。接下来先讨论一下PHP 7的变量类型。
类型PHP 7中变量的类型定义在zend_types.h文件中,不仅有常见的类型,还有一些只在内部使用的类型,具体定义如下:
PHP 7中定义了20种宏,用来标记u1.v.type字段,其中伪类型将逐渐淘汰,这里暂不讨论。根据不同的type值取value中对应的不同值。以u1.v.type值为IS_ARRAY为例,那么取value.arr的值,对应zend_array。同样,如果u1.v.type值为IS_LONG,通过value. lval取值。
除了常见类型之外,这里有几个新增的类型需要注意。
1)IS_UNDEF:标记未定义,表示数据可以被覆盖或者删除。比如在对数组元素进行unset操作时,PHP 7并不会直接将数据从分配给HashTable的内存中删掉,而是先将该元素所在的Bucket的位置标记为IS_UNDEF,当HashTable中IS_UNDEF元素个数到达一定阈值时,进行rehash操作时再将IS_UNDEF标记的元素覆盖或删除。
2)IS_TRUE和IS_FALSE:这里将IS_BOOL优化成两个,直接将布尔类型的标记记录在type中,这样做可以优化类型的检查,不需要再做一次类型判断。
3)IS_REFERENCE:是新增的类型,PHP 7中使用不同的处理方式来处理“&”,后面展开阐述。
4)IS_INDIRECT:同样也是新增的类型,由于PHP 7中HashTable的设计跟PHP5中有很大的不同,所以在解决全局符号表访问CV变量表的问题上,引入了IS_INDRECT类型。
5)IS_PTR:该类型被定义为指针类型,用来解析value.ptr,通常用在函数类型上。比如声明一个函数或者方法。
6)_IS_ERROR:为新增的错误类型,校验zval的类型是否合法。介绍完PHP 7中变量的类型,下面来对每种类型进行详细的探讨。
对于整型和浮点型的数据,由于其占用空间小,在zval中是直接存储的,所以在进行赋值的时候会直接创建两个zval,在对应的value中取lval或dval。举例如下:
对于上面的代码,详细说明如下。
❑ 对于$a=10,是一个整型变量,将生成一个zval,其中u1.v.type=IS_LONG,整型value.lval=10。
❑ 对于$b = $a,此时,直接拷贝了一个zval,因为zval只有16字节,所以这里没有做写时拷贝(copy-on-write),而是直接做了拷贝。
❑ 对于$a=20,此时,修改了a对应的value.lval=20,而b对应的value.lval并不改变。
❑ 对于unset($a),此时,修改a的u1.v.type=IS_UNDEF,但此时并不是真正把内存释放,因为b是拷贝出来的,也不会受影响。
同样对于浮点型的变量类型,对应的u1.v.type=IS_DOUBLE,使用的是value.dval,而dval恰好是double型的。整体而言,PHP 7对整型和浮点型变量的实现比较简单,非常容易理解。接下来讨论一下字符串类型变量的实现。
PHP 7中定义了一个新的结构体_zend_string,第4章会详细介绍,这里简单说一下,其结构如下:
_zend_string的头部维护着gc的信息,并且冗余了hash值h,这个操作据说为PHP7提高了5%的性能,避免了在数组操作中hash值的重复计算。len表示字符串的长度,val记录了字符串的内容。这里的val值采用了柔性数组(被收入到C99标准中),这种方式相较于PHP 5中的字符串与结构体分离,读写的效率更高。PHP 7中的字符串是通过zval.str指向zend_string结构体的,如下图所示。
图:PHP 7中的字符串
字符串类型有不同的属性维护在头部的zend_refcounted_h结构体中,比如PHP 7中有一种内部字符串INTERNED STRING,而作用于字符串本身有IS_STR_INTERNED标志位,但不是zval的。字符串中还有一些类似的标志位:
数组是PHP代码中比较重要的一个结构,本质上PHP的数组是有序的字典,即它们表示key-value对的有序列表,其中key-value映射是使用HashTable实现的。PHP将字符串key通过哈希函数运算返回一个整数。这个整数被用作“普通”数组的索引。但是带来新的问题是,两个不同的字符串可能得到相同的哈希值,因此这样的HashTable需要实现某种机制来解决冲突。
PHP 7中HashTable的经过有了很大的改变,也为PHP 7带来了巨大的性能提升。本书第5章有详细阐述,这里暂不做讨论。这里想说的是HashTable结构体头部包含gc结构体,如下面的代码所示:
头部的gc结构体解决数组的引用计数和循环引用的问题,第4节有详细讲解,暂不在这里展开。
说到引用的问题,不得不说一下PHP中传值和传址的区别。
传值即PHP代码中的赋值操作,如上面的代码所示,当改变$b值时,$a的值需要保持不变,因此需要在此分离。传址意味着当改变$b的值时,$a也会跟着变。
从PHP 7的zval设计上可以看到,zval没有存储引用计数的相关信息,所以在处理“&”符号引用的问题上,PHP 7采用完全不同的一种方式。先来看看PHP 5是如何处理的。PHP 5在引入引用计数后,使用了refcount__gc来记录次数,同时使用is_ref__gc来记录是否是引用类型。以上面的例子为例:
将$a赋值给$b, refcount__gc会增加,但是并不会改变引用类型。当使用“&”操作时,将$b赋值给$c, zval的is_ref__gc值会改变,使得此的zval必须进行分离,但是实际上它们的值还没有变化,这使得需要在堆中维护两个值为“hello”的zval。PHP 7引入了新的类型IS_REFERENCE来处理这个问题,首先看看zend_reference的结构体:
zend_reference由记录gc信息的zend_refcounted_h结构体和zval结构体组成,由val来存储实际的值,zend_refcounted_h结构体用来存储引用计数的信息。前面提到过,在PHP 7中复杂类型的引用计数的信息都记录在自身头部的gc中,zval没有存储引用计数的字段,所以增加了这种结构用于垃圾回收。下面看看PHP 7中使用传址时变量结构的变化:
从上面的流程中可以看出,当使用“&”操作时,会创建一种新的中间结构体zend_reference,这个结构体会指向真正的zend_string结构体,所以zend_string结构体的引用计数不变,同时zend_reference结构体的引用计数变为2,因为$c和$b此时的类型都会变为zend_reference。这样的好处是原始的zend_string在内存中始终只有一份(避免了由于字符串的重复申请导致的内存浪费),更加易于维护。在进行赋值时字符串只会增加引用计数,下图所示是正常的赋值。
图:$b = $a的赋值过程
当使用“&”后,会改变“=”号两边的变量的类型,新生成的类型指向字符串当前的地址,如下图所示。
图:进“&”操作之后的示意图