PHP數(shù)組的內(nèi)部實(shí)現(xiàn)你了解嗎
前言
這幾天在翻github的時(shí)候, 碰巧看到了php的源碼, 就 down 下來隨便翻了翻
那么PHP中什么玩意最引人注目嘞? 一定是數(shù)組了, PHP中的數(shù)組太強(qiáng)大了, 于是就想著不如進(jìn)去看看數(shù)組的實(shí)現(xiàn)部分. 這篇文章打算全程針對(duì)代碼進(jìn)行解讀了.
以下代碼基于最新 php8.1. commitId: ea62b8089acef6551d6cece5dfb2ce0b040a7b83 .感興趣的可自行下載查看.
探究
首先, 如此強(qiáng)大的數(shù)組功能應(yīng)該是有單獨(dú)文件進(jìn)行定義的. 因此搜索了array.h array.c文件, 哎, array.c文件是存在的.
查看后發(fā)現(xiàn), array.c文件中定義了PHP數(shù)組的系統(tǒng)函數(shù), 比如krsort count等等. 但是, array的實(shí)現(xiàn)又在哪里呢?
隨便找一個(gè)方法array_flip, 其中第一行定義變量如下:
zval *array;
也就是說, 方法接收的參數(shù)是結(jié)構(gòu)體zval. 但是, zval這個(gè)結(jié)構(gòu)體看名字應(yīng)該是變量而不是數(shù)組啊. 果然, 再看下面變量的使用:

拿到變量后, 判斷變量的類型, 會(huì)根據(jù)不同類型進(jìn)行不同的處理.
那么這里為什么不直接接數(shù)組類型呢? 因?yàn)?code>PHP的弱類型, 所有的變量都是zval, 其實(shí)際類型定義在zval結(jié)構(gòu)體中. 這里順便看一下zval結(jié)構(gòu)體的實(shí)現(xiàn):
(從這里開始, 下方所有內(nèi)容不再詳細(xì)說明查找過程, 反正就七找八找的)
zval
zval結(jié)構(gòu)體定義在zend_types.h文件中, 這就是PHP弱類型的秘密了. 對(duì)其中各個(gè)部分的個(gè)人理解, 以注釋的形式添加到代碼中了.
/*
* 其在 大端和小端 使用了不同的順序定義.
* 想來是為了解決大小端環(huán)境的問題, 保證在不同的設(shè)備上可以讀取到相同的位
*/
#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
// 對(duì)不同變量類型的定義
/* Regular data types: Must be in sync with zend_variables.c. */
#define IS_UNDEF 0
#define IS_NULL 1
#define IS_FALSE 2
// ...
// 進(jìn)行結(jié)構(gòu)體的重命名
typedef struct _zval_struct zval;
/*
* 變量聯(lián)合體定義.
* 此聯(lián)合體和保存各種類型的變量
*/
typedef union _zend_value {
zend_long lval; // 8B
double dval; // 8B
zend_refcounted *counted; // 引用計(jì)數(shù). 8B
zend_string *str; // 字符串. 8B
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; // 8B
} zend_value; // 綜上: 8B
// 變量的結(jié)構(gòu)體
struct _zval_struct {
// 使用 zend_value 聯(lián)合體保存當(dāng)前元素的值. 8B
zend_value value; /* value */
/*
* 用來保存變量類型
* 這里為什么要使用聯(lián)合體呢?
* 眾所周知, 聯(lián)合體中變量是共用內(nèi)存的
* 而其中的兩個(gè)內(nèi)容都是4字節(jié)的.
* 因此, 我認(rèn)為是為了方便使用.
* 在統(tǒng)一操作時(shí)可使用 type_info, 有可以通過結(jié)構(gòu)體分別獲取每一位
* (不過這只是個(gè)人理解, 沒有進(jìn)行求證)
*/
union {
uint32_t type_info; // 4B
struct {
ZEND_ENDIAN_LOHI_3(
// 用來保存當(dāng)前變量的類型. 也就是上面的一批定義. 1B
zend_uchar type, /* active type */
// 當(dāng)前變量的一些標(biāo)志位. 如: 常量類型/不可修改 等等. 1B
zend_uchar type_flags,
union { // 2B
uint16_t extra; /* not further specified */
} u)
} v; // 4B
} u1; // 4B
// 上面兩個(gè)結(jié)構(gòu)體共占用 12B, 而內(nèi)存對(duì)其需要16B, 因此有4個(gè)字節(jié)是空著的
// 下面的聯(lián)合體可以將這4B 充分利用.
// 這里根據(jù)不同的變量類型使用不同的變量. 比如: next, 在下面介紹數(shù)組的時(shí)候有用
union {
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* cache slot (for RECV_INIT) */
uint32_t opline_num; /* opline number (for FAST_CALL) */
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 property_guard; /* single property guard */
uint32_t constant_flags; /* constant flags */
uint32_t extra; /* not further specified */
} u2;
};
zend_array
在查看zval的時(shí)候, 應(yīng)該注意到其中的zend_array類型了. 不用看了, 看名字也知道, 數(shù)組就是它了.
為了在下面查看數(shù)組結(jié)構(gòu)體時(shí), 這里對(duì)PHP中數(shù)組的實(shí)現(xiàn)做一個(gè)簡(jiǎn)短的介紹.
結(jié)構(gòu)介紹
眾所周知, PHP中數(shù)組是通過hashTable實(shí)現(xiàn)的, 但是hashTable又是如何保證讀取順序的呢? 通過如下兩個(gè)數(shù)組實(shí)現(xiàn)了一個(gè)有序 hash:

每次新增元素都向data 數(shù)組后面添加, 這樣foreach的時(shí)候遍歷data 數(shù)組, 讀到的順序就和放入的順序是一樣的了.
但是, 這不就是數(shù)組么? hash呢? 如何保證讀取的高效呢? 在第二個(gè)hash 數(shù)組中, hash 數(shù)組中保存的是元素在data 數(shù)組中的索引.
從數(shù)組中讀取keya元素的步驟是這樣的:
1.計(jì)算a的hash值為2
2.idx=indexList[2]
3.data=dataList[idx]
那么hash沖突又是如何解決的呢? 對(duì)于哈希沖突, 目前有開放尋址和鏈表兩種處理方式, 不過大部分實(shí)現(xiàn)都采用鏈表的方式. 這里也不例外.
數(shù)組中, b c d 的hash值均為4, 他們?nèi)齻€(gè)直接組成一個(gè)鏈表. 而index 數(shù)組中保存鏈表頭的地址.
好, PHP數(shù)組的實(shí)現(xiàn)結(jié)構(gòu)概念部分介紹完了. 接下來看一下PHP是如何實(shí)現(xiàn)的吧.
結(jié)構(gòu)體
在介紹結(jié)構(gòu)體代碼之前, 還是得先上一個(gè)圖. 在上方介紹中存在dataList indexList兩個(gè)數(shù)組. 在PHP的實(shí)現(xiàn)中, 或許是為了節(jié)省空間. 將這兩個(gè)數(shù)組合并成了一個(gè), 只需要記錄一個(gè)地址. 如下圖:

