php zval - yaokun123/php-wiki GitHub Wiki

变量之zval

记得网上流传甚广的段子“PHP是世界上最好的语言”,暂且不去讨论是否言过其实,但至少PHP确实有独特优势的,比如它的弱类型,即只需要$符号即可声明变量,使得PHP入手门槛极低,成为大家所青睐的Web服务端语言。那么它的变量是如何实现的呢?我们今天就来学习一下PHP的基本变量。

一、引言

PHP的变量存储在zval结构体中,在执行阶段中编译为op_array时就能看到zval的身影。结构体定义在Zend/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)	    /* 保留字段 */
		} 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;
};

二、结构体剖析

2.1、zend_value

结构体的第一个变量是zend_value,顾名思义,它其实也是一个结构体,用于存放变量的值,比如整型、浮点型、引用计数、字符串、数组、对象、资源等。zend_value定义了众多类型的指针,但这些类型并不都是变量的类型,有些是给内核自己使用的,比如指针ast、zv、ptr。

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;

我们抽取几个常用的类型讲述一下

2.1.1、字符串

字符串str对应的结构体是zend_string,它有四个成员,定义如下。

struct _zend_string {
	zend_refcounted_h gc;
	zend_ulong        h;                /* hash value */
	size_t            len;
	char              val[1];
};
  • gc:变量的引用计数信息,用于内存管理。
  • h:字符串通过Time33算法计算的到的Hash值,避免了在数组操作中hash值的重复计算,据说提高了PHP7百分之5的性能。
  • len:字符串的长度。通过这个值保证二进制安全。
  • val:字符串的内容,val[1]并不表示只能存储1个字节,在字符串分配时实际上是操作了malloc(sizeof(zend_string)+字符串的长度),也就是会多分配一些内存,而多出来的内存起始位置就是val,这样就可以将字符串直接存储到val,并通过val进行读取,这种采用了柔性数组的方式,读写效率更高。

字符串的二进制安全

学习过C语言的应该知道,字符串中除了最后一个字符外不允许含有\0,否则会被认为是字符串的结束字符,这就导致了C语言的字符串有很多的限制,比如不存储图片、文件等二进制数据。但是PHP就没有这样的限制,它的字符串可以存储二进制数据,并不会出现任何报错,而PHP的这种能力就叫做字符串的二进制安全。

C语言代码如下:

main() {
    char a[] = "aaa\0b";    /* 含有\0的字符串 */
    printf("%d\n", strlen(a));  /* 长度为3,\0后的b被忽略 */
}

PHP代码:

<?php
    $a = "aaa\0b";
    echo strlen($a);    //输出5
?>

但是PHP不是C语言写的吗?为什么PHP不会报错?我们再来回顾一下zend_string结构体,还记得成员变量len吗?它是实现二进制安全的关键,我们不需要像C一样通过\0来判定字符串是否被读取完成,而是通过长度len来判断,这样就保证了字符串的二进制安全。

写时复制

//b ZEND_ECHO_SPEC_CV_HANDLER
//r test.php
//n
//p z
//p *z
//p $2.value.str
//p *$2.value.str
//p *$2.value.str.val@12
//c
<?php

    //const string
    $c = "hello world";
    echo $c;//refcount=0;常量字符串refcount为0;7.3版本改掉了

    $a = time()."string";//refcount=1;变量字符串
    echo $a;

    //copy on write
    $b = $a;
    echo $a;//refcount=2
    echo $b;
    $b = "hello";
    echo $a;//refcount=1
    echo $b;//refcount=0

2.1.2、数组

数组是PHP中非常强大、灵活的一种数据类型,它的底层实现为散列表(HashTable,也称作:哈希表),除了我们熟悉的PHP用户空间的Array类型之外,内核中也随处用到散列表,比如函数、类、常量、已include文件的索引表、全局符号表等都用的HashTable存储。

散列表是根据关键码值(Key value)而直接进行访问的数据结构,它的key - value之间存在一个映射函数,可以根据key通过映射函数直接索引到对应的value值,它不以关键字的比较为基本操作,采用直接寻址技术(就是说,它是直接通过key映射到内存地址上去的),从而加快查找速度,在理想情况下,无须任何比较就可以找到待查关键字,查找的期望时间为O(1)。

数组结构

存放记录的数组称做散列表,这个数组用来存储value,而value具体在数组中的存储位置由映射函数根据key计算确定,映射函数可以采用取模的方式,key可以通过一些譬如“times 33”的算法得到一个整形值,然后与数组总大小取模得到在散列表中的存储位置。这是一个普通散列表的实现,PHP散列表的实现整体也是这个思路,只是有几个特殊的地方,下面就是PHP中HashTable的数据结构:

//Bucket:散列表中存储的元素
typedef struct _Bucket {
	zval              val;              //存储的具体value,这里嵌入了一个zval,而不是一个指针
	zend_ulong        h;                //key根据times 33计算得到的哈希值,或者是数值索引编号
	zend_string      *key;              //存储元素的key
} Bucket;

