PHP 5 中数组的内存消耗情况分析

270 阅读1分钟

  创建一个包含 100000 个整数的数组,每个元素都唯一:

$startMemory = memory_get_usage();
$array = range(1, 100000);
echo memory_get_usage() - $startMemory, ' bytes';

  理论上,在 64 位的机器上,每个整数长度为 8 bytes,100000 个整数所占的内存空间应该为 800000 bytes。但运行上述代码,发现实际的内存消耗为 14649024 bytes。

   那么,这些额外的内存空间到底是如何被消耗的呢

⒈ 联合体 zvalue_value

  PHP 是动态类型的语言,每个变量的数据类型只有到代码真正运行的时候才能确定。另外,在一个变量的生命周期中,变量的数据类型也会随时发生变化。所以,PHP 使用联合体来存储变量的值:

typedef union _zvalue_value {
    long lval;  // 存储整型、布尔、资源句柄
    double dval;  // 存储浮点
    struct {
        char *val;
        int len;
    } str;  // 存储字符串
    HashTable *ht;  // 存储数组
    zend_object_value obj;  // 存储对象
} zvalue_value;

typedef struct _zend_object_value {
    zend_object_handle handle;
    const zend_object_handlers *handlers;
} zend_object_value;
  • long 类型是一个有符号整型,不适合做位运算。在 32 位机器上,long 的长度为 4 bytes;在 64 位机器上,类 unix 系统的长度为 8 bytes,Windows 系统长度为 4 bytes。由于不同的平台 long 的长度不同,这就导致 long 类型在不同平台的上下限也不一样。所以,PHP 中 int 类型的上下限必须通过预定义常量 PHP_INT_MAX 和 PHP_INT_MIN 获取
  • double 类型按照 IEEE-754 的规范长度为 8 bytes
  • 结构体 str 存储了字符串值的指针,以及字符串的长度。指针的长度为 8 bytes,int 长度为 4 bytes,但考虑到内存对齐,在 64 位机器上 str 实际长度为 16 bytes
  • HashTable 存储的也是一个指针,长度为 8 bytes
  • zend_object_value 是一个结构体,其中 handle 是一个无符号整型的 ID,用于标识和获取真正的 object 数据;第二部分是一个指向对象处理程序结构的指针,这些处理程序定义了对象的实际行为。考虑到内存对齐,这个结构提的长度应该为 16 bytes。

   由于联合体的长度取决于其长度最大的成员的长度,综上所述,zvalue_value 的长度为 16 bytes。100000 个元素所占的内存空间应该为 1600000 bytes。

  1. 在 PHP 中,为了兼容库函数对字符串的处理方式,通常以空字符 ‘\0’ 作为字符串的结尾
  2. 为了允许 PHP 字符串中出现空字符 ‘\0’,PHP 的字符串需要额外记录字符串的长度
  3. PHP 的字符串长度以 byte 为单位,不包括结尾的空字符
  4. 记录 PHP 字符串长度的值的类型为 int,不是 long,所以 PHP 字符串能允许的最大长度为 2147483647 bytes

⒉ 结构体 zval

   zvalue_value 只是存储了变量的值,除此之外 PHP 还需要存储变量的类型以及与垃圾回收机制相关的信息,所以此处又有了 zval 结构体

typedef struct _zval_struct {
    zvalue_value value;  // 变量值
    zend_uint refcount__gc;  // 引用计数
    zend_uchar type;  // 变量类型
    zend_uchar is_ref__gc;  // 变量是否被引用
} zval;

  在 zval 结构体中,zvalue_value 长度 16 bytes,zend_int 长度 4 bytes,zend_uchar 长度 1 byte,所以整个 zval 结构体的长度为 22 bytes,考虑到内存对齐,实际长度应该为 24 bytes。

   这样,100000 个元素所占用的内存空间应该为 2400000 bytes。

⒊ 垃圾回收相关

   自 PHP 5.3 起,为了解决变量循环引用造成内存泄漏的问题,引入了新的 GC(垃圾回收)机制。为了适应新的 GC 机制,PHP 引入了新的结构体 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_gc_info 结构体中,zval 长度为 24 bytes,联合体 u 的长度为 8 bytes。所以,zval_gc_info 的长度为 32 bytes。

   此时,100000 个元素所占内存空间为 3200000 bytes。

