技术分享之PHP5内存使用分析

本篇文章粗略翻译自nikic大作,内容根据自己的情况做了一些增删。作者是php开发组成员,从源码里面也可以看到他的影子。

nikic

目标

  • 了解简单PHP代码内存组成情况
  • 简单介绍HashTable与Bucket的结构
  • 对PHP的一些实现细节(垃圾回收、内存分配)的确认

实验环境

  • MacOs,x86_64
  • PHP 5.6.36,编译选项
    1
    ./configure --disable-all --prefix=$HOME/php_no_debug

php5的内存使用分析

demo

1
2
3
$startMemory = memory_get_usage();
$array = range(1, 100000);
echo (memory_get_usage() - $startMemory) / 1024 / 1024, ' m';

在64位环境下输出大概占用13.97M。

下面一步一步的分析都有什么占用了内存。

整型占用的资源

当前的linux都是LP64模型,在该模型下,int占用4字节,long int 8字节(不确定可以用c代码的sizeof看一下)。即使我们知道整型存储的是long int,那内存占用也仅有这么一点:

1
echo 100000 * 8 / 1024 /1024, ' m';

约为:0.76M。

zvalue_value union

PHP为了实现弱类型,内部使用了联合体来存储内容,所以不能单独算int的大小,而且要考虑上联合体的大小,联合体结构如下:

1
2
3
4
5
6
7
8
9
10
11
typedef union _zvalue_value {
long lval; /* long value */
double dval; /* double value */
struct {
char *val;
int len;
} str;
HashTable *ht; /* hash table value */
zend_object_value obj;
zend_ast *ast;
} zvalue_value;

除了zend_object_value都能直接看出来大小,在Zend/zend_types.h找到他的定义如下,可知占用12字节。

1
2
3
4
5
6
typedef unsigned int zend_object_handle;

typedef struct _zend_object_value {
zend_object_handle handle;
const zend_object_handlers *handlers;
} zend_object_value;

联合体占用字节等于其中最大的元素,也就是上面的str或者zend_object_value结构体,共计12字节,再根据内存对齐原则补充为16字节。

所以现在的大小是:

1
echo 100000 * 16 / 1024 / 1024, ' m';

约为:1.53M。

zval struct

再往下推导就是zval了,union用来储存实际值,但是外层都会包裹着zval,其中有type字段来获得变量类型。zval格式如下:

1
2
3
4
5
6
7
8
9
10
11
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};

// zend_types.h
typedef unsigned char zend_uchar;
typedef unsigned int zend_uint;

结构体占用的空间是所有元素的总和,所以这个结构体的大小为:16 + 4 + 1 + 1 = 22字节,同样内存对齐之后是24字节。

所以现在的大小是:

1
echo 100000 * 24 / 1024 / 1024, ' m';

约为:2.29M。

垃圾回收

在php5.3之前的垃圾回收机制不能处理循环引用内存泄露的问题,如下php代码输出2,代表清除的根缓冲区中的可能根,如果去掉unset($a)就是0,因为没有内存泄露产生。

1
2
3
4
5
6
7
<?php
$a = array('one');
$a[] = &$a;
unset($a);

$b = gc_collect_cycles();
var_dump($b);

如果php只接收http请求,那么所有的空间会在请求结束后被释放,但是随着php应用越来越广泛,对于长期运行的脚本,如果这种情况发生还多次,占用的空间就很可观了。

所以5.3之后引入了新的垃圾回收算法,为了实现新的算法,必须添加一些额外的字段,也即所有的zval都会被下面的结构体包裹:

1
2
3
4
5
6
7
typedef struct _zval_gc_info {
zval z;
union {
gc_root_buffer *buffered;
struct _zval_gc_info *next;
} u;
} zval_gc_info;

这个格式大小是:24 + 8 = 32字节,现在的大小是:

1
echo 100000 * 32 / 1024 / 1024, ' m';

约为:3.05M。

Zend MM allocator

php不像c一样需要手动释放内存,所以为了达到这个目的,需要添加一些额外的字段,对我们来说比较重要的是每次分配内存完成都会有一个额外的分配头。结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;

去掉条件编译,也就是多了两个size_tsize_t在64位平台占8字节,总共为:32 + 8 + 8 = 48字节,所以现在大小是:

1
echo 100000 * 48 / 1024 / 1024, ' m';

约为:4.58M。

Bucket

但是我们上面考虑的都出单独的元素,而示例中的代码是一个数组,所以就涉及到bucket结构,如下:

1
2
3
4
5
6
7
8
9
10
11
typedef struct bucket {
ulong h; /* Used for numeric indexing */
uint nKeyLength;
void *pData;
void *pDataPtr;
struct bucket *pListNext;
struct bucket *pListLast;
struct bucket *pNext;
struct bucket *pLast;
const char *arKey;
} Bucket;

大小总共为:8(ulong) + 4(uint) + 7 * 8(pointer) = 68字节,加上内存对齐的原因,共计:72字节。
除此之外还需要添加上前面说的内存分配头16字节,共计88字节。
再之后还得再添加一个bucket的引用指针,也即HashTable结构里面的Bucket **arBuckets,占用8字节。所以最终是:88 + 8 = 96字节。

每个zval对应一个bucket,每个bucket对应数组的一个元素。,所以zval与bucket的总大小是:96 + 48 = 144字节。那么最终的大小就是:

1
echo 100000 * 144 / 1024 / 1024, ' m';

约为:13.73M。

到此为止,我们的分析和最开始的13.97M基本匹配了。