//HashTable结构
typedef struct _zend_array HashTable;
struct _zend_array {
	zend_refcounted_h gc;
	union {
		struct {
			ZEND_ENDIAN_LOHI_4(
				zend_uchar    flags,
				zend_uchar    nApplyCount,
				zend_uchar    nIteratorsCount,
				zend_uchar    reserve)
		} v;
		uint32_t flags;
	} u;
	uint32_t          nTableMask;
	Bucket           *arData;
	uint32_t          nNumUsed;
	uint32_t          nNumOfElements;
	uint32_t          nTableSize;
	uint32_t          nInternalPointer;
	zend_long         nNextFreeElement;
	dtor_func_t       pDestructor;
};
  • gc:变量的引用计数信息,用于内存管理。
  • nTableMask:计算bucket索引时的掩码。//它的值是nTableSize的负数,nTableMask=-nTableSize。
  • arData:bucket数组。//数组的每一个元素都保存在这里,默认指向第一个元素。
  • nNumUsed:已用bucket数。//当前使用的Bucket数,但不都是有效的,因为有的Bucket虽然被unset了但是没有马上被删除,而是做了IS_UNDEF标记。
  • nNumOfElements:已有元素数,nNumOfElements <= nNumUsed,因为删除的并不是直接从arData中移除。//有效的Bucket数,这个就与上面不同了,这里记录的是真实有效的Bucket数量。
  • nTableSize:数组的大小,为2^n。//数组的总容量。
  • nIternalPointer:数值索引。//当前遍历的指针。
  • nNextFreeElement:下一个索引的值,比如每次给数组新增数据时,该值就会加一,$a[] = 1。
  • pDestructor:析构函数,在删除或覆盖某个元素时,调用该函数,可以对旧元素进行清理。
  • u:这里的u主要还是起到辅助作用,比如flags用来设置散列表的一些属性是否持久化、是否已经初始化等。

HashTable中有两个非常相近的值:nNumUsed、nNumOfElements,nNumOfElements表示哈希表已有元素数,那这个值不跟nNumUsed一样吗?为什么要定义两个呢?实际上它们有不同的含义,当将一个元素从哈希表删除时并不会将对应的Bucket移除,而是将Bucket存储的zval修改为IS_UNDEF,只有扩容时发现nNumOfElements与nNumUsed相差达到一定数量(这个数量是:ht->nNumUsed - ht->nNumOfElements > (ht->nNumOfElements >> 5))时才会将已删除的元素全部移除,重新构建哈希表。所以nNumUsed>=nNumOfElements。

HashTable中另外一个非常重要的值arData,这个值指向存储元素数组的第一个Bucket,插入元素时按顺序 依次插入 数组,比如第一个元素在arData[0]、第二个在arData[1]...arData[nNumUsed]。PHP数组的有序性正是通过arData保证的,这是第一个与普通散列表实现不同的地方。

既然arData并不是按key映射的散列表,那么映射函数是如何将key与arData中的value建立映射关系的呢?

实际上这个散列表也在arData中,比较特别的是散列表在ht->arData内存之前,分配内存时这个散列表与Bucket数组一起分配,arData向后移动到了Bucket数组的起始位置,并不是申请内存的起始位置,这样散列表可以由arData指针向前移动访问到,即arData[-1]、arData[-2]、arData[-3]......散列表的结构是uint32_t,它保存的是value在Bucket数组中的位置。

所以,整体来看HashTable主要依赖arData实现元素的存储、索引。插入一个元素时先将元素按先后顺序插入Bucket数组,位置是idx,再根据key的哈希值映射到散列表中的某个位置nIndex,将idx存入这个位置;查找时先在散列表中映射到nIndex,得到value在Bucket数组的位置idx,再从Bucket数组中取出元素。

比如:

$arr["a"] = 1;
$arr["b"] = 2;
$arr["c"] = 3;
$arr["d"] = 4;

unset($arr["c"]);

对应的HashTable如下图所示。

映射函数

映射函数(即:散列函数)是散列表的关键部分,它将key与value建立映射关系,一般映射函数可以根据key的哈希值与Bucket数组大小取模得到,即key->h % ht->nTableSize,但是PHP却不是这么做的:

nIndex = key->h | ht->nTableMask;

显然位运算要比取模更快。

nTableMask为nTableSize的负数,即:nTableMask = -nTableSize,因为nTableSize等于2^n,所以nTableMask二进制位右侧全部为0,也就保证了nIndex落在数组索引的范围之内(|nIndex| <= nTableSize):

11111111 11111111 11111111 11111000   -8
11111111 11111111 11111111 11110000   -16
11111111 11111111 11111111 11100000   -32
11111111 11111111 11111111 11000000   -64
11111111 11111111 11111111 10000000   -128

哈希碰撞

哈希碰撞是指不同的key可能计算得到相同的哈希值(数值索引的哈希值直接就是数值本身),但是这些值又需要插入同一个散列表。一般解决方法是将Bucket串成链表,查找时遍历链表比较key。

PHP的实现也是如此,只是将链表的指针指向转化为了数值指向,即:指向冲突元素的指针并没有直接存在Bucket中,而是保存到了value的zval中:

