前言
这几天在翻github 的时候, 碰巧看到了php 的源码, 就 down 下来随便翻了翻. 地址: https://github.com/php/php-src
那么PHP 中什么玩意最引人注目嘞? 一定是数组了, PHP 中的数组太强大了, 于是就想着不如进去看看数组的实现部分. 这篇文章打算全程针对代码进行解读了.
以下代码基于最新 php8.1 . commitId: ea62b8089acef6551d6cece5dfb2ce0b040a7b83 .感兴趣的可自行下载查看.
探究
首先, 如此强大的数组功能应该是有单独文件进行定义的. 因此搜索了array.h array.c 文件, 哎, array.c 文件是存在的.
查看后发现, array.c 文件中定义了PHP 数组的系统函数, 比如krsort count 等等. 但是, array 的实现又在哪里呢?
随便找一个方法array_flip , 其中第一行定义变量如下:
zval *array;
也就是说, 方法接收的参数是结构体zval . 但是, zval 这个结构体看名字应该是变量而不是数组啊. 果然, 再看下面变量的使用:
拿到变量后, 判断变量的类型, 会根据不同类型进行不同的处理.
那么这里为什么不直接接数组类型呢? 因为PHP 的弱类型, 所有的变量都是zval , 其实际类型定义在zval 结构体中. 这里顺便看一下zval 结构体的实现:
(从这里开始, 下方所有内容不再详细说明查找过程, 反正就七找八找的)
zval
zval 结构体定义在zend_types.h 文件中, 这就是PHP 弱类型的秘密了. 对其中各个部分的个人理解, 以注释的形式添加到代码中了.
#ifdef WORDS_BIGENDIAN
# define ZEND_ENDIAN_LOHI_3(lo, mi, hi) hi; mi; lo;
#else
# define ZEND_ENDIAN_LOHI_3(lo, mi, hi) lo; mi; hi;
#endif
#define IS_UNDEF 0
#define IS_NULL 1
#define IS_FALSE 2
typedef struct _zval_struct zval;
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;
} zend_value;
struct _zval_struct {
zend_value value;
union {
uint32_t type_info;
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type,
zend_uchar type_flags,
union {
uint16_t extra;
} u)
} v;
} u1;
union {
uint32_t next;
uint32_t cache_slot;
uint32_t opline_num;
uint32_t lineno;
uint32_t num_args;
uint32_t fe_pos;
uint32_t fe_iter_idx;
uint32_t property_guard;
uint32_t constant_flags;
uint32_t extra;
} u2;
};
zend_array
在查看zval 的时候, 应该注意到其中的zend_array 类型了. 不用看了, 看名字也知道, 数组就是它了.
为了在下面查看数组结构体时, 这里对PHP 中数组的实现做一个简短的介绍.
结构介绍
众所周知, PHP 中数组是通过hashTable 实现的, 但是hashTable 又是如何保证读取顺序的呢? 通过如下两个数组实现了一个有序 hash:
每次新增元素都向data 数组 后面添加, 这样foreach 的时候遍历data 数组 , 读到的顺序就和放入的顺序是一样的了.
但是, 这不就是数组么? hash 呢? 如何保证读取的高效呢? 在第二个hash 数组 中, hash 数组 中保存的是元素在data 数组 中的索引.
从数组中读取keya 元素的步骤是这样的:
- 计算
a 的hash 值为2 idx=indexList[2] data=dataList[idx]
那么hash 冲突又是如何解决的呢? 对于哈希冲突, 目前有开放寻址 和链表 两种处理方式, 不过大部分实现都采用链表 的方式. 这里也不例外.
数组中, b c d 的hash 值均为4 , 他们三个直接组成一个链表. 而index 数组 中保存链表头的地址.
好, PHP 数组的实现结构概念部分介绍完了. 接下来看一下PHP 是如何实现的吧.
结构体
在介绍结构体代码之前, 还是得先上一个图. 在上方介绍中存在dataList indexList 两个数组. 在PHP 的实现中, 或许是为了节省空间. 将这两个数组合并成了一个, 只需要记录一个地址. 如下图:
上图的说明是为了防止你看到结构体中的union 会懵. 一样的, 我将自己的理解放到注释中了.
typedef struct _zend_array zend_array;
typedef struct _zend_array HashTable;
typedef struct _Bucket {
zval val;
zend_ulong h;
zend_string *key;
} Bucket;
typedef struct _zend_array HashTable;
struct _zend_array {
zend_refcounted_h gc;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar _unused,
zend_uchar nIteratorsCount,
zend_uchar _unused2)
} v;
uint32_t flags;
} u;
union {
uint32_t *arHash;
Bucket *arData;
zval *arPacked;
};
uint32_t nNumOfElements;
uint32_t nNumUsed;
uint32_t nTableSize;
uint32_t nTableMask;
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};
nTableMask
nTableMask 变量在计算元素的的散列值(在indexList 中的索引)时使用.
首先在上面, indexList 与dataList 大小相等, 且都等于nTableSize . 也就是说, 散列值的取值范围为: [-nTableSize, -1] .
PHP 中是如何处理的呢? 其计算规则为: nIndex = h | ht->nTableMask; 其中 nTableMask=-nTableSize .
这里简单证明一下, 还记得上面提到过, nTableMask 的取值为2的 n 次幂. 我们假设长度为16. (为了简化逻辑, 以8byte 为例).
那么, nTableMask 等于 -16, 其二进制补码表示为: 11110000 . 我们分别使用两个极端值和nTableMask 进行或运算.
11110000 与00000000 进行或运算, 结果为11110000 , 其值等于-16.
11110000 与01111111 进行或运算, 结果为11111111 , 其值等于 -1.
刚好与需要的取值范围相等. 既然是通过变量nTableSize 计算得到的, 为什么要单独使用变量记录呢? 我想是为了效率吧. 毕竟hash 取值的操作是很频繁的. 而位运算是很快的, 如果加上额外的计算操作会导致其效率下降.
数组插入操作
通过上面的介绍, 对于其插入操作应该如何实现想比心中有数了. 这里简单罗列一下:
#define ZEND_HASH_IF_FULL_DO_RESIZE(ht) \
if ((ht)->nNumUsed >= (ht)->nTableSize) { \
zend_hash_do_resize(ht); \
}
static zend_always_inline zval *_zend_hash_add_or_update_i(HashTable *ht, zend_string *key, zval *pData, uint32_t flag)
{
ZEND_HASH_IF_FULL_DO_RESIZE(ht);
add_to_hash:
if (!ZSTR_IS_INTERNED(key)) {
zend_string_addref(key);
HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
}
idx = ht->nNumUsed++;
ht->nNumOfElements++;
arData = ht->arData;
p = arData + idx;
p->key = key;
p->h = h = ZSTR_H(key);
nIndex = h | ht->nTableMask;
Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);
HT_HASH_EX(arData, nIndex) = HT_IDX_TO_HASH(idx);
if (flag & HASH_LOOKUP) {
ZVAL_NULL(&p->val);
} else {
ZVAL_COPY_VALUE(&p->val, pData);
}
return &p->val;
}
其他的数组操作函数这里就不再罗列了, 感兴趣的下载源码自己看一下吧.
hash 函数
在上面查看函数zend_hash_do_resize 的时候, 突然想到了一个有意思的事情, 函数每次扩容都是乘2的操作. 如果说, 有一个长度为65536的数组, 每一个 key 的散列值计算后均为0, 那么hashTable 不就退化为链表了么?
具体是什么思路呢? 第一个元素 key 为0, 那么根据长度取模, 第二个元素就是 65536, 第三个元素就是 65536*2, 这样每次插入的时候都需要遍历链表, 导致插入效率变慢. 整个demo 试一下.
<?php
function echoCallCostTime($msg, $call){
$startTime = microtime(true) * 1000;
$call();
$endTime = microtime(true) * 1000;
$diffTime = $endTime - $startTime;
echo "$msg 耗时 $diffTime", PHP_EOL;
}
$size = 2**16;
$array = [];
echoCallCostTime('异常数组-构造', function () use ($size, &$array){
$array = array();
for ($i = 0; $i <= $size; $i++) {
$key = $size * $i;
$array[$key] = 0;
}
});
echoCallCostTime('异常数组-首个元素访问', function () use ($array){
$b = $array[0];
});
echoCallCostTime('异常数组-最后元素访问', function () use ($array, $size){
$b = $array[$size * $size];
});
echoCallCostTime('普通数组-构造', function () use ($size, &$array){
$array = array();
for ($i = 0; $i <= $size; $i++) {
$array[$i] = 0;
}
});
echoCallCostTime('普通数组-首个元素访问', function () use ($array){
$b = $array[0];
});
echoCallCostTime('普通数组-最后元素访问', function () use ($array, $size){
$b = $array[$size];
});
我们先按照这个逻辑推理一下, 异常数组的构造一定比普通数组耗时要久, 因为每次插入都要遍历链表嘛.
而且, 异常数组的首个元素访问时间要更新, 因为它现在出在链表的末尾, 要想访问它就要将链表遍历一遍. 看下结果:
和之前的推论丝毫不差, 而且性能相差很多倍哦. 不过这里hash 算法的具体实现我没有看
原文链接: https://hujingnb.com/archives/746
|