本文翻译自 nikic 的一篇博文。

PHP中的数组到底占用多大的空间?

简要:这篇文章我并不是按照原文逐字逐句的都翻译过来,其中略去了一些与本文知识点无关的内容,加入了一些个人理解,不过版权还是归原作者所有。文章主要讨论的是 PHP5.x 中的内存使用,当然在新版本PHP7.x 中内存的占用这里也有一些提及,内存占用情况大约是本文所提PHP5的1/3。更多的信息可以参考我翻译的另一篇文章 -- PHP7中hashtable的实现

本文中所有的知识介绍和内容总结都基于下面的实际案例。

构造一个含有100000个不重复的整型元素的数组并且测量其占用的内存数量,实现代码如下:

// 记录开始内存状态
$startMemory = memory_get_usage();
// 生成包含1-100000为数据元素的数组
$array = range(1, 100000);
// 获取数组占用的内存空间大小(单位字节:bytes)
echo memory_get_usage() - $startMemory, ' bytes';

那么现在你能计算出上面的数组占用内存空间大小吗?如果你有c语言基础,那么你该知道在c语言中,一个整型数据(在64位机器上使用长整型来表示)大小是 8 bytes,那么上面的数组中包含 100000 个整型元素,就意味着实际占用内存是 800000 bytes,大约是 0.76 MBs, 但实际的结果是这样吗?

现在保存并运行一下上面的代码,运行结果是 14649024 bytes ,大约是 13.97 MBs ! 没错,这个大约是我们上面计算结果的 18.3 倍!那么这超出的17.3倍多的空间从哪儿来的呢?

分析及总结

下面是数组构成元素中一部分主要元素所占内存大小的表格:

元素名                        |  64 bit   | 32 bit
---------------------------------------------------
zval                         |  24 bytes | 16 bytes
+ cyclic GC info             |   8 bytes |  4 bytes
+ allocation header          |  16 bytes |  8 bytes
===================================================
zval (value) total           |  48 bytes | 28 bytes
===================================================
bucket                       |  72 bytes | 36 bytes
+ allocation header          |  16 bytes |  8 bytes
+ pointer                    |   8 bytes |  4 bytes
===================================================
bucket (array element) total |  96 bytes | 48 bytes
===================================================
total total                  | 144 bytes | 76 bytes

上面的相关数字依赖于你所使用的操作系统,你的编译器甚至你的编译配置,不同的环境可能会有不同的结果。例如,如果你编译PHP源码时开启了调试(debug)模式或者线程安全(thread-safety),都会得到不同的结果。

我的环境 : 普通的64位机器,linux系统,PHP5.3!

上面得到在最终结果是数组的每个元素占用内存的大小是 144 bytes 。如果我们用数组元素的个数 100000 乘以这里的 144 bytes 得到的结果是 14400000 bytes ,大约是 13.73 MBs 。这个结果就与我们的真实测试结果非常接近了!当然并不完全一致!还有些空间大多数是数组中的 哈希表中用到与 buckets 相关的指针域空间, 这个我们马上就会讲!

OK,咱们继续分析 :)

zvalue_value 共用体(union)

首先让咱们看看 PHP 是如何存储值的!如你所知,PHP是一种弱类型的语言,但弱类型不代表没类型,况且PHP是由C构建的,一种强类型的语言构建了一种弱类型的语言,所以 PHP 内部肯定有自动快速定位数据类型的相关方法!

在 PHP 语言文件 zend.h 的大约 307 行 定义了如下的 共用体(numion)类型变量:

typedef union _zvalue_value {
    long lval;                // 整型 和 布尔类型
    double dval;              // 浮点类型 (doubles)
    struct {                  // 字符串
        char *val;            //     存储字符串值
        int len;              //     字符串长度
    } str;
    HashTable *ht;            // 数组 (hash tables)
    zend_object_value obj;    // 对象类型
} zvalue_value;

如果你没有C语言基础,也没关系,共用体概念很容易理解:一个共用体意味着一种将各种数据类型组合在一起的一种方案!你可以理解为PHP中的类!
共用体的使用和类也很相似!在这里,如果你使用了 zvalue_value->lval ,实际上你就是用了一个整型的数据!入果你使用 zvalue_value->ht 那就相当于使用了一个指向某个 hashtable (实际上是array) 的指针.

当然,实在搞不懂也没事儿!在这里你需要记住一件事儿就行了!就是共用体(nuion)中的所有成员共同使用一块内存,所以一个共用体的大小等于其元素中最大的那个元素的大小!!!

上面的共用体中最大的元素是 字符串结构体(也理解为PHP中的类吧 >_<)

struct {                  // 字符串
    char *val;            //     存储字符串值
    int len;              //     字符串长度
} str;

和对象类型

zend_object_value obj;    // 对象类型

它们两个一样大,不过我们主要关注其中一个就好了,比如 位置更靠上的字符串结构体!

