变量的类型和实现

PHP 的变量是弱类型的,也实现了如整型、浮点型、字符串、数组和对象等类型。PHP 中的变量是使用结构体 zval 来表示的,在介绍 PHP 7 的 zval 之前,先了解一下 PHP 5 的 zval 设计。

PHP 5 的 zval

首先来看 PHP 5 中 _zval_struct(zval) 这个结构体:

struct _zval_struct { // zval
    /* 变量信息 */
    zvalue_value value; /* value值 */
    zend_uint refcount__gc;
    zend_uchar type; /* 类型 */
    zend_uchar is_ref__gc;
};
typedef union _zvalue_value {
    long lval; /* 长整型 */
    double dval; /* 浮点型 */
    struct {
        char *val;
        int len;
    } str;  /* String value */
    HashTable *ht; /* Array or object (hash table) */
    zend_object_value obj; /* Object value */
    zend_ast *ast; /* zend_ast 结构体用于表示抽象语法树中的节点 */
} zvalue_value;

PHP 5 的 zval 核心由一个 zvalue_value 类型的联合体和 zend_uchar 类型的 type 组成。在 PHP 5.3 之后相继引入了。

  • refcount__gc 字段通过引用计数进行垃圾回收(PHP 使用引用计数来管理变量的生命周期。当引用计数变为零时,变量的内存会被释放。)。

  • type:type 字段表示变量的类型。常见的类型有:

    • IS_NULL (0)

    • IS_LONG (1)

    • IS_DOUBLE (2)

    • IS_BOOL (3)

    • IS_ARRAY (4)

    • IS_OBJECT (5)

    • IS_STRING (6)

    • IS_RESOURCE (7)

    • IS_CONSTANT (8)

    • IS_CONSTANT_AST (9)

  • 同时增加了新的字段 is_ref__gc 来标记是否为引用类型(如果是引用,则该值为 1,否则为 0。)。

默认在 i386:x86-64 架构下,上面的 zvalue_value 结构体中 lvaldval 大小为 8 字节,str 结构体大小为 12 字节,htast 是指针类型,大小为 8 字节,obj 结构体大小为 12 字节,所以在内存对齐的情况下 _zval_struct 中的 value 大小为 16 字节,加上 refcount__gc 大小为 4 字节和两个 1 字节的 typeis_ref__gc, _zval_struct 结构体本身大小为 24 字节(考虑到结构体对齐)。根据 3.1.2 节中讨论的结构体和联合体的知识,PHP 5 中 zval 的示例如图3-4所示。

image 2024 06 07 00 43 21 080
Figure 1. 图3-4 PHP 5中 _zval_struct 的大小

假设有一个 PHP 变量:

<?php
$a = 42;

在 PHP 内部,它可能会被表示为一个 zval 结构体:

zval a;
a.value.lval = 42;
a.refcount__gc = 1;
a.type = IS_LONG;
a.is_ref__gc = 0;

当我们执行 $b = &$a; 时,$ais_ref__gc 字段会被设置为 1,表示它是一个引用:

zval a;
a.value.lval = 42;
a.refcount__gc = 2;  // $a 和 $b 都引用这个值
a.type = IS_LONG;
a.is_ref__gc = 1;    // 表示这是一个引用

引用计数和垃圾回收

PHP 5 中的垃圾回收主要通过引用计数来管理。当变量的引用计数减少到零时,变量的内存会被释放。例如:

$a = 42;
$b = $a;
unset($b);

在这个例子中,$a$b 最初都引用同一个 zval。当调用 unset($b) 时,zval 的引用计数减少到 1。只有当 unset($a) 时,引用计数减少到 0,内存才会被释放。

这是不是说,PHP 5 中一个变量就占用一个 zval 呢?其实不是的。PHP 5 中现有的 zval 结构里每个字段都有明确的定义,不可轻易修改,因此在 PHP 5 时代一些对 zval 的优化都是通过结构映射的方式,例如在 PHP 5.3 之后为了解决循环引用的问题,通过重写分配 zval 的宏,对 zval 进行扩充。新的分配方法如下所示:

#undef  ALLOC_ZVAL
#define ALLOC_ZVAL(z)                                     \
    do {                                                  \
        (z) = (zval*)emalloc(sizeof(zval_gc_info));     \
        GC_ZVAL_INIT(z);                                 \
    } while (0)

#define GC_ZVAL_INIT(z) {      \
    (z)->refcount__gc = 1;     \
    (z)->type = IS_NULL;       \
    (z)->is_ref__gc = 0;       \
}
  • 设置引用计数:将 refcount__gc 设置为 1,表示这个 zval 当前有一个引用。

  • 设置类型:将 type 设置为 IS_NULL,表示这个 zval 目前是一个空值。

  • 设置引用标识:将 is_ref__gc 设置为 0,表示这个 zval 目前不是一个引用。

ALLOC_ZVAL 是一个宏,用于分配和初始化一个新的 zval 结构体。

GC_ZVAL_INIT(z) 宏用于初始化一个 zval 结构体,并设置相关的引用计数和类型信息,使其能够正确地参与 PHP 的垃圾回收机制。

这里申请的结构体为 _zval_gc_info,是由一个 zval 结构体和一个 u 联合体组成的,其中 u 大小为 8 字节,_zval_gc_info 结构体如下所示:

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

通过这种方式,PHP 5 中一个变量实际分配了 32 字节,如图3-5所示。

image 2024 06 07 14 20 57 855
Figure 2. 图3-5 PHP 5中_zval_struct实际大小

扩充后的 zval 带来了新的问题,因为整型和浮点型不需要进行 gc,所以对于整型和浮点型会有内存浪费。

循环引用是指两个或多个对象相互引用对方,导致引用计数无法减少到零,进而导致内存泄漏。整型和浮点型变量无法形成循环引用,因为它们的值是原始类型,没有指向其他变量的引用。

整型和浮点型变量的内存管理非常简单。它们的值直接存储在 zval 结构体的 value 字段中,内存分配和释放的过程相对简单:

typedef union _zvalue_value {
    long lval;             /* Long integer value */
    double dval;           /* Double value */
    struct {
        char *val;
        int len;
    } str;                 /* String value */
    HashTable *ht;         /* Array or object (hash table) */
    zend_object_value obj; /* Object value */
} zvalue_value;

对于整型变量,lval 字段直接存储整数值。对于浮点型变量,dval 字段直接存储浮点数值。这些值不涉及复杂的内存分配和引用计数。

不仅如此,在开启 Zend 内存池的情况下,zval_gc_info 在内存池中分配,内存池会为每个 zval_gc_info 额外申请一个大小为 16 字节的 zend_mm_block 结构体(限于篇幅,这里不展开讨论),用来存放内存相关信息。zend_mm_block 结构如下(size_t 占用 8 字节):

typedef struct _zend_mm_block_info {
    size_t _size;
    size_t _prev;
} zend_mm_block_info;

typedef struct _zend_mm_block {
    zend_mm_block_info info;
} zend_mm_block;

最终一个变量在 PHP 5 中实际占用的内存大小为 48 字节,内存占用情况如图3-6所示。

image 2024 06 07 14 26 05 889

48 字节的大小其实有很多的浪费,而这点 PHP 开发者在 PHP 7 中做了重点优化,那么接下来看一下 PHP 7 中 zval 的实现。

PHP 7 的 zval

PHP 7 中 zval 结构体有了一些变化,zval 依然保留了 value 字段,但跟 PHP 5 不同的是 value 里面支持更丰富的类型,且 PHP 7 的 zval 不再存储复杂类型的结构,复杂类型的数据都是通过指针操作的,新的联合体中 value 的内存占用只有 8 字节。zval 结构体如下所示:

struct _zval_struct {
    zend_value        value;  /* 变量的值 */
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    type,      /* 标明zval类型,即变量类型(如 IS_NULL、IS_LONG、IS_DOUBLE、IS_STRING、IS_ARRAY、IS_OBJECT 等) */
                zend_uchar    type_flags, /* 用于存储变量类型的额外标志信息。 */
                zend_uchar    const_flags, /* 用于常量的标志信息。 */
                zend_uchar    reserved) /* 保留字段,用于未来的扩展。 */
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t     next;               /* 用来解决哈希冲突,详见第5章*/
        uint32_t     cache_slot;         /* 运行时缓存 */
        uint32_t     lineno;             /* 对于zend_ast_zval存行号 */
        uint32_t     num_args;           /* EX(This) 参数个数*/
        uint32_t     fe_pos;             /* foreach的位置 */
        uint32_t     fe_iter_idx;        /* foreach游标的标记*/
        uint32_t     access_flags;       /* 类的常量访问标识 */
        uint32_t     property_guard;     /* 单一属性保护 */
    } u2;
};

保留的 value 字段,它在 PHP 7 中的定义如下所示:

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;               /*抽象语法树*/
    zval              *zv;               /*zval类型*/
    void              *ptr;              /*指针类型*/
    zend_class_entry *ce;                /*class类型*/
    zend_function    *func;              /*function类型*/
    struct {
        uint32_t w1;
        uint32_t w2;
    } ww;
} zend_value;

zend_value 是一个联合体,包含了不同类型的值,如整型、浮点型、字符串、数组、对象等。

从上面的定义可以看出,value 支持更多的类型。除了 value 字段之外,zval 结构体中还有两个重要的字段 u1u2,它们都是联合体结构,却各有用途,用于优化内存布局和性能。

