PHP7内核学习笔记 - 数组

  • 理想状况下,无需任何比较就能找到待查关键字,查找的期望时间复杂度为O(1)

PHP7散列表基本结构:
// zend_array和HashTable的含义是相同的,没有任何区别
typedef struct _zend_array zend_array;
typedef struct _zend_array HashTable;

struct _zend_array {
    zend_refcounted_h gc;
    // 此处的union可以先忽略
    union {
        ...
    } u;
    // 用于散列函数映射存储在arData数组中的下标
    uint32_int    nTableMask;
    // 存储元素数组,每个元素的结构统一为Bucket,arData指向第一个Bucket
    Bucket        *arData;
    // 已用Bucket数
    uint32_t      nNumUsed;
    // 数组实际存储的元素数
    uint32_t      nNumOfElements;
    // 数组的总容量
    uint32_t      nTableSize;
    
    uint32_t      nInternalPointer;
    
    // 下一个可用的数值索引,如arr[] = 1;arr["a"] = 2;arr[] = 3;则nNextFreeElement = 2;
    zend_long     nNextFreeElement;
    dtor_func_t   pDestructor;
}

散列表的结构中有很多成员,以下是比较重要的几个成员:

  • arData:散列表中保存 存储元素(即Bucket)的数组,其内存是连续的,arData指向数组的起始位置
  • nTableSize:数组的总容量,即可以容纳的元素数量。arData的内存大小是根据这个值确定的,他的大小是2的幂次方最小为8。也就是说散列表的大小依次按照8163264…进行递增。
  • nTableMask:这个值在散列函数根据keyhash code映射元素的位置时用到。他的值实际就是nTableSize负数,即nTableMask = -nTableSize,用位运算来表示的话则为:nTableMask = ~nTableSize + 1此处需要撰写一篇笔记专门说明,参考本文件夹内《4.2、原码、反码和补码》)。
  • nNumUsednNumOfElementsnNumUsed是指当前使用掉Bucket数量,而nNumOfElements则是数组中的有效元素的数量,注意两者是有区别的。在数组中,使用掉的Bucket并非一定是有效元素,当我们从数组中删除元素的时候并不会把元素马上从数组中移除,而是将元素的类型标记IS_UNDEF。只有在数组的容量超限,需要进行扩容时才会删除。这一机制使得nNumUsed >= nNumOfElements。如果数组没有扩容,那么nNumUsed将一直是递增的,无论是否删除元素。
  • nNextFreeElement:这个值是给自动确认数值索引使用的,从0开始。比如$a[] = 1,这个时候就将这值增加为1,下次再有$a[]的操作时就使用刚刚得到的1作为新元素的索引值。
  • pDestructor:当删除或覆盖数组中的某个元素时,如果提供了这个函数句柄,则在删除或覆盖后调用此函数,对旧元素进行清理。
  • u:这个结构主要是一些辅助作用,比如flags用来设置散列表的一些属性:是否持久化、是否已经初始化等。

Bucket

Bucket的结构较为简单,此结构主要用于保存元素的keyvalue。除了这两个还有一个整型的h,它的含义是hash code:如果元素是数值索引,那么它的元素就是数值索引的值;如果是字符串索引,那么就根据字符串key通过Time33算法计算得到散列值h的值用来映射元素的存储位置。另外,存储的value直接嵌入Bucket结构中:

typedef struct _Bucket {
    zval         val;// 存储的具体value,这里嵌入一个zval,而不是一个指针
    zend_ulong   h;// key根据Time33计算得到的哈希值,或者是数值索引
    zend_string  *key;// 存储元素的key
} Bucket;

arData

arData在数组初始化的时候并不分配内存,而是在首次插入数据的时候触发内存分配。由zend_hash_real_init_ex()进行内存的分配,分配的大小包括中间表元素数组,共计:nTableSize * (sizeof(Bucket) + sizeof(uint32_t))。分配完成后会把HashTable->u.flags打上HASH_FLAG_INITIALIZED掩码,这样下次再插入的时候发现已经分配了内存就不会再触发内存分配了。分配完成后,HashTable->arData会指向第一个Bucket的位置。

完成内存的分配之后就可以进行插入操作了。插入时首先将元素按照顺序插入arData,然后将其在arData中的位置存储到中间表,它在中间表中的位置计算方式是:根据keyhash code(即key->h)与nTableMask计算后得到的中间表的位置(nIndex = h | ht->nTableMask)。


哈希冲突

散列表中不同元素的key有时候经过hash计算会得到相同的结果(即想存入相同的映射表的位置),而映射表中的一个位置只能存储一个元素,这时候就发生了哈希冲突

常用的解决方法是把冲突的Bucket串成链表,这样一来映射表映射的就不是一个Bucket元素,而是一个Bucket链表。在查找的时候需要根据key遍历链表来查找相应的元素。PHP就是采用的这种方式解决哈希冲突。

HashTable中的Bucket会记录与他冲突的元素的位置(当前是只能标记相邻的)。在设置映射时,如果发现对应的位置已经有元素了,就会把已经存在的值(后加:的位置)放到新申请的Bucket中再把映射表指向新申请Bucket。《PHP7内核剖析》书中有句话叫做“即每次都会把冲突的元素插到开头”,这里有两点不明是冲突的元素是指新元素还是旧元素(后续查看,是新元素),是链表的开头是指新申请的Bucket还是之前的旧Bucket(后续查看,是新申请的Bucket)。这里的描述与例子是冲突的,例子中并没有旧的值放入新申请Bucket,例子截图如下:
http://read.likefirework.com/public/youdao/php/kernal/hashcollision.png

关于上述冲突的说明:“就会把已经存在的值(后加:的位置)放到新申请的Bucket中再把映射表指向新申请Bucket。”这句话坑死爹爹了。。之前一直感觉很奇怪,才有了下面的疑惑。这边解读一下:这里是指把已经存在的值的位置放到新申请的Bucket中的next中,并非是把整个值拷过去。。

个人觉得明明只要把新Bucket连接到旧的Bucket后面就可以了,为何需要修改映射表的指向呢?很繁琐啊,把值拷来拷去的。。

写在最后:将冲突流程走了一遍之后发现,现在的做法确实是最优的:冲突时只需要修改映射表的值,再将新元素的next指向之前的元素。如果按照我的想法,保持映射表位置不变,那么就需要写入新元素,再修改末端元素的指向。假如链表中冲突元素已经较多,定位到末端元素也是个费劲的事,还不如现在这种方案,确保每次只影响两个地方即可。


查找

查找过程比较简单,流程如下:首先根据key计算hash code,即zend_string->hnTableMask计算得到的nIndex。然后根据nIndex从映射表查找到链表头元素位置idx。从idx取出第一个Bucket,开始与key进行对比,判断是否是需要查找的元素,如果是就终止遍历,否就继续根据zval.u2.next往下查找。。

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