PHP 數組底層實現原理


   數組在 PHP 中非常強大、靈活的一種數據類型,和 Java、C 等靜態語言不同,我們在初始化 PHP 數組的時候不必指定大小和存儲數據的類型,在賦值的時候可以通過數字索引,也可以通過字符串索引的方式:

   

   基於 PHP 數組的強大特性,我們可以輕易實現更加復雜的數據結構,比如棧、隊列、列表、集合、字典等。PHP 數組功能之所以如此強大,得益於底層基於散列表實現。

 

PHP數組底層數據結構 

   PHP 數組底層依賴的散列表數據結構定義如下(位於 Zend/zend_types.h):

   

   這個散列表中有很多成員,我們挑幾個比較重要的來講講:

  • arData:散列表中保存存儲元素的數組,其內存是連續的,arData指向數組的起始位置;
  • nTableSize:數組的總容量,即可以容納的元素數,arData 的內存大小就是根據這個值確定的,它的大小的是2的冪次方,最小為8,然后按照 8、16、32...依次遞增;
  • nTableMask:這個值在散列函數根據 key 的哈希值映射元素的時候用到,它的值實際就是 nTableSize 的負數,即 nTableMask = -nTableSize,用位運算來表示就是 nTableMask = ~nTableSize+1;
  • nNumUsed、nNumOfElements:nNumUsed 是指數組當前使用的 Bucket 數,但不是數組有效元素個數,因為某個數組元素被刪除后並沒有立即從數組中刪除,而是將其標記為 IS_UNDEF,只有在數組需要擴容時才會真正刪除,nNumOfElements 則表示數組中有效的元素數量,即調用 count 函數返回值,如果沒有擴容,nNumUsed 一直遞增,無論是否刪除元素;
  • nNextFreeElement:這個是給自動確定數值索引使用的,默認從 0 開始,比如 $arr[] = 200,這個時候 nNextFreeElement 值會自動加 1;
  • pDestructor:當刪除或覆蓋數組中的某個元素時,如果提供了這個函數句柄,則在刪除或覆蓋時調用此函數,對舊元素進行清理;
  • u:這個聯合體結構主要用於一些輔助作用

   Bucket 的結構比較簡單,主要用來保存元素的 key 和 value,以及一個整型的 h(散列值,或者叫哈希值):如果元素是數值索引,則其值就是數值索引的值;如果是字符串索引,那么其值就是 key 通過 Time33 算法計算得到的散列值,h 的值用來最終映射元素的存儲位置。Bucket 的數據結構如下:

   

 

PHP 數組的基本實現 

   散列表主要由兩部分組成:存儲元素數組、散列函數。散列表的基本實現前面已經探討過,PHP 中的數組除了具備散列表的基本特點之外,還有一個特別的地方,那就是它是有序的(與Java中的HashMap的無序有所不同):數組中各元素的順序和插入順序一致。這個是怎么實現的呢?

   為了實現 PHP 數組的有序性,PHP 底層的散列表在散列函數與元素數組之間加了一層映射表,這個映射表也是一個數組,大小和存儲元素的數組相同,存儲元素的類型為整型,用於保存元素在實際存儲的有序數組中的下標 —— 元素按照先后順序依次插入實際存儲數組,然后將其數組下標按照散列函數散列出來的位置存儲在新加的映射表中:

   

   這樣,就可以完成最終存儲數據的有序性了。

   PHP 數組底層結構中並沒有顯式標識這個中間映射表,而是與 arData 放到了一起,在數組初始化的時候並不僅僅分配用於存儲 Bucket 的內存,還會分配相同數量的 uint32_t 大小的空間,這兩塊空間是一起分配的,然后將 arData 偏移到存儲元素數組的位置,而這個中間映射表就可以通過 arData 向前訪問到。

 

