【PHP 源码学习】2019-03-12 PHP 基本变量笔记
baiyan
全部视频:https://segmentfault.com/a/11…
源视频地址:http://replay.xesv5.com/ll/24…
引入及基本概念
- 变量本质上就是给一段内存空间起了个名字
- 如果让我们自己基于 C 语言设计一个存储如 $a = 1 变量的数据结构,应该如何设计?
- 变量的基本要素是类型与值,其中部分类型还有其他的描述字段(如长度等)
- 首先应该定义一个结构体作为基本的数据结构
- 第一个问题:变量类型如何存储? 答:用一个 unsigned char 类型的字段存足够,因为 unsigned char 类型最多能够表示 2^8 = 256 种类型。
- PHP7 中以 zval 表示所有的变量,它是一个结构体。先看 zval 的基本结构:
typedef unsigned char zend_uchar;
struct _zval_struct {
zend_value value; /* 下面会讲 */
union {
struct {
ZEND_ENDIAN_LOHI_4( // 大小端问题,详情看 "PHP 内存管理 3 笔记”zend_uchar type, // 注意这里就是存放变量类型的地方,char 类型
zend_uchar type_flags, // 类型标记
zend_uchar const_flags, // 是否是常量
zend_uchar reserved) // 保留字段
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; /* 数组模拟链表,在下文链地址法解决哈希冲突时使用 */
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 */
uint32_t access_flags; /* class constant access flags */
uint32_t property_guard; /* single property guard */
uint32_t extra; /* not further specified */
} u2;
};
- 注意关注中文注释的部分,PHP 就是利用 C 语言的 unsigned char 类型,存储了所有变量的类型。
- 在 PHP 中,所有变量的类型如下:
/* 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
/* fake types */
#define _IS_BOOL 13
#define IS_CALLABLE 14
#define IS_ITERABLE 19
#define IS_VOID 18
/* internal types */
#define IS_INDIRECT 15
#define IS_PTR 17
#define _IS_ERROR 20
- 第二个问题:变量的值如何存储? 答:如果是 a 是 1 用 int;如果是 1.1,用 double;是 ’1’ 用 char * 等等,但是变量的值的类型只有 1 种,不可能同时用到多种类型去存值,故我们可以把这一大堆东西放到 1 个 union 里面即可,源码中存储变量类型的联合体叫做 zend_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; // 存抽象语法树
zval *zv; // 内部使用
void *ptr; // 不确定类型,取出来之后强转
zend_class_entry *ce; // 存类
zend_function *func;// 存函数
struct {
uint32_t w1;
uint32_t w2;
} ww; // 这个 union 一共 8B,这个结构体每个字段都是 4B,因为所有联合体字段共用一块内存,故相当于取了一半的 union
} zend_value;
- 由于某些类型的变量需要额外的一些描述信息(如字符串、数组),其复杂度更高,为了节省空间,就只在 zend_value 结构体中存了一个结构体指针,其真正的值在 zend_string、zend_array 这些结构体中(下面会讲)。
- zend_value 类型是一个联合体,共占用 8B。因为变量只有一种类型,所以就可以利用联合体共用一块内存的特性,来存储变量的类型。注意最后一个结构体是一个小技巧,通过取 ww 结构体的其中一个字段,可以取到联合体变量高 4 位或者低 4 位,这样就不用手动编写多余代码去取了。
- 在 PHP7 中,zend_value 占用 8B,而 u1 占用 4B,u2 占用 4B,经过内存对齐,一个 zval 占用 16B,相较 PHP5,占用的内存大幅减少。
利用 gdb 查看变量底层存储情况
- 示例代码:
<?php
$a = 1;
echo $a;
$b = 1.1;
echo $b;
$c = "hello";
echo $c;
$d = [1,2,3];
echo $d;
- 首先在 ZEND_ECHO_SPEC_CV_HANDLER 打一个断点。在 PHP 虚拟机中,一条指令对应一个 handler,这里对应的就是 echo 的语法。首先我们执行到了 $a = 1 处,打印这个 z 变量的值,可以看到 lval = 1,它就是用来存放 $a 的值的。然后再关注联合体 u1 中的 type 字段的值为 4,对照上文类型对照表,正好对应 IS_LONG 类型。记录下它的地址 0x7ffff381c080,下文将要使用。
- 用 c 命令回到 PHP 代码继续执行到 $b = 1.1 处,打印 zval 的情况:
- 可以看到 double 类型的值被存放到了 dval 变量中,这里存在精度问题(不展开),且 u1 的 type 是 5,对应 IS_DOUBLE 类型。这里的地址是 0x7ffff381c090,正好与上一个 $a 的地址 0x7ffff381c080 相差 16B,即一个 zval 的大小,验证了 zval 是 16B 的结论。
- 使用 c 命令继续往下执行,到了 $c = “hello“处:
- 可以看到这个 zval 中 u1 中 type 字段为 6,即 IS_STRING 类型。遇到字符串类型会取 value 中的 str 字段,它是一个 zend_string 类型(专门用来存字符串,下面会讲)的结构体指针。
-
首先思考一个问题,如果让我们自己基于 C 语言设计一个 zend_string,应该如何设计?
-
- 存放字符串值的字符数组
- 存放长度
- 这样好像差不多就够了,那么思考一个问题:如果想临时给字符串追加或减少应该如何处理,如让 hello 变成 hello world?因为 C 语言中的字符数组是固定的空间大小,并不能自动扩容。那么如何高效地将字符数组扩容或缩小呢?那就要使用 C 语言结构体中的 柔性数组 了。
- 我们先来看一下 zend_string 类型的结构:
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* 冗余的 hash 值 */
size_t len;
char val[1];
};
- gc 字段表示引用计数,与引用计数和垃圾回收相关,它也是一个结构体类型,这里不展开。
- h 字段表示字符串的哈希值,在数组的 key 中有用,方便快速定位。这里是以空间换时间的思想,将这个值记录下来,就不用每次用的时候都去计算字符串的哈希值,提升了性能。
- len 字段表示字符串长度
- 这里 char val[1] 就是一个柔性数组,在 redis 等源码中也被大量使用。它的大小是不确定的,必须放在结构体的尾部。它可以被当做一个占位符,是紧跟着结构体的一块连续内存空间。如果这里存的是一个 char * 的话,就会指向一块随机的内存,而并不是紧跟着结构体的连续内存。
- 继续 c 命令往下执行,到了 $d = [1,2,3]; 处。我们可以看到 u1.v.type 的值是 7,即 IS_ARRAY 类型,接下来查看 arr 字段的内容:
PHP 数组源码分析展开
- 我们具体看一下这个数组的结构:
struct _zend_array {
zend_refcounted_h gc;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar nApplyCount,
zend_uchar nIteratorsCount,
zend_uchar consistency)
} v;
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket *arData; // 实际存储数组元素的 bucket
uint32_t nNumUsed;
uint32_t nNumOfElements;
uint32_t nTableSize;
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};
typedef struct _zend_array HashTable;
- 这是数组的基本数据结构,其中包括一些描述数组大小以及用于遍历的指针等,它的别名又叫 HashTable。
- 我们主要关注 *arData 字段,我们往数组中插入的数据就放在 bucket 中,每一个 bucket 中存了一个 key-value 对,还有一个冗余的哈希值:
typedef struct _Bucket {
zval val; // 元素的值
zend_ulong h; // 冗余的哈希值
zend_string *key; // 元素的 key
} Bucket;
- 要想将任意 key 值)(如字符串)映射到一段固定长度大小的数组空间中,那么最适合的就是哈希算法(PHP 中用的是 time33 算法),但是它不能正好映射到数组大小范围之内,且存在哈希冲突问题。
- 在 PHP 中,其实在实际存储数据的 bucket 前面,额外申请了一部分内存,就是用来解决上述问题。
- 我们将第一步由 time33 哈希算法求出来的 h 值,将其与 nTableMask(也就是数组的 size – 1)做 或运算得到 bucket 的索引下标,这样可以保证最终的索引下标在 [-n, -1] 范围之内,这里称之为slot,它的具体计算公式为:
nIndex = h | nTableMask
- 相同 hash 值计算出来的 nIndex(即 slot)的值是相同的。
-
然后在 slot 的对应空间内存上第一个 bucket 对应的索引下标,然后将元素存入对应索引下标的 bucket 数组中。查找过程也是类似的(下面会细讲),它们都是 O(1)的时间复杂度,但是这样就会出现 哈希冲突,解决哈希冲突通常有两种算法:
- 开放定址法
- 链地址法
-
比较常用的是链地址法,但如果同一个 hash 值上的链表过长,会把同一个 hash 值上的所有链表节点都遍历一遍,时间复杂度会退化为 O(n)。PHP5 中有一个漏洞,攻击者不断让你的链表变长,使得数组查询变慢,不断消耗服务器性能,最终 QPS 会下降的非常之快。要解决链地址法的哈希冲突所带来的性能下降问题,有如下思路:
- 扩容,重新进行哈希运算(rehash)
- 将链表换成红黑树 / 跳表 …(O(1)退化成 O(logn))问题的本质是链表的效率较低,故用其他数据结构代替链表
- PHP7 中的链表是一种逻辑上的链表。每一个 bucket 维护下一个 bucket 在数组中的索引,而不通过指针维护上下游关系。上文提到的在 bucket 之前额外申请的内存在这个地方亦要派上用场了。由于相同 hash 值经过或运算得到的 slot 值也是相同的,其 slot 中的值就指向第一个 bucket,然后第一个 bucket 中的 val 字段中的 u2 联合体中的 next 字段 (如 arData[0].val.u2.next 字段) 又指向了下一个相同 slot 的 bucket 单元 …… 最终实现了 头插法 版本的数组模拟链表。
- 下面举一个 PHP 代码的例子来描述数组的插入与查找过程:
$a['foo'] = 1;
$a[] = 2;
$a['s'] = 3;
$a['x'] = 4;
-
这是一个非常简单的几个数组赋值语句,我们具体看一下它们的插入过程:
- $a['foo'] = 1; 这里的 key 和 value 如果是字符串,需要单独在 zend_string 结构中存储其真实的值和长度,这里做了简化。
- $a[] = 2;
- $a['s'] = 3; 这里注意需要先修改索引数组,保证索引数组中第一个指向的 bucket 数组单元是最后插入 bucket 数组的值(头插法),并且修改 val.u2.next 指针(因为所有 val 都是 zval 类型),指向上一个具有相同 hash 值的元素。
- $a['x'] = 4; 同上
-
再来看一个数组查询过程,例如访问 $a[‘s’]的值:
- 经过 time33 哈希算法算出哈希值 h
- 计算出索引数组的 nIndex = h | nTableMask = -7(假设)
- 访问索引数组,取出索引为 - 7 位置上的元素值为 3
- 访问 bucket 数组,取出索引为 3 位置上的 key,为 x,发现并不等于 s,那么继续查找,访问 val.u2.next 指针,为 2
- 取出索引为 2 位置上的 key,为 s,发现正好是我们要找的那个 key
- 取出对应的 val 值 3
下面我们再看一段 PHP 代码:
<?php
for ($i = 0; $i<=200000; $i++){$arr1[$i] = $i;
}
for($i = 200000; $i>=0; $i--) {$arr2[$i] = $i;
}
- 思考:这两段代码占用的内存是否相同?
- 答:第一个 for 循环占用的内存更少。
-
那么为什么会这样呢?先看两个概念:packed array 与 hash array
-
packed array 的特点:
- key 是数字,且顺序递增
- 位置固定,如访问 key 是 0 的元素,即 $arr1[0],就直接访问 bucket 数组的第 0 个位置即可(即 arData[0])
- 因为可以直接访问,不需要使用前面额外的索引数组,PHP 中只使用了 2 个数组单元并赋值为初始的 -1
- 由此可见,第一个循环就是以 packed array 的形式存储的,由于不用索引数组,索引节省了 200000 – 2 个内存单元
- 如果不满足上述条件,就是一个 hash array
- 我们看第二个 for 循环,如果想访问 key 是 200000 的元素,若按照 packed array 的方法,直接访问 bucket 数组的第 200000 元素(即 arData[200000]),就会得到错误的值 0(因为 $arr2[200000] = 200000),所以只能通过使用索引数组来间接访问元素
- 因为索引数组需要索引到 bucket 数组的所有位置,所以其大小等于 bucket 数组的大小,多使用了 200000 – 2 个内存单元,故占用的内存更多,所以在工作中,尽量使用 packed array,来减少对服务器内存的使用量。
-
- 思考:如果一个 packed array 中间空出来许多元素,即:
$arr = [
0 => 0,
1 => 1,
2 => 2,
100000 => 3
];
- 显然这样如果使用 packed array 会浪费许多 bucket 数组的空间,在这种情况下,可能用 hash array 的效率就会更高一些,在这里,PHP 内核就需要做出权衡,经过比较之后,选择一种最优化的方案。