在这里字符串结构体存储了一个指针( 8 bytes )和一个整型数据( 4 bytes ), 一共是 12 bytes 大小!但是分配该变量的内存大小一定是其中最大的元素的整数倍,所以实际上这个结构体占用了 16 bytes 的内存大小。而又由于该结构体是所在共用体中最大的,所以这个共用体的大小也是 16 bytes 。

现在我们知道了PHP中每个元素存储值要用 16 bytes , 那么我们一共有100000个元素,所以应该是一共是 1600000 bytes,大约是 1.53 MBs 。 但是实际大小是 13.97 MBs, 所以应该还有额外的我们没研究到的!

所以,继续研究!!!>_

zval 结构体(struct)

上面我们谈到的共用体 zvalue_value, 只是用来存储变量的值的,但是不要忘了,PHP也必须知道一个变量的其它相关信息,比如变量的类型、变量的垃圾回收信息等等!PHP要做到这些是通过一个叫做 zval 的结构体来完成的!当然有些人或许早就听过这个东东!^_^。

zval定义如下:

struct _zval_struct {
    zvalue_value value;     // 存储值
    zend_uint refcount__gc; // 变量的引用计数器 (for GC 垃圾销毁用到)
    zend_uchar type;        // 变量类型
    zend_uchar is_ref__gc;  // 变量是否是被引用 (&)
};

一个结构体struct的大小等于它含有的元素大小之和

zvalue_value : 16 bytes
zend_uint : 4 bytes
zend_uchars : 1 byte
is_ref__gc : 1 byte

一共是 22 bytes。但是会分配8的倍数也就是 24 bytes 给这个结构体!

所以现在我们有100000个元素,大小一共就是 100000乘以24 等于 2400000 bytes!大约是2.29 MB。差距变小了,但是距离真实的值仍然有很大空间!

垃圾回收器 : cycles collector (as of PHP 5.3)

PHP 5.3 提供了一个新的循环垃圾回收器。为了完成这件事儿,PHP必须存出一些额外的数据。大家可以点击连接到官方手册了解更多信息,这里不是我们的重点,俺就不多讲啦!在这里我们要知道的是 PHP 会记录每一个 zbal 到 一个新的 zval_gc_info 结构体中作为记录。

zval_gc_info 代码定义如下:

typedef struct _zval_gc_info {
    zval z;
    union {
        gc_root_buffer       *buffered;
        struct _zval_gc_info *next;
    } u;
} zval_gc_info;

对比 zval 的实现,PHP实现了的 zval_gc_info 仅仅是增加了 一个共用体(union), 共用体包又含了两个指针!而对于共用体而言,它的大小等于包含元素中最大的那个:在这里两个元素都是指针,所以两个元素大小相同都是 8

那么现在我们再加上 zval 的大小 24 bytes,每个元素的大小 就已经变成了 32 bytes。 再次乘以 100000 个元素, 得到的内存大小是 3200000 bytes, 大约是 3.05 MB。不过还是差得远!

继续努力!ヾ(◍°∇°◍)ノ゙

Zend 控制内存分配器 : Zend MM allocator

C 语言中我们要自己完成内存的申请和释放,但是 PHP 可以自动帮你控制内存分配和回收操作。为了完成这项工作,PHP使用了在传统内存控制器的基础上经过了优化的一个内存控制器 : Zend内存控制器(Zend Memory Manager), 简称为 Zend MM。

Zend MM 是在一个名为 Doug Lea's 内存分配器的基础上添加了一些 PHP 的优化和特点(例如内存限制,请求完成后及时回收等等) 来构建的。

在这里我们要关心的是 MM 会在每一次分配完成之后会为每一个 分配空间 添加一个 分配头信息(allocation header)。

具体的代码定义如下:

typedef struct _zend_mm_block {
    zend_mm_block_info info;
#if ZEND_DEBUG                      // 开启调试
    unsigned int magic;
# ifdef ZTS                         // 开启多线程
    THREAD_T thread_id;
# endif
    zend_mm_debug_info debug;
#elif ZEND_MM_HEAP_PROTECTION       // 开启堆保护
    zend_mm_debug_info debug;
#endif
} zend_mm_block;


typedef struct _zend_mm_block_info {
#if ZEND_MM_COOKIES     // 开启 MM cookies     
    size_t _cookie;
#endif
    size_t _size;       // 分配空间的大小
    size_t _prev;       // 上一块空间
} zend_mm_block_info;

上面的代码中你会发现有一堆编译参数的检查!如果你开启了堆保护(heap protection)、多线程(multi-threading)、调试(debug) 或者 MM cookies,那么在生成分配头信息是就要大得多(有可能很大)!

本例中我们假设所有的配置项都是关闭的。那么剩下的就只有两个 size_ts 类型的变量 : _size 和 _prev。 一个 size_t 的大小是 8 bytes (64 位机器上), 所以 分配头大小一共是 16 bytes , 并且每一块儿分配空间上都有一个分配头信息。