數組的初始化

   數組的初始化主要是針對 HashTable 成員的設置,初始化時並不會立即分配 arData 的內存,插入第一個元素之后才會分配 arData 的內存。初始化操作可以通過 zend_hash_init 宏完成,最后由 _zend_hash_init_int 函數處理(該函數定義在 Zend/zend_hash.c 文件中):

   

   此時的 HashTable 只是設置了散列表的大小及其他成員的初始值,還無法用來存儲元素。

 

插入數據

   插入時會檢查數組是否已經分配存儲空間,因為初始化並沒有實際分配 arData 的內存,在第一次插入時才會根據 nTableSize 的大小分配,分配以后會把 HashTable->u.flags 打上 HASH_FLAG_INITIALIZED 掩碼,這樣,下次插入時發現已經分配了就不會重復操作,這段檢查邏輯位於 _zend_hash_add_or_update_i 函數中:

if (UNEXPECTED(!(HT_FLAGS(ht) & HASH_FLAG_INITIALIZED))) {
    zend_hash_real_init_mixed(ht);
    if (!ZSTR_IS_INTERNED(key)) {
        zend_string_addref(key);
        HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
        zend_string_hash_val(key);
    }
    goto add_to_hash;
}

   如果 arData 還沒有分配,則最終由 zend_hash_real_init_mixed_ex 完成內存分配:

   

   分配完 arData 的內存后就可以進行插入操作了,插入時先將元素按照順序插入 arData,然后將其在 arData 數組中的位置存儲到根據 key 的散列值與 nTableMask 計算得到的中間映射表中的對應位置:

   

   上述只是最基本的插入處理,不涉及已存在數據的覆蓋和清理。

 

哈希沖突

   PHP 數組底層的散列表采用鏈地址法解決哈希沖突,即將沖突的 Bucket 串成鏈表。

   HashTable 中的 Bucket 會記錄與它沖突的元素在 arData 數組中的位置,這也是一個鏈表,沖突元素的保存位置不在 Bucket 結構中,而是保存在了存儲元素 zval 的 u2 結構中,即    Bucket.val.u2.next,所以插入時分為以下兩步:

// 將映射表中原來的值保存到新 Bucket 中,哈希沖突時會用到(以鏈表方式解決哈希沖突)
Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);
// 再把新元素數組存儲位置更新到數據表中
// 保存idx:((unit32_t*))(ht->arData)[nIndex] = idx
HT_HASH_EX(arData, nIndex) = HT_IDX_TO_HASH(idx);

 

數組查找

   清楚了 HashTable 的實現和哈希沖突的解決方式之后,查找的過程就比較簡單了:首先根據 key 計算出的散列值與 nTableMask 計算得到最終散列值 nIndex,然后根據散列值從中間映射表中得到存儲元素在有序存儲數組中的位置 idx,接着根據 idx 從有序存儲數組(即 arData)中取出 Bucket,遍歷該 Bucket,判斷 Bucket 的 key 是否是要查找的 key,如果是則終止遍歷,否則繼續根據 zval.u2.next 遍歷比較。

   對應的底層源碼如下:

   

 

刪除數據

   關於數組數據刪除前面我們在介紹散列表中的 nNumUsed 和 nNumOfElements 字段時已經提及過,從數組中刪除元素時,並沒有真正移除,並重新 rehash,而是當 arData 滿了之后,才會移除無用的數據,從而提高性能。即數組在需要擴容的情況下才會真正刪除元素:首先檢查數組中已刪除元素所占比例,如果比例達到閾值則觸發重新構建索引的操作,這個過程會把已刪除的 Bucket 移除,然后把后面的 Bucket 往前移動補上空位,如果還沒有達到閾值則會分配一個原數組大小 2 倍的新數組,然后把原數組的元素復制到新數組上,最后重建索引,重建索引會將已刪除的 Bucket 移除。

   對應底層代碼如下:

    

   除此之外,數組還有很多其他操作,比如復制、合並、銷毀、重置等,這些操作對應的代碼都位於 zend_hash.c 中,感興趣的同學可以去看看。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM