PHP數組的內部實現你瞭解嗎

前言

這幾天在翻github的時候, 碰巧看到瞭php的源碼, 就 down 下來隨便翻瞭翻 

那麼PHP中什麼玩意最引人註目嘞? 一定是數組瞭, PHP中的數組太強大瞭, 於是就想著不如進去看看數組的實現部分. 這篇文章打算全程針對代碼進行解讀瞭.

以下代碼基於最新 php8.1. commitId: ea62b8089acef6551d6cece5dfb2ce0b040a7b83 .感興趣的可自行下載查看.

探究

首先, 如此強大的數組功能應該是有單獨文件進行定義的. 因此搜索瞭array.h array.c文件, 哎, array.c文件是存在的.

查看後發現, array.c文件中定義瞭PHP數組的系統函數, 比如krsort count等等. 但是, array的實現又在哪裡呢?

隨便找一個方法array_flip, 其中第一行定義變量如下:

zval *array;

也就是說, 方法接收的參數是結構體zval. 但是, zval這個結構體看名字應該是變量而不是數組啊. 果然, 再看下面變量的使用:

image-20220306202851666

拿到變量後, 判斷變量的類型, 會根據不同類型進行不同的處理.

那麼這裡為什麼不直接接數組類型呢? 因為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
// 對不同變量類型的定義
/* Regular data types: Must be in sync with zend_variables.c. */
#define IS_UNDEF					0
#define IS_NULL						1
#define IS_FALSE					2
// ...
// 進行結構體的重命名
typedef struct _zval_struct     zval;
/*
* 變量聯合體定義.
* 此聯合體和保存各種類型的變量
*/
typedef union _zend_value {
    zend_long         lval; // 8B
    double            dval; // 8B
    zend_refcounted  *counted; // 引用計數. 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
// 變量的結構體
struct _zval_struct {
    // 使用 zend_value 聯合體保存當前元素的值. 8B
    zend_value        value;			/* value */
    /*
    * 用來保存變量類型
    * 這裡為什麼要使用聯合體呢?
    * 眾所周知, 聯合體中變量是共用內存的
    * 而其中的兩個內容都是4字節的.
    * 因此, 我認為是為瞭方便使用.
    * 在統一操作時可使用 type_info, 有可以通過結構體分別獲取每一位
    * (不過這隻是個人理解, 沒有進行求證)
    */
    union {
        uint32_t type_info; // 4B
        struct {
            ZEND_ENDIAN_LOHI_3(
                // 用來保存當前變量的類型. 也就是上面的一批定義. 1B
                zend_uchar    type,			/* active type */
                // 當前變量的一些標志位. 如: 常量類型/不可修改 等等. 1B
                zend_uchar    type_flags,
                union { // 2B
                uint16_t  extra;        /* not further specified */
            } u)
        } v; // 4B
    } u1; // 4B
    // 上面兩個結構體共占用 12B, 而內存對其需要16B, 因此有4個字節是空著的
    // 下面的聯合體可以將這4B 充分利用.
    // 這裡根據不同的變量類型使用不同的變量. 比如: next, 在下面介紹數組的時候有用
    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的時候, 應該註意到其中的zend_array類型瞭. 不用看瞭, 看名字也知道, 數組就是它瞭.

為瞭在下面查看數組結構體時, 這裡對PHP中數組的實現做一個簡短的介紹.

結構介紹

眾所周知, PHP中數組是通過hashTable實現的, 但是hashTable又是如何保證讀取順序的呢? 通過如下兩個數組實現瞭一個有序 hash:

image-20220307205048722

每次新增元素都向data 數組後面添加, 這樣foreach的時候遍歷data 數組, 讀到的順序就和放入的順序是一樣的瞭.

但是, 這不就是數組麼? hash呢? 如何保證讀取的高效呢? 在第二個hash 數組中, hash 數組中保存的是元素在data 數組中的索引.

從數組中讀取keya元素的步驟是這樣的:

1.計算ahash值為2

2.idx=indexList[2]

3.data=dataList[idx]

那麼hash沖突又是如何解決的呢? 對於哈希沖突, 目前有開放尋址鏈表兩種處理方式, 不過大部分實現都采用鏈表的方式. 這裡也不例外.

數組中, b c d 的hash值均為4, 他們三個直接組成一個鏈表. 而index 數組中保存鏈表頭的地址.

好, PHP數組的實現結構概念部分介紹完瞭. 接下來看一下PHP是如何實現的吧.

結構體

在介紹結構體代碼之前, 還是得先上一個圖. 在上方介紹中存在dataList indexList兩個數組. 在PHP的實現中, 或許是為瞭節省空間. 將這兩個數組合並成瞭一個, 隻需要記錄一個地址. 如下圖:

image-20220307212124533

上圖的說明是為瞭防止你看到結構體中的union會懵. 一樣的, 我將自己的理解放到註釋中瞭.

typedef struct _zend_array      zend_array;
// 沒毛病, 數組的別名就是 hashTable
typedef struct _zend_array HashTable;
// 用來保存數組中的數據
typedef struct _Bucket {
    // 當前元素值
    zval              val;
    // 當前元素的 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; // 對數組進行引用計數. 8B
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                /*
                * 標志位. 其常量定義如下:
                * #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
    // 用來保存數組中的元素信息. 這是一個數組, 記錄數組首地址.
    // 關於這裡的 兩個數組為什麼使用 聯合體記錄, 在上圖中解釋瞭. 
    union {
        // 用來讀取上方的 hashList 8B
        uint32_t     *arHash;   /* hash table (allocated above this pointer) */
        // 用來讀取上方的 dataList 8B
        Bucket       *arData;   /* array of hash buckets */
        // 當前數組中其實保存瞭兩個數組, 但是對於key是連續數字的數組來說, arrHash 其實並不需要. 可以直接使用數組存儲
        // 所以使用瞭 arPacked 來表示key全部為數字的, 通過標識位 HASH_FLAG_PACKED 來標識. 以節省內存占用
        // 所以, 其實對於連續數字的數組, 其底層真的是數組, 而不是 hashTable
        // 這裡你可以簡單的實驗一下, 通過構造一個連續數字索引的數字, 然後在給其賦值一個key 為字符串的元素, 通過 memory_get_usage 函數查看內存的變化. 很明顯的. 
        zval         *arPacked; /* packed array of zvals */
    }; // 8B
    // 數組中存儲的元素個數. 4B
    uint32_t          nNumOfElements;
    /*
    * 向數組中添加元素時, 使用的數組索引. 
    * 此變量與 nNumOfElements 的區別是,
    * 當數組中元素釋放的時候, 比如 unset 操作.
    * nNumOfElements 進行減一操作, 而 nNumUsed 變量不變.
    * 同時, 元素也並沒有從數組中抹去, 僅僅是將其 type 修改為 IS_UNDEF
    * 等到下一次內存擴充的時候, 在將這些元素釋放掉, 以保證釋放的高效
    * 4B
    */
    uint32_t          nNumUsed;
    // 記錄當前數組已經分配的地址大小. 2的 n 次冪(分配地址每次乘2). 4B
    uint32_t          nTableSize;
    // 計算 key 的 hash 散列值時使用(在下方具體介紹). 4B
    uint32_t          nTableMask;
    // 數組遍歷是使用的遊標, 如調用函數: next/prev/end/reset/current 等. 4B
    uint32_t          nInternalPointer;
    /*
    * 用來記錄下一個元素插入時的默認 key.
    * 比如代碼:
    * $arr = [];
    * $arr[] = 1; // 相當於 $arr[0]=1;
    * 但是, 你或許會疑惑, 這還需要單獨記錄麼? 直接使用數組的大小計算不就行瞭?
    * 再看一段:
    * $arr = [];
    * $arr['a'] = 1;
    * $arr[] = 2; // 相當於 $arr[0] = 1;
    * 是不是懂瞭??
    * 8B
    */
    zend_long         nNextFreeElement;
    /*
    * 此方法在數組中的元素更新或刪除時調用.
    * 若元素是引用計數的類型, 會更新其引用計數
    * 相當於元素的析構函數
    * 8B
    */
    dtor_func_t       pDestructor;
}; // 56B

nTableMask

nTableMask變量在計算元素的的散列值(在indexList中的索引)時使用.

首先在上面, indexListdataList大小相等, 且都等於nTableSize. 也就是說, 散列值的取值范圍為: [-nTableSize, -1].

PHP中是如何處理的呢? 其計算規則為: nIndex = h | ht->nTableMask; 其中 nTableMask=-nTableSize.

這裡簡單證明一下, 還記得上面提到過, nTableMask的取值為2的 n 次冪. 我們假設長度為16. (為瞭簡化邏輯, 以8byte 為例).

那麼, nTableMask等於 -16, 其二進制補碼表示為: 11110000. 我們分別使用兩個極端值和nTableMask進行或運算.

1111000000000000進行或運算, 結果為11110000, 其值等於-16.

1111000001111111進行或運算, 結果為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);		/* If the Hash table is full, resize it */
add_to_hash:
    // INTERNED 字符串不會被銷毀, 用來實現相同字符串共用內存
    // 當數組中所有key 都是 INTERNED 字符串
    // 那麼數組釋放的時候就不需要釋放 key 瞭, 同時數組 copy 的時候也不需要增加字符串引用計數
    // HASH_FLAG_STATIC_KEYS 標記位就是用來標記數組中所有 key 均為 INTERNED 字符串
    // 若當前字符串不是 INTERNED 的, 則修改數組的標記位
    if (!ZSTR_IS_INTERNED(key)) {
        zend_string_addref(key);
        HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
    }
    // 獲取當前元素的 dataList index
    idx = ht->nNumUsed++;
    // 數組中元素內容增加
    ht->nNumOfElements++;
    // 元素賦值
    arData = ht->arData;
    p = arData + idx;
    p->key = key;
    p->h = h = ZSTR_H(key);
    // 計算 hashList index
    nIndex = h | ht->nTableMask;
    // 這一步就是用來處理 hash 沖突的
    // 將當前元素的 next 指向原來 hashList 中的值
    Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);
    // 更新 hashList
    HT_HASH_EX(arData, nIndex) = HT_IDX_TO_HASH(idx);
    // 對 val 進行賦值. 
    // 這裡判斷標志位 HASH_LOOKUP, 然後將 val 置為 null. 這裡看瞭半天沒看懂其作用, 如果有知道的還望不吝賜教
    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];
});

我們先按照這個邏輯推理一下, 異常數組的構造一定比普通數組耗時要久, 因為每次插入都要遍歷鏈表嘛.

而且, 異常數組的首個元素訪問時間要更新, 因為它現在出在鏈表的末尾, 要想訪問它就要將鏈表遍歷一遍. 看下結果:

image-20220307225236844

和之前的推論絲毫不差, 而且性能相差很多倍哦. 不過這裡hash算法的具體實現我沒有看

總結

本篇文章就到這裡瞭,希望能夠給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容! 

推薦閱讀: