资讯专栏INFORMATION COLUMN

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

Fundebug / 2368人阅读

摘要:中以表示所有的变量,它是一个结构体。类型是一个联合体,共占用。字段表示字符串的哈希值,在数组的中有用,方便快速定位。字段表示字符串长度这里就是一个柔性数组,在等源码中也被大量使用。

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;            /* 存储变量的zhi*/
    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查看变量底层存储情况

示例代码:


首先在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代码:

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/31283.html

相关文章

  • 【LNMPR源码学习笔记汇总

    摘要:此文用于汇总跟随陈雷老师及团队的视频,学习源码过程中的思考整理与心得体会,此文会不断更新视频传送门每日学习记录使用录像设备记录每天的学习源码学习源码学习内存管理笔记源码学习内存管理笔记源码学习内存管理笔记源码学习基本变量笔记 此文用于汇总跟随陈雷老师及团队的视频,学习源码过程中的思考、整理与心得体会,此文会不断更新 视频传送门:【每日学习记录】使用录像设备记录每天的学习 PHP7...

    Barrior 评论0 收藏0
  • PHP源码学习2019-03-18 复习前面的内容

    摘要:调用函数时,它将用户释放的内存块连接到空闲链上。这个联合体共占用字节。是数字,且顺序递增位置固定,如访问是的元素,即,就直接访问数组的第个位置即可即,这样就不需要前面的索引数组。 baiyan 全部视频:https://segmentfault.com/a/11... 原视频地址:http://replay.xesv5.com/ll/24... 本笔记中部分图片截自视频中的片段,图片版...

    lindroid 评论0 收藏0
  • Codeigniter 4.0-dev 版源码学习笔记之二——入口以及初始化操作

    摘要:通过这个函数可以很方便的在程序运行期间执行很多常见操作。此文可以转载,但转载前需要发邮件到进行沟通,未沟通的均视作侵权。 index.php index.php 是整个框架的入口文件,也就是说所有的请求都要从它这里开始。因为 index.php 源码非常简洁,那么我们直接放一张源码截图,按着截图说一下源码。 showImg(https://segmentfault.com/img/re...

    _ivan 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<