67677新澳门手机版 > 67677新澳门手机版 > 变量在 PHP7 内部的实现
变量在 PHP7 内部的实现
2020-03-20 16:38

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

PHP7变量的内部实现-part 1

本文翻译自Nikita的文章,水平有限,如有错误,欢迎指正查看原文

受篇幅限制,这篇文章将分为两个部分。本部分会讲解PHP5和PHP7在zval结构体的差异,同时也会讨论引用的实现。第二部分会深入探究一些数据类型如string和对象的实现。

要理解本文,你应该对 PHP5 中变量的实现有了一些了解,本文重点在于解释 PHP7 中 zval 的变化。
由于大量的细节描述,本文将会分成两个部分:第一部分主要描述 zval(zend value) 的实现在 PHP5 和 PHP7 中有何不同以及引用的实现。第二部分将会分析单独类型(strings、objects)的细节。
PHP5 中的 zval

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

PHP5中的zval

PHP5中zval结构体的定义如下:

typedef struct _zval_struct {    zvalue_value value;    zend_uint refcount__gc;    zend_uchar type;    zend_uchar is_ref__gc;} zval;

可以看到,zval由value、type和一些额外的__gc信息组成。__gc与垃圾回收相关,我们稍后讨论。value是一个共用体,可以存储y一个zval各种可能的值。

typedef union _zvalue_value {    long lval;                 // For booleans, integers and resources    double dval;               // For floating point numbers    struct {                   // For strings        char *val;        int len;    } str;    HashTable *ht;             // For arrays    zend_object_value obj;     // For objects    zend_ast *ast;             // For constant expressions} zvalue_value;

C语言中,共用体的尺寸与它最大的成员尺寸相同,在某一时刻只能有一个成员处于活动状态。共用体所有的成员都存储在相同的内存,根据你访问的成员不同,内容会被解释成不同的类型。以上面的共用体为例,如果访问lval,值将被解释为一个有符号整型;而访问dval将被解释成双精度浮点型。以此类推。

为了弄清结构体中哪个成员处于活动状态,zval会存储一个整型type来标识具体的数据类型。

#define IS_NULL     0      /* Doesn't use value */#define IS_LONG     1      /* Uses lval */#define IS_DOUBLE   2      /* Uses dval */#define IS_BOOL     3      /* Uses lval with values 0 and 1 */#define IS_ARRAY    4      /* Uses ht */#define IS_OBJECT   5      /* Uses obj */#define IS_STRING   6      /* Uses str */#define IS_RESOURCE 7      /* Uses lval, which is the resource ID *//* Special types used for late-binding of constants */#define IS_CONSTANT 8#define IS_CONSTANT_AST 9

PHP5 中 zval 结构体定义如下:
typedef struct _zval_struct {
    zvalue_value value;
    zend_uint refcount__gc;
    zend_uchar type;
    zend_uchar is_ref__gc;
} zval;
如上,zval 包含一个 value、一个 type 以及两个 __gc 后缀的字段。value 是个联合体,用于存储不同类型的值:
typedef union _zvalue_value {
    long lval;                 // 用于 bool 类型、整型和资源类型
    double dval;               // 用于浮点类型
    struct {                   // 用于字符串
        char *val;
        int len;
    } str;
    HashTable *ht;             // 用于数组
    zend_object_value obj;     // 用于对象
    zend_ast *ast;             // 用于常量表达式(PHP5.6 才有)
} zvalue_value;
C 语言联合体的特征是一次只有一个成员是有效的并且分配的内存与需要内存最多的成员匹配(也要考虑内存对齐)。所有成员都存储在内存的同一个位置,根据需要存储不同的值。当你需要 lval 的时候,它存储的是有符号整形,需要 dval 时,会存储双精度浮点数。
需要指出的是是联合体中当前存储的数据类型会记录到 type 字段,用一个整型来标记:
#define IS_NULL     0      /* Doesn't use value */
#define IS_LONG     1      /* Uses lval */
#define IS_DOUBLE   2      /* Uses dval */
#define IS_BOOL     3      /* Uses lval with values 0 and 1 */
#define IS_ARRAY    4      /* Uses ht */
#define IS_OBJECT   5      /* Uses obj */
#define IS_STRING   6      /* Uses str */
#define IS_RESOURCE 7      /* Uses lval, which is the resource ID */

第一部分讲了 PHP5 和 PHP7 中关于变量最基础的实现和变化。这里再重复一下,主要的变化就是 zval 不再单独分配内存,不自己存储引用计数。整型浮点型等简单类型直接存储在 zval 中。复杂类型则通过指针指向一个独立的结构体。

PHP5中的引用计数

除少数例外,在PHP5中zval都是分配在堆内存的,PHP需要通过某种方式跟踪哪些zval在被使用,哪些应该被释放。为达到这个目的,引用计数被使用。引用计数即在结构体中用refcount__gc成员来记录该结构体被“引用”了多少次。例如,在$a = $b = 42中,42被两个变量引用,所以它的引用计数为2。如果引用计数变成0,则意味着该值没被使用,可以被释放。

需要注意的是引用计数的“引用”(即一个值被引用的次数)与“PHP引用”($a=&$b)毫无关系。在接下来的内容里,我会始终使用“引用”和“PHP引用”这两个术语来释疑这两个概念。就当前来说,我们先把“PHP引用”放在一边。

与引用计数密切相关的一个概念是“写时复制”(copy on write):zval只能在其内容未被修改的时候才能在多个变量间共享。要实现修改,zval必选被复制,而改动只能在复制出的zval上进行。

以下例子展示了写时复制和zval销毁。

$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; // $c -> zval_1(type=IS_LONG, value=42, refcount=1)           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)unset; // zval_1 被销毁,因为其refcount=0           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

引用计数有一个致命缺陷:它不能检测和释放循环引用。为解决这个问题,PHP额外使用了环收集器。当一个zval的引用计数减少的时候,它就有一定几率是循环引用的一部分,该zval就被写入到“根缓冲区”。当根缓冲区满后,可能的引用环将被标记并收集,同时启动垃圾回收。

为了支持这个环收集器,实际使用了如下的zval结构体:

typedef struct _zval_gc_info {    zval z;    union {        gc_root_buffer       *buffered;        struct _zval_gc_info *next;    } u;} zval_gc_info;

zval_gc_info结构体内置了普通zval和一个指针-注意u是一个共用体,也就是说实际上只有一个指针,它可能指向两种不同的类型。buffered指针用来存储zval在根缓冲区中的引用位置,如果zval在环收集器运行之前就被销毁,那么该指针将会从根缓冲区移除。next指针在收集器销毁值的时候会被用到,但是我不会深入讲解这一点。

/* Special types used for late-binding of constants */
#define IS_CONSTANT 8
#define IS_CONSTANT_AST 9
PHP5 中的引用计数

复杂的 zval 数据值有一个共同的头,其结构由 zend_refcounted 定义:

改进的动机

先讨论一下基于64位系统的内存占用。首先,zvalue_value共用体占用16个字节,因为它的str和obj成员都那么大。整个zval结构体一共24个字节(由于内存对齐[padding]),而zval_gc_info是32字节。除此之外,在堆分配的过程中,又增加了16字节的分配开销。由此一个zval就占用48字节--尽管该zval可能在多个地方都被用到。

现在我们就可以分析下这种zval实现方式低效的地方。考虑用zval存储整数的情况,整数占用8个字节,另外类型标示是必需的,它本身占用一个字节,但是由于内存对齐,实际上就要加上8个字节。

这16字节是我们真正“需要”的空间,此外,为了处理引用计数和垃圾回收,我们增加了16字节;由于分配开销又增加了另外16字节。更不用提还要处理分配和后续的释放,这都是很昂贵的操作。

由此引发了一个问题:一个简单的整数真的需要存储为一个有引用计数、可垃圾回收,并且是堆分配的值吗?答案当然是不需要,这样做是没道理的。

以下概述了PHP5中zval实现方式的一些主要问题:

  • zval总是需要堆分配。
  • zval总是会被引用计数且携带环收集信息,即使是在共享值不划算和不能形成引用环的情况下。
  • 当处理对象和资源时,直接对zval进行引用计数会导致双重计数。原因会在下一部分讨论。
  • 某些情况会引入很多的间接操作。比如为了访问一个对象,一共要进行4次指针跳转。这也将在下一篇中分析。
  • 直接对zval进行引用计数意味着值只能在zval间共享。比如我们不能在zval和哈希表key之间共享一个字符串(不将哈希表key用zval变量存放)。

在PHP5中,zval 的内存是单独从堆(heap)中分配的(有少数例外情况),PHP 需要知道哪些 zval 是正在使用的,哪些是需要释放的。所以这就需要用到引用计数:zval 中 refcount__gc 的值用于保存 zval 本身被引用的次数,比如 $a = $b = 42 语句中,42 被两个变量引用,所以它的引用计数就是 2。如果引用计数变成 0,就意味着这个变量已经没有用了,内存也就可以释放了。
注意这里提及到的引用计数指的不是 PHP 代码中的引用(使用 &),而是变量的使用次数。后面两者需要同时出现时会使用『PHP 引用』和『引用』来区分两个概念,这里先忽略掉 PHP 的部分。
一个和引用计数紧密相关的概念是『写时复制』:对于多个引用来说,zaval 只有在没有变化的情况下才是共享的,一旦其中一个引用改变 zval 的值,就需要复制(”separated”)一份 zval,然后修改复制后的 zval。
下面是一个关于『写时复制』和 zval 的销毁的例子:
$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)

struct _zend_refcounted {
    uint32_t refcount;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    type,
                zend_uchar    flags,
                uint16_t      gc_info)
        } v;
        uint32_t type_info;
    } u;
};