u1 中 v 值中的字段含义

  1. type:记录变量类型。

  2. type_flag:对应变量类型特有的标记,不同类型的变量对应的 flag 也不同。所对应的标记如下:

    /* zval.u1.v.type_flags */
    IS_TYPE_CONSTANT         //是常量类型
    IS_TYPE_IMMUTABLE        //不可变的类型,比如存在共享内存中的数组
    IS_TYPE_REFCOUNTED       //需要引用计数的类型,表示变量是有引用计数的。大多数 PHP 变量都是有引用计数的,这使得 PHP 可以使用引用计数技术来自动管理内存。
    IS_TYPE_COLLECTABLE      //可能包含循环引用的类型(IS_ARRAY, IS_OBJECT)
    IS_TYPE_COPYABLE         //可被复制的类型

    例如,如果一个 zval 结构体表示一个常量,并且这个常量是不可变的,它的 type_flags 可能会设置为 IS_TYPE_CONSTANT | IS_TYPE_IMMUTABLE

    type_flags 的具体使用取决于 PHP 引擎内部的实现和需求。它通过在 zval 结构体中提供额外的位掩码信息,允许 PHP 引擎在运行时更细致地处理不同类型的变量和常量。这种灵活性和细节化的标志信息有助于 PHP 在处理各种情况下能够精确控制变量的行为和属性。

  3. const_flag:常量类型的标记,对应的属性有:

    /* zval.u1.v.const_flags */
    #define IS_CONSTANT_UNQUALIFIED     0x010
    #define IS_CONSTANT_VISITED_MARK    0x020
    #define IS_CONSTANT_CLASS           0x080  /* __CLASS__ trail类 */
    #define IS_CONSTANT_IN_NAMESPACE    0x100  /* 只用在opline->extended_value */
  4. reserved:保留字段。

PHP 7 的 zval 类型字段改为使用联合体结构中的 u1.v.type 表示,u1type_info 和结构体 v 组成,由 3.1.2 节中联合体的知识可知道,vtype_info 共享一块内存,两个字段长度均为 4 字节,修改 type_info 等同于修改结构体 v 中的值,其实 type_info 就是 v 中的 4 个 char 的组合,如图3-7所示。

image 2024 06 07 14 48 59 753
Figure 3. 图3-7 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 开始的版本通过 valueu1 已经可以表示任何类型,并记录一些类型的属性。另外还有一个 u2,其实是后来增加的辅助字段,u2 里面的字段均是 uint32_t 类型,占用 4 字节,所以 u2 的大小是 4 字节,这样 zval 总共占用的内存是 16 字节,但是如果没有 u2 字段,在内存对齐的情况下,zval 同样占用 16 字节。与其浪费,不如用来记录一些特殊信息。那么 u2 都记录了哪些信息呢?

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:对象类的访问标志,常用的标识有 publicprotectedprivate。这个会在第 6 章中阐述。

  8. property_guard:防止类中魔术方法的循环调用,比如 __get__set 等。

通过上面的介绍发现,u2 的辅助字段里面记录了很多类型的信息,这些信息对内部功能的实现都有很大好处,或提升了缓存友好性或减少了内存寻址的操作。同时相对于 PHP 5 时代的 zval, PHP 7 的 zval 节省了很大的内存。PHP 7 的 zval 内存占用如图3-8所示。

image 2024 06 07 16 24 12 480
Figure 4. 图3-8 PHP 7 的 zval 内存占用

在 PHP 5 时代,所有的变量都在 中申请,但是对于临时变量是没有必要的,而 PHP 7 对此做了优化,这种临时变量直接在 中申请。接下来先讨论一下 PHP 7 的变量类型。

PHP 7 变量类型

PHP 7 中变量的类型定义在 zend_types.h 文件中,不仅有常见的类型,还有一些只在内部使用的类型,具体定义如下:

