乐趣区

【PHP源码学习】2019-03-12 PHP基本变量笔记

【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 内核就需要做出权衡,经过比较之后,选择一种最优化的方案。
    退出移动版