变量在 PHP7 内部的实现(二)
在上篇文章给大家介绍了变量在PHP7内部的实现(一),本篇继续给大家介绍php7内部实现相关知识,感兴趣的朋友通过本篇文章一起学习吧。
本文第一部分和第二均翻译自NikitaPopov(nikic,PHP官方开发组成员,柏林科技大学的学生)的博客。为了更符合汉语的阅读习惯,文中并不会逐字逐句的翻译。
要理解本文,你应该对PHP5中变量的实现有了一些了解,本文重点在于解释PHP7中zval的变化。
第一部分讲了PHP5和PHP7中关于变量最基础的实现和变化。这里再重复一下,主要的变化就是zval不再单独分配内存,不自己存储引用计数。整型浮点型等简单类型直接存储在zval中。复杂类型则通过指针指向一个独立的结构体。
复杂的zval数据值有一个共同的头,其结构由zend_refcounted定义:
struct_zend_refcounted{ uint32_trefcount; union{ struct{ ZEND_ENDIAN_LOHI_3( zend_uchartype, zend_ucharflags, uint16_tgc_info) }v; uint32_ttype_info; }u; };
这个头存储有refcount(引用计数),值的类型type和循环回收的相关信息gc_info以及类型标志位flags。
接下来会对每种复杂类型的实现单独进行分析并和PHP5的实现进行比较。引用虽然也属于复杂类型,但是上一部分已经介绍过了,这里就不再赘述。另外这里也不会讲到资源类型(因为作者觉得资源类型没什么好讲的)。
字符串
PHP7中定义了一个新的结构体zend_string用于存储字符串变量:
struct_zend_string{ zend_refcountedgc; zend_ulongh;/*hashvalue*/ size_tlen; charval[1]; };
除了引用计数的头以外,字符串还包含哈希缓存h,字符串长度len以及字符串的值val。哈希缓存的存在是为了防止使用字符串做为hashtable的key在查找时需要重复计算其哈希值,所以这个在使用之前就对其进行初始化。
如果你对C语言了解的不是很深入的话,可能会觉得val的定义有些奇怪:这个声明只有一个元素,但是显然我们想存储的字符串偿付肯定大于一个字符的长度。这里其实使用的是结构体的一个『黑』方法:在声明数组时只定义一个元素,但是实际创建zend_string时再分配足够的内存来存储整个字符串。这样我们还是可以通过val访问完整的字符串。
当然这属于非常规的实现手段,因为我们实际的读和写的内容都超过了单字符数组的边界。但是C语言编译器却不知道你是这么做的。虽然C99也曾明确规定过支持『柔性数组』,但是感谢我们的好朋友微软,没人能在不同的平台上保证C99的一致性(所以这种手段是为了解决Windows平台下柔性数组的支持问题)。
新的字符串类型的结构比原生的C字符串更方便使用:第一是因为直接存储了字符串的长度,这样就不用每次使用时都去计算。第二是字符串也有引用计数的头,这样也就可以在不同的地方共享字符串本身而无需使用zval。一个经常使用的地方就是共享hashtable的key。
但是新的字符串类型也有一个很不好的地方:虽然可以很方便的从zend_string中取出C字符串(使用str->val即可),但反过来,如果将C字符串变成zend_string就需要先分配zend_string需要的内存,再将字符串复制到zend_string中。这在实际使用的过程中并不是很方便。
字符串也有一些特有的标志(存储在GC的标志位中的):
#defineIS_STR_PERSISTENT(1<<0)/*allocatedusingmalloc*/ #defineIS_STR_INTERNED(1<<1)/*internedstring*/ #defineIS_STR_PERMANENT(1<<2)/*internedstringsurvivingrequestboundary*/
持久化的字符串需要的内存直接从系统本身分配而不是zend内存管理器(ZMM),这样它就可以一直存在而不是只在单次请求中有效。给这种特殊的分配打上标记便于zval使用持久化字符串。在PHP5中并不是这样处理的,是在使用前复制一份到ZMM中。
保留字符(internedstrings)有点特殊,它会一直存在直到请求结束时才销毁,所以也就无需进行引用计数。保留字符串也不可重复(duplicate),所以在创建新的保留字符时也会先检查是否有同样字符的已经存在。所有PHP源码中不可变的字符串都是保留字符(包括字符串常量、变量名函数名等)。持久化字符串也是请求开始之前已经创建好的保留字符。但普通的保留字符在请求结束后会销毁,持久化字符串却始终存在。
如果使用了opcache的话保留字符会被存储在共享内存(SHM)中这样就可以在所有PHP进程质检共享。这种情况下持久化字符串也就没有存在的意义了,因为保留字符也是不会被销毁的。
数组
因为之前的文章有讲过新的数组实现,所以这里就不再详细描述了。虽然最近有些变化导致之前的描述不是十分准确了,但是基本的概念还是一致的。
这里要说的是之前的文章中没有提到的数组相关的概念:不可变数组。其本质上和保留字符类似:没有引用计数且在请求结束之前一直存在(也可能在请求结束之后还存在)。
因为某些内存管理方便的原因,不可变数组只会在开启opcache时会使用到。我们来看看实际使用的例子,先看以下的脚本:
<?php for($i=0;$i<1000000;++$i){ $array[]=['foo']; } var_dump(memory_get_usage());
开启opcache时,以上代码会使用32MB的内存,不开启的情况下因为$array每个元素都会复制一份['foo'],所以需要390MB。这里会进行完整的复制而不是增加引用计数值的原因是防止zend虚拟机操作符执行的时候出现共享内存出错的情况。我希望不使用opcache时内存暴增的问题以后能得到改善。
PHP5中的对象
在了解PHP7中的对象实现直线我们先看一下PHP5的并且看一下有什么效率上的问题。PHP5中的zval会存储一个zend_object_value结构,其定义如下:
typedefstruct_zend_object_value{ zend_object_handlehandle; constzend_object_handlers*handlers; }zend_object_value;
handle是对象的唯一ID,可以用于查找对象数据。handles是保存对象各种属性方法的虚函数表指针。通常情况下PHP对象都有着同样的handler表,但是PHP扩展创建的对象也可以通过操作符重载等方式对其行为自定义。
对象句柄(handler)是作为索引用于『对象存储』,对象存储本身是一个存储容器(bucket)的数组,bucket定义如下:
typedefstruct_zend_object_store_bucket{ zend_booldestructor_called; zend_boolvalid; zend_ucharapply_count; union_store_bucket{ struct_store_object{ void*object; zend_objects_store_dtor_tdtor; zend_objects_free_object_storage_tfree_storage; zend_objects_store_clone_tclone; constzend_object_handlers*handlers; zend_uintrefcount; gc_root_buffer*buffered; }obj; struct{ intnext; }free_list; }bucket; }zend_object_store_bucket;
这个结构体包含了很多东西。前三个成员只是些普通的元数据(对象的析构函数是否被调用过、bucke是否被使用过以及对象被递归调用过多少次)。接下来的联合体用于区分bucket是处于使用中的状态还是空闲状态。上面的结构中最重要的是struct_store_object子结构体:
第一个成员object是指向实际对象(也就是对象最终存储的位置)的指针。对象实际并不是直接嵌入到对象存储的bucket中的,因为对象不是定长的。对象指针下面是三个用于管理对象销毁、释放与克隆的操作句柄(handler)。这里要注意的是PHP销毁和释放对象是不同的步骤,前者在某些情况下有可能会被跳过(不完全释放)。克隆操作实际上几乎几乎不会被用到,因为这里包含的操作不是普通对象本身的一部分,所以(任何时候)他们在每个对象中他们都会被单独复制(duplicate)一份而不是共享。
这些对象存储操作句柄后面是一个普通的对象handlers指针。存储这几个数据是因为有时候可能会在zval未知的情况下销毁对象(通常情况下这些操作都是针对zval进行的)。
bucket也包含了refcount的字段,不过这种行为在PHP5中显得有些奇怪,因为zval本身已经存储了引用计数。为什么还需要一个多余的计数呢?问题在于虽然通常情况下zval的『复制』行为都是简单的增加引用计数即可,但是偶尔也会有深度复制的情况出现,比如创建一个全新的zval但是保存同样的zend_object_value。这种情况下两个不同的zval就用到了同一个对象存储的bucket,所以bucket自身也需要进行引用计数。这种『双重计数』的方式是PHP5的实现内在的问题。GC根缓冲区中的buffered指针也是由于同样的原因才需要进行完全复制(duplicate)。
现在看看对象存储中指针指向的实际的object的结构,通常情况下用户层面的对象定义如下:
typedefstruct_zend_object{ zend_class_entry*ce; HashTable*properties; zval**properties_table; HashTable*guards; }zend_object;
zend_class_entry指针指向的是对象实现的类原型。接下来的两个元素是使用不同的方式存储对象属性。动态属性(运行时添加的而不是在类中定义的)全部存在properties中,不过只是属性名和值的简单匹配。
不过这里有针对已经声明的属性的一个优化:编译期间每个属性都会被指定一个索引并且属性本身是存储在properties_table的索引中。属性名称和索引的匹配存储在类原型的hashtable中。这样就可以防止每个对象使用的内存超过hashtable的上限,并且属性的索引会在运行时有多处缓存。
guards的哈希表是用于实现魔术方法的递归行为的,比如__get,这里我们不深入讨论。
除了上文提到过的双重计数的问题,这种实现还有一个问题是一个最小的只有一个属性的对象也需要136个字节的内存(这还不算zval需要的内存)。而且中间存在很多间接访问动作:比如要从对象zval中取出一个元素,先需要取出对象存储bucket,然后是zendobject,然后才能通过指针找到对象属性表和zval。这样这里至少就有4层间接访问(并且实际使用中可能最少需要七层)。
PHP7中的对象
PHP7的实现中试图解决上面这些问题,包括去掉双重引用计数、减少内存使用以及间接访问。新的zend_object结构体如下:
struct_zend_object{ zend_refcountedgc; uint32_thandle; zend_class_entry*ce; constzend_object_handlers*handlers; HashTable*properties; zvalproperties_table[1]; };
可以看到现在这个结构体几乎就是一个对象的全部内容了:zend_object_value已经被替换成一个直接指向对象和对象存储的指针,虽然没有完全移除,但已经是很大的提升了。
除了PHP7中惯用的zend_refcounted头以外,handle和对象的handlers现在也被放到了zend_object中。这里的properties_table同样用到了C结构体的小技巧,这样zend_object和属性表就会得到一整块内存。当然,现在属性表是直接嵌入到zval中的而不是指针。
现在对象结构体中没有了guards表,现在如果需要的话这个字段的值会被存储在properties_table的第一位中,也就是使用__get等方法的时候。不过如果没有使用魔术方法的话,guards表会被省略。
dtor、free_storage和 clone三个操作句柄之前是存储在对象操作bucket中,现在直接存在handlers表中,其结构体定义如下:
struct_zend_object_handlers{ /*offsetofrealobjectheader(usuallyzero)*/ intoffset; /*generalobjectfunctions*/ zend_object_free_obj_tfree_obj; zend_object_dtor_obj_tdtor_obj; zend_object_clone_obj_tclone_obj; /*individualobjectfunctions*/ //...restisaboutthesameinPHP5 };
handler表的第一个成员是offset,很显然这不是一个操作句柄。这个offset是现在的实现中必须存在的,因为虽然内部的对象总是嵌入到标准的zend_object中,但是也总会有添加一些成员进去的需求。在PHP5中解决这个问题的方法是添加一些内容到标准的对象后面:
structcustom_object{ zend_objectstd; uint32_tsomething; //... };
这样如果你可以轻易的将zend_object*添加到structcustom_object*中。这也是C语言中常用的结构体继承的做法。但是在PHP7中这种实现会有一个问题:因为zend_object在存储属性表时用了结构体hack的技巧,zend_object尾部存储的PHP属性会覆盖掉后续添加进去的内部成员。所以PHP7的实现中会把自己添加的成员添加到标准对象结构的前面:
structcustom_object{ uint32_tsomething; //... zend_objectstd; };
不过这样也就意味着现在无法直接在zend_object*和structcustom_object*进行简单的转换了,因为两者都一个偏移分割开了。所以这个偏移量就需要被存储在对象handler表中的第一个元素中,这样在编译时通过offsetof()宏就能确定具体的偏移值。
也许你会好奇既然现在已经直接(在zend_value中)存储了zend_object的指针,那现在就不需要再到对象存储中去查找对象了,为什么PHP7的对象者还保留着handle字段呢?
这是因为现在对象存储仍然存在,虽然得到了极大的简化,所以保留handle仍然是有必要的。现在它只是一个指向对象的指针数组。当对象被创建时,会有一个指针插入到对象存储中并且其索引会保存在handle中,当对象被释放时,索引也会被移除。
那么为什么现在还需要对象存储呢?因为在请求结束的阶段会在存在某个节点,在这之后再去执行用户代码并且取指针数据时就不安全了。为了避免这种情况出现PHP会在更早的节点上执行所有对象的析构函数并且之后就不再有此类操作,所以就需要一个活跃对象的列表。
并且handle对于调试也是很有用的,它让每个对象都有了一个唯一的ID,这样就很容易区分两个对象是同一个还是只是有相同的内容。虽然HHVM没有对象存储的概念,但它也存了对象的handle。
和PHP5相比,现在的实现中只有一个引用计数(zval自身不计数),并且内存的使用量有了很大的缩减:40个字节用于基础对象,每个属性需要16个字节,并且这还是算了zval之后的。间接访问的情况也有了显著的改善,因为现在中间层的结构体要么被去掉了,要么就是直接嵌入的,所以现在读取一个属性只有一层访问而不再是四层。
间接zval
到现在我们已经基本提到过了所有正常的zval类型,但是也有一对特殊类型用于某些特定的情况的,其中之一就是PHP7新添加的IS_INDIRECT。
间接zval指的就是其真正的值是存储在其他地方的。注意这个IS_REFERENCE类型是不同的,间接zval是直接指向另外一个zval而不是像zend_reference结构体一样嵌入zval。
为了理解在什么时候会出现这种情况,我们来看一下PHP中变量的实现(实际上对象属性的存储也是一样的情况)。
所有在编译过程中已知的变量都会被指定一个索引并且其值会被存在编译变量(CV)表的相应位置中。但是PHP也允许你动态的引用变量,不管是局部变量还是全局变量(比如$GLOBALS),只要出现这种情况,PHP就会为脚本或者函数创建一个符号表,这其中包含了变量名和它们的值之间的映射关系。
但是问题在于:怎么样才能实现两个表的同时访问呢?我们需要在CV表中能够访问普通变量,也需要能在符号表中访问编译变量。在PHP5中CV表用了双重指针zval**,通常这些指针指向中间的zval*的表,zval*最终指向的才是实际的zval:
+------CV_ptr_ptr[0] |+----CV_ptr_ptr[1] ||+--CV_ptr_ptr[2] ||| ||+->CV_ptr[0]-->somezval |+--->CV_ptr[1]-->somezval +----->CV_ptr[2]-->somezval
当需要使用符号表时存储zval*的中间表其实是没有用到的而zval**指针会被更新到hashtablebuckets的响应位置中。我们假定有$a、$b和$c三个变量,下面是简单的示意图:
CV_ptr_ptr[0]-->SymbolTable["a"].pDataPtr-->somezval CV_ptr_ptr[1]-->SymbolTable["b"].pDataPtr-->somezval CV_ptr_ptr[2]-->SymbolTable["c"].pDataPtr-->somezval
但是PHP7的用法中已经没有这个问题了,因为PHP7中的hashtable大小发生变化时hashtablebucket就失效了。所以PHP7用了一个相反的策略:为了访问CV表中存储的变量,符号表中存储INDIRECT来指向CV表。CV表在符号表的生命周期内不会重新分配,所以也就不会存在有无效指针的问题了。
所以加入你有一个函数并且在CV表中有$a、$b和$c,同时还有一个动态分配的变量$d,符号表的结构看起来大概就是这个样子:
SymbolTable["a"].value=INDIRECT-->CV[0]=LONG42 SymbolTable["b"].value=INDIRECT-->CV[1]=DOUBLE42.0 SymbolTable["c"].value=INDIRECT-->CV[2]=STRING-->zend_string("42") SymbolTable["d"].value=ARRAY-->zend_array([4,2])
间接zval也可以是一个指向IS_UNDEF类型zval的指针,当hashtable没有和它关联的key时就会出现这种情况。所以当使用unset($a)将CV[0]的类型标记为UNDEF时就会判定符号表不存在键值为a的数据。
常量和AST
还有两个需要说一下的在PHP5和PHP7中都存在的特殊类型IS_CONSTANT和IS_CONSTANT_AST。要了解他们我们还是先看以下的例子:
<?php functiontest($a=ANSWER, $b=ANSWER*ANSWER){ return$a+$b; } define('ANSWER',42); var_dump(test());//int(42+42*42)·
test()函数的两个参数的默认值都是由常量ANSWER构成,但是函数声明时常量的值尚未定义。常量的具体值只有通过define()定义时才知道。
由于以上问题的存在,参数和属性的默认值、常量以及其他接受『静态表达式』的东西都支持『延时绑定』直到首次使用时。
常量(或者类的静态属性)这些需要『延时绑定』的数据就是最常需要用到IS_CONSTANT类型zval的地方。如果这个值是表达式,就会使用IS_CONSTANT_AST类型的zval指向表达式的抽象语法树(AST)。
到这里我们就结束了对PHP7中变量实现的分析。后面我可能还会写两篇文章来介绍一些虚拟机优化、新的命名约定以及一些编译器基础结构的优化的内容(这是作者原话)。