#define IS_UNDEF                     0 /*标记未使用类型*/
#define IS_NULL                      1 /*NULL*/
#define IS_FALSE                     2 /*布尔false*/
#define IS_TRUE                      3 /*布尔true*/
#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 /*参考类型(内部使用)*/
#define IS_CONSTANT                 11 /*常量类型*/
#define IS_CONSTANT_AST             12 /*常量类型的AST树*/
/*伪类型*/
#define _IS_BOOL                    13 /*表示变量的类型是布尔类型*/
#define IS_CALLABLE                 14 /*是一个类型常量,用于判断一个变量是否可以作为可调用的函数或方法*/
#define IS_ITERABLE                 19 /*是 PHP 7.1 引入的一个类型常量,用于判断一个变量是否可以迭代*/
#define IS_VOID                     18 /*在 PHP 7.1 引入了 void 类型的返回声明,用于函数声明时指定函数没有返回值*/
/*内部类型*/
#define IS_INDIRECT                 15 /*间接类型*/
#define IS_PTR                      17 /*指针类型*/
#define _IS_ERROR                   20 /*错误类型*/

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,当 HashTableIS_UNDEF 元素个数到达一定阈值时,进行 rehash 操作时再将 IS_UNDEF 标记的元素覆盖或删除。

  2. IS_TRUEIS_FALSE:这里将 IS_BOOL 优化成两个,直接将布尔类型的标记记录在 type 中,这样做可以优化类型的检查,不需要再做一次类型判断。

  3. IS_REFERENCE:是新增的类型,PHP 7 中使用不同的处理方式来处理 “&”,后面展开阐述。

  4. IS_INDIRECT:同样也是新增的类型,由于 PHP 7 中 HashTable 的设计跟 PHP 5 中有很大的不同,所以在解决全局符号表访问 CV 变量表的问题上,引入了 IS_INDRECT 类型。

  5. IS_PTR:该类型被定义为指针类型,用来解析 value.ptr,通常用在函数类型上。比如声明一个函数或者方法。

  6. _IS_ERROR:为新增的错误类型,校验 zval 的类型是否合法。

介绍完 PHP 7 中变量的类型,下面来对每种类型进行详细的探讨。

整型和浮点型

对于整型和浮点型的数据,由于其占用空间小,在 zval 中是 直接存储 的,所以在进行赋值的时候会直接创建两个 zval,在对应的 value 中取 lvaldval。举例如下:

$a = 10;   // $a = zval_1(u1.v.type=IS_LONG, value.lval=10)

$b = $a;   // $a = zval_1(u1.v.type=IS_LONG, value.lval=10)
            // $b = zval_2(u1.v.type=IS_LONG, value.lval=10)

$a = 20;   // $a = zval_1(u1.v.type=IS_LONG, value.lval=20)
            // $b = zval_2(u1.v.type=IS_LONG, value.lval=10)

unset($a); // $a = zval_1(u1.v.type=IS_UNDEF, value.lval=20)
            // $b = zval_2(u1.v.type=IS_LONG, value.lval=10)

对于上面的代码,详细说明如下。

  • 对于 $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),此时,修改 au1.v.type=IS_UNDEF,但此时并不是真正把内存释放,因为 b 是拷贝出来的,也不会受影响。

同样对于浮点型的变量类型,对应的 u1.v.type=IS_DOUBLE,使用的是 value.dval,而 dval 恰好是 double 型的。整体而言,PHP 7 对整型和浮点型变量的实现比较简单,非常容易理解。接下来讨论一下字符串类型变量的实现。

字符串类型

PHP 7 中定义了一个新的结构体 _zend_string,第 4 章会详细介绍,这里简单说一下,其结构如下:

struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong        h;                 /* hash value */
    size_t             len;
    char               val[1];
};

_zend_string 的头部维护着 gc 的信息,并且冗余了 hashh,这个操作据说为 PHP 7 提高了 5% 的性能,避免了在数组操作中 hash 值的重复计算。len 表示字符串的长度,val 记录了字符串的内容。这里的 val 值采用了柔性数组(被收入到 C99 标准中),这种方式相较于 PHP 5 中的字符串与结构体分离,读写的效率更高。

PHP 7 中的字符串是通过 zval.str 指向 zend_string 结构体的,如图3-9所示。

image 2024 06 07 17 12 40 050
Figure 5. 图3-9 PHP 7中的字符串

字符串类型有不同的属性维护在头部的 zend_refcounted_h 结构体中,比如 PHP 7 中有一种内部字符串 INTERNED STRING,而作用于字符串本身有 IS_STR_INTERNED 标志位,但不是 zval 的。字符串中还有一些类似的标志位:

IS_STR_PERSISTENT            //是malloc分配内存的字符串
IS_STR_INTERNED              //INTERNED STRING
IS_STR_PERMANENT             //不可变的字符串,起哨兵作用
IS_STR_CONSTANT              //代表常量的字符串
IS_STR_CONSTANT_UNQUALIFIED  //带有可能命名空间的常量字符串

数组

数组是 PHP 代码中比较重要的一个结构,本质上 PHP 的数组是有序的字典,即它们表示 key-value 对的有序列表,其中 key-value 映射是使用 HashTable 实现的。PHP 将字符串 key 通过哈希函数运算返回一个整数。这个整数被用作 “普通” 数组的索引。但是带来新的问题是,两个不同的字符串可能得到相同的哈希值,因此这样的 HashTable 需要实现某种机制来解决冲突。

PHP 7 中 HashTable 的经过有了很大的改变,也为 PHP 7 带来了巨大的性能提升。本书第 5 章有详细阐述,这里暂不做讨论。这里想说的是 HashTable 结构体头部包含 gc 结构体,如下面的代码所示:

struct _zend_array {
    zend_refcounted_h gc;
    //…代码省略…//

头部的 gc 结构体解决数组的引用计数和循环引用的问题,3.4 节有详细讲解,暂不在这里展开。

引用

说到引用的问题,不得不说一下 PHP 中传值和传址的区别。

传值即 PHP 代码中的赋值操作,如上面的代码所示,当改变 $b 值时,$a 的值需要保持不变,因此需要在此分离。传址意味着当改变 $b 的值时,$a 也会跟着变。

从 PHP 7 的 zval 设计上可以看到,zval 没有存储引用计数的相关信息,所以在处理 “&” 符号引用的问题上,PHP 7 采用完全不同的一种方式。先来看看 PHP 5 是如何处理的。

PHP 5 在引入引用计数后,使用了 refcount__gc 来记录次数,同时使用 is_ref__gc 来记录是否是引用类型。以上面的例子为例:

$a = 'hello'; //$a -> zval1(type=IS_STRING, refcount__gc=1, is_ref__gc=0)
$b = $a; //$b, $a -> zval1(type=IS_STRING, refcount__gc=2, is_ref__gc=0)
$c = &$b; //$a -> zval1(type=IS_STRING, refcount__gc=1, is_ref__gc=0)
        //$c, $b -> zval2(type=IS_STRING, refcount__gc=2, is_ref__gc=1)

$a 赋值给 $b, refcount__gc 会增加,但是并不会改变引用类型。当使用 “&” 操作时,将 $b 赋值给 $c, zvalis_ref__gc 值会改变,使得此时的 zval 必须进行分离,但是实际上它们的值还没有变化,这使得需要在堆中维护两个值为 “hello” 的 zval

PHP 7 引入了新的类型 IS_REFERENCE 来处理这个问题,首先看看 zend_reference 的结构体:

struct _zend_reference {
    zend_refcounted_h gc;
    zval               val;
};

zend_reference 由记录 gc 信息的 zend_refcounted_h 结构体和 zval 结构体组成,由 val 来存储实际的值,zend_refcounted_h 结构体用来存储引用计数的信息。前面提到过,在 PHP 7 中复杂类型的引用计数的信息都记录在自身头部的 gc 中,zval 没有存储引用计数的字段,所以增加了这种结构用于垃圾回收。下面看看 PHP 7 中使用传址时变量结构的变化:

/* 上面提到的zend_string都是同一个地址 */
$a = 'hello'.time(); //$a -> zend_string(refcount=1, val)
$b = $a ; //$b, $a -> zend_string(refcount=2, val)
$c = &$b; //$a -> zend_string(refcount=2, val)
//$c, $b -> zval(type=IS_REFERENCE, refcount=2) -> zend_string(refcount=2, val)

从上面的流程中可以看出,当使用 “&” 操作时,会创建一种新的中间结构体 zend_reference,这个结构体会指向真正的 zend_string 结构体,所以 zend_string 结构体的引用计数不变,同时 zend_reference 结构体的引用计数变为 2,因为 $c$b 此时的类型都会变为 zend_reference。这样的好处是原始的 zend_string 在内存中始终只有一份(避免了由于字符串的重复申请导致的内存浪费),更加易于维护。

在进行赋值时字符串只会增加引用计数,图3-10所示是正常的赋值。

image 2024 06 07 17 31 55 768
Figure 6. 图3-10 $b = $a的赋值过程

当使用 “&” 后,会改变 “=” 号两边的变量的类型,新生成的类型指向字符串当前的地址,如图3-11所示。

image 2024 06 07 17 32 51 280
Figure 7. 图3-11 进“&”操作之后的示意图

间接 zval

从前面的学习中可以知道,PHP 7 中变量的类型有 20 种,而 PHP 5 中只有 11 种。常规类型是一些比较容易理解的类型,而内部类型绝对很陌生了。内部类型是对外无感知的,只在内部使用。PHP 7 中的内部类型都有哪些呢?请看下面。

#define IS_INDIRECT                  15  // 间接zval
#define IS_PTR                       17  // 指针zval
#define _IS_ERROR                    20  // 内部使用的错误类型

上面的三种都是内部类型,但是本节主要说的是 IS_INDIRECT 类型。

PHP 代码通过词法和语法解析生成 AST 树(第 10 章会介绍如何通过词法和语法解析生成 AST 树),zend 引擎根据 AST 树生成 opcodes 数组(后面第 11 章也会介绍 PHP 7 是如何生成 op_array 的),zend 引擎模拟了一个执行栈来逐条执行 opcodes 数组中的 opcode。在这里有一个需要注意的地方,在编译时已知的所有变量都被赋予一个索引,并且它们的值将存储在编译变量(CV)表中。但是 PHP 也允许通过使用变量来动态地引用变量,或者如果在全局范围内,则使用 $GLOBALS。如果发生这样的访问,PHP 将为函数/脚本创建一个符号表,其中包含从变量名到其值的映射。由于 CV 表不会在符号表的生命周期中重新分配,因此对于存储在 CV 表中的变量访问,符号散列表(symbol_table)中的变量通过 INDIRECT 指向 CV 所在的位置。

为了便于理解,以一段 PHP 代码为例来讲解:

<?php
class User
{
    function hello()
    {
        global $a;
        $a = 'hello';
    }
}

$obj = new User();
$obj->hello();
echo $a;

在上面的代码中,$a 只在 User 类的方法中出现,在调用 hello 方法之前,还不能直接访问。当使用关键字 global 后,$a 会在全局符号表和 CV 表中创建一个关系,这便是 INDIRECT 类型。为了更好地理解,通过 gdb 打印此时符号表中 “a” 的信息,具体如下所示:

(gdb) p *ex.symbol_table.arData[8].key
$9 = {gc = {refcount = 0, u = {v = {type = 6 '\006', flags = 2 '\002', gc_info = 0}, type_info = 518}}, h = 9223372036854953478, len = 1, val = "a"}
(gdb) p ex.symbol_table.arData[8].val
$10 = {value = {lval = 140737350021264, dval = 6.9533489732241371e-310, counted = 0x7ffff7c13090, str = 0x7ffff7c13090, arr = 0x7ffff7c13090, obj =  0x7ffff7c13090,  res  = 0x7ffff7c13090,  ref  =  0x7ffff7c13090,  ast  = 0x7ffff7c13090, zv = 0x7ffff7c13090, ptr = 0x7ffff7c13090, ce =  0x7ffff7c13090,  func  =  0x7ffff7c13090,  ww  =  {w1  =  4156633232,  w2  = 32767}}, u1 = {v = {type = 15 '\017', type_flags = 0 '\000', const_flags = 0 '\000', reserved = 0 '\000'}, type_info = 15}, u2 = {next = 4294967295, cache_slot = 4294967295, lineno = 4294967295, num_args = 4294967295, fe_pos = 4294967295, fe_iter_idx = 4294967295, access_flags = 4294967295, property_guard = 4294967295}}

这里需要注意的是 PHP 7 中的符号表是 HashTable(第 5 章会有详细的讲解),所以通过上面的形式可以取出。关于 CV 表,在第 11 章中会有阐述。

PHP 中的符号表

PHP 引擎在运行时维护了多个符号表,其中包括全局符号表和局部符号表。全局符号表存储了在全局作用域中声明的变量,而局部符号表则根据执行上下文动态创建和销毁,存储了函数、方法或代码块中的局部变量。

全局符号表的特点和作用