struct _zval_struct {
    zend_value        value;            /* value */
    ...
    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的位置保存到新value的zval.u2.next中,然后将新插入的value的位置更新到散列表,也就是后面冲突的value始终插入header。所以查找过程类似:

zend_ulong h = zend_string_hash_val(key);
uint32_t idx = ht->arHash[h & ht->nTableMask];
while (idx != INVALID_IDX) {
    Bucket *b = &ht->arData[idx];
    if (b->h == h && zend_string_equals(b->key, key)) {
        return b;
    }
    idx = Z_NEXT(b->val); //移到下一个冲突的value
}
return NULL;

插入、查找、删除 这几个基本操作比较简单,不再赘述,定位到元素所在Bucket位置后的操作类似单链表的插入、删除、查找。

扩容 散列表可存储的value数是固定的,当空间不够用时就要进行扩容了。

PHP散列表的大小为2^n,插入时如果容量不够则首先检查已删除元素所占比例,如果达到阈值(ht->nNumUsed - ht->nNumOfElements > (ht->nNumOfElements >> 5),则将已删除元素移除,重建索引,如果未到阈值则进行扩容操作,扩大为当前大小的2倍,将当前Bucket数组复制到新的空间,然后重建索引。

//zend_hash.c
static void ZEND_FASTCALL zend_hash_do_resize(HashTable *ht)
{

    if (ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)) {
        //只有到一定阈值才进行rehash操作
        zend_hash_rehash(ht); //重建索引数组
    } else if (ht->nTableSize < HT_MAX_SIZE) {
        //扩容
        void *new_data, *old_data = HT_GET_DATA_ADDR(ht);
        //扩大为2倍,加法要比乘法快,小的优化点无处不在...
        uint32_t nSize = ht->nTableSize + ht->nTableSize;
        Bucket *old_buckets = ht->arData;

        //新分配arData空间,大小为:(sizeof(Bucket) + sizeof(uint32_t)) * nSize
        new_data = pemalloc(HT_SIZE_EX(nSize, -nSize), ...);
        ht->nTableSize = nSize;
        ht->nTableMask = -ht->nTableSize;
        //将arData指针偏移到Bucket数组起始位置
        HT_SET_DATA_ADDR(ht, new_data);
        //将旧的Bucket数组拷到新空间
        memcpy(ht->arData, old_buckets, sizeof(Bucket) * ht->nNumUsed);
        //释放旧空间
        pefree(old_data, ht->u.flags & HASH_FLAG_PERSISTENT);
        
        //重建索引数组:散列表
        zend_hash_rehash(ht);
        ...
    }
    ...
}

#define HT_SET_DATA_ADDR(ht, ptr) do { \
        (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
    } while (0)

重建散列表

当删除元素达到一定数量或扩容后都需要重建散列表,因为value在Bucket位置移动了或哈希数组nTableSize变化了导致key与value的映射关系改变,重建过程实际就是遍历Bucket数组中的value,然后重新计算映射值更新到散列表,除了更新散列表之外,这里还有一个重要的处理:移除已删除的value,开始的时候我们说过,删除value时只是将value的type设置为IS_UNDEF,并没有实际从Bucket数组中删除,如果这些value一直存在那么将浪费很多空间,所以这里会把它们移除,操作的方式也比较简单:将后面未删除的value依次前移,具体过程如下:

//zend_hash.c
ZEND_API int ZEND_FASTCALL zend_hash_rehash(HashTable *ht)
{
    Bucket *p;
    uint32_t nIndex, i;
    ...
    i = 0;
    p = ht->arData;
    if (ht->nNumUsed == ht->nNumOfElements) { //没有已删除的直接遍历Bucket数组重新插入索引数组即可
        do {
            nIndex = p->h | ht->nTableMask;
            Z_NEXT(p->val) = HT_HASH(ht, nIndex);
            HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
            p++;
        } while (++i < ht->nNumUsed);
    } else {
        do {
            if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {
                //有已删除元素则将后面的value依次前移,压实Bucket数组
                ......
                while (++i < ht->nNumUsed) {
                    p++;
                    if (EXPECTED(Z_TYPE_INFO(p->val) != IS_UNDEF)) {
                        ZVAL_COPY_VALUE(&q->val, &p->val);
                        q->h = p->h;
                        nIndex = q->h | ht->nTableMask;
                        q->key = p->key;
                        Z_NEXT(q->val) = HT_HASH(ht, nIndex);
                        HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(j);
                        if (UNEXPECTED(ht->nInternalPointer == i)) {
                            ht->nInternalPointer = j;
                        }
                        q++;
                        j++;
                    }
                }
                ......
                ht->nNumUsed = j;
                break;
            }
            
            nIndex = p->h | ht->nTableMask;
            Z_NEXT(p->val) = HT_HASH(ht, nIndex);
            HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
            p++;
        }while(++i < ht->nNumUsed);
    }
}

数组的调试技巧

//b zend_compile    PHP中许多地方会初始化数组,所以不能直接在_zend_hash_init上打断点,代码中的数组初始化会在词法语法分析阶段,所以先在zend_compile上打断点
//c
//b _zend_hash_init
//c
//bt    查看一下调用堆栈,确认是在代码中的数组初始化
//n
//p ht
//p *ht
//n
//
//
//p nSize
//p ht->nTableSize
//n
//n
//p *ht   arData有地址了
.......

//b zend_execute    调试数组赋值
//c
//p *op_array      last=15说明有15条指令。指令集在opcodes中
//p $10.opcodes[0]   查看第一条指令,可以看到数组handler的操作
//n 
//n.....             直到发现zend_execute_ex函数,单步跟踪进去
//s
//n.....             直到发现ZEND_OPCODE_HANDLER_ARGS_PASSTHRU handler ,单步跟踪进去
//s
//n n 
//p value
//p *value
//p $13.value.arr
//p *$13.value.arr
//n
//p variable_ptr
<?php