上圖的說明是為了防止你看到結(jié)構(gòu)體中的union會(huì)懵. 一樣的, 我將自己的理解放到注釋中了.
typedef struct _zend_array zend_array;
// 沒毛病, 數(shù)組的別名就是 hashTable
typedef struct _zend_array HashTable;
// 用來保存數(shù)組中的數(shù)據(jù)
typedef struct _Bucket {
// 當(dāng)前元素值
zval val;
// 當(dāng)前元素的 hash
zend_ulong h; /* hash value (or numeric index) */
// 元素的 key
zend_string *key; /* string key or NULL for numerics */
} Bucket;
typedef struct _zend_array HashTable;
struct _zend_array {
zend_refcounted_h gc; // 對(duì)數(shù)組進(jìn)行引用計(jì)數(shù). 8B
union {
struct {
ZEND_ENDIAN_LOHI_4(
/*
* 標(biāo)志位. 其常量定義如下:
* #define HASH_FLAG_CONSISTENCY ((1<<0) | (1<<1))
* #define HASH_FLAG_PACKED (1<<2)
* #define HASH_FLAG_UNINITIALIZED (1<<3)
* #define HASH_FLAG_STATIC_KEYS (1<<4) // long and interned strings
* #define HASH_FLAG_HAS_EMPTY_IND (1<<5)
* #define HASH_FLAG_ALLOW_COW_VIOLATION (1<<6)
*/
zend_uchar flags,
zend_uchar _unused,
zend_uchar nIteratorsCount,
zend_uchar _unused2)
} v;
uint32_t flags; // 4B
} u; // 4B
// 用來保存數(shù)組中的元素信息. 這是一個(gè)數(shù)組, 記錄數(shù)組首地址.
// 關(guān)于這里的 兩個(gè)數(shù)組為什么使用 聯(lián)合體記錄, 在上圖中解釋了.
union {
// 用來讀取上方的 hashList 8B
uint32_t *arHash; /* hash table (allocated above this pointer) */
// 用來讀取上方的 dataList 8B
Bucket *arData; /* array of hash buckets */
// 當(dāng)前數(shù)組中其實(shí)保存了兩個(gè)數(shù)組, 但是對(duì)于key是連續(xù)數(shù)字的數(shù)組來說, arrHash 其實(shí)并不需要. 可以直接使用數(shù)組存儲(chǔ)
// 所以使用了 arPacked 來表示key全部為數(shù)字的, 通過標(biāo)識(shí)位 HASH_FLAG_PACKED 來標(biāo)識(shí). 以節(jié)省內(nèi)存占用
// 所以, 其實(shí)對(duì)于連續(xù)數(shù)字的數(shù)組, 其底層真的是數(shù)組, 而不是 hashTable
// 這里你可以簡(jiǎn)單的實(shí)驗(yàn)一下, 通過構(gòu)造一個(gè)連續(xù)數(shù)字索引的數(shù)字, 然后在給其賦值一個(gè)key 為字符串的元素, 通過 memory_get_usage 函數(shù)查看內(nèi)存的變化. 很明顯的.
zval *arPacked; /* packed array of zvals */
}; // 8B
// 數(shù)組中存儲(chǔ)的元素個(gè)數(shù). 4B
uint32_t nNumOfElements;
/*
* 向數(shù)組中添加元素時(shí), 使用的數(shù)組索引.
* 此變量與 nNumOfElements 的區(qū)別是,
* 當(dāng)數(shù)組中元素釋放的時(shí)候, 比如 unset 操作.
* nNumOfElements 進(jìn)行減一操作, 而 nNumUsed 變量不變.
* 同時(shí), 元素也并沒有從數(shù)組中抹去, 僅僅是將其 type 修改為 IS_UNDEF
* 等到下一次內(nèi)存擴(kuò)充的時(shí)候, 在將這些元素釋放掉, 以保證釋放的高效
* 4B
*/
uint32_t nNumUsed;
// 記錄當(dāng)前數(shù)組已經(jīng)分配的地址大小. 2的 n 次冪(分配地址每次乘2). 4B
uint32_t nTableSize;
// 計(jì)算 key 的 hash 散列值時(shí)使用(在下方具體介紹). 4B
uint32_t nTableMask;
// 數(shù)組遍歷是使用的游標(biāo), 如調(diào)用函數(shù): next/prev/end/reset/current 等. 4B
uint32_t nInternalPointer;
/*
* 用來記錄下一個(gè)元素插入時(shí)的默認(rèn) key.
* 比如代碼:
* $arr = [];
* $arr[] = 1; // 相當(dāng)于 $arr[0]=1;
* 但是, 你或許會(huì)疑惑, 這還需要單獨(dú)記錄么? 直接使用數(shù)組的大小計(jì)算不就行了?
* 再看一段:
* $arr = [];
* $arr['a'] = 1;
* $arr[] = 2; // 相當(dāng)于 $arr[0] = 1;
* 是不是懂了??
* 8B
*/
zend_long nNextFreeElement;
/*
* 此方法在數(shù)組中的元素更新或刪除時(shí)調(diào)用.
* 若元素是引用計(jì)數(shù)的類型, 會(huì)更新其引用計(jì)數(shù)
* 相當(dāng)于元素的析構(gòu)函數(shù)
* 8B
*/
dtor_func_t pDestructor;
}; // 56B
nTableMask
nTableMask變量在計(jì)算元素的的散列值(在indexList中的索引)時(shí)使用.
首先在上面, indexList與dataList大小相等, 且都等于nTableSize. 也就是說, 散列值的取值范圍為: [-nTableSize, -1].
PHP中是如何處理的呢? 其計(jì)算規(guī)則為: nIndex = h | ht->nTableMask; 其中 nTableMask=-nTableSize.
這里簡(jiǎn)單證明一下, 還記得上面提到過, nTableMask的取值為2的 n 次冪. 我們假設(shè)長(zhǎng)度為16. (為了簡(jiǎn)化邏輯, 以8byte 為例).
那么, nTableMask等于 -16, 其二進(jìn)制補(bǔ)碼表示為: 11110000. 我們分別使用兩個(gè)極端值和nTableMask進(jìn)行或運(yùn)算.
11110000與00000000進(jìn)行或運(yùn)算, 結(jié)果為11110000, 其值等于-16.
11110000與01111111進(jìn)行或運(yùn)算, 結(jié)果為11111111, 其值等于 -1.
剛好與需要的取值范圍相等. 既然是通過變量nTableSize計(jì)算得到的, 為什么要單獨(dú)使用變量記錄呢? 我想是為了效率吧. 畢竟hash取值的操作是很頻繁的. 而位運(yùn)算是很快的, 如果加上額外的計(jì)算操作會(huì)導(dǎo)致其效率下降.
數(shù)組插入操作
通過上面的介紹, 對(duì)于其插入操作應(yīng)該如何實(shí)現(xiàn)想比心中有數(shù)了. 這里簡(jiǎn)單羅列一下:
// 判斷需要時(shí)對(duì)數(shù)組進(jìn)行擴(kuò)容
#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)
{
// 一些額外處理...
// 需要時(shí)對(duì)數(shù)組進(jìn)行擴(kuò)充
ZEND_HASH_IF_FULL_DO_RESIZE(ht); /* If the Hash table is full, resize it */
add_to_hash:
// INTERNED 字符串不會(huì)被銷毀, 用來實(shí)現(xiàn)相同字符串共用內(nèi)存
// 當(dāng)數(shù)組中所有key 都是 INTERNED 字符串
// 那么數(shù)組釋放的時(shí)候就不需要釋放 key 了, 同時(shí)數(shù)組 copy 的時(shí)候也不需要增加字符串引用計(jì)數(shù)
// HASH_FLAG_STATIC_KEYS 標(biāo)記位就是用來標(biāo)記數(shù)組中所有 key 均為 INTERNED 字符串
// 若當(dāng)前字符串不是 INTERNED 的, 則修改數(shù)組的標(biāo)記位
if (!ZSTR_IS_INTERNED(key)) {
zend_string_addref(key);
HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
}
// 獲取當(dāng)前元素的 dataList index
idx = ht->nNumUsed++;
// 數(shù)組中元素內(nèi)容增加
ht->nNumOfElements++;
// 元素賦值
arData = ht->arData;
p = arData + idx;
p->key = key;
p->h = h = ZSTR_H(key);
// 計(jì)算 hashList index
nIndex = h | ht->nTableMask;
// 這一步就是用來處理 hash 沖突的
// 將當(dāng)前元素的 next 指向原來 hashList 中的值
Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);
// 更新 hashList
HT_HASH_EX(arData, nIndex) = HT_IDX_TO_HASH(idx);
// 對(duì) val 進(jìn)行賦值.
// 這里判斷標(biāo)志位 HASH_LOOKUP, 然后將 val 置為 null. 這里看了半天沒看懂其作用, 如果有知道的還望不吝賜教
if (flag & HASH_LOOKUP) {
ZVAL_NULL(&p->val);
} else {
ZVAL_COPY_VALUE(&p->val, pData);
}
return &p->val;
}
其他的數(shù)組操作函數(shù)這里就不再羅列了, 感興趣的下載源碼自己看一下吧.
hash 函數(shù)
在上面查看函數(shù)zend_hash_do_resize的時(shí)候, 突然想到了一個(gè)有意思的事情, 函數(shù)每次擴(kuò)容都是乘2的操作. 如果說, 有一個(gè)長(zhǎng)度為65536的數(shù)組, 每一個(gè) key 的散列值計(jì)算后均為0, 那么hashTable不就退化為鏈表了么?
具體是什么思路呢? 第一個(gè)元素 key 為0, 那么根據(jù)長(zhǎng)度取模, 第二個(gè)元素就是 65536, 第三個(gè)元素就是 65536*2, 這樣每次插入的時(shí)候都需要遍歷鏈表, 導(dǎo)致插入效率變慢. 整個(gè)demo 試一下.
<?php
// 統(tǒng)計(jì)函數(shù)的耗時(shí)
function echoCallCostTime($msg, $call){
$startTime = microtime(true) * 1000;
$call();
$endTime = microtime(true) * 1000;
$diffTime = $endTime - $startTime;
echo "$msg 耗時(shí) $diffTime", PHP_EOL;
}
$size = 2**16;
$array = [];
echoCallCostTime('異常數(shù)組-構(gòu)造', function () use ($size, &$array){
$array = array();
for ($i = 0; $i <= $size; $i++) {
$key = $size * $i;
$array[$key] = 0;
}
});
echoCallCostTime('異常數(shù)組-首個(gè)元素訪問', function () use ($array){
$b = $array[0];
});
echoCallCostTime('異常數(shù)組-最后元素訪問', function () use ($array, $size){
$b = $array[$size * $size];
});
echoCallCostTime('普通數(shù)組-構(gòu)造', function () use ($size, &$array){
$array = array();
for ($i = 0; $i <= $size; $i++) {
$array[$i] = 0;
}
});
echoCallCostTime('普通數(shù)組-首個(gè)元素訪問', function () use ($array){
$b = $array[0];
});
echoCallCostTime('普通數(shù)組-最后元素訪問', function () use ($array, $size){
$b = $array[$size];
});
我們先按照這個(gè)邏輯推理一下, 異常數(shù)組的構(gòu)造一定比普通數(shù)組耗時(shí)要久, 因?yàn)槊看尾迦攵家闅v鏈表嘛.
而且, 異常數(shù)組的首個(gè)元素訪問時(shí)間要更新, 因?yàn)樗F(xiàn)在出在鏈表的末尾, 要想訪問它就要將鏈表遍歷一遍. 看下結(jié)果:

和之前的推論絲毫不差, 而且性能相差很多倍哦. 不過這里hash算法的具體實(shí)現(xiàn)我沒有看
總結(jié)
本篇文章就到這里了,希望能夠給你帶來幫助,也希望您能夠多多關(guān)注腳本之家的更多內(nèi)容!
相關(guān)文章
php中訪問修飾符的知識(shí)點(diǎn)總結(jié)
在本篇文章里小編給大家分享了關(guān)于php中訪問修飾符的知識(shí)點(diǎn)總結(jié),興趣的朋友們可以學(xué)習(xí)參考下。2019-01-01
總結(jié)的一些PHP開發(fā)中的tips(必看篇)
下面小編就為大家?guī)硪黄偨Y(jié)的一些PHP開發(fā)中的tips(必看篇)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-03-03
php基于閉包實(shí)現(xiàn)函數(shù)的自調(diào)用(遞歸)實(shí)例分析
這篇文章主要介紹了php基于閉包實(shí)現(xiàn)函數(shù)的自調(diào)用,結(jié)合實(shí)例形式分析了php閉包實(shí)現(xiàn)遞歸的操作方法,需要的朋友可以參考下2016-11-11
php設(shè)計(jì)模式 Interpreter(解釋器模式)
php設(shè)計(jì)模式 Interpreter(解釋器模式),需要的朋友可以參考下。2011-06-06
PHP下利用header()函數(shù)設(shè)置瀏覽器緩存的代碼
PHP高級(jí)應(yīng)用學(xué)習(xí)筆記之 利用header()函數(shù)設(shè)置瀏覽器緩存2010-09-09
PHP使用標(biāo)準(zhǔn)庫spl實(shí)現(xiàn)的觀察者模式示例
這篇文章主要介紹了PHP使用標(biāo)準(zhǔn)庫spl實(shí)現(xiàn)的觀察者模式,結(jié)合實(shí)例形式分析了php基于spl標(biāo)準(zhǔn)庫的觀察者模式相關(guān)實(shí)現(xiàn)與使用操作技巧,需要的朋友可以參考下2018-08-08
PHP實(shí)現(xiàn)的簡(jiǎn)單在線計(jì)算器功能示例
這篇文章主要介紹了PHP實(shí)現(xiàn)的簡(jiǎn)單在線計(jì)算器功能,涉及php數(shù)值運(yùn)算與表單操作相關(guān)技巧,需要的朋友可以參考下2017-08-08