  1. 存储全局变量:

    • 全局符号表存储了所有在全局作用域(例如在顶层 PHP 脚本中声明的变量)中定义的变量。

  2. 哈希表结构:

    • 符号表以哈希表的形式实现,这样可以快速地根据变量名查找和访问对应的 zval 结构体。

  3. 变量生命周期管理:

    • 符号表帮助 PHP 引擎管理变量的生命周期,包括变量的声明、赋值、销毁以及在内存管理中的优化和释放。

  4. 全局作用域的管理:

    • 全局符号表也包括了超全局变量(如 $_GET$_POST 等),这些变量在任何地方都可以访问到,因为它们存储在全局符号表中。

PHP 引擎内部实现

在 PHP 引擎的内部实现中,全局符号表是一个 HashTable 结构,其中的每个元素都是一个键值对,键是变量名,值是对应的 zval 结构体。PHP 引擎在执行脚本时,会根据变量的声明和使用动态地更新符号表,并根据需要进行内存管理和优化。

在 PHP 中,CV 表(Compiled Variables Table)是一个与编译时变量相关的数据结构,用于在编译和执行脚本时高效管理变量。这种表格结构主要用于存储编译后的函数和方法中的局部变量和参数。

CV 表的作用

CV 表的主要作用是优化变量访问的速度和效率。与全局符号表不同,CV 表主要用于局部变量和参数的管理,特别是在函数和方法的上下文中。

工作原理

当 PHP 脚本被编译时,编译器会将每个函数和方法的局部变量和参数收集起来,并存储在一个数组中。这个数组就是 CV 表。每个变量在 CV 表中都有一个唯一的索引,通过这个索引可以快速访问和操作变量。

示例

假设有以下 PHP 代码:

function example($param) {
    $localVar = 10;
    echo $param + $localVar;
}

example(5);

在编译阶段,example 函数的参数 $param 和局部变量 $localVar 会被存储在 CV 表中。这样在运行时,PHP 引擎可以通过 CV 表中的索引快速访问这些变量。

使用 CV 表的优势

  1. 性能优化:

    • 通过将变量存储在数组中并使用索引访问,可以显著减少变量查找的时间,提高执行效率。

  2. 内存管理:

    • CV 表在函数调用时分配并在函数返回时释放,有助于更高效的内存管理。

  3. 编译时优化:

    • 编译器在编译阶段确定变量的索引位置,可以进行更多的优化,例如常量折叠和内联优化。

常量和常量AST

常量就是指值固定,在执行期间不会改变,这些固定的值也叫作字面量。PHP 底层在做词法和语法解析时会将字面量解析,并将其类型修改为 IS_CONST。为了方便理解,以 PHP 代码为例:

<? php
$a = "hello";

上面示例中 “hello”“a” 就是常量,常量在 PHP 底层被标记为可引用和可复制的。常量 AST 树比较特殊,是用来标记特殊的 AST 树的,这种树可以有引用计数。它跟普通的 AST 树节点的区别是头部多了一个 zend_refcounted_h 结构,用于存储引用计数相关信息。它的结构如下:

struct _zend_ast_ref {
    zend_refcounted_h gc;
    zend_ast         *ast;
};

typedef struct _zend_ast {
    zend_ast_kind kind;  // AST 节点类型
    zend_ast_attr attr;  // 节点属性
    uint32_t lineno;     // 行号
    uint32_t children;   // 子节点数量
    zend_ast *child[1];  // 子节点数组
} zend_ast;

普通的 zend_ast 节点通过底层定义的宏可以转换成常量 AST 类型。该宏的定义如下所示:

#define ZVAL_NEW_AST(z, a) do {                             \
        zval *__z = (z);                                    \
        zend_ast_ref *_ast =                                \
        (zend_ast_ref *) emalloc(sizeof(zend_ast_ref));     \
        GC_REFCOUNT(_ast) = 1;                              \
        GC_TYPE_INFO(_ast) = IS_CONSTANT_AST;               \
        _ast->ast = (a);                                    \
        Z_AST_P(__z) = _ast;                                \
        Z_TYPE_INFO_P(__z) = IS_CONSTANT_AST_EX;            \
    } while (0)

ZVAL_NEW_AST 是一个宏,用于将一个 zval 初始化为一个新的 AST 节点。它在 zend_ast.h 头文件中定义。

常量 AST 也是可引用和可拷贝的,这个跟常量是一样的。

在 PHP 中,常量 AST 树和普通 AST 树的主要区别在于它们处理和表示的节点类型及其用途。AST(抽象语法树)用于表示源代码的结构,而常量 AST 树则特别用于优化和管理代码中的常量表达式。以下是常量 AST 树和普通 AST 树的详细区别及其作用。

示例比较

普通 AST 树示例

<?php
$x = 2 * 3 + 5;

生成的普通 AST 树可能是:

AST_ASSIGN
├── AST_VAR('$x')
└── AST_BINARY_OP('+')
    ├── AST_BINARY_OP('*')
    │   ├── AST_ZVAL(2)
    │   └── AST_ZVAL(3)
    └── AST_ZVAL(5)

常量 AST 树示例

<?php
const B = 2 * 3 + 5;

生成的常量 AST 树可能是:

AST_CONST_DECL
├── AST_CONST_ELEM
    ├── AST_NAME('B')
    └── AST_ZVAL(11)

在这个常量 AST 树中,2 * 3 + 5 被提前计算为 11

普通 AST 树和常量 AST 树在 PHP 中都有重要的作用,但它们的用途和优化目标不同。普通 AST 树用于表示和解析源代码的语法结构,而常量 AST 树则专注于在编译时进行常量表达式的求值和优化,从而提高运行时的性能。理解这两种 AST 树的区别有助于深入理解 PHP 的编译和执行过程。

资源类型

文件句柄、Socket 链接等是资源类型,通过 void * 指针可以指向任何结构体。资源类型的数据结构如下:

struct _zend_resource {
    zend_refcounted_h gc; /* 引用计数和垃圾回收信息 */
    int                handle; /* 资源句柄 */
    int                type; /* 资源类型 */
    void              *ptr; /* 指向实际资源的指针 */
};

资源类型使用的地方比较广,在使用时根据不同的类型对 void * 指针进行强制转换。

对象

在 PHP 5 时代,对象存储在 _zend_object_value 结构中:

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

handle 是一个无符号 int,通过 handle 可以在全局的对象池里索引到指定对象。handlers 指向一个包含多个函数指针的结构体,比如对象的析构、释放、读属性等操作函数。但是对象的真正数据并没有在这里,而是存在全局的 EG(objects_store) 中,objects_store 数据结构如下:

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;

typedef struct _zend_objects_store {
    zend_object_store_bucket *object_buckets; /* 对象存储桶数组 */
    zend_uint top; /* 下一个可用槽的位置 */
    zend_uint size; /* 存储桶数组的大小 */
    int free_list_head; /* 自由列表头,用于重用已释放的槽 */
} zend_objects_store;

要获取 object,需要通过下面这种方式,经过多次查找:

EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj

不同于 zval 的一点是,对象在 zend_object_store_bucket 桶中维护了另外一个 refcount 来记录对象的引用计数,以保证其在垃圾回收时可以正常被回收。两套引用计数和获取对象的多次内存读取,使得对象效率比较低。

PHP 7 中的对象试图解决上面的问题,从它的结构可以看出与 PHP 5 中对象的区别,PHP 7 中对象的结构如下所示:

struct _zend_object {
    zend_refcounted_h gc;
    uint32_t          handle; //对象句柄 TODO: may be removed ? ? ?
    zend_class_entry *ce; /* 指向对象所属类的 zend_class_entry 结构体的指针。通过该指针,可以访问对象的类名、属性和方法 */
    const zend_object_handlers *handlers; /* 指向对象处理程序的指针。对象处理程序定义了对象的行为,例如访问属性、调用方法等 */
    HashTable        *properties;
    zval               properties_table[1]; /* 属性数组,这个数组用于动态管理对象的属性,使得 PHP 可以支持灵活的属性添加和删除*/
};

PHP 7 中对象的属性数据存储在 properties_table 数组中,而 properties 是一个 HashTable,它的 key 为对象的属性名,value 为属性值在 properties_table 数组中的偏移量,通过偏移量可以在 properties_table 数组中取到真正的数据。为什么这样设计呢?在第 6 章有详细的讲解,这里就不展开了。

另外对象结构体的头部也包含了引用计数的信息,如前面所说,复杂类型的引用计数都是由自身来维护的。头部的 gc 结构体解决了 PHP 5 中重复计数的问题。

与 PHP 5 中的对象结构体进行比较,有没有觉得 PHP 7 中的对象更加简洁呢?结合 zval 支持对象类型 IS_OBJECT,获取一个对象时通过 value.obj 读取内存一次即可,有没有感觉清爽了很多呢?

知识

zend_always_inline 和 zend_always_inline

define zend_always_inline inline __attribute__((always_inline))

define zend_never_inline __attribute__((noinline))
  • zend_always_inline 宏通过结合 inline 关键字和 __attribute__((always_inline)) 属性,强制编译器内联指定的函数,以优化性能,特别是在 PHP 的 Zend 引擎中,频繁调用的小函数可以通过这种方式减少函数调用的开销。

  • zend_never_inline 宏通过使用 __attribute__((noinline)) 属性,明确告诉编译器不要内联指定的函数。这在调试、特定性能优化和函数调用管理等场景下非常有用,确保函数始终作为独立的调用存在。

常用的宏

ZEND_SAME_FAKE_TYPE

#define ZEND_SAME_FAKE_TYPE(faketype, realtype) ( \
    (faketype) == (realtype) \
    || ((faketype) == _IS_BOOL && ((realtype) == IS_TRUE || (realtype) == IS_FALSE)) \
)

ZEND_SAME_FAKE_TYPE 宏用于比较 PHP 数据类型,特别是处理布尔类型的情况。它在需要判断两个类型是否相同时非常有用,包括基本类型比较和布尔类型的特殊处理。这个宏通过简化类型比较逻辑,提高了代码的可读性和维护性。

下面是一个使用 ZEND_SAME_FAKE_TYPE 宏的完整示例:

#include <stdio.h>

#define IS_LONG 1
#define IS_STRING 2
#define _IS_BOOL 3
#define IS_TRUE 4
#define IS_FALSE 5

#define ZEND_SAME_FAKE_TYPE(faketype, realtype) ( \
    (faketype) == (realtype) \
    || ((faketype) == _IS_BOOL && ((realtype) == IS_TRUE || (realtype) == IS_FALSE)) \
)

void check_type(int faketype, int realtype) {
    if (ZEND_SAME_FAKE_TYPE(faketype, realtype)) {
        printf("Types match: faketype = %d, realtype = %d\n", faketype, realtype);
    } else {
        printf("Types do not match: faketype = %d, realtype = %d\n", faketype, realtype);
    }
}

int main() {
    int faketype1 = IS_LONG;
    int realtype1 = IS_LONG;

    int faketype2 = _IS_BOOL;
    int realtype2 = IS_TRUE;

    int faketype3 = _IS_BOOL;
    int realtype3 = IS_FALSE;

    int faketype4 = IS_STRING;
    int realtype4 = IS_TRUE;

    check_type(faketype1, realtype1); // Types match: faketype = 1, realtype = 1
    check_type(faketype2, realtype2); // Types match: faketype = 3, realtype = 4
    check_type(faketype3, realtype3); // Types match: faketype = 3, realtype = 5
    check_type(faketype4, realtype4); // Types do not match: faketype = 2, realtype = 4

    return 0;
}