    $a = [];

    $a[1] = "a";

    $a[] = "b";

    $a["k1"] = "v1";
    $a["k2"] = "v2";

    echo $a["k1"];

    $a["k1"] = "c";

    unset($a["k2"]);

2.1.3、对象/资源

对象比较常见,资源指的是tcp连接、文件句柄等等类型,这种类型比较灵活,可以随意定义struct,通过ptr指向

struct _zend_object {
	zend_refcounted_h gc;
	uint32_t          handle;
	zend_class_entry *ce;
	const zend_object_handlers *handlers;
	HashTable        *properties;
	zval              properties_table[1];
};
  • gc:引用计数。
  • handle:一次请求期间对象的编号,每一个对象都有一个唯一的编号,与创建的先后顺序有关,主要是在垃圾回收的时候使用。
  • ce:该对象所属的类。
  • handlers:对象操作的处理函数,比如成员属性的读写、成员方法的获取、对象的销毁克隆等。
  • properties:普通成员属性的哈希表,初始化对象时该值为NULL。
  • properties_table:用来存储普通成员的属性值,对象对非静态成员属性的操作就是通过这个数组。

2.1.4、引用

引用是PHP中比较特殊的一种类型,它实际是指向另外一个PHP变量,对它的修改会直接改动实际指向的zval,可以简单的理解为C中的指针,在PHP中通过&操作符产生一个引用变量,也就是说不管以前的类型是什么,&首先会创建一个zend_reference结构,其内嵌了一个zval,这个zval的value指向原来zval的value(如果是布尔、整形、浮点则直接复制原来的值),然后将原zval的类型修改为IS_REFERENCE,原zval的value指向新创建的zend_reference结构。

struct _zend_reference {
	zend_refcounted_h gc;
	zval              val;
};

结构非常简单,除了公共部分zend_refcounted_h外只有一个val,举个示例看下具体的结构关系:

$a = "time:" . time();      //$a    -> zend_string_1(refcount=1)
$b = &$a;                   //$a,$b -> zend_reference_1(refcount=2) -> zend_string_1(refcount=1)

最终的结果如图:

注意:引用只能通过&产生,无法通过赋值传递,比如:

$a = "time:" . time();      //$a    -> zend_string_1(refcount=1)
$b = &$a;                   //$a,$b -> zend_reference_1(refcount=2) -> zend_string_1(refcount=1)
$c = $b;                    //$a,$b -> zend_reference_1(refcount=2) -> zend_string_1(refcount=2)
                            //$c    -> zend_string_1(refcount=2)

$b = &$a这时候$a、$b的类型是引用,但是$c = $b并不会直接将$b赋值给$c,而是把$b实际指向的zval赋值给$c,如果想要$c也是一个引用则需要这么操作:

$a = "time:" . time();      //$a       -> zend_string_1(refcount=1)
$b = &$a;                   //$a,$b    -> zend_reference_1(refcount=2) -> zend_string_1(refcount=1)
$c = &$b;/*或$c = &$a*/     //$a,$b,$c -> zend_reference_1(refcount=3) -> zend_string_1(refcount=1) 

这个也表示PHP中的 引用只可能有一层 ,不会出现一个引用指向另外一个引用的情况 ,也就是没有C语言中指针的指针的概念。

2.2、u1

u1是一个联合体,它联合了结构体v和整型type_info。下面我们先来看一下结构体v的构成。

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;

2.2.1、type

type是指变量的类型,刚在2.1中讲到了zend_value是用来存储变量的值,所以也应该有地方存储变量的类型,而这就是type的职责。以下是PHP定义的所有变量类型,有我们熟知的布尔、NULL、浮点、数组、字符串等类型。也有陌生的undef、indirect、ptr类型。

/* regular data types */
#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   /* 常量抽象语法树 */

/* 伪类型 */
#define _IS_BOOL					13   
#define IS_CALLABLE					14

/* 内部类型  */
#define IS_INDIRECT             	                15   /* 间接类型 */
#define IS_PTR						17   /* 指针类型 */
  • IS_UNDEF:标记未定义,表示数据可以被覆盖或删除。
  • IS_TRUE/IS_FALSE:本来在PHP5中统一用IS_BOOL来代替,这里分成两个可以避免一次类型的检查。
  • IS_REFERRENCE:引用类型,用于处理PHP脚本中的符号&。
  • IS_PTR:用来解析value.ptr,通常用在函数类型上,比如声明一个函数或方法。
  • IS_INDIRECT:用于解决在全局符号表访问CV变量的问题。

2.2.2、type_flags

可以把它理解为子类型,上面提到了变量的类型,这个是针对不同类型的子类型或标记,type_flags一共有以下6种。

/* zval.u1.v.type_flags */
#define IS_TYPE_CONSTANT			(1<<0)  /* 常量 */
#define IS_TYPE_IMMUTABLE			(1<<1)  /* 不可变的类型 */
#define IS_TYPE_REFCOUNTED			(1<<2)  /* 需要引用计数的类型 */
#define IS_TYPE_COLLECTABLE			(1<<3)  /* 可能包含循环引用的类型 */
#define IS_TYPE_COPYABLE			(1<<4)  /* 可被复制的类型 */
#define IS_TYPE_SYMBOLTABLE			(1<<5)  /* 符号表类型 */

2.2.3、const_flags

常量类型的标记,对应的属性为:

/* zval.u1.v.const_flags */
#define IS_CONSTANT_UNQUALIFIED		0x010
#define IS_LEXICAL_VAR				0x020
#define IS_LEXICAL_REF				0x040
#define IS_CONSTANT_CLASS           0x080  /* __CLASS__ in trait */
#define IS_CONSTANT_IN_NAMESPACE	0x100  /* used only in opline->extended_value */

2.2.4、type_info

type_info与结构体v共用内存,修改type_info等同于修改结构体v的值,所以type_info是v中四个char的组合。

2.3、u2

本来使用u1和zend_value就可以表示变量的,没有必要定义u2,但是我们来看一下,如果没有u2,在内存对齐的情况下zval内存大小为16个字节,当联合了u2后依然是占用16个字节。既然有或没有占用内存大小相同,不如用它来记录一些附属信息。下面我们来看下u2都存储了哪些内容。

2.3.1、next

用来解决哈希冲突问题,记录冲突的下一个元素位置。

2.3.2、cache_slot

运行时缓存,在执行函数时回去缓存中查找,若缓存中没有则到全局function表中查找。

2.3.3、lineno

文件执行的行号,应用在AST节点上。Zend引擎在词法和语法解析时会把当前执行的文件行号记录下来,记录在zend_ast中的lineno中。

2.3.4、num_args

函数调用时传入函数的参数个数。

2.3.5、fe_pos

用于遍历数组时记录当前遍历的位置,比如每次执行foreach时fe_pos都会加一,当再次调用foreach进行遍历时,fe_post会进行重置。

2.3.6、fe_iter_idx

这个与fe_pos类似,只不过它是针对对象的。对象的属性也是HashTable,传入的参数是对象时,会获取对象的属性,所以遍历对象就是在变量对象的属性。

三、内存管理

接下来分析下变量的分配、销毁。

在分析变量内存管理之前我们先自己想一下可能的实现方案,最简单的处理方式:定义变量时alloc一个zval及对应的value结构(ref/arr/str/res...),赋值、函数传参时硬拷贝一个副本,这样各变量最终的值完全都是独立的,不会出现多个变量同时共用一个value的情况,在执行完以后直接将各变量及value结构free掉。

这种方式是可行的,而且内存管理也很简单,但是,硬拷贝带来的一个问题是效率低,比如我们定义了一个变量然后赋值给另外一个变量,可能后面都只是只读操作,假如硬拷贝的话就会有多余的一份数据,这个问题的解决方案是: 引用计数+写时复制 。PHP变量的管理正是基于这两点实现的。

3.1、引用计数

引用计数是指在value中增加一个字段refcount记录指向当前value的数量,变量复制、函数传参时并不直接硬拷贝一份value数据,而是将refcount++,变量销毁时将refcount--,等到refcount减为0时表示已经没有变量引用这个value,将它销毁即可。

$a = "time:" . time();   //$a       ->  zend_string_1(refcount=1)
$b = $a;                 //$a,$b    ->  zend_string_1(refcount=2)
$c = $b;                 //$a,$b,$c ->  zend_string_1(refcount=3)

unset($b);               //$b = IS_UNDEF  $a,$c ->  zend_string_1(refcount=2)

引用计数的信息位于给具体value结构的gc中:

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;

从上面的zend_value结构可以看出并不是所有的数据类型都会用到引用计数,long、double直接都是硬拷贝,只有value是指针的那几种类型才__可能__会用到引用计数。

下面再看一个例子:

$a = "hi~";
$b = $a;

猜测一下变量$a/$b的引用情况。

这个不跟上面的例子一样吗?字符串"hi~"有$a/$b两个引用,所以zend_string1(refcount=2)。但是这是错的,gdb调试发现上面例子zend_string的引用计数为0。这是为什么呢?

$a,$b -> zend_string_1(refcount=0,val="hi~")

事实上并不是所有的PHP变量都会用到引用计数,标量:true/false/double/long/null是硬拷贝自然不需要这种机制,但是除了这几个还有两个特殊的类型也不会用到:interned string(内部字符串,就是上面提到的字符串flag:IS_STR_INTERNED)、immutable array,它们的type是IS_STRING、IS_ARRAY,与普通string、array类型相同,那怎么区分一个value是否支持引用计数呢?还记得zval.u1中那个类型掩码type_flag吗?正是通过这个字段标识的,这个字段除了标识value是否支持引用计数外还有其它几个标识位,按位分割,注意:type_flag与zval.value->gc.u.flag不是一个值。

支持引用计数的value类型其zval.u1.type_flag包含 (注意是&,不是等于)IS_TYPE_REFCOUNTED:

#define IS_TYPE_REFCOUNTED          (1<<2)

下面具体列下哪些类型会有这个标识:

|     type       | refcounted |
+----------------+------------+
|simple types    |            |
|string          |      Y     |
|interned string |            |
|array           |      Y     |
|immutable array |            |
|object          |      Y     |
|resource        |      Y     |
|reference       |      Y     |

simple types很显然用不到,不再解释,string、array、object、resource、reference有引用计数机制也很容易理解,下面具体解释下另外两个特殊的类型:

  • interned string: 内部字符串,这是种什么类型?我们在PHP中写的所有字符都可以认为是这种类型,比如function name、class name、variable name、静态字符串等等,我们这样定义:$a = "hi~";后面的字符串内容是唯一不变的,这些字符串等同于C语言中定义在静态变量区的字符串:char *a = "hi~";,这些字符串的生命周期为request期间,request完成后会统一销毁释放,自然也就无需在运行期间通过引用计数管理内存。

  • immutable array: 只有在用opcache的时候才会用到这种类型,不清楚具体实现,暂时忽略。

3.2、写时复制

上一小节介绍了引用计数,多个变量可能指向同一个value,然后通过refcount统计引用数,这时候如果其中一个变量试图更改value的内容则会重新拷贝一份value修改,同时断开旧的指向,写时复制的机制在计算机系统中有非常广的应用,它只有在必要的时候(写)才会发生硬拷贝,可以很好的提高效率,下面从示例看下:

$a = array(1,2);
$b = &$a;
$c = $a;

//发生分离
$b[] = 3;

最终的结果:

不是所有类型都可以copy的,比如对象、资源,实时上只有string、array两种支持,与引用计数相同,也是通过zval.u1.type_flag标识value是否可复制的:

#define IS_TYPE_COPYABLE         (1<<4)

|     type       |  copyable  |
+----------------+------------+
|simple types    |            |
|string          |      Y     |
|interned string |            |
|array           |      Y     |
|immutable array |            |
|object          |            |
|resource        |            |
|reference       |            |

copyable 的意思是当value发生duplication时是否需要或者能够copy,这个具体有两种情形下会发生:

  • 从 literal变量区 复制到 局部变量区 ,比如:$a = [];实际会有两个数组,而$a = "hi~";//interned string则只有一个string

  • 局部变量区分离时(写时复制):如改变变量内容时引用计数大于1则需要分离,$a = [];$b = $a; $b[] = 1;这里会分离,类型是array所以可以复制,如果是对象:$a = new user;$b = $a;$a->name = "dd";这种情况是不会复制object的,$a、$b指向的对象还是同一个

3.3、变量回收

PHP变量的回收主要有两种:主动销毁、自动销毁。主动销毁指的就是 unset ,而自动销毁就是PHP的自动管理机制,在return时减掉局部变量的refcount,即使没有显式的return,PHP也会自动给加上这个操作,另外一个就是写时复制时会断开原来value的指向,这时候也会检查断开后旧value的refcount。

3.4、垃圾回收

PHP变量的回收是根据refcount实现的,当unset、return时会将变量的引用计数减掉,如果refcount减到0则直接释放value,这是变量的简单gc过程,但是实际过程中出现gc无法回收导致内存泄漏的bug,先看下一个例子:

$a = [1];
$a[] = &$a;

unset($a);

可以看到,unset($a)之后由于数组中有子元素指向$a,所以refcount > 0,无法通过简单的gc机制回收,这种变量就是垃圾,垃圾回收器要处理的就是这种情况,目前垃圾只会出现在array、object两种类型中,所以只会针对这两种情况作特殊处理:当销毁一个变量时,如果发现减掉refcount后仍然大于0,且类型是IS_ARRAY、IS_OBJECT则将此value放入gc可能垃圾双向链表中,等这个链表达到一定数量后启动检查程序将所有变量检查一遍,如果确定是垃圾则销毁释放。

标识变量是否需要回收也是通过u1.type_flag区分的:

#define IS_TYPE_COLLECTABLE

|     type       | collectable |
+----------------+-------------+
|simple types    |             |
|string          |             |
|interned string |             |
|array           |      Y      |
|immutable array |             |
|object          |      Y      |
|resource        |             |
|reference       |             |

四、 静态变量

PHP中局部变量分配在zend_execute_data结构上,每次执行zend_op_array都会生成一个新的zend_execute_data,局部变量在执行之初分配,然后在执行结束时释放,这是局部变量的生命周期,而局部变量中有一种特殊的类型:静态变量,它们不会在函数执行完后释放,当程序执行离开函数域时静态变量的值被保留下来,下次执行时仍然可以使用之前的值。

PHP中的静态变量通过static关键词创建:

function my_func(){
    static $count = 4;
    $count++;
    echo $count,"\n";
}
my_func();
my_func();
===========================
5
6

4.1、静态变量的存储

静态变量既然不会随执行的结束而释放,那么很容易想到它的保存位置:zend_op_array->static_variables,这是一个哈希表,所以PHP中的静态变量与普通局部变量不同,它们没有分配在执行空间zend_execute_data上,而是以哈希表的形式保存在zend_op_array中。

静态变量只会初始化一次,注意:它的初始化发生在编译阶段而不是执行阶段,上面这个例子中:static $count = 4;是在编译阶段发现定义了一个静态变量,然后插进了zend_op_array->static_variables中,并不是执行的时候把static_variables中的值修改为4,所以上面执行的时候会输出5、6,再次执行并没有重置静态变量的值。

这个特性也意味着静态变量初始的值不能是变量,比如:static $count = $xxx;这样定义将会报错

4.2、静态变量的访问

局部变量通过编译时确定的编号进行读写操作,而静态变量通过哈希表保存,这就使得其不能像普通变量那样有一个固定的编号,有一种可能是通过变量名索引的,那么究竟是否如此呢?我们分析下其编译过程。

静态变量编译的语法规则:

statement:
    ...
    |   T_STATIC static_var_list ';'    { $$ = $2; }
    ...
;

static_var_list:
        static_var_list ',' static_var { $$ = zend_ast_list_add($1, $3); }
    |   static_var { $$ = zend_ast_create_list(1, ZEND_AST_STMT_LIST, $1); }
;

static_var:
        T_VARIABLE          { $$ = zend_ast_create(ZEND_AST_STATIC, $1, NULL); }
    |   T_VARIABLE '=' expr { $$ = zend_ast_create(ZEND_AST_STATIC, $1, $3); }
;

语法解析后生成了一个ZEND_AST_STATIC语法树节点,接着再看下这个节点编译为opcode的过程:zend_compile_static_var。

void zend_compile_static_var(zend_ast *ast)
{
    zend_ast *var_ast = ast->child[0];
    zend_ast *value_ast = ast->child[1];
    zval value_zv;

    if (value_ast) {
        //定义了初始值
        zend_const_expr_to_zval(&value_zv, value_ast);
    } else {
        //无初始值
        ZVAL_NULL(&value_zv);
    }

    zend_compile_static_var_common(var_ast, &value_zv, 1);
}

这里首先对初始化值进行编译,最终得到一个固定值,然后调用:zend_compile_static_var_common()处理,首先判断当前编译的zend_op_array->static_variables是否已创建,未创建则分配一个HashTable,接着将定义的静态变量插入:

//zend_compile_static_var_common():
if (!CG(active_op_array)->static_variables) {
    ALLOC_HASHTABLE(CG(active_op_array)->static_variables);
    zend_hash_init(CG(active_op_array)->static_variables, 8, NULL, ZVAL_PTR_DTOR, 0);
}
//插入静态变量
zend_hash_update(CG(active_op_array)->static_variables, Z_STR(var_node.u.constant), value);

插入静态变量哈希表后并没有完成,接下来还有一个重要操作:

//生成一条ZEND_FETCH_W的opcode
opline = zend_emit_op(&result, by_ref ? ZEND_FETCH_W : ZEND_FETCH_R, &var_node, NULL);
opline->extended_value = ZEND_FETCH_STATIC;

if (by_ref) {
    zend_ast *fetch_ast = zend_ast_create(ZEND_AST_VAR, var_ast);
    //生成一条ZEND_ASSIGN_REF的opcode
    zend_emit_assign_ref_znode(fetch_ast, &result);
}

后面生成了两条opcode:

