http://0x1.im/blog/php/Internal-value-representation-in-PHP-7-part-1.html

→About→Links→Github→公众号

Scholer's Blog

[译]变量在 PHP7 内部的实现一

Dec 10, 2015

本文第一部分和第二均翻译自Nikita Popov(nikicPHP 官方开发组成员柏林科技大学的学生) 的博客。为了更符合汉语的阅读习惯文中并不会逐字逐句的翻译。

要理解本文你应该对 PHP5 中变量的实现有了一些了解本文重点在于解释 PHP7 中 zval 的变化。

由于大量的细节描述本文将会分成两个部分第一部分主要描述 zval(zend value) 的实现在 PHP5 和 PHP7 中有何不同以及引用的实现。第二部分将会分析单独类型strings、objects的细节。

PHP5 中的 zval

PHP5 中 zval 结构体定义如下

typedefstruct_zval_struct{zvalue_valuevalue;zend_uintrefcount__gc;zend_uchartype;zend_ucharis_ref__gc;}zval;

如上zval 包含一个value、一个type以及两个__gc后缀的字段。value是个联合体用于存储不同类型的值

typedefunion_zvalue_value{longlval;//用于bool类型、整型和资源类型doubledval;//用于浮点类型struct{//用于字符串char*val;intlen;}str;HashTable*ht;//用于数组zend_object_valueobj;//用于对象zend_ast*ast;//用于常量表达式(PHP5.6才有)}zvalue_value;

C 语言联合体的特征是一次只有一个成员是有效的并且分配的内存与需要内存最多的成员匹配也要考虑内存对齐。所有成员都存储在内存的同一个位置根据需要存储不同的值。当你需要lval的时候它存储的是有符号×××需要dval时会存储双精度浮点数。

需要指出的是是联合体中当前存储的数据类型会记录到type字段用一个整型来标记

#defineIS_NULL0/*Doesn'tusevalue*/#defineIS_LONG1/*Useslval*/#defineIS_DOUBLE2/*Usesdval*/#defineIS_BOOL3/*Useslvalwithvalues0and1*/#defineIS_ARRAY4/*Usesht*/#defineIS_OBJECT5/*Usesobj*/#defineIS_STRING6/*Usesstr*/#defineIS_RESOURCE7/*Useslval,whichistheresourceID*//*Specialtypesusedforlate-bindingofconstants*/#defineIS_CONSTANT8#defineIS_CONSTANT_AST9PHP5 中的引用计数

在PHP5中zval 的内存是单独从堆heap中分配的有少数例外情况PHP 需要知道哪些 zval 是正在使用的哪些是需要释放的。所以这就需要用到引用计数zval 中refcount__gc的值用于保存 zval 本身被引用的次数比如$a = $b = 42语句中42被两个变量引用所以它的引用计数就是 2。如果引用计数变成 0就意味着这个变量已经没有用了内存也就可以释放了。

注意这里提及到的引用计数指的不是 PHP 代码中的引用使用&而是变量的使用次数。后面两者需要同时出现时会使用『PHP 引用』和『引用』来区分两个概念这里先忽略掉 PHP 的部分。

一个和引用计数紧密相关的概念是『写时复制』对于多个引用来说zaval 只有在没有变化的情况下才是共享的一旦其中一个引用改变 zval 的值就需要复制”separated”一份 zval然后修改复制后的 zval。

下面是一个关于『写时复制』和 zval 的销毁的例子

<?php$a=42;//$a->zval_1(type=IS_LONG,value=42,refcount=1)$b=$a;//$a,$b->zval_1(type=IS_LONG,value=42,refcount=2)$c=$b;//$a,$b,$c->zval_1(type=IS_LONG,value=42,refcount=3)//下面几行是关于zval分离的$a+=1;//$b,$c->zval_1(type=IS_LONG,value=42,refcount=2)//$a->zval_2(type=IS_LONG,value=43,refcount=1)unset($b);//$c->zval_1(type=IS_LONG,value=42,refcount=1)//$a->zval_2(type=IS_LONG,value=43,refcount=1)unset($c);//zval_1isdestroyed,becauserefcount=0//$a->zval_2(type=IS_LONG,value=43,refcount=1)

引用计数有个致命的问题无法检查并释放循环引用使用的内存。为了解决这问题PHP 使用了循环回收的方法。当一个 zval 的计数减一时就有可能属于循环的一部分这时将 zval 写入到『根缓冲区』中。当缓冲区满时潜在的循环会被打上标记并进行回收。

因为要支持循环回收实际使用的 zval 的结构实际上如下

typedefstruct_zval_gc_info{zvalz;union{gc_root_buffer*buffered;struct_zval_gc_info*next;}u;}zval_gc_info;

zval_gc_info结构体中嵌入了一个正常的 zval 结构同时也增加了两个指针参数但是共属于同一个联合体u所以实际使用中只有一个指针是有用的。buffered指针用于存储 zval 在根缓冲区的引用地址所以如果在循环回收执行之前 zval 已经被销毁了这个字段就可能被移除了。next在回收销毁值的时候使用这里不会深入。

修改动机

下面说说关于内存使用上的情况这里说的都是指在 64 位的系统上。首先由于strobj占用的大小一样zvalue_value这个联合体占用 16 个字节bytes的内存。整个zval结构体占用的内存是 24 个字节考虑到内存对齐zval_gc_info的大小是 32 个字节。综上在堆相对于栈分配给 zval 的内存需要额外的 16 个字节所以每个 zval 在不同的地方一共需要用到 48 个字节要理解上面的计算方式需要注意每个指针在 64 位的系统上也需要占用 8 个字节。

在这点上不管从什么方面去考虑都可以认为 zval 的这种设计效率是很低的。比如 zval 在存储整型的时候本身只需要 8 个字节即使考虑到需要存一些附加信息以及内存对齐额外 8 个字节应该也是足够的。

在存储整型时本来确实需要 16 个字节但是实际上还有 16 个字节用于引用计数、16 个字节用于循环回收。所以说 zval 的内存分配和释放都是消耗很大的操作我们有必要对其进行优化。

从这个角度思考一个整型数据真的需要存储引用计数、循环回收的信息并且单独在堆上分配内存吗答案是当然不这种处理方式一点都不好。

这里总结一下 PHP5 中 zval 实现方式存在的主要问题

zval 总是单独从堆中分配内存

zval 总是存储引用计数和循环回收的信息即使是整型这种可能并不需要此类信息的数据

在使用对象或者资源时直接引用会导致两次计数原因会在下一部分讲

某些间接访问需要一个更好的处理方式。比如现在访问存储在变量中的对象间接使用了四个指针指针链的长度为四。这个问题也放到下一部分讨论

直接计数也就意味着数值只能在 zval 之间共享。如果想在 zval 和 hashtable key 之间共享一个字符串就不行除非 hashtable key 也是 zval。

PHP7 中的 zval

在 PHP7 中 zval 有了新的实现方式。最基础的变化就是 zval 需要的内存不再是单独从堆上分配不再自己存储引用计数。复杂数据类型比如字符串、数组和对象的引用计数由其自身来存储。这种实现方式有以下好处

简单数据类型不需要单独分配内存也不需要计数

不会再有两次计数的情况。在对象中只有对象自身存储的计数是有效的

由于现在计数由数值自身存储所以也就可以和非 zval 结构的数据共享比如 zval 和 hashtable key 之间

间接访问需要的指针数减少了。

我们看看现在 zval 结构体的定义现在在 zend_types.h 文件中

struct_zval_struct{zend_valuevalue;/*value*/union{struct{ZEND_ENDIAN_LOHI_4(zend_uchartype,/*activetype*/zend_uchartype_flags,zend_ucharconst_flags,zend_ucharreserved)/*callinfoforEX(This)*/}v;uint32_ttype_info;}u1;union{uint32_tvar_flags;uint32_tnext;/*hashcollisionchain*/uint32_tcache_slot;/*literalcacheslot*/uint32_tlineno;/*linenumber(forastnodes)*/uint32_tnum_args;/*argumentsnumberforEX(This)*/uint32_tfe_pos;/*foreachposition*/uint32_tfe_iter_idx;/*foreachiteratorindex*/}u2;};

结构体的第一个元素没太大变化仍然是一个value联合体。第二个成员是由一个表示类型信息的整型和一个包含四个字符变量的结构体组成的联合体可以忽略ZEND_ENDIAN_LOHI_4宏它只是用来解决跨平台大小端问题的。这个子结构中比较重要的部分是type和以前类似和type_flags这个接下来会解释。

上面这个地方也有一点小问题value本来应该占 8 个字节但是由于内存对齐哪怕只增加一个字节实际上也是占用 16 个字节使用一个字节就意味着需要额外的 8 个字节。但是显然我们并不需要 8 个字节来存储一个 type 字段所以我们在u1的后面增加了了一个名为u2的联合体。默认情况下是用不到的需要使用的时候可以用来存储 4 个字节的数据。这个联合体可以满足不同场景下的需求。

PHP7 中value的结构定义如下

typedefunion_zend_value{zend_longlval;/*longvalue*/doubledval;/*doublevalue*/zend_refcounted*counted;zend_string*str;zend_array*arr;zend_object*obj;zend_resource*res;zend_reference*ref;zend_ast_ref*ast;zval*zv;void*ptr;zend_class_entry*ce;zend_function*func;struct{uint32_tw1;uint32_tw2;}ww;}zend_value;

首先需要注意的是现在 value 联合体需要的内存是 8 个字节而不是 16。它只会直接存储整型lval或者浮点型dval数据其他情况下都是指针上面提到过指针占用 8 个字节最下面的结构体由两个 4 字节的无符号整型组成。上面所有的指针类型除了特殊标记的都有一个同样的头zend_refcounted用来存储引用计数

typedefstruct_zend_refcounted_h{uint32_trefcount;/*referencecounter32-bit*/union{struct{ZEND_ENDIAN_LOHI_3(zend_uchartype,zend_ucharflags,/*usedforstrings&objects*/uint16_tgc_info)/*keepsGCrootnumber(or0)andcolor*/}v;uint32_ttype_info;}u;}zend_refcounted_h;

现在这个结构体肯定会包含一个存储引用计数的字段。除此之外还有typeflagsgc_infotype存储的和 zval 中的 type 相同的内容这样 GC 在不存储 zval 的情况下单独使用引用计数。flags在不同的数据类型中有不同的用途这个放到下一部分讲。

gc_info和 PHP5 中的buffered作用相同不过不再是位于根缓冲区的指针而是一个索引数字。因为以前根缓冲区的大小是固定的10000 个元素所以使用一个 16 位2 字节的数字代替 64 位8 字节的指针足够了。gc_info中同样包含一个『颜色』位用于回收时标记结点。

zval 内存管理

上文提到过 zval 需要的内存不再单独从堆上分配。但是显然总要有地方来存储它所以会存在哪里呢实际上大多时候它还是位于堆中所以前文中提到的地方重点不是而是单独分配只不过是嵌入到其他的数据结构中的比如 hashtable 和 bucket 现在就会直接有一个 zval 字段而不是指针。所以函数表编译变量和对象属性在存储时会是一个 zval 数组并得到一整块内存而不是散落在各处的 zval 指针。之前的zval *现在都变成了zval

之前当 zval 在一个新的地方使用时会复制一份zval *并增加一次引用计数。现在就直接复制 zval 的值忽略u2某些情况下可能会增加其结构指针指向的引用计数如果在进行计数。

那么 PHP 怎么知道 zval 是否正在计数呢不是所有的数据类型都能知道因为有些类型比如字符串或数组并不是总需要进行引用计数。所以type_info字段就是用来记录 zval 是否在进行计数的这个字段的值有以下几种情况

#defineIS_TYPE_CONSTANT(1<<0)/*special*/#defineIS_TYPE_IMMUTABLE(1<<1)/*special*/#defineIS_TYPE_REFCOUNTED(1<<2)#defineIS_TYPE_COLLECTABLE(1<<3)#defineIS_TYPE_COPYABLE(1<<4)#defineIS_TYPE_SYMBOLTABLE(1<<5)/*special*/

注在 7.0.0 的正式版本中上面这一段宏定义的注释这几个宏是供zval.u1.v.type_flags使用的。这应该是注释的错误因为这个上述字段是zend_uchar类型。

type_info的三个主要的属性就是『可计数』refcounted、『可回收』collectable和『可复制』copyable。计数的问题上面已经提过了。『可回收』用于标记 zval 是否参与循环不如字符串通常是可计数的但是你却没办法给字符串制造一个循环引用的情况。

是否可复制用于表示在复制时是否需要在复制时制造原文用的 “duplication” 来表述用中文表达出来可能不是很好理解一份一模一样的实体。”duplication” 属于深度复制比如在复制数组时不仅仅是简单增加数组的引用计数而是制造一份全新值一样的数组。但是某些类型比如对象和资源即使 “duplication” 也只能是增加引用计数这种就属于不可复制的类型。这也和对象和资源现有的语义匹配现有PHP7 也是这样不单是 PHP5。

下面的表格上标明了不同的类型会使用哪些标记x标记的都是有的特性。『简单类型』simple types指的是整型或布尔类型这些不使用指针指向一个结构体的类型。下表中也有『不可变』immutable的标记它用来标记不可变数组的这个在下一部分再详述。

interned string保留字符在这之前没有提过其实就是函数名、变量名等无需计数、不可重复的字符串。

|refcounted|collectable|copyable|immutable----------------+------------+-------------+----------+----------simpletypes||||string|x||x|internedstring||||array|x|x|x|immutablearray||||xobject|x|x||resource|x|||reference|x|||

要理解这一点我们可以来看几个例子这样可以更好的认识 zval 内存管理是怎么工作的。

下面是整数行为模式在上文中 PHP5 的例子的基础上进行了一些简化

<?php$a=42;//$a=zval_1(type=IS_LONG,value=42)$b=$a;//$a=zval_1(type=IS_LONG,value=42)//$b=zval_2(type=IS_LONG,value=42)$a+=1;//$a=zval_1(type=IS_LONG,value=43)//$b=zval_2(type=IS_LONG,value=42)unset($a);//$a=zval_1(type=IS_UNDEF)//$b=zval_2(type=IS_LONG,value=42)

这个过程其实挺简单的。现在整数不再是共享的变量直接就会分离成两个单独的 zval由于现在 zval 是内嵌的所以也不需要单独分配内存所以这里的注释中使用=来表示的而不是指针符号->unset 时变量会被标记为IS_UNDEF。下面看一下更复杂的情况

<?php$a=[];//$a=zval_1(type=IS_ARRAY)->zend_array_1(refcount=1,value=[])$b=$a;//$a=zval_1(type=IS_ARRAY)->zend_array_1(refcount=2,value=[])//$b=zval_2(type=IS_ARRAY)---^//zval分离在这里进行$a[]=1//$a=zval_1(type=IS_ARRAY)->zend_array_2(refcount=1,value=[1])//$b=zval_2(type=IS_ARRAY)->zend_array_1(refcount=1,value=[])unset($a);//$a=zval_1(type=IS_UNDEF),zend_array_2被销毁//$b=zval_2(type=IS_ARRAY)->zend_array_1(refcount=1,value=[])

这种情况下每个变量变量有一个单独的 zval但是是指向同一个有引用计数zend_array的结构体。修改其中一个数组的值时才会进行复制。这点和 PHP5 的情况类似。

类型Types

我们大概看一下 PHP7 支持哪些类型zval 使用的类型标记

/*regulardatatypes*/#defineIS_UNDEF0#defineIS_NULL1#defineIS_FALSE2#defineIS_TRUE3#defineIS_LONG4#defineIS_DOUBLE5#defineIS_STRING6#defineIS_ARRAY7#defineIS_OBJECT8#defineIS_RESOURCE9#defineIS_REFERENCE10/*constantexpressions*/#defineIS_CONSTANT11#defineIS_CONSTANT_AST12/*internaltypes*/#defineIS_INDIRECT15#defineIS_PTR17

这个列表和 PHP5 使用的类似不过增加了几项

IS_UNDEF用来标记之前为NULL的 zval 指针和IS_NULL并不冲突。比如在上面的例子中使用unset注销变量

IS_BOOL现在分割成了IS_FALSEIS_TRUE两项。现在布尔类型的标记是直接记录到 type 中这么做可以优化类型检查。不过这个变化对用户是透明的还是只有一个『布尔』类型的数据PHP 脚本中。

PHP 引用不再使用is_ref来标记而是使用IS_REFERENCE类型。这个也要放到下一部分讲

IS_INDIRECTIS_PTR是特殊的内部标记。

实际上上面的列表中应该还存在两个 fake types这里忽略了。

IS_LONG类型表示的是一个zend_long的值而不是原生的 C 语言的 long 类型。原因是 Windows 的 64 位系统LLP64上的long类型只有 32 位的位深度。所以 PHP5 在 Windows 上只能使用 32 位的数字。PHP7 允许你在 64 位的操作系统上使用 64 位的数字即使是在 Windows 上面也可以。

zend_refcounted的内容会在下一部分讲。下面看看 PHP 引用的实现。

引用

PHP7 使用了和 PHP5 中完全不同的方法来处理 PHP&符号引用的问题这个改动也是 PHP7 开发过程中大量 bug 的根源。我们先从 PHP5 中 PHP 引用的实现方式说起。

通常情况下 写时复制原则意味着当你修改一个 zval 之前需要对其进行分离来保证始终修改的只是某一个 PHP 变量的值。这就是传值调用的含义。

但是使用 PHP 引用时这条规则就不适用了。如果一个 PHP 变量是 PHP 引用就意味着你想要在将多个 PHP 变量指向同一个值。PHP5 中的is_ref标记就是用来注明一个 PHP 变量是不是 PHP 引用在修改时需不需要进行分离的。比如

<?php$a=[];//$a->zval_1(type=IS_ARRAY,refcount=1,is_ref=0)->HashTable_1(value=[])$b=&$a;//$a,$b->zval_1(type=IS_ARRAY,refcount=2,is_ref=1)->HashTable_1(value=[])$b[]=1;//$a=$b=zval_1(type=IS_ARRAY,refcount=2,is_ref=1)->HashTable_1(value=[1])//因为is_ref的值是1,所以PHP不会对zval进行分离

但是这个设计的一个很大的问题在于它无法在一个 PHP 引用变量和 PHP 非引用变量之间共享同一个值。比如下面这种情况

<?php$a=[];//$a->zval_1(type=IS_ARRAY,refcount=1,is_ref=0)->HashTable_1(value=[])$b=$a;//$a,$b->zval_1(type=IS_ARRAY,refcount=2,is_ref=0)->HashTable_1(value=[])$c=$b//$a,$b,$c->zval_1(type=IS_ARRAY,refcount=3,is_ref=0)->HashTable_1(value=[])$d=&$c;//$a,$b->zval_1(type=IS_ARRAY,refcount=2,is_ref=0)->HashTable_1(value=[])//$c,$d->zval_1(type=IS_ARRAY,refcount=2,is_ref=1)->HashTable_2(value=[])//$d是$c的引用,但却不是$a的$b,所以这里zval还是需要进行复制//这样我们就有了两个zval,一个is_ref的值是0,一个is_ref的值是1.$d[]=1;//$a,$b->zval_1(type=IS_ARRAY,refcount=2,is_ref=0)->HashTable_1(value=[])//$c,$d->zval_1(type=IS_ARRAY,refcount=2,is_ref=1)->HashTable_2(value=[1])//因为有两个分离了的zval,$d[]=1的语句就不会修改$a和$b的值.

这种行为方式也导致在 PHP 中使用引用比普通的值要慢。比如下面这个例子

<?php$array=range(0,1000000);$ref=&$array;var_dump(count($array));//<--这里会进行分离

因为count()只接受传值调用但是$array是一个 PHP 引用所以count()在执行之前实际上会有一个对数组进行完整的复制的过程。如果$array不是引用这种情况就不会发生了。

现在我们来看看 PHP7 中 PHP 引用的实现。因为 zval 不再单独分配内存也就没办法再使用和 PHP5 中相同的实现了。所以增加了一个IS_REFERENCE类型并且专门使用zend_reference来存储引用值

struct_zend_reference{zend_refcountedgc;zvalval;};

本质上zend_reference只是增加了引用计数的 zval。所有引用变量都会存储一个 zval 指针并且被标记为IS_REFERENCEval和其他的 zval 的行为一样尤其是它也可以在共享其所存储的复杂变量的指针比如数组可以在引用变量和值变量之间共享。

我们还是看例子这次是 PHP7 中的语义。为了简洁明了这里不再单独写出 zval只展示它们指向的结构体

<?php$a=[];//$a->zend_array_1(refcount=1,value=[])$b=&$a;//$a,$b->zend_reference_1(refcount=2)->zend_array_1(refcount=1,value=[])$b[]=1;//$a,$b->zend_reference_1(refcount=2)->zend_array_1(refcount=1,value=[1])

上面的例子中进行引用传递时会创建一个zend_reference注意它的引用计数是 2因为有两个变量在使用这个 PHP 引用。但是值本身的引用计数是 1因为zend_reference只是有一个指针指向它。下面看看引用和非引用混合的情况

<?php$a=[];//$a->zend_array_1(refcount=1,value=[])$b=$a;//$a,$b,->zend_array_1(refcount=2,value=[])$c=$b//$a,$b,$c->zend_array_1(refcount=3,value=[])$d=&$c;//$a,$b->zend_array_1(refcount=3,value=[])//$c,$d->zend_reference_1(refcount=2)---^//注意所有变量共享同一个zend_array,即使有的是PHP引用有的不是$d[]=1;//$a,$b->zend_array_1(refcount=2,value=[])//$c,$d->zend_reference_1(refcount=2)->zend_array_2(refcount=1,value=[1])//只有在这时进行赋值的时候才会对zend_array进行赋值

这里和 PHP5 最大的不同就是所有的变量都可以共享同一个数组即使有的是 PHP 引用有的不是。只有当其中某一部分被修改的时候才会对数组进行分离。这也意味着使用count()时即使给其传递一个很大的引用数组也是安全的不会再进行复制。不过引用仍然会比普通的数值慢因为存在需要为zend_reference结构体分配内存间接并且引擎本身处理这一块儿也不快的的原因。

结语

总结一下 PHP7 中最重要的改变就是 zval 不再单独从堆上分配内存并且不自己存储引用计数。需要使用 zval 指针的复杂类型比如字符串、数组和对象会自己存储引用计数。这样就可以有更少的内存分配操作、更少的间接指针使用以及更少的内存分配。

文章的第二部分我们会讨论复杂类型的问题。

More:

Jan 07,2017再见2016我在腾讯这一年

Jan 02,2017如何学习 PHP 源码 - 从编译开始

Website powered byJekyll, hosted onGithuband theme ofmarcgg
All of the blog's articles are underCreative commonslicense unless stated otherwise. Everything else is .