PHP數(shù)組的內(nèi)部實(shí)現(xiàn)你了解嗎
前言
這幾天在翻github
的時候, 碰巧看到了php
的源碼, 就 down 下來隨便翻了翻
那么PHP
中什么玩意最引人注目嘞? 一定是數(shù)組了, PHP
中的數(shù)組太強(qiáng)大了, 于是就想著不如進(jìn)去看看數(shù)組的實(shí)現(xiàn)部分. 這篇文章打算全程針對代碼進(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)又在哪里呢?
隨便找一個方法array_flip
, 其中第一行定義變量如下:
zval *array;
也就是說, 方法接收的參數(shù)是結(jié)構(gòu)體zval
. 但是, zval
這個結(jié)構(gòu)體看名字應(yīng)該是變量而不是數(shù)組啊. 果然, 再看下面變量的使用:
拿到變量后, 判斷變量的類型, 會根據(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
弱類型的秘密了. 對其中各個部分的個人理解, 以注釋的形式添加到代碼中了.
/* * 其在 大端和小端 使用了不同的順序定義. * 想來是為了解決大小端環(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 // 對不同變量類型的定義 /* 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)存的 * 而其中的兩個內(nèi)容都是4字節(jié)的. * 因此, 我認(rèn)為是為了方便使用. * 在統(tǒng)一操作時可使用 type_info, 有可以通過結(jié)構(gòu)體分別獲取每一位 * (不過這只是個人理解, 沒有進(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 // 上面兩個結(jié)構(gòu)體共占用 12B, 而內(nèi)存對其需要16B, 因此有4個字節(jié)是空著的 // 下面的聯(lián)合體可以將這4B 充分利用. // 這里根據(jù)不同的變量類型使用不同的變量. 比如: next, 在下面介紹數(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
的時候, 應(yīng)該注意到其中的zend_array
類型了. 不用看了, 看名字也知道, 數(shù)組就是它了.
為了在下面查看數(shù)組結(jié)構(gòu)體時, 這里對PHP
中數(shù)組的實(shí)現(xiàn)做一個簡短的介紹.
結(jié)構(gòu)介紹
眾所周知, PHP
中數(shù)組是通過hashTable
實(shí)現(xiàn)的, 但是hashTable
又是如何保證讀取順序的呢? 通過如下兩個數(shù)組實(shí)現(xiàn)了一個有序 hash:
每次新增元素都向data 數(shù)組
后面添加, 這樣foreach
的時候遍歷data 數(shù)組
, 讀到的順序就和放入的順序是一樣的了.
但是, 這不就是數(shù)組么? hash
呢? 如何保證讀取的高效呢? 在第二個hash 數(shù)組
中, hash 數(shù)組
中保存的是元素在data 數(shù)組
中的索引.
從數(shù)組中讀取keya元素的步驟是這樣的:
1.計(jì)算a
的hash
值為2
2.idx=indexList[2]
3.data=dataList[idx]
那么hash
沖突又是如何解決的呢? 對于哈希沖突, 目前有開放尋址
和鏈表
兩種處理方式, 不過大部分實(shí)現(xiàn)都采用鏈表
的方式. 這里也不例外.
數(shù)組中, b
c
d
的hash
值均為4
, 他們?nèi)齻€直接組成一個鏈表. 而index 數(shù)組
中保存鏈表頭的地址.
好, PHP
數(shù)組的實(shí)現(xiàn)結(jié)構(gòu)概念部分介紹完了. 接下來看一下PHP
是如何實(shí)現(xiàn)的吧.
結(jié)構(gòu)體
在介紹結(jié)構(gòu)體代碼之前, 還是得先上一個圖. 在上方介紹中存在dataList
indexList
兩個數(shù)組. 在PHP
的實(shí)現(xiàn)中, 或許是為了節(jié)省空間. 將這兩個數(shù)組合并成了一個, 只需要記錄一個地址. 如下圖:
上圖的說明是為了防止你看到結(jié)構(gòu)體中的union
會懵. 一樣的, 我將自己的理解放到注釋中了.
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; // 對數(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ù)組中的元素信息. 這是一個數(shù)組, 記錄數(shù)組首地址. // 關(guān)于這里的 兩個數(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í)保存了兩個數(shù)組, 但是對于key是連續(xù)數(shù)字的數(shù)組來說, arrHash 其實(shí)并不需要. 可以直接使用數(shù)組存儲 // 所以使用了 arPacked 來表示key全部為數(shù)字的, 通過標(biāo)識位 HASH_FLAG_PACKED 來標(biāo)識. 以節(jié)省內(nèi)存占用 // 所以, 其實(shí)對于連續(xù)數(shù)字的數(shù)組, 其底層真的是數(shù)組, 而不是 hashTable // 這里你可以簡單的實(shí)驗(yàn)一下, 通過構(gòu)造一個連續(xù)數(shù)字索引的數(shù)字, 然后在給其賦值一個key 為字符串的元素, 通過 memory_get_usage 函數(shù)查看內(nèi)存的變化. 很明顯的. zval *arPacked; /* packed array of zvals */ }; // 8B // 數(shù)組中存儲的元素個數(shù). 4B uint32_t nNumOfElements; /* * 向數(shù)組中添加元素時, 使用的數(shù)組索引. * 此變量與 nNumOfElements 的區(qū)別是, * 當(dāng)數(shù)組中元素釋放的時候, 比如 unset 操作. * nNumOfElements 進(jìn)行減一操作, 而 nNumUsed 變量不變. * 同時, 元素也并沒有從數(shù)組中抹去, 僅僅是將其 type 修改為 IS_UNDEF * 等到下一次內(nèi)存擴(kuò)充的時候, 在將這些元素釋放掉, 以保證釋放的高效 * 4B */ uint32_t nNumUsed; // 記錄當(dāng)前數(shù)組已經(jīng)分配的地址大小. 2的 n 次冪(分配地址每次乘2). 4B uint32_t nTableSize; // 計(jì)算 key 的 hash 散列值時使用(在下方具體介紹). 4B uint32_t nTableMask; // 數(shù)組遍歷是使用的游標(biāo), 如調(diào)用函數(shù): next/prev/end/reset/current 等. 4B uint32_t nInternalPointer; /* * 用來記錄下一個元素插入時的默認(rèn) key. * 比如代碼: * $arr = []; * $arr[] = 1; // 相當(dāng)于 $arr[0]=1; * 但是, 你或許會疑惑, 這還需要單獨(dú)記錄么? 直接使用數(shù)組的大小計(jì)算不就行了? * 再看一段: * $arr = []; * $arr['a'] = 1; * $arr[] = 2; // 相當(dāng)于 $arr[0] = 1; * 是不是懂了?? * 8B */ zend_long nNextFreeElement; /* * 此方法在數(shù)組中的元素更新或刪除時調(diào)用. * 若元素是引用計(jì)數(shù)的類型, 會更新其引用計(jì)數(shù) * 相當(dāng)于元素的析構(gòu)函數(shù) * 8B */ dtor_func_t pDestructor; }; // 56B
nTableMask
nTableMask
變量在計(jì)算元素的的散列值(在indexList
中的索引)時使用.
首先在上面, indexList
與dataList
大小相等, 且都等于nTableSize
. 也就是說, 散列值的取值范圍為: [-nTableSize, -1]
.
PHP
中是如何處理的呢? 其計(jì)算規(guī)則為: nIndex = h | ht->nTableMask;
其中 nTableMask=-nTableSize
.
這里簡單證明一下, 還記得上面提到過, nTableMask
的取值為2的 n 次冪. 我們假設(shè)長度為16. (為了簡化邏輯, 以8byte 為例).
那么, nTableMask
等于 -16, 其二進(jìn)制補(bǔ)碼表示為: 11110000
. 我們分別使用兩個極端值和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ì)算操作會導(dǎo)致其效率下降.
數(shù)組插入操作
通過上面的介紹, 對于其插入操作應(yīng)該如何實(shí)現(xiàn)想比心中有數(shù)了. 這里簡單羅列一下:
// 判斷需要時對數(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ù)組進(jìn)行擴(kuò)充 ZEND_HASH_IF_FULL_DO_RESIZE(ht); /* If the Hash table is full, resize it */ add_to_hash: // INTERNED 字符串不會被銷毀, 用來實(shí)現(xiàn)相同字符串共用內(nèi)存 // 當(dāng)數(shù)組中所有key 都是 INTERNED 字符串 // 那么數(shù)組釋放的時候就不需要釋放 key 了, 同時數(shù)組 copy 的時候也不需要增加字符串引用計(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); // 對 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ù)每次擴(kuò)容都是乘2的操作. 如果說, 有一個長度為65536的數(shù)組, 每一個 key 的散列值計(jì)算后均為0, 那么hashTable
不就退化為鏈表了么?
具體是什么思路呢? 第一個元素 key 為0, 那么根據(jù)長度取模, 第二個元素就是 65536, 第三個元素就是 65536*2, 這樣每次插入的時候都需要遍歷鏈表, 導(dǎo)致插入效率變慢. 整個demo 試一下.
<?php // 統(tǒng)計(jì)函數(shù)的耗時 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('異常數(shù)組-構(gòu)造', function () use ($size, &$array){ $array = array(); for ($i = 0; $i <= $size; $i++) { $key = $size * $i; $array[$key] = 0; } }); echoCallCostTime('異常數(shù)組-首個元素訪問', 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ù)組-首個元素訪問', function () use ($array){ $b = $array[0]; }); echoCallCostTime('普通數(shù)組-最后元素訪問', function () use ($array, $size){ $b = $array[$size]; });
我們先按照這個邏輯推理一下, 異常數(shù)組的構(gòu)造一定比普通數(shù)組耗時要久, 因?yàn)槊看尾迦攵家闅v鏈表嘛.
而且, 異常數(shù)組的首個元素訪問時間要更新, 因?yàn)樗F(xiàn)在出在鏈表的末尾, 要想訪問它就要將鏈表遍歷一遍. 看下結(jié)果:
和之前的推論絲毫不差, 而且性能相差很多倍哦. 不過這里hash
算法的具體實(shí)現(xiàn)我沒有看
總結(jié)
本篇文章就到這里了,希望能夠給你帶來幫助,也希望您能夠多多關(guān)注腳本之家的更多內(nèi)容!
相關(guān)文章
總結(jié)的一些PHP開發(fā)中的tips(必看篇)
下面小編就為大家?guī)硪黄偨Y(jié)的一些PHP開發(fā)中的tips(必看篇)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-03-03php基于閉包實(shí)現(xiàn)函數(shù)的自調(diào)用(遞歸)實(shí)例分析
這篇文章主要介紹了php基于閉包實(shí)現(xiàn)函數(shù)的自調(diào)用,結(jié)合實(shí)例形式分析了php閉包實(shí)現(xiàn)遞歸的操作方法,需要的朋友可以參考下2016-11-11php設(shè)計(jì)模式 Interpreter(解釋器模式)
php設(shè)計(jì)模式 Interpreter(解釋器模式),需要的朋友可以參考下。2011-06-06PHP下利用header()函數(shù)設(shè)置瀏覽器緩存的代碼
PHP高級應(yīng)用學(xué)習(xí)筆記之 利用header()函數(shù)設(shè)置瀏覽器緩存2010-09-09PHP使用標(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-08PHP實(shí)現(xiàn)的簡單在線計(jì)算器功能示例
這篇文章主要介紹了PHP實(shí)現(xiàn)的簡單在線計(jì)算器功能,涉及php數(shù)值運(yùn)算與表單操作相關(guān)技巧,需要的朋友可以參考下2017-08-08