深入理解PHP數組底層實現

PHP數組是一個神奇而強大的數據結構,數組既可以是連續的數組,也可以是存儲K-V映射的map。而在PHP7中,相比於PHP5,對數組進行了很大的修改。

  • 數組的語義
  • 數組的概念
  • PHP5數組的實現
  • PHP7數組的實現
    - 基本結構
    - 初始化
    - packed array 和 hash array的區別
    - 插入、更新、查找、刪除
    - 哈希衝突的解決
    - 擴容和rehash操作
    - 數組的遞歸保護

一、數組的語義
本質上,PHP數組是一個有序的字典,它需要同時滿足一下兩個語義。
語義一:PHP數組是一個字典,存儲着鍵—值(key—value)對。通過鍵可以快速地找到對應的值,鍵可以是整形,也可以是字符串。
語義二:PHP數組是有序的。這個有序是指插入順序,遍歷數組的時候,遍歷元素的順序應該和插入順序一致,而不像普通字典一樣是隨機的。
爲了實現語義一,PHP用HashTable來存儲鍵—值對,但是HashTable本身並不能保證語義二,PHP不同版本都對HashTable進行了額外的設計來保證有序,下面會進行介紹。
二、數組的概念
在這裏插入圖片描述
key: 鍵,通過它可以快速檢索到對應的value。一般爲數字或字符串。
value : 值,目標數據。可以是複雜的數據結構。
bucket: 桶,HashTable中存儲數據的單元。用來存儲key和value以及輔助信息的容器。
slot: 槽,HashTable有多個槽,一個bucket必須從屬於具體的某一個slot,一個slot下可以有多個bucket。
哈希函數: 需要自己實現,在存儲的時候,會對key應用哈希函數確定所在slot。
哈希衝突:當多個key經過哈希計算後,得出的slot的位置是同一個,那麼就叫作哈希衝突。一般解決衝突的方法是鏈地址法和開放地址法。PHP採用鏈地址法,將同一個slot中的bucket通過鏈表鏈接起來。
在具體實現中,PHP基於上述基本概念對bucket以及哈希函數進行了一些補充,增加了hash1函數以生成h值,然後通過hash2函數散列到不同的slot。
在這裏插入圖片描述
增加這個中間h值的作用:

  1. HashTable中key可能是數字,也可能是字符串,所以bucket在設計key的時候,需要做拆分,拆分數字key和字符串key,在上圖bucket中,“h” 代表數字key, “”key“ 代表字符串key。實際上,對於數字key,hash1不做任何處理。
  2. 每個字符串都有一個h值,這個h值可以加快字符串的比較速度,當比較兩個字符串是否相等,先比較key1和key2的h值是否相等,如果相等,再去比較字符串的長度以及內容。否則直接判定不相等。
    二、PHP5數組的實現
    首先看一下PHP5的bucket以及HashTable結構定義:
typedef struct bucket {  
    ulong h;                   /* 4字節 對char *key進行hash後的值,或者是用戶指定的數字索引值/* Used for numeric indexing */
    uint nKeyLength;           /* 4字節 字符串索引長度,如果是數字索引,則值爲0 */  
    void *pData;               /* 4字節 實際數據的存儲地址,指向value,一般是用戶數據的副本,如果是指針數據,則指向pDataPtr,這裏又是個指針,zval存放在別的地方*/
    void *pDataPtr;            /* 4字節 引用數據的存儲地址,如果是指針數據,此值會指向真正的value,同時上面pData會指向此值 */  
    struct bucket *pListNext;  /* 4字節 整個哈希表的該元素的下一個元素*/  
    struct bucket *pListLast;  /* 4字節 整個哈希表的該元素的上一個元素*/  
    struct bucket *pNext;      /* 4字節 同一個槽,雙向鏈表的下一個元素的地址 */  
    struct bucket *pLast;      /* 4字節 同一個槽,雙向鏈表的上一個元素的地址*/  
    char arKey[1];             /* 1字節 保存當前值所對於的key字符串,這個字段只能定義在最後,實現變長結構體*/  
} Bucket;

(1)這裏bucket新增三個元素:
arkey: 對應HashTable設計中的key,表示字符串key。
h: 對應HashTable設計中的h,表示數字key或者字符串key的h值。
pData和pDataPtr: 對應HashTable設計中的value。
一般value存儲在pData所指向的內存,pDataPtr是NULL,但如果value的大小等於一個指針的大小,那麼不會再額外申請內存存儲,而是直接存儲在pDataPtr上,再讓pData指向pDataPtr,可以減少內存碎片。
(2)爲了實現數組的兩個語義,bucket裏面有pListLast、pListNext、pLast、pNext這4個指針,維護兩種雙向鏈表。一種是全局鏈表,按插入順序將所有bucket全部串聯起來,整個HashTable只有一個全局鏈表。另一個是局部鏈表,爲了解決哈希衝突,每個slot維護着一個鏈表,將所有哈希衝突的bucket串聯起來。也就是,每一個bucket都處在一個雙向鏈表上。pLast和pNext分別指向局部鏈表的前一個和後一個bucket,pListLast和pListTNext則指向全部鏈表的前一個和後一個。

typedef struct _hashtable {  
    uint nTableSize;           /*4 哈希表中Bucket的槽的數量,初始值爲8,每次resize時以2倍速度增長*/
    uint nTableMask;           /*4 nTableSize-1 ,索引取值的優化 */
    uint nNumOfElements;       /*4 哈希表中Bucket中當前存在的元素個數,count()函數會直接返回此值*/
    ulong nNextFreeElement;    /*4 下一個數字索引的位置 */
    Bucket *pInternalPointer;  /*4 當前遍歷的指針(foreach比for快的原因之一) 用於元素遍歷*/
    Bucket *pListHead;         /*4 存儲數組頭元素指針 */
    Bucket *pListTail;         /*4 存儲數組尾元素指針 */
    Bucket **arBuckets;        /*4 指針數組,數組中每個元素都是指針,存儲hash數組 */
    dtor_func_t pDestructor;   /*4 在刪除元素時執行的回調函數,用於資源的釋放 /* persistent 指出了Bucket內存分配的方式。如果persisient爲TRUE,則使用操作系統本身的內存分配函數爲Bucket分配內存,否則使用PHP的內存分配函數。*/
    zend_bool persistent;      /*1 */
    unsigned char nApplyCount; /*1 標記當前hash Bucket被遞歸訪問的次數(防止多次遞歸)*/
    zend_bool bApplyProtection;/*1 標記當前hash桶允許不允許多次訪問,不允許時,最多隻能遞歸3次 */
#if ZEND_DEBUG  
    int inconsistent;          /*4 */ 
#endif  
} HashTable; 

這裏解釋一下:
nTableMask: 掩碼。總是等於nTableSize - 1,即2^n - 1,因此,nTableMask的每一位都是1。上文提到的哈希過程中,key經過hash1函數,轉爲h值,h值通過hash2函數轉爲slot值。這裏的hash2函數就是slot = h & nTableMask,進而通過arBuckets[slot]取得當前slot鏈表的頭指針。
pListHead / pListTail 爲了實現數組第二語義(有序)HashTable維護了一個全局鏈表這倆指針分別指向這個全局鏈表的頭和尾。

到這裏分析一下,PHP7爲什麼要重寫數組實現。

  1. 每一個bucket都需要一次內存分配。
  2. key—value中的value都是zval。這種情況下,每個bucket需要維護指向zval的指針pDataPtr以及指向pDataPtr的指針pData。
  3. 爲了保證數組的兩個語義,每一個bucket需要維護4個指向bucket的指針。
    以上原因,導致性能不好。
    三、PHP7數組實現
    既然都用HashTable,如果通過鏈地址法解決哈希衝突,那麼鏈表是必然需要的,爲了保證有序性,的確需要再維護一個全局鏈表,看起來PHP5已經是無懈可擊了。
    實際上,PHP7也是通過鏈地址法,但是此 “鏈” 非彼 “鏈”。PHP5的鏈表是在物理上得到鏈表,鏈表中bucket之間的上下游關係通過真實存在的指針來維護。而PHP7的鏈表是一種邏輯上的鏈表,所有bucket都分配在連續的數組內存中,不再通過指針來維護上下游關係,每一個bucket只維護下一個bucket在數組中的索引(因爲是連續內存,通過索引可以快速定位到bucket),即可完成鏈表上的bucket的遍歷。
    好的,揭開PHP7數組底層結構的廬山真面目:
  4. 基本結構:
typedef struct _Bucket {
    zval              val;      /* 對應HashTable設計中的value */ 
    zend_ulong        h;        /* 對應HashTable設計中的h,表示數字key或者字符串key的h值。*/        
    zend_string      *key;      /* 對應HashTable設計中的key */          
} Bucket;

bucket可以分爲3種:未使用、有效、無效。
未使用:最初所有bucket都是未使用狀態。
有效:存儲着有效的數據。
無效:當bucket上的數據被刪除時,有效bucket就會變爲無效bucket。
在內存分佈上,有效和無效bucket會交替分佈。但都在未使用bucket的前面。插入的時候永遠在未使用bucket上進行,當無效bucket過多,而有效bucekt很少時,對整個bucket數組進行rehash操作這樣稀疏的有效bucket就變得連續而緊密,部分無效bucket會被重新利用而變得有效,還有一部分有效bucket和無效bucket會被釋放出來,重新變爲未使用bucket。
在這裏插入圖片描述

struct _zend_array { 
    zend_refcounted_h  gc;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    flags,
                zend_uchar    nApplyCount,  /* 循環遍歷保護 */
                zend_uchar    nInteratorsCount,
                zend_uchar    consistency)
        } v;
        uint32_t flags;
    } u;
    uint32_t          nTableMask;           /* 掩碼,用於根據hash值計算存儲位置,永遠等於nTableSize-1 */
    Bucket           *arData;               /* 存放實際數據 */
    uint32_t          nNumUsed;             /* arData數組已經使用的數量 */
    uint32_t          nNumOfElements;       /* hash表中元素個數 */
    uint32_t          nTableSize;           /* hash表的大小 HashTable的大小,始終爲2的指數(8,16,32,64...)。最小爲8,最大值根據機器不同而不同*/
    uint32_t          nInternalPointer;     /* 用於HashTable遍歷 */
    zend_long         nNextFreeElement;     /* 下一個空閒可用位置的數字索引 */
    dtor_func_t       pDestructor;          /* 析構函數 */
} HashTable;

在這裏插入圖片描述
u.flags是32位的無符號整形,取值範圍是0 ~ 2^32 - 1, 而u.v.flags是8位的無符號字符,取值範圍是0 ~ 255。
u.v.flags: 用各個bit來表達HashTable的各種標記。共有下面6種flag,分別對應u.v.flags的第1位至第6位。

#define HASH_FLAG_PERSISTENT       (1 << 0)  //是否使用持久化內存(不使用內存池)
#define HASH_FLAG_APPLY_PROTECTION (1 << 1)  //是否開啓遞歸遍歷保護
#define HASH_FLAG_PACKED           (1 << 2) //是否是packed  array
#define HASH_FLAG_INITIALIZED      (1 << 3) //是否已經初始化
#define HASH_FLAG_STATIC_KEYS      (1 << 4) //標記HashTable的key是否爲long key或者內部字符串key
#define HASH_FLAG_HAS_EMPTY_IND    (1 << 5) //是否存在空的間接val
  1. 初始化:
    (1)申請一塊HashTable結構體內存,並初始化各個字段。
    (2)分配bucket數組內存,修改一些字段值。

最後,想說底層實現還有packed array和 hash array 的區別、插入/刪除/查找/更新的操作、哈希衝突的解決、擴容和rehash等操作,感興趣的小夥伴,可以繼續深入學習。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章