  • ZEND_FETCH_W: 这条opcode对应的操作是创建一个IS_INDIRECT类型的zval,指向static_variables中对应静态变量的zval

  • ZEND_ASSIGN_REF: 它的操作是引用赋值,即将一个引用赋值给CV变量

通过上面两条opcode可以确定静态变量的读写过程:首先根据变量名在static_variables中取出对应的zval,然后将它修改为引用类型并赋值给局部变量,也就是说static $count = 4;包含了两个操作,严格的将$count并不是真正的静态变量,它只是一个指向静态变量的局部变量,执行时实际操作是:$count = & static_variables["count"];。上面例子$count与static_variables["count"]间的关系如图所示。

五、全局变量

PHP中在函数、类之外直接定义的变量可以在函数、类成员方法中通过global关键词引入使用,这些变量称为:全局变量。

这些直接在PHP中定义的变量(包括include、require文件中的)相对于函数、类方法而言它们是全局变量,但是对自身执行域zend_execute_data而言它们是普通的局部变量,自身执行时它们与普通变量的读写方式完全相同。

function test() {
    global $id;
    $id++;
}

$id = 1;
test();
echo $id;

5.1、全局变量初始化

全局变量在整个请求执行期间始终存在,它们保存在EG(symbol_table)中,也就是全局变量符号表,与静态变量的存储一样,这也是一个哈希表,主脚本(或include、require)在zend_execute_ex执行开始之前会把当前作用域下的所有局部变量添加到EG(symbol_table)中,这一步操作后面介绍zend执行过程时还会讲到,这里先简单提下:

ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value)
{
    ...
    i_init_execute_data(execute_data, op_array, return_value);
    zend_execute_ex(execute_data);
    ...
}