PHP7中的zval

通过以上讨论,我们引进了PHP7新的zval实现。最根本的改变是zval不再是堆分配且它自身不再存储引用计数。相反的,对zval指向的任何复杂类型值(如字符串、数组、对象),这些值将自己存储引用计数。这有以下优点:

  • 简单值不需要分配且不用引用计数。
  • 不再有双重引用计数。对对象来说,只有在对象本身存在引用计数。
  • 由于引用计数保存在值中,这个可以独立于zval结构而被复用。同一个字符串能同时被zval和哈希表key引用。
  • 间接操作少了很多,也就是说在获取一个值的时候需要跳转的指针数量变少了。

新的zval定义如下:

struct _zval_struct {    zend_value value;    union {        struct {            ZEND_ENDIAN_LOHI_4(                zend_uchar type,                zend_uchar type_flags,                zend_uchar const_flags,                zend_uchar reserved)        } v;        uint32_t type_info;    } u1;    union {        uint32_t var_flags;        uint32_t next;                 // hash collision chain        uint32_t cache_slot;           // literal cache slot        uint32_t lineno;               // line number (for ast nodes)        uint32_t num_args;             // arguments number for EX        uint32_t fe_pos;               // foreach position        uint32_t fe_iter_idx;          // foreach iterator index    } u2;};

第一个成员跟之前类似,也是一个value共同体。第二个成员是个整数,用来存储类型信息,它被一个共用体分隔成独立的字节空间(可忽略ZEND_ENDIAN_LOHI_4宏,它是用来保证在不同字节序平台上布局的一致性)。这个子结构中type和type_flags比较重要,我将稍后讨论他们。

此时有一个小问题:value成员占8字节空间,由于结构体内存对齐,即使增加一个字节也会让zval内存增长到16字节。然而很明显我们不需要8个字节来仅仅存放类型信息。这就是为什么此zval包含了一个额外的u2共用体,它默认情况下是没被占用的,但是却可以根据需要存储4字节的数据。这个共用体中不同的成员用来实现该额外数据片段不同的用途。

PHP7中的value共用体看起来略有不同:

typedef union _zend_value {    zend_long         lval;    double            dval;    zend_refcounted  *counted;    zend_string      *str;    zend_array       *arr;    zend_object      *obj;    zend_resource    *res;    zend_reference   *ref;    zend_ast_ref     *ast;    // Ignore these for now, they are special    zval             *zv;    void             *ptr;    zend_class_entry *ce;    zend_function    *func;    struct {        ZEND_ENDIAN_LOHI(            uint32_t w1,            uint32_t w2)    } ww;} zend_value;

首先要注意到这个共用体占用8字节而不是16字节。它仅仅会直接存储整数和双精度浮点数,对其它类型它都会存储对应指针。所有的指针类型(除了什么代码中标记为特殊的)都会引用计数并且有一个通用的头部,定义为zend_refcounted:

struct _zend_refcounted {    uint32_t refcount;    union {        struct {            ZEND_ENDIAN_LOHI_3(                zend_uchar    type,                zend_uchar    flags,                uint16_t      gc_info)        } v;        uint32_t type_info;    } u;};

不用说这个结构会包含引用计数。另外,它还包含type、flags和gc_info。type是复制的zval的type,它使得GC在不存储zval的情况下就能区分不同的引用计数结构。根据类型的不同,flags有不同的使用目的,这些会在下一部分按类型分别讨论。

gc_info等同于老zval中的buffered成员。不同的是它存储了在根缓冲区中的索引,来代替之前的指针。因为跟缓冲区尺寸固定,用16字节的数子而不是64位的指针就足够了。gc_info还含有该节点的“颜色”信息,这在垃圾回收中用来标记节点。

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

这个头存储有 refcount(引用计数),值的类型 type 和循环回收的相关信息 gc_info 以及类型标志位 flags

zval内存管理

我已经提到zval不再是单独的堆分配。然而很明显它仍然需要被存在某个地方,那么这是怎么实现的呢?尽管zval大多数时候仍是堆分配数据结构的一部分,不过它们是直接嵌入到这些数据结构中的。比如哈希表就会直接内置zval而不是存放一个指向另一zval的指针。函数的编译变量表或者对象的属性表会直接保存为一个拥有连续内存的zval数组,而不再存储指向散落各处zval的指针。因此当前的zval存储通常都会少了一层的间接引用,也就是说现在的zval相当于之前的zval*。

当一个zval在新的地方被引用时,按照之前的方式,就意味着要复制zavl*并增加它的引用计数。现在则需要复制zval的内容,同时如果该zval指向的值用到引用计数的话则还要增加该值的引用计数。

PHP是如何知道一个值是否用到引用计数的呢?这不能仅仅依靠类型来判断,因为有些类型比如字符串和数组并不总是引用计数的。相反的,会根据构成zval的type_info的一个字节来判断是否引用计数。另外还有其它几个字节编码了该类型的一些特征。

#define IS_TYPE_CONSTANT            (1<<0)   /* special */#define IS_TYPE_IMMUTABLE           (1<<1)   /* special */#define IS_TYPE_REFCOUNTED          (1<<2)#define IS_TYPE_COLLECTABLE         (1<<3)#define IS_TYPE_COPYABLE            (1<<4)#define IS_TYPE_SYMBOLTABLE         (1<<5)   /* special */

一个类型能拥有的三个主要特征是引用计数、可回收和可复制。引用计数的含义已讨论过,可回收意味着该zval可能参与循环引用。举例来说,字符串是引用计数的,但是却没法用字符串构造一个引用环。

可复制性决定了在为一个变量创建“副本”的时候它的值是否需要执行拷贝。副本是硬拷贝,比如复制指向数组的zval时,就不是简单的增加数组的引用计数,而是要创建该数组的一个新的独立拷贝。然而对对象和资源这些类型来说,复制应该仅仅增加引用计数--这些类型就是所谓的不可复制。这与对象和资源在进行传递时的语义相符。

以下表格展示了不同类型和它们所用的标识。“简单类型”指整数和布尔值这类不需要用指针指向一个单独结构的类型。同时还用一列展示了“不可变”标记,它用来标记不可变数组,这将在下一部分详细讨论。

                | refcounted | collectable | copyable | immutable----------------+------------+-------------+----------+----------simple types    |            |             |          |string          |      x     |             |     x    |interned string |            |             |          |array           |      x     |      x      |     x    |immutable array |            |             |          |     xobject          |      x     |      x      |          |resource        |      x     |             |          |reference       |      x     |             |          |

来看一下在实际中zval管理是如何工作的。先基于上文PHP5的例子来讨论一下整型实现:

$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 = zval_1(type=IS_UNDEF)           // $b = zval_2(type=IS_LONG, value=42)

这个例子挺无趣的。简单来说就是整型不会再被共用,这些变量都有单独的zval。不要忘了zval不再需要单独分配,它们是内嵌的,我通过把->换成=来表示这种变化。unset一个变量会把对应zval的type设置为IS_UNDEF。现在来考虑一下当涉及复杂类型时的情况,这种案例有趣的多。

$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 = zval_1(type=IS_UNDEF) and zend_array_2 is destroyed           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

本例中每个变量依然有单独的zval,但是这些zval都指向了同一个zend_array结构。同PHP5一样,当发生修改时,数组需要被复制。

unset($b); // $c -> zval_1(type=IS_LONG, value=42, refcount=1)
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

接下来会对每种复杂类型的实现单独进行分析并和 PHP5 的实现进行比较。引用虽然也属于复杂类型,但是上一部分已经介绍过了,这里就不再赘述。另外这里也不会讲到资源类型(因为作者觉得资源类型没什么好讲的)。

类型

看一下PHP7是如何支持各种数据类型的:

// regular data types#define IS_UNDEF                    0#define IS_NULL                     1#define IS_FALSE                    2#define IS_TRUE                     3#define IS_LONG                     4#define IS_DOUBLE                   5#define IS_STRING                   6#define IS_ARRAY                    7#define IS_OBJECT                   8#define IS_RESOURCE                 9#define IS_REFERENCE                10// constant expressions#define IS_CONSTANT                 11#define IS_CONSTANT_AST             12// internal types#define IS_INDIRECT                 15#define IS_PTR                      17

这个列表跟PHP5类似,但有一些内容增加:

  • IS_UNDEF类型替代了之前的NULL zval指针(注意与IS_NULL zval区分),比如在上面引用计数的例子中,变量被unset时,zval的类型就被置为IS_UNDEF。
  • IS_BOOL类型被细分成了IS_FALSE和IS_TRUE。由此布尔变量的值就被编码在类型中,这就使得一些基于类型检查的优化成为可能。这个改变对用户层是透明的,仍然有一个“布尔”类型。
  • 在zval上,PHP引用不再使用is_ref标识,而是用IS_REFERENCE类型。下一部分将会讨论。
  • IS_INDIRECT和IS_PTR是特殊的内部类型。

IS_LONG目前存储的是zend_long类型的值,而不是一个普通的C语言long整数。原因是在64位windows上,long型只有32位,于是在windows上PHP5的IS_LONG总是32位的。在64位操作系统上,即使你使用的是windows,PHP7都允许你使用64位的数字。

zend_refcounted类型相关的细节将在下一部分讨论,现在我们先看一下PHP引用的实现。

unset($c); // zval_1 is destroyed, because refcount=0
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)
引用计数有个致命的问题:无法检查并释放循环引用(使用的内存)。为了解决这问题,PHP 使用了循环回收的方法。当一个 zval 的计数减一时,就有可能属于循环的一部分,这时将 zval 写入到『根缓冲区』中。当缓冲区满时,潜在的循环会被打上标记并进行回收。
因为要支持循环回收,实际使用的 zval 的结构实际上如下:
typedef struct _zval_gc_info {
    zval z;
    union {
        gc_root_buffer       *buffered;
        struct _zval_gc_info *next;
    } u;
} zval_gc_info;
zval_gc_info 结构体中嵌入了一个正常的 zval 结构,同时也增加了两个指针参数,但是共属于同一个联合体 u,所以实际使用中只有一个指针是有用的。buffered 指针用于存储 zval 在根缓冲区的引用地址,所以如果在循环回收执行之前 zval 已经被销毁了,这个字段就可能被移除了。next 在回收销毁值的时候使用,这里不会深入。
修改动机

字符串

PHP7 中定义了一个新的结构体 zend_string 用于存储字符串变量:

struct _zend_string {
    zend_refcounted   gc;
    zend_ulong        h;        /* hash value */
    size_t            len;
    char              val[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 的标志位中的):

#define IS_STR_PERSISTENT           (1/* allocated using malloc */
#define IS_STR_INTERNED             (1/* interned string */
#define IS_STR_PERMANENT            (1/* interned string surviving request boundary */

持久化的字符串需要的内存直接从系统本身分配而不是 zend 内存管理器(ZMM),这样它就可以一直存在而不是只在单次请求中有效。给这种特殊的分配打上标记便于 zval 使用持久化字符串。在 PHP5 中并不是这样处理的,是在使用前复制一份到 ZMM 中。

保留字符(interned strings)有点特殊,它会一直存在直到请求结束时才销毁,所以也就无需进行引用计数。保留字符串也不可重复(duplicate),所以在创建新的保留字符时也会先检查是否有同样字符的已经存在。所有 PHP 源码中不可变的字符串都是保留字符(包括字符串常量、变量名函数名等)。持久化字符串也是请求开始之前已经创建好的保留字符。但普通的保留字符在请求结束后会销毁,持久化字符串却始终存在。

如果使用了 opcache 的话保留字符会被存储在共享内存(SHM)中这样就可以在所有 PHP 进程质检共享。这种情况下持久化字符串也就没有存在的意义了,因为保留字符也是不会被销毁的。

引用

PHP7处理PHP引用的方式与PHP5完全不同(我可以告诉你这个改变是PHP7最大的bug来源之一)。PHP5中引用的实现如下:

通常,写时复制机制意味着在修改之前,zval要先进行分离,以保证不会把其它共用该zval的变量给一起修改了。这与值传递的语义相符。

对PHP引用来说,就不是这种情况了。如果一个值是引用,那么修改的时候就希望其它变量也同步被修改。PHP5用is_ref来判断一个值是不是PHP引用,以及在修改的时候是否要执行分离操作。看一个例子:

$a = [];  // $a     -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1$b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1$b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1])

这种设计一个很重大的问题就是不能在普通变量和PHP引用之前共享一个值。考虑如下情形:

$a = [];  // $a         -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1$b = $a;  // $a, $b     -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1$c = $b   // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1$d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2          // $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          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[1])          // 由于有两个独立的zval $d[] = 1 不会修改到$a和$b.

这种行为就导致使用PHP引用通常比普通变量更慢。下面的例子就有这个问题:

$array = range(0, 1000000);$ref =& $array;var_dump(count; // <-- 这里发生zval分离

因为count()的参数是按值传递的,而$array是一个引用变量,在把它传递给count()时,会对该数组执行完整的复制。如果$array不是引用,它的值就可以共用,在传递的时候就不会发生复制。

现在来看下PHP7中引用的实现。由于zval不再是独立分配,不再可能使用PHP5一样的方式。转而增加了IS_REFERENCEl类型,它的值是如下的zend_reference结构:

struct _zend_reference {    zend_refcounted   gc;    zval              val;};

所以zend_reference本质上只是一个有引用计数的zval。在一个引用集合中所有的变量都会保存一份IS_REFERENCEl类型的zval,并且指向同一个zend_reference实例。val跟其他zval类似,特别是它可以共享其指向的复杂值。比如数组可以在普通变量和引用变量之间共享。

还是上面的示例代码,来看一下在PHP7下的情形。为了简洁性,我不会再写变量的zval,只展示它们指向的值。

$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(有两个变量用到了这个引用),但是值本身的引用计数是1(只有一个zend_reference指向了该值)。再考虑下引用变量和普通变量混合的情况:

$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, 即使有的是引用,有的不是。$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一个重要的不同是所有的变量都能共享同一个数组,即使有的是引用变量有的不是。只有当进行修改的时候才会发生分离。这意味着在PHP7中把一个很大的引用数组传递给count()是安全的,因为不会复制。但是引用仍然会比普通变量慢,因为需要分配zend_reference结构(以及由此产生的间接操作),而且机器码处理起来也不会很快。

下面说说关于内存使用上的情况,这里说的都是指在 64 位的系统上。首先,由于 str 和 obj 占用的大小一样, 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

数组

因为之前的文章有讲过新的数组实现,所以这里就不再详细描述了。虽然最近有些变化导致之前的描述不是十分准确了,但是基本的概念还是一致的。

这里要说的是之前的文章中没有提到的数组相关的概念:不可变数组。其本质上和保留字符类似:没有引用计数且在请求结束之前一直存在(也可能在请求结束之后还存在)。

因为某些内存管理方便的原因,不可变数组只会在开启 opcache 时会使用到。我们来看看实际使用的例子,先看以下的脚本:

for ($i = 0; $i  1000000; ++$i) {
    $array[] = ['foo'];
}
var_dump(memory_get_usage());

开启 opcache 时,以上代码会使用 32MB 的内存,不开启的情况下因为 $array 每个元素都会复制一份 ['foo'] ,所以需要 390MB。这里会进行完整的复制而不是增加引用计数值的原因是防止 zend 虚拟机操作符执行的时候出现共享内存出错的情况。我希望不使用 opcache 时内存暴增的问题以后能得到改善。

总结

总的来说,PHP7主要的改变是zval不再是独立的堆分配且其本身不再存储引用计数。转而是它们指向的复杂类型的值(如字符串、数组、对象)会存储引用计数。这通常会带来更少的内存分配、间接操作和内存使用。

下一部分将会讨论其它复杂类型。

在 PHP7 中 zval 有了新的实现方式。最基础的变化就是 zval 需要的内存不再是单独从堆上分配,不再自己存储引用计数。复杂数据类型(比如字符串、数组和对象)的引用计数由其自身来存储。这种实现方式有以下好处:
简单数据类型不需要单独分配内存,也不需要计数;
不会再有两次计数的情况。在对象中,只有对象自身存储的计数是有效的;
由于现在计数由数值自身存储,所以也就可以和非 zval 结构的数据共享,比如 zval 和 hashtable key 之间;
间接访问需要的指针数减少了。
我们看看现在 zval 结构体的定义(现在在 zend_types.h 文件中):
struct _zval_struct {
    zend_value        value;            /* value */
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    type,         /* active type */
                zend_uchar    type_flags,
                zend_uchar    const_flags,
                zend_uchar    reserved)     /* call info for EX(This) */
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t     var_flags;
        uint32_t     next;                 /* hash collision chain */
        uint32_t     cache_slot;           /* literal cache slot */
        uint32_t     lineno;               /* line number (for ast nodes) */
        uint32_t     num_args;             /* arguments number for EX(This) */
        uint32_t     fe_pos;               /* foreach position */
        uint32_t     fe_iter_idx;          /* foreach iterator index */
    } u2;
};
结构体的第一个元素没太大变化,仍然是一个 value 联合体。第二个成员是由一个表示类型信息的整型和一个包含四个字符变量的结构体组成的联合体(可以忽略 ZEND_ENDIAN_LOHI_4 宏,它只是用来解决跨平台大小端问题的)。这个子结构中比较重要的部分是 type(和以前类似)和 type_flags,这个接下来会解释。
上面这个地方也有一点小问题:value 本来应该占 8 个字节,但是由于内存对齐,哪怕只增加一个字节,实际上也是占用 16 个字节(使用一个字节就意味着需要额外的 8 个字节)。但是显然我们并不需要 8 个字节来存储一个 type 字段,所以我们在 u1 的后面增加了了一个名为 u2 的联合体。默认情况下是用不到的,需要使用的时候可以用来存储 4 个字节的数据。这个联合体可以满足不同场景下的需求。
PHP7 中 value 的结构定义如下:
typedef union _zend_value {
    zend_long         lval;             /* long value */
    double            dval;             /* double value */
    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_t w1;
        uint32_t w2;
    } ww;
} zend_value;
首先需要注意的是现在 value 联合体需要的内存是 8 个字节而不是 16。它只会直接存储整型(lval)或者浮点型(dval)数据,其他情况下都是指针(上面提到过,指针占用 8 个字节,最下面的结构体由两个 4 字节的无符号整型组成)。上面所有的指针类型(除了特殊标记的)都有一个同样的头(zend_refcounted)用来存储引用计数:
typedef struct _zend_refcounted_h {
    uint32_t         refcount;          /* reference counter 32-bit */
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    type,
                zend_uchar    flags,    /* used for strings & objects */
                uint16_t      gc_info)  /* keeps GC root number (or 0) and color */
        } v;
        uint32_t type_info;
    } u;
} zend_refcounted_h;
现在,这个结构体肯定会包含一个存储引用计数的字段。除此之外还有 type、flags 和 gc_info。type 存储的和 zval 中的 type 相同的内容,这样 GC 在不存储 zval 的情况下单独使用引用计数。flags 在不同的数据类型中有不同的用途,这个放到下一部分讲。
gc_info 和 PHP5 中的 buffered 作用相同,不过不再是位于根缓冲区的指针,而是一个索引数字。因为以前根缓冲区的大小是固定的(10000 个元素),所以使用一个 16 位(2 字节)的数字代替 64 位(8 字节)的指针足够了。gc_info 中同样包含一个『颜色』位用于回收时标记结点。
zval 内存管理