⒋ zend memory manager (ZMM)

   PHP 为了对内存的分配、销毁等操作进行管理,自定义了内存管理模块 zend memory manager。凡是经过 ZMM 分配的内存,都会额外增加一个 header 来存储 ZMM 的相关信息。

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
    size_t _cookie;
#endif
    size_t _size;
    size_t _prev;
} zend_mm_block_info;

   ZMM 在编译的过程中会检查众多的选项,任何选项只要设置为TRUE,都会增加 header 的长度。假设所有的选项都为 FALSE,则 header 的长度应该为 16 bytes(size_t 的长度为 8 bytes)。

   这样,100000 个元素每个元素还需要额外增加 16 bytes 的 header,实际占用的内存空间为 4800000 bytes。

⒌ buckets

   C 语言中的数组索引必须是连续的整数,但 PHP 中的数组索引可以是整数、字符串甚至是二者同时存在。PHP 在底层使用 hashtable 来实现数组。

   在 PHP 数组中,如果索引为字符串则使用 hash 函数转换为整数;如果索引为整数,则不需要转换直接使用。这样就满足了底层 C 语言对数组索引的要求。由于理论上字符串的数量远大于程序所能表示的整数的数量,所以不同的字符串索引在转换成整数后可能会产生冲突。PHP 中使用链表来存储索引产生冲突的数组元素,链表中的每一项所使用结构成为 bucket:

typedef struct bucket {
    ulong h;
    uint nKeyLength;
    void *pData;
    void *pDataPtr;
    struct bucket *pListNext;
    struct bucket *pListLast;
    struct bucket *pNext;
    struct bucket *pLast;
    char *arKey;
} Bucket;

   在 bucket 中,h 用来存储数组 key 的 hash 结果,nKeyLength 用来存储数组 key 的长度。如果数组的 key 为整数,h 直接存储数组的 key,此时 nKeyLength 的值为 0;但如果数组的 key 为字符串,则 h 存储 key 的 hash 结果,nKeyLength 存储 key 的长度,arKey 存储数组 key 的地址。

   PHP 中引入了链表来解决 hash 碰撞的问题,为了记录 bucket 在链表中的未知,bucket 引入两个指针 pLast 和 pNext 分别记录链表中 bucket 的上一个元素和下一个元素。如果 bucket 目前已经是链表中的最后一个元素,则 pNext 为 NULL。

   同样,为了保证任何时候读取数组元素都是按照插入时的顺序返回,bucket 引入了另外两个指针 pListLast 和 pListNext 来记录数组插入时该元素的上一个元素以及下一个元素。

   pData 用来存储数组元素的值,但该值只是对数组元素中的值的复制。另外,如果数组元素是指针,则指针的值存入 pDataPtr 中,此时 pData 存储的是 pDataPtr 的地址。

   综上,考虑内存对齐,一个 bucket 的长度为 72 byttes。由于 bucket 的内存通过 ZMM 分配,故需要额外增加 16 bytes 的 header。另外,在底层 C 的数组中,每一项需要存储一个 bucket 链表的地址,所以又额外增加了 8 bytes。所以,bucket 的总长度为 96 bytes。

在本例中,数组 key 为整数且都唯一,所以不会产生 hash 冲突。这样,底层 C 数组中的元素个数与 PHP 数组的元素个数相同。

   由前述信息,一个 zval 的长度 48 bytes,一个 bucket 长度 96 bytes,总共 144 bytes。所以 100000 个元素所消耗的内存空间为 14400000 bytes。

⒍ 未初始化的 bucket

   为了提高效率,PHP 在初始化数组时 bucket 的数量为 8。当 PHP 数组中的元素个数到达这个限制时,PHP 会将 bucket 的数量翻倍。如此循环,所以 100000 个元素的数组实际分配的 bucket 的数量为 217=1310722^{17} = 131072 。这样,有 31072 个 bucket 因为没有值而没有被初始化,但这些 bucket 的地址仍然需要被记录,这就需要 8×31072=2485768 \times 31072 = 248576 bytes。

   这样,整个数组总共消耗的内存空间为 14648576 bytes。