i_init_execute_data()这个函数中会把局部变量插入到EG(symbol_table):

ZEND_API void zend_attach_symbol_table(zend_execute_data *execute_data)
{
    zend_op_array *op_array = &execute_data->func->op_array;
    HashTable *ht = execute_data->symbol_table;

    if (!EXPECTED(op_array->last_var)) { 
        return;
    }

    zend_string **str = op_array->vars;
    zend_string **end = str + op_array->last_var;
    //局部变量数组起始位置
    zval *var = EX_VAR_NUM(0);

    do{
        zval *zv = zend_hash_find(ht, *str);
        //插入全局变量符号表
        zv = zend_hash_add_new(ht, *str, var);
        //哈希表中value指向局部变量的zval
        ZVAL_INDIRECT(zv, var);
        ...
    }while(str != end);
}

从上面的过程可以很直观的看到,在执行前遍历局部变量,然后插入EG(symbol_table),EG(symbol_table)中的value直接指向局部变量的zval,示例经过这一步的处理之后(此时局部变量只是分配了zval,但还未初始化,所以是IS_UNDEF):

5.2、全局变量的访问

与静态变量的访问一样,全局变量也是将原来的值转换为引用,然后在global导入的作用域内创建一个局部变量指向该引用:

global $id; // 相当于:$id = & EG(symbol_table)["id"];

具体的操作过程不再细讲,与静态变量的处理过程一致,这时示例中局部变量与全局变量的引用情况如下图。

5.3、超全局变量

全部变量除了通过global引入外还有一类特殊的类型,它们不需要使用global引入而可以直接使用,这些全局变量称为:超全局变量。

超全局变量实际是PHP内核定义的一些全局变量:$GLOBALS、$_SERVER、$_REQUEST、$_POST、$_GET、$_FILES、$_ENV、$_COOKIE、$_SESSION、argv、argc。

5.4、销毁

局部变量如果没有手动销毁,那么在函数执行结束时会将它们销毁,而全局变量则是在整个请求结束时才会销毁,即使是我们直接在PHP脚本中定义在函数外的那些变量。

void shutdown_destructors(void)
{
    if (CG(unclean_shutdown)) {
        EG(symbol_table).pDestructor = zend_unclean_zval_ptr_dtor;
    }
    zend_try {
        uint32_t symbols;
        do {
            symbols = zend_hash_num_elements(&EG(symbol_table));
            //销毁
            zend_hash_reverse_apply(&EG(symbol_table), (apply_func_t) zval_call_destructor);
        } while (symbols != zend_hash_num_elements(&EG(symbol_table)));
    }
    ...
}

六、常量

常量是一个简单值的标识符(名字)。如同其名称所暗示的,在脚本执行期间该值不能改变。常量默认为大小写敏感。通常常量标识符总是大写的

常量名和其它任何 PHP 标签遵循同样的命名规则。合法的常量名以字母或下划线开始,后面跟着任何字母,数字或下划线。

PHP中的常量通过define()函数定义:

define('CONST_VAR_1', 1234);

5.1、常量的存储

在内核中常量存储在EG(zend_constant)哈希表中,访问时也是根据常量名直接到哈希表中查找,其实现比较简单。

常量的数据结构:

typedef struct _zend_constant {
    zval value;   //常量值
    zend_string *name; //常量名
    int flags;  //常量标识位
    int module_number; //所属扩展、模块
} zend_constant;

常量的几个属性都比较直观,这里只介绍下flags,它的值可以是以下三个中任意组合:

#define CONST_CS                (1<<0)  //大小写敏感
#define CONST_PERSISTENT        (1<<1)  //持久化的
#define CONST_CT_SUBST          (1<<2)  //允许编译时替换
  • CONST_CS: 大小写敏感,默认是开启的,用户通过define()定义的始终是区分大小写的,通过扩展定义的可以自由选择

  • CONST_PERSISTENT: 持久化的,只有通过扩展、内核定义的才支持,这种常量不会在request结束时清理掉

  • CONST_CT_SUBST: 允许编译时替换,编译时如果发现有地方在读取常量的值,那么编译器会尝试直接替换为常量值,而不是在执行时再去读取,目前这个flag只有TRUE、FALSE、NULL三个常量在使用

5.2、常量的销毁

非持久化常量在request请求结束时销毁,具体销毁操作在:

php_request_shutdown()->zend_deactivate()->shutdown_executor()->clean_non_persistent_constants()。

void clean_non_persistent_constants(void)
{
    if (EG(full_tables_cleanup)) {
        zend_hash_apply(EG(zend_constants), clean_non_persistent_constant_full);
    } else {
        zend_hash_reverse_apply(EG(zend_constants), clean_non_persistent_constant);
    }
}

然后从哈希表末尾开始向前遍历EG(zend_constants),将非持久化常量删除,直到碰到第一个持久化常量时,停止遍历,正常情况下所有通过扩展定义的常量一定是在PHP中通过define定义之前,当然也并非绝对,这里只是说在所有常量均是在MINT阶段定义的情况。

持久化常量是在php_module_shutdown()阶段销毁的,具体过程与上面类似。

⚠️ **GitHub.com Fallback** ⚠️