PHP5 中的对象

在了解 PHP7 中的对象实现直线我们先看一下 PHP5 的并且看一下有什么效率上的问题。PHP5 中的 zval 会存储一个 zend_object_value 结构,其定义如下:

typedef struct _zend_object_value {
    zend_object_handle handle;
    const zend_object_handlers *handlers;
} zend_object_value;

handle 是对象的唯一 ID,可以用于查找对象数据。handles 是保存对象各种属性方法的虚函数表指针。通常情况下 PHP 对象都有着同样的 handler 表,但是 PHP 扩展创建的对象也可以通过操作符重载等方式对其行为自定义。

对象句柄(handler)是作为索引用于『对象存储』,对象存储本身是一个存储容器(bucket)的数组,bucket 定义如下:

typedef struct _zend_object_store_bucket {
    zend_bool destructor_called;
    zend_bool valid;
    zend_uchar apply_count;
    union _store_bucket {
        struct _store_object {
            void *object;
            zend_objects_store_dtor_t dtor;
            zend_objects_free_object_storage_t free_storage;
            zend_objects_store_clone_t clone;
            const zend_object_handlers *handlers;
            zend_uint refcount;
            gc_root_buffer *buffered;
        } obj;
        struct {
            int next;
        } 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 的结构,通常情况下用户层面的对象定义如下:

typedef struct _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,然后是 zend object,然后才能通过指针找到对象属性表和 zval。这样这里至少就有 4 层间接访问(并且实际使用中可能最少需要七层)。

上文提到过 zval 需要的内存不再单独从堆上分配。但是显然总要有地方来存储它,所以会存在哪里呢?实际上大多时候它还是位于堆中(所以前文中提到的地方重点不是堆,而是单独分配),只不过是嵌入到其他的数据结构中的,比如 hashtable 和 bucket 现在就会直接有一个 zval 字段而不是指针。所以函数表编译变量和对象属性在存储时会是一个 zval 数组并得到一整块内存而不是散落在各处的 zval 指针。之前的 zval * 现在都变成了 zval。
之前当 zval 在一个新的地方使用时会复制一份 zval * 并增加一次引用计数。现在就直接复制 zval 的值(忽略 u2),某些情况下可能会增加其结构指针指向的引用计数(如果在进行计数)。
那么 PHP 怎么知道 zval 是否正在计数呢?不是所有的数据类型都能知道,因为有些类型(比如字符串或数组)并不是总需要进行引用计数。所以 type_info 字段就是用来记录 zval 是否在进行计数的,这个字段的值有以下几种情况:
#define IS_TYPE_CONSTANT            (1/* special */
#define IS_TYPE_IMMUTABLE           (1/* special */
#define IS_TYPE_REFCOUNTED          (1
#define IS_TYPE_COLLECTABLE         (1
#define IS_TYPE_COPYABLE            (1
#define IS_TYPE_SYMBOLTABLE         (1/* 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
----------------+------------+-------------+----------+----------
simple types    |            |             |          |
string          |      x     |             |     x    |
interned string |            |             |          |
array           |      x     |      x      |     x    |
immutable array |            |             |          |     x
object          |      x     |      x      |          |
resource        |      x     |             |          |
reference       |      x     |             |          |
要理解这一点,我们可以来看几个例子,这样可以更好的认识 zval 内存管理是怎么工作的。
下面是整数行为模式,在上文中 PHP5 的例子的基础上进行了一些简化 :
$a = 42;   // $a = zval_1(type=IS_LONG, value=42)

PHP7 中的对象

PHP7 的实现中试图解决上面这些问题,包括去掉双重引用计数、减少内存使用以及间接访问。新的 zend_object 结构体如下:

struct _zend_object {
    zend_refcounted   gc;
    uint32_t          handle;
    zend_class_entry *ce;
    const zend_object_handlers *handlers;
    HashTable        *properties;
    zval              properties_table[1];
};

可以看到现在这个结构体几乎就是一个对象的全部内容了:zend_object_value 已经被替换成一个直接指向对象和对象存储的指针,虽然没有完全移除,但已经是很大的提升了。

除了 PHP7 中惯用的 zend_refcounted 头以外,handle 和 对象的 handlers 现在也被放到了 zend_object 中。这里的 properties_table 同样用到了 C 结构体的小技巧,这样 zend_object 和属性表就会得到一整块内存。当然,现在属性表是直接嵌入到 zval 中的而不是指针。

现在对象结构体中没有了 guards 表,现在如果需要的话这个字段的值会被存储在 properties_table 的第一位中,也就是使用 __get 等方法的时候。不过如果没有使用魔术方法的话,guards 表会被省略。

dtorfree_storageclone 三个操作句柄之前是存储在对象操作 bucket 中,现在直接存在 handlers 表中,其结构体定义如下:

struct _zend_object_handlers {
    /* offset of real object header (usually zero) */
    int                                     offset;
    /* general object functions */
    zend_object_free_obj_t                  free_obj;
    zend_object_dtor_obj_t                  dtor_obj;
    zend_object_clone_obj_t                 clone_obj;
    /* individual object functions */
    // ... rest is about the same in PHP 5
};

handler 表的第一个成员是 offset,很显然这不是一个操作句柄。这个 offset 是现在的实现中必须存在的,因为虽然内部的对象总是嵌入到标准的 zend_object 中,但是也总会有添加一些成员进去的需求。在 PHP5 中解决这个问题的方法是添加一些内容到标准的对象后面:

struct custom_object {
    zend_object std;
    uint32_t something;
    // ...
};

这样如果你可以轻易的将 zend_object* 添加到 struct custom_object* 中。这也是 C 语言中常用的结构体继承的做法。但是在 PHP7 中这种实现会有一个问题:因为 zend_object 在存储属性表时用了结构体 hack 的技巧,zend_object 尾部存储的 PHP 属性会覆盖掉后续添加进去的内部成员。所以 PHP7 的实现中会把自己添加的成员添加到标准对象结构的前面:

struct custom_object {
    uint32_t something;
    // ...
    zend_object std;
};

不过这样也就意味着现在无法直接在 zend_object*struct custom_object* 进行简单的转换了,因为两者都一个偏移分割开了。所以这个偏移量就需要被存储在对象 handler 表中的第一个元素中,这样在编译时通过 offsetof() 宏就能确定具体的偏移值。

也许你会好奇既然现在已经直接(在 zend_value 中)存储了 zend_object 的指针,那现在就不需要再到对象存储中去查找对象了,为什么 PHP7 的对象者还保留着 handle 字段呢?

这是因为现在对象存储仍然存在,虽然得到了极大的简化,所以保留 handle 仍然是有必要的。现在它只是一个指向对象的指针数组。当对象被创建时,会有一个指针插入到对象存储中并且其索引会保存在 handle 中,当对象被释放时,索引也会被移除。

那么为什么现在还需要对象存储呢?因为在请求结束的阶段会在存在某个节点,在这之后再去执行用户代码并且取指针数据时就不安全了。为了避免这种情况出现 PHP 会在更早的节点上执行所有对象的析构函数并且之后就不再有此类操作,所以就需要一个活跃对象的列表。

并且 handle 对于调试也是很有用的,它让每个对象都有了一个唯一的 ID,这样就很容易区分两个对象是同一个还是只是有相同的内容。虽然 HHVM 没有对象存储的概念,但它也存了对象的 handle。

和 PHP5 相比,现在的实现中只有一个引用计数(zval 自身不计数),并且内存的使用量有了很大的缩减:40 个字节用于基础对象,每个属性需要 16 个字节,并且这还是算了 zval 之后的。间接访问的情况也有了显著的改善,因为现在中间层的结构体要么被去掉了,要么就是直接嵌入的,所以现在读取一个属性只有一层访问而不再是四层。

$b = $a;   // $a = zval_1(type=IS_LONG, value=42)
           // $b = zval_2(type=IS_LONG, value=42)

间接 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] --> some zval
| +---> CV_ptr[1] --> some zval
+-----> CV_ptr[2] --> some zval

当需要使用符号表时存储 zval* 的中间表其实是没有用到的而 zval** 指针会被更新到 hashtable buckets 的响应位置中。我们假定有 $a$b$c 三个变量,下面是简单的示意图:

CV_ptr_ptr[0] --> SymbolTable["a"].pDataPtr --> some zval
CV_ptr_ptr[1] --> SymbolTable["b"].pDataPtr --> some zval
CV_ptr_ptr[2] --> SymbolTable["c"].pDataPtr --> some zval

但是 PHP7 的用法中已经没有这个问题了,因为 PHP7 中的 hashtable 大小发生变化时 hashtable bucket 就失效了。所以 PHP7 用了一个相反的策略:为了访问 CV 表中存储的变量,符号表中存储 INDIRECT 来指向 CV 表。CV 表在符号表的生命周期内不会重新分配,所以也就不会存在有无效指针的问题了。

所以加入你有一个函数并且在 CV 表中有 $a$b$c,同时还有一个动态分配的变量 $d,符号表的结构看起来大概就是这个样子:

SymbolTable["a"].value = INDIRECT --> CV[0] = LONG 42
SymbolTable["b"].value = INDIRECT --> CV[1] = DOUBLE 42.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 的数据。

$a += 1;   // $a = zval_1(type=IS_LONG, value=43)
           // $b = zval_2(type=IS_LONG, value=42)

上一篇:没有了 下一篇:没有了