那么现在我们又要重新更新我们的 zval 大小了。加上了分配头信息之后的 zval 的大小就是 48 bytes 了。再次乘以 100000 个元素结果是 4.58 MB, 真实的情况是 13.97 MB,我们计算的值已经是真实值的 1/3 了。

ok, 继续 ヾ(◍°∇°◍)ノ゙

Buckets

目前位置我们只考虑了值的存储。但是PHP中的数组数据结构也要占用很多的空间。所实话,PHP中的所谓“数组”实际上并不是纯粹的数组!而是“哈希表”或者说是“字典”。那么PHP中的哈希表是如何工作的呢?其实PHP底层是使用C语言实现的,所以这里的哈希表采用了和C中哈希表数据结构相似的做法!每一个元素被创建都会对应着一个哈希表存储在C构建的数组中,并且如果发生了“冲突”(具有相同哈希值的元素指向同一块数组地址)就会使用双向链表来解决! 当要访问一个元素时,PHP首先计算散列值找到对应的 bucket,然后遍历链表,一个一个元素的比较关键值。

bucket 的代码定义如下(可以查看 zend_hash.h#54):

typedef struct bucket {
    ulong h;                  // 哈希表
    uint nKeyLength;          // 字符串key的长度 
    void *pData;              // 实际的数据
    void *pDataPtr;           
    struct bucket *pListNext; // PHP 数组是有序的. 找到序列中的下一个
    struct bucket *pListLast; // 数组序列中的上一个
    struct bucket *pNext;     // 双向链表中元素的下一个元素
    struct bucket *pLast;     // 双向链表元素的上一个元素
    const char *arKey;        // 字符串key 
} Bucket;

分析完上面的代码你会发现 PHP 中的“数组”实际上使用了一种经过抽象的类似数组的数据结构来存储数据(PHP数组既是数组,又是字典还是链表,当然要用很多信息啦>_<)。

现在让我们统计一下一个 bucket 的大小。无符号长整型(ulong)占用8 bytes,无符号整型(uint) 占用 4 bytes,再加上7个指针,每个指针占用 8 bytes, 一共是 68 bytes,要对齐为 8的倍数, 所以一个 bucket 占用 72 bytes。

Buckets 就像 zvals 一样分配空间也要加上分配头信息, 所以一个 bucket 需要额外的 16 bytes, 就变成了 88 bytes. 再加上在C数组中要存储指向这些 buckets 的指针(Bucket **arBuckets;) , 每个元素还要再加上 8 bytes。所以每一个 bucket 一共需要 96 bytes 空间来存储。

所以,如果每个值都要一个bucket, 那么一个bucket要96 bytes,一个zval要 48 bytes, 一共是 144 bytes!100000个元素就是 14400000 bytes,即 13.37 MB。

谜题解开了!

哎,等等,还少 0.24 MB 呢!!!

剩余的 0.24 MB 是未初始化的 buckets 的空间 : C语言的数组中存储buckets的空间在理想状态下与存储的数组元素的数量大致相同。这种方法可以尽量减少数据的“碰撞”(除非你想浪费更多空间)。但是 PHP 并不会每次添加元素都要重新分配内存,如果每次都重新分配就效率太低了!PHP 采用的方案是当元素达到数组大小的边界时就将数组大小扩展一倍!所以数组的容量总是2的n次方。

按照上面的分析,那么我们需要100000个空间,但实际上数组拥有的容量是 2^17 = 131072。那么这些buckets并不会完全初始化(所以我们没有必要完全花费掉 96 bytes),但是bucket指针的内存空间(bucket内的)仍然会被初始化。所以剩余的31072个没被使用的数组空间每个元素仍然占用了 8 bytes。一共是248576 bytes,大约是 0.23 MB。正好是多出来的 0.23 MB 空间!(不过还是少一些空间的,例如哈希表本身也要花一部分,数组背身也要占一部分空间,等等)。

这次,所有谜题全部解开了!

我们从中有什么收获呢?

PHP 不是 C 语言,所以你就别期望一门动态类型的语言例如 PHP 能够像 C 语言那样可以高效的使用内存了!

但是如果在PHP中你想更加高效的使用内存,有一种更好的方案推荐 : 使用 SplFixedArray 构建大的,静态的数组!

下面有一个关于 SplFixedArray 的测试样例:

$startMemory = memory_get_usage();
$array = new SplFixedArray(100000);
for ($i = 0; $i < 100000; ++$i) {
    $array[$i] = $i;
}
echo memory_get_usage() - $startMemory, ' bytes';

上面的代码实际上和我们之前做了一样的事 : 创建了一个包含100000个不重复元素的数组。但是我们运行这段代码,你会发现这个数组仅仅占用了 5600640 bytes!这是因为对于上面我们创建的是静态数组,静态数组不需要 bucket 结构!所以这个数组仅仅消耗掉的空间有 : 每个元素占用了一个 zval(48 bytes)和一个指针(8 bytes),一共 56 bytes!

所以如果你明确知道数组的长度或者需要一个很大的数组空间,使用 SplFixedArray 是个不错的选择!