浅析PHP数组的实现

一. PHP数组特点介绍

       php数组可谓是php的核心,其key=>value的存储结构,让我们处理数据可以游刃有余,并且在一般情况下增删查改都是O(1)的时间复杂度,并表现出一些特点让我们对其内部的结构充满了好奇。

「数组内存申请不需要自己控制」:数组的内存申请和释放是内部控制的,不需要人为的去干预。

「兼容关联数组和索引数组」:数组可以以数字为下标进行操作,也可以以字符串为下标进行操作,其实他俩本质上都是key=>value的格式。

「可以无key值存储」:像a[] = 10; 这样去存储一个value值,事实上他也是有key值的。

「不会出现数组越界的问题」:不会像我们操作C/C++等语言一样,要注意数组的边界,一定不能正向或者逆向越界。在php中不会有这些问题的存在。

「value值无严格的定义和统一」:这里就是php内部自己实现了一个范型。

「foreach遍历数组与插入顺序有关」:插入顺序和遍历输出一致。

二. PHP数组结构的演变

这个地方主要讲PHP5.6=>PHP7.0=>PHP7.1的演变过程

 

浅析PHP数组的实现

PHP5.6的数组结构

我们可以看到,数组本质就是一个hashtable结构,左侧的0~nTablemask便是hash下标,而后面有一个双向链表,便是我们通常所说的hash冲突的链地址法。

而绿色的双向链表,则是foreach遍历用的,这个地方用双向链表,主要是为了逆序访问来用的,遍历的时候,就是从pListHead开始不断的next便可以遍历所有的元素。这样要比下标遍历hashtable快得多。

但是这个结构有很明显的缺点:

①. 每次插入/删除元素都要申请/释放内存,这样会严重的导致内存碎片化,降低内存的使用率。

②. 再者,每次插入元素的时候都要去申请内存,会有一次寻址的过程,时间效率低。

③. 指针结构很复杂,有大量的指针操作。

 

浅析PHP数组的实现

PHP7.0的数组结构

针对上面5.6所有的问题,提出了如上的结构。arData结构便是我们插入数据的结构,是一段连续的内存,在初始化/数组扩充时一次性内存申请好,这样内存便不会产生碎片化问题,并且我们看,hash冲突的时候是单链表,并且没有插入元素时候的双向链表,因为我们可以直接扫描数组便可以顺序/逆序访问插入的数据。

 

浅析PHP数组的实现

PHP7.1的数组结构

如上是最新的数据结构,最主要的区别是把7.0的两段内存转化为一段内存,算是在内存上的继续优化。他的内存申请是从下标-8位置申请的,然后移动指针到key1位置。

三. PHP7.0数组源码实现

3.1 PHP7.0数组的主要数据结构

 

浅析PHP数组的实现

Bucket结构

Bucket结构便是我们所说的保存插入数据的结构。主要包括:key(字符串,如果是数字下标,转化位字符串), value, h(只会计算一次,如果是数组下标,直接把key作为h)。

 

浅析PHP数组的实现

PHP数组的结构

这便是PHP7.0的数组结构,我后面讲全部是用7.0结构讲,比较清晰。nNextFreeElement是无key值存储时使用的数据,用来分配key值。

3.2 PHP数组的hash方法

 

浅析PHP数组的实现

PHP hash源码

如上是php的hash源码,看一眼容易把人瞎蒙,然而把它简化一下便是:

 

浅析PHP数组的实现

PHP 源码转化

简单讲一下。其中源码中把 hash*33 转化为 (hash<<5) + hash

在一个就是,使用了循环展开策略,减少不必要的循环判断,在一个是连续的执行相同的指令,提高指令的cache命中率。可以看着:https://en.wikipedia.org/wiki/Loop_unrolling

那么计算出hash值来之后,并不是我们想要的下标,因为很有可能超过了hashmask值,所以需要一步计算来得到index。

•7.0:

hashmask = tablesize – 1;   //tablesize一定是2的指数倍,和申请内存有关系,后面讲

h = hashcode & hashmask;  //计算index

hashmask=7(10)=111(2)       //十进制下的7转为二进制下的111

0<=h<8                                 //所有值 & hashmask 一定在0~7之间

•7.1:

hashmask = -tablesize;

h = hashcode | hashmask;

hashmask=-8(10)=11111000(2补码)

-8<=h<=-1

7.1的结构因为是hash负下标,所以用的另一种计算策略,大同小异。

3.3 PHP数组插入数据

 

浅析PHP数组的实现

PHP插入数据的流程图

大致流程如上,看一下就好。讲一下插入数据的结构变化。

 

浅析PHP数组的实现

插入数据结构变化

在我们插入key6这个key值之后,在arData数据往后排,我们假设他与key1的hash冲突,所以使用链地址法,直接插入到链表首部。

3.4 PHP数组删除数据

在上面的结构,假如我们删除key1,会有两种考虑。

①. 删除该元素之后,我们是否要保证空间的利用率,把后面的数据移动,覆盖当前位置。

②. 删除元素之后,直接把该位置空出。

其实第一种策略是不可接受,因为这样会导致时间复杂度退化为O(n),而第二种策略,我们控制当前位置之后,我们可以在内存扩充时,把这些空位置给删除掉即可。

浅析PHP数组的实现

PHP数组删除数据

3.4 PHP数组内存申请及rehash

假设我们要在如下结构中插入key9

浅析PHP数组的实现

PHP 数组结构

发现当前arData已经满了,所以我们申请新内存(初始大小位2,以2的倍数不断扩充),把所有数据以内存的形式copy过去,然后扫描数组,把无用的数据给删除掉(用移动后面元素的策略)。因为nTableMask数值改变了然后进行rehash。

这里有一个点值得提一下:把原数据copy过去的时候,我们可以选择遍历原先的数组,把有用的数据copy过去,也可以先把原内存的数据复制到新内存的位置,然后去遍历数组去移动数据。PHP实现选择了第二种,这样考虑应该是我们删除元素的次数比较少,直接以内存的形式copy数据要比遍历copy的速度快,在权衡之下选择了第二种。

 

浅析PHP数组的实现

插入数据之后

结构变化如上图。

四. PHP数组的可优化点

4.1 PHP数组的退化案例

本案例参考的鸟哥的博客。

 

浅析PHP数组的实现

攻击代码

大概测了一下

浅析PHP数组的实现

运行结果

我们可以看到,时间效率差别很大。我们来分析一下为什么会这样。

•插入元素:key=0,1*65536,2*65536,….,65535*65536

•插入元素特点:h=key&65535=0,生成绝对的hash冲突导致退化1*****00000000&0****011111111=0

•大致的插入计算次数:1+2+3+4+5+…+65536(每次插入都会冲突,从而会去查询是否有相同的key值,所以会有扫描的次数总和如此)

•导致插入元素时间复杂度退化为O(n*(1+n)/2)=O(n^2)

那么65536^2在计算机1s处理10^8运算的情况下,大概36s以上,所以运行结果算是可预期的。

 

浅析PHP数组的实现

hash冲突的结构

4.2 可进行优化的策略

我们参考Java中hashtable的实现来使用红黑树解决这个问题。

•①.设定链表冲突长度的阀值

•②.当链表长度超过阀值的时候,以key值为键值进行红黑树的转化,key全是字符串,所以我们可以以key的字典序进行排序

•③.Hashtable的指针指向树根

•④.增删查改,不断对红黑树结构进行调整

•⑤.使增删查改时间复杂度达到最差为O(logn)

我们来看下结构的变化为:

 

浅析PHP数组的实现

加入红黑树后的结构变化

如果不了解红黑树,可以去参考一下《算法导论》中的详解

五. PHP数组的总结

「数组内存申请不需自己控制」

hashtable内存大小与arData大小相同,并且一定为2的指数倍不断扩展

「兼容关联数组和索引数组」

索引是hash值同时也是key值,转化为关联数组,关联数组用hashtable实现

「可以无索引(key值)存储」

无索引存储时,会自动为其分配最后一个没有被使用过的下标来作为key和hash值进行存储

「不会出现数组越界的情况」

索引即hash值,用无符号长整形存储,不会出现负向越界

索引的大小与内存的大小没有绝对的关系,不会出现正向越界

「value值无严格的定义或统一」

php对value值进行了范型处理

「foreach遍历数组与插入顺序有关」

foreach是对arData遍历,而arData是顺序存储的

用foreach比用for进行下标访问永远要快