这里简单介绍下bucket中的元素的含义:
h:当数组索引为数字的时候,存放在这里。同时arKey为NULL,nKeyLength为0。
arKey:索引为字符串的时候,存放在这里。同时nKeyLength为字符串的长度,harKey经过hask函数处理后的数字。
四个bucket指针构成了两个双向链表,pListNextpListLast构成的是数组元素之间的双向链表,用来维护数组元素顺序。pNextpLast构成的是hash冲突时候的双向链表(因为php使用拉链法来解决hash冲突)。
pDatapDataPtr是最不好理解的,两者的关系是pData = &pDataPtr
我理解的是pData直接存储了zval的地址,而pDataPtr存储的是值的地址的指针。因为当一个值是地址的指针,查找值的时候需要这样:*(*pData),这样就需要两次寻址,而如果把这个指针放在pDataPtr,虽然同样需要两次寻址,但是*pData与bucket是存储在一起的,同样根据局部性原理,效率会好一些。

剩余的空间

但是等等,好像还差了0.24M。

这部分内容是未初始化的bucket,当然最理想的情况是用多少分配多少,同时能保证hash冲突的情况最小。但是频繁的分配内存效率很低,所以就涉及到bucket的分配算法。简单讲为:分配的大小是2的几次幂,并且是大于数组的元素个数的最小值。

2^16 < 100000 < 2^17,所以共计分配了2^17次方个bucket,去掉使用了的100000,剩余的为31072个bucket,这些bucket不需要占用96字节,但是bucket里面需要保存指向他们的指针,也即8字节。所以这部分的大小是:

1
echo 31072 * 8 / 1024 / 1024, ' m';

约为:0.24M。

Unbelievable,这个过程分析完了,数据也对上了。但是这里面还有一些误差,比如hashtable分配之类的,就不继续深入了。

结论

放一下原文的结论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                             |  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

思考题

如果demo代码改成这样,占用内存为55.93m

  1. 去掉rand元素后的内存会变成多少?为什么?
  2. 去掉rand元素后跟demo相比又差了什么?
1
2
3
4
5
6
7
8
9
10
$startMemory = memory_get_usage();
$array = array();
for ($i = 0; $i < 100000; $i++) {
$array[] = array(
$i,
rand(0, 10000),
);
}

echo (memory_get_usage() - $startMemory) / 1024 / 1024, ' m';

答:

  1. 这种情况如果去掉rand元素,也就是数组里面少了一个bucket元素,一个bucket元素为72字节,添加上分配头16字节,再添加上zval,共计72 + 16 + 48 = 136字节,这里不需要添加8字节的指针是因为这个指针是内层数组共用的。所以算下来就是:
1
echo 136 * 100000 / 1024 / 1024, ' m';

约为:12.97M。

  1. 去掉rand跟demo相比相差的是:demo为一位数组,这个题为二维数组,所以相比demo多了x个hashtable,每个hashtable下又多了一个bucket,bucket又多关联了一个zval。

总结下来就是:

1
2
demo:bucket -> zval
本题:bucket -> zval -> hashtable -> bucket -> zval

注:按这个分析进行计算,内存使用量并不匹配,可能涉及到多维数组资源分配的问题。

HashTable

顺带介绍一下php5的hashtable的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct _hashtable {
uint nTableSize;
uint nTableMask;
uint nNumOfElements;
ulong nNextFreeElement;
Bucket *pInternalPointer; /* Used for element traversal */
Bucket *pListHead;
Bucket *pListTail;
Bucket **arBuckets;
dtor_func_t pDestructor;
zend_bool persistent;
unsigned char nApplyCount;
zend_bool bApplyProtection;
#if ZEND_DEBUG
int inconsistent;
#endif
} HashTable;

arBuckets是动态变更大小的c数组。其中元素个数为nNumOfElements,也是php代码count($arr)的返回值。
nTableSizearBuckets数组的长度,为2的次幂。
nNextFreeElementarBuckets连续的下一个可用的key。调用$a[] = xx的时候会使用该值。
pListHeadpListTail是双向链表的头和尾,指向arBucketspInternalPointer代表遍历的时候current值。
pDestructor是一个函数指针,跟析构函数一样,做一些资源回收之类的工作。
persistent是一个标识,用来区分该变量是否持久化分配,大多数情况都是0,通常也应该在请求结束后释放资源。在之前分享php扩展的时候,php常量的创建好像也有一个类似的标识。
bApplyProtection都是指定hashtable是否做递归保护,默认是1,也就是启用的,当启用的时候,递归深度达到某一值就会报错。而递归的深度就存储在nApplyCount中。

着重介绍一下nTableSizenTableMask的关系,php的hash函数返回的是ulong类型,通常要比nTableSize(uint)大很多,所以需要做一个映射,也即取模操作key = h % nTableSize。根据上面的介绍可知nTableSize是2的次幂,所以h % nTableSize等于h & (nTableSize - 1),因为nTableSize - 1保留了nTableSize的所有低位1(如下代码段),按位与的效果与取模效果一样,但是后者的效率要高于前者,所以php为了性能优化,把nTableSize - 1存储在nTableMask中。

1
2
nTableSize     = 128 = 0b00000000.00000000.00000000.10000000
nTableSize - 1 = 127 = 0b00000000.00000000.00000000.01111111

拓展

如果需要固定长度数组,可以用spl标准库里面提供的工具:

1
2
3
4
5
6
$startMemory = memory_get_usage();
$array = new SplFixedArray(100000);
for ($i = 0; $i < 100000; ++$i) {
$array[$i] = $i;
}
echo (memory_get_usage() - $startMemory) / 1024 / 1024, ' m';

约为:5.34M。

这是因为这种结构的数组每个元素只需要一个zval和一个指针,而不需要bucket结构,所以只占用48 + 8 = 56字节。

参考文献

(完)