[译] PHP7 数组:HashTable
简介
几乎每个C程序中都会使用到哈希表。鉴于C语言只允许使用整数作为数组的键名,PHP 设计了哈希表,将字符串的键名通过哈希算法映射到大小有限的数组中。这样无法避免的会产生碰撞,PHP 使用了链表解决这个问题。
众多哈希表的实现方式,无一完美。每种设计都着眼于某一个侧重点,有的减少了 CPU 使用率,有的更合理地使用内存,有的则能够支持线程级的扩展。
实现哈希表的方式之所以存在多样性,是因为每种实现方式都只能在各自的关注点上提升,而无法面面俱到。
数据结构
开始介绍之前,我们需要事先声明一些事情:
- 哈希表的键名可能是字符串或者是整数。当是字符串时,我们声明类型为
zend_string
;当是整数时,声明为zend_ulong
。 - 哈希表的顺序遵循表内元素的插入顺序。
- 哈希表的容量是自动伸缩的。
- 在内部,哈希表的容量总是2的倍数。
- 哈希表中每个元素一定是
zval
类型的数据。
以下是 HashTable 的结构体:
struct _zend_array {
zend_refcounted_h gc;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar nApplyCount,
zend_uchar nIteratorsCount,
zend_uchar reserve)
} v;
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket *arData;
uint32_t nNumUsed;
uint32_t nNumOfElements;
uint32_t nTableSize;
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};
这个结构体占56个字节。
其中最重要的字段是 arData
,它是一个指向 Bucket
类型数据的指针,Bucket
结构定义如下:
typedef struct _Bucket {
zval val;
zend_ulong h; /* hash value (or numeric index) */
zend_string *key; /* string key or NULL for numerics */
} Bucket;
Bucket
中不再使用指向一个 zval
类型数据的指针,而是直接使用数据本身。因为在 PHP7 中,zval
不再使用堆分配,因为需要堆分配的数据会作为 zval
结构中的一个指针存储。(比如 PHP 的字符串)。
下面是 arData
在内存中存储的结构:
我们注意到所有的Bucket都是按顺序存放的。
插入元素
PHP 会保证数组的元素按照插入的顺序存储。这样当使用 foreach
循环数组时,能够按照插入的顺序遍历。假设我们有这样的数组:
<?php
$a = [9 => "foo", 2 => 42, []];
var_dump($a);
array(3) {
[9]=>
string(3) "foo"
[2]=>
int(42)
[10]=>
array(0) {
}
}
所有的数据在内存上都是相邻的。
这样做,处理哈希表的迭代器的逻辑就变得相当简单。只需要直接遍历 arData
数组即可。遍历内存中相邻的数据,将会极大的利用 CPU 缓存。因为 CPU 缓存能够读取到整个 arData
的数据,访问每个元素将在微妙级。
size_t i;
Bucket p;
zval val;
for (i=0; i < ht->nTableSize; i++) {
p = ht->arData[i];
val = p.val;
/* do something with val */
}
如你所见,数据被顺序存放到 arData
中。为了实现这样的结构,我们需要知道下一个可用的节点的位置。这个位置保存在数组结构体中的 nNumUsed
字段中。
每当添加一个新的数据时,我们保存后,会执行 ht->nNumUsed++
。当 nNumUsed
值到达哈希表所有元素的最大值(nNumOfElements
)时,会触发“压缩或者扩容”的算法。
以下是向哈希表插入元素的简单实现示例:
idx = ht->nNumUsed++; /* take the next avalaible slot number */
ht->nNumOfElements++; /* increment number of elements */
/* ... */
p = ht->arData + idx; /* Get the bucket in that slot from arData */
p->key = key; /* Affect it the key we want to insert at */
/* ... */
p->h = h = ZSTR_H(key); /* save the hash of the current key into the bucket */
ZVAL_COPY_VALUE(&p->val, pData); /* Copy the value into the bucket's value : add operation */
我们可以看到,插入时只会在 arData
数组的结尾插入,而不会填充已经被删除的节点。
删除元素
当删除哈希表中的一项元素时,哈希表不会自动伸缩实际存储的数据空间,而是设置了一个值为 UNDEF 的 zval
,表示当前节点已经被删除。
如下图所示:
因此,在循环数组元素时,需要特殊判断空节点:
size_t i;
Bucket p;
zval val;
for (i=0; i < ht->nTableSize; i++) {
p = ht->arData[i];
val = p.val;
if (Z_TYPE(val) == IS_UNDEF) { /* empty hole ? */
continue; /* skip it */
}
/* do something with val */
}
即使是一个十分巨大的哈希表,循环每个节点并跳过那些删除的节点也是非常快速的,这得益于 arData
的节点在内存中存放的位置总是相邻的。
哈希定位元素
当我们得到一个字符串的键名,我们必须使用哈希算法计算得到哈希后的值,并且能够通过哈希值索引找到 arData
中对应的那个元素。
我们并不能直接使用哈希后的值作为 arData
数组的索引,因为这样就无法保证元素按照插入顺序存储。
举个例子:如果我插入的键名先是 foo,然后是 bar,假设 foo 哈希后的结果是5,而bar 哈希后的结果是3。如果我们将 foo 存在 arData[5]
,而 bar 存在 arData[3]
,这意味着 bar 元素要在 foo 元素的前面,这和我们插入的顺序正好是相反的。
所以,当我们通过算法哈希了键名后,我们需要一张 转换表,转换表保存了哈希后的结果与实际存储的节点的映射关系。
这里在设计的时候取了个巧:将转换表存储以 arData
起始指针为起点做镜面映射存储。这样,我们不需要额外的空间存储,在分配 arData
空间的同时也分配了转换表。
以下是有8个元素的哈希表 + 转换表的数据结构:
现在,当我们要访问 foo 所指的元素时,通过哈希算法得到值后按照哈希表分配的元素大小做取模,就能得到我们在转换表中存储的节点索引值。
如我们所见,转换表中的节点的索引与数组数据元素的节点索引是相反数的关系,nTableMask
等于哈希表大小的负数值,通过取模我们就能得到0到-7之间的数,从而定位到我们所需元素所在的索引值。综上,我们为 arData
分配存储空间时,需要使用tablesize * sizeof(bucket) + tablesize * sizeof(uint32) 的计算方式计算存储空间大小。
在源码里也清晰的划分了两个区域:
#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))
#define HT_DATA_SIZE(nTableSize) ((size_t)(nTableSize) * sizeof(Bucket))
#define HT_SIZE_EX(nTableSize, nTableMask) (HT_DATA_SIZE((nTableSize)) + HT_HASH_SIZE((nTableMask)))
#define HT_SIZE(ht) HT_SIZE_EX((ht)->nTableSize, (ht)->nTableMask)
Bucket *arData;
arData = emalloc(HT_SIZE(ht)); /* now alloc this */
我们将宏替换的结果展开:
(((size_t)(((ht)->nTableSize)) * sizeof(Bucket)) + (((size_t)(uint32_t)-(int32_t)(((ht)->nTableMask))) * sizeof(uint32_t)))
碰撞冲突
接下来我们看看如何解决哈希表的碰撞冲突问题。哈希表的键名可能会被哈希到同一个节点。所以,当我们访问到转换后的节点,我们需要对比键名是否我们查找的。如果不是,我们将通过 zval.u2.next
字段读取链表上的下一个数据。
注意这里的链表结构并没像传统链表一样在在内存中分散存储。我们直接读取 arData
整个数组,而不是通过堆(heap)获取内存地址分散的指针。
这是 PHP7 性能提升的一个重要点。数据局部性让 CPU 不必经常访问缓慢的主存储,而是直接从 CPU 的 L1 缓存中读取到所有的数据。
所以,我们看到向哈希表添加一个元素是这样操作的:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
if (ht->nInternalPointer == HT_INVALID_IDX) {
ht->nInternalPointer = idx;
}
zend_hash_iterators_update(ht, HT_INVALID_IDX, idx);
p = ht->arData + idx;
p->key = key;
if (!ZSTR_IS_INTERNED(key)) {
zend_string_addref(key);
ht->u.flags &= ~HASH_FLAG_STATIC_KEYS;
zend_string_hash_val(key);
}
p->h = h = ZSTR_H(key);
ZVAL_COPY_VALUE(&p->val, pData);
nIndex = h | ht->nTableMask;
Z_NEXT(p->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);
同样的规则也适用于删除元素:
#define HT_HASH_TO_BUCKET_EX(data, idx) ((data) + (idx))
#define HT_HASH_TO_BUCKET(ht, idx) HT_HASH_TO_BUCKET_EX((ht)->arData, idx)
h = zend_string_hash_val(key); /* get the hash from the key (assuming string key here) */
nIndex = h | ht->nTableMask; /* get the translation table index */
idx = HT_HASH(ht, nIndex); /* Get the slot corresponding to that translation index */
while (idx != HT_INVALID_IDX) { /* If there is a corresponding slot */
p = HT_HASH_TO_BUCKET(ht, idx); /* Get the bucket from that slot */
if ((p->key == key) || /* Is it the right bucket ? same key pointer ? */
(p->h == h && /* ... or same hash */
p->key && /* and a key (string key based) */
ZSTR_LEN(p->key) == ZSTR_LEN(key) && /* and same key length */
memcmp(ZSTR_VAL(p->key), ZSTR_VAL(key), ZSTR_LEN(key)) == 0)) { /* and same key content ? */
_zend_hash_del_el_ex(ht, idx, p, prev); /* that's us ! delete us */
return SUCCESS;
}
prev = p;
idx = Z_NEXT(p->val); /* get the next corresponding slot from current one */
}
return FAILURE;
转换表和哈希表的初始化
HT_INVALID_IDX
作为一个特殊的标记,在转换表中表示:对应的数据节点没有有效的数据,直接跳过。
哈希表之所以能极大地减少那些创建时就是空值的数组的开销,得益于他的两步的初始化过程。当新的哈希表被创建时,我们只创建两个转换表节点,并且都赋予HT_INVALID_IDX
标记。
#define HT_MIN_MASK ((uint32_t) -2)
#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))
#define HT_SET_DATA_ADDR(ht, ptr) do { (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); } while (0)
static const uint32_t uninitialized_bucket[-HT_MIN_MASK] = {HT_INVALID_IDX, HT_INVALID_IDX};
/* hash lazy init */
ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)
{
/* ... */
ht->nTableSize = zend_hash_check_size(nSize);
ht->nTableMask = HT_MIN_MASK;
HT_SET_DATA_ADDR(ht, &uninitialized_bucket);
ht->nNumUsed = 0;
ht->nNumOfElements = 0;
}
注意到这里不需要使用堆分配内存,而是使用静态的内存区域,这样更轻量。
然后,当第一个元素插入时,我们会完整的初始化哈希表,这时我们才创建所需的转换表的空间(如果不确定数组大小,则默认是8个元素)。这时,我们将使用堆分配内存。
#define HT_HASH_EX(data, idx) ((uint32_t*)(data))[(int32_t)(idx)]
#define HT_HASH(ht, idx) HT_HASH_EX((ht)->arData, idx)
(ht)->nTableMask = -(ht)->nTableSize;
HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT));
memset(&HT_HASH(ht, (ht)->nTableMask), HT_INVALID_IDX, HT_HASH_SIZE((ht)->nTableMask))
HT_HASH
宏能够使用负数偏移量访问转换表中的节点。哈希表的掩码总是负数,因为转换表的节点的索引值是 arData
数组的相反数。这才是C语言的编程之美:你可以创建无数的节点,并且不需要关心内存访问的性能问题。
以下是一个延迟初始化的哈希表结构:
哈希表的碎片化、重组和压缩
当哈希表填充满并且还需要插入元素时,哈希表必须重新计算自身的大小。哈希表的大小总是成倍增长。当对哈希表扩容时,我们会预分配 arBucket
类型的C数组,并且向空的节点中存入值为 UNDEF 的 zval
。在节点插入数据之前,这里会浪费 (new_size - old_size) * sizeof(Bucket) 字节的空间。
如果一个有1024个节点的哈希表,再添加元素时,哈希表将会扩容到2048个节点,其中1023个节点都是空节点,这将消耗 1023 * 32 bytes = 32KB 的空间。这是 PHP 哈希表实现方式的缺陷,因为没有完美的解决方案。
编程就是一个不断设计妥协式的解决方案的过程。在底层编程中,就是对 CPU 还是内存的一次取舍。
哈希表可能全是 UNDEF 的节点。当我们插入许多元素后,又删除了它们,哈希表就会碎片化。因为我们永远不会向 arData
中间节点插入数据,这样我们就可能会看到很多UNDEF 节点。
举个例子来说:
重组 arData
可以整合碎片化的数组元素。当哈希表需要被重组时,首先它会自我压缩。当它压缩之后,会计算是否需要扩容,如果需要的话,同样是成倍扩容。如果不需要,数据会被重新分配到已有的节点中。这个算法不会在每次元素被删除时运行,因为需要消耗大量的 CPU 计算。
以下是压缩后的数组:
压缩算法会遍历所有 arData
里的元素并且替换原来有值的节点为 UNDEF。如下所示:
Bucket *p;
uint32_t nIndex, i;
HT_HASH_RESET(ht);
i = 0;
p = ht->arData;
do {
if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {
uint32_t j = i;
Bucket *q = p;
while (++i < ht->nNumUsed) {
p++;
if (EXPECTED(Z_TYPE_INFO(p->val) != IS_UNDEF)) {
ZVAL_COPY_VALUE(&q->val, &p->val);
q->h = p->h;
nIndex = q->h | ht->nTableMask;
q->key = p->key;
Z_NEXT(q->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(j);
if (UNEXPECTED(ht->nInternalPointer == i)) {
ht->nInternalPointer = j;
}
q++;
j++;
}
}
ht->nNumUsed = j;
break;
}
nIndex = p->h | ht->nTableMask;
Z_NEXT(p->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
p++;
} while (++i < ht->nNumUsed);
结语
到此,PHP 哈希表的实现基础已经介绍完毕,关于哈希表还有一些进阶的内容没有翻译,因为接下来我准备继续分享 PHP 内核的其他知识点,关于哈希表感兴趣的同学可以移步到原文。
原文地址
http://jpauli.github.io//2016/04/08/hashtables.html
来源:http://joshuais.me/yi-php7-shu-zu-hashtable/
PHP 内核分析:变量
变量
变量的数据结构
变量保存在 zval
的结构体中(与 PHP5 相同,但数据结构做了很大改变)。zval
结构体定义在 Zend/zend_types.h
文件中,结构体如下:
typedef struct _zval_struct zval;
struct _zval_struct {
zend_value value; /* value */
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type, /* active type */
zend_uchar type_flags,
zend_uchar const_flags,
zend_uchar reserved) /* call info for EX(This) */
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* literal cache slot */
uint32_t lineno; /* line number (for ast nodes) */
uint32_t num_args; /* arguments number for EX(This) */
uint32_t fe_pos; /* foreach position */
uint32_t fe_iter_idx; /* foreach iterator index */
uint32_t access_flags; /* class constant access flags */
uint32_t property_guard; /* single property guard */
} u2;
};
zval
的结构体中有3个属性:
- zend_value 保存变量的值。
- u1 保存变量的类型和是否为用户定义的常量等信息。
- u2 保存变量的额外信息(遇到具体场景再详细说明属性的含义)。
这里可以看到设计上与 PHP5 相比本质的区别在于减少了
zval
结构体所占的空间大小。我们来做一个简单的计算,zend_value
结构体占用内存为8个字节(详情在后续章节里展开说明),u1
占用了4个字节,u2
占用了4个字节,总共为8 + 4 + 4 = 16字节。相比较 PHP5 中变量需要占用48个字节,减少了有2/3之多。这样的优化,对于 PHP7 的效率提升起到了关键的作用。
变量的值
结构体中的 zend_value
属性保存了变量的值,同样在 Zend/zend_types.h
文件中定义:
typedef union _zend_value {
zend_long lval; /* long value */
double dval; /* double value */
zend_refcounted *counted;
zend_string *str;
zend_array *arr;
zend_object *obj;
zend_resource *res;
zend_reference *ref;
zend_ast_ref *ast;
zval *zv;
void *ptr;
zend_class_entry *ce;
zend_function *func;
struct {
uint32_t w1;
uint32_t w2;
} ww;
} zend_value;
zend_value
是一个联合体,不同变量类型的值保存在不同的属性中。从联合体的定义可以看出,除了整型和双精度类型数据,其他的数据都以指针的方式存储在联合体中。这样做使得 zend_value
联合体的大小仅仅为8个字节。同时对于整型和双精度数据则不使用额外的指针,减少了这两种类型数据读取时的操作,算是平衡了存储和计算资源的一种设计。
变量的类型
zval.u1.v.type
属性表示变量的类型,常规的数据类型定义如下:
#define IS_UNDEF 0
#define IS_NULL 1
#define IS_FALSE 2
#define IS_TRUE 3
#define IS_LONG 4
#define IS_DOUBLE 5
#define IS_STRING 6
#define IS_ARRAY 7
#define IS_OBJECT 8
#define IS_RESOURCE 9
#define IS_REFERENCE 10
/* constant expressions */
#define IS_CONSTANT 11
#define IS_CONSTANT_AST 12
/* fake types */
#define _IS_BOOL 13
#define IS_CALLABLE 14
#define IS_ITERABLE 19
#define IS_VOID 18
/* internal types */
#define IS_INDIRECT 15
#define IS_PTR 17
#define _IS_ERROR 20
通过 Z_TYPE
宏我们可以能容易的获取到变量的类型。
static zend_always_inline zend_uchar zval_get_type(const zval* pz) {
return pz->u1.v.type;
}
/* we should never set just Z_TYPE, we should set Z_TYPE_INFO */
#define Z_TYPE(zval) zval_get_type(&(zval))
这里建议设置变量类型时使用 Z_TYPE_INFO
,我们先看下 Z_TYPE_INFO
的定义:
#define Z_TYPE_INFO(zval) (zval).u1.type_info
#define Z_TYPE_INFO_P(zval_p) Z_TYPE_INFO(*(zval_p))
#define ZVAL_UNDEF(z) do { \
Z_TYPE_INFO_P(z) = IS_UNDEF; \
} while (0)
Z_TYPE_INFO
表示 zval.ui.type_info
。这样设置类型的时候,不需要对它的字段分别赋值,而是可以统一赋值:
zval1.u1.type_info = zval2.u1.type_info
就相当于如下序列赋值:
zval1.u1.v.type = zval2.u1.v.type
zval1.u1.v.type_flags = zval2.u1.v.type_flags
zval1.u1.v.const_flags = zval2.u1.v.const_flags
zval1.u1.v.reserved = zval2.u1.v.reserved
定义 zval.u1
需要 ZEND_ENDIAN_LOHI_4
宏,目的是保证在大端或小端的机器上,定义的字段都按照一样的顺序排列,保证了上面统一赋值的设计的可移植型。
变量的类型
前面,我们介绍了变量的类型的数据结构,接下来,我们来看看一些特殊的变量类型的设计。
引用类型
相较于 PHP5,引用类型的变量设计方式进行了比较大的改动。
通常的变量,根据写时拷贝的原则,当变量发生改变之前,我们会将指向相同值的变量拷贝分离。但对于引用类型来说,这样做肯定是不正确的,因为我们希望引用同一个值的变量同时发生改变。在 PHP5 的内核中,通过 is_ref
标志位表示一个变量是否是一个引用类型,通过下面的例子,我们可以看到引用在 PHP5 中是如何工作的:
<?php
$a = []; // $a -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[])
$b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1])
这样设计最大的问题在于,没有办法把同一个数据在引用和非引用的变量上共享。
<?php
$a = []; // $a -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b = $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
$c = $b // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1(value=[])
$d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
// $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[])
// 因为 is_ref 不同,这个时候虽然 $c/$d 的值还是和 $a/$b 相同,但我们不得不拷贝一份 HashTable_2
到了 PHP7 时代,引用有了自己独立的类型和数据结构:
struct _zend_reference {
zend_refcounted_h gc;
zval val;
};
之前的简单例子在 PHP7 下是这样工作的:
<?php
$a = []; // $a -> zend_array_1(refcount=1, value=[])
$b =& $a; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[])
$b[] = 1; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[1])
我们再来看看同一个数据在引用和非引用变量之间是如何做到共享的:
<?php
$a = []; // $a -> zend_array_1(refcount=1, value=[])
$b = $a; // $a, $b, -> zend_array_1(refcount=2, value=[])
$c = $b // $a, $b, $c -> zend_array_1(refcount=3, value=[])
$d =& $c; // $a, $b -> zend_array_1(refcount=3, value=[])
// $c, $d -> zend_reference_1(refcount=2) ---^
// 创建引用变量的时候,不需要再复制原始的数据了,只需要创建一个引用类型数据并且指向原始数据就可以了
$d[] = 1; // $a, $b -> zend_array_1(refcount=2, value=[])
// $c, $d -> zend_reference_1(refcount=2) -> zend_array_2(refcount=1, value=[1])
// 只有值发生改变时,才拷贝一份新数据,符合写时拷贝的原则
总结一下,PHP7 相较之前的设计,最大的收益在于可以在引用和非引用的变量之间共享真实的数据,从而真正做到了写时拷贝。
数组类型
PHP 的数据是通过 HashTable 实现,关于 PHP7 的 HashTable 的设计,我已经在一篇译文里 [译] PHP7 数组:HashTable 里详细描述了,就不在此展开说明了。
对象类型
我们还是首先看下在 PHP5 时代,对象是如何实现的。那时,对象被存储在_zend_object_value
结构体中:
typedef struct _zend_object_value {
zend_object_handle handle;
const zend_object_handlers *handlers;
} zend_object_value;
其中,handle
是一个可以在全局的对象池里索引到指定对象的唯一标识。handlers
是个包含了多个指针函数的结构体,这些指针函数包含对对象属性的操作。
然而,对象真正的数据并没有直接保存在该结构体里,而需要通过全局的对象池中索引到。对象真正的数据存储在 _zend_object
结构体中:
typedef struct _zend_object {
zend_class_entry *ce;
HashTable *properties;
zval **properties_table;
HashTable *guards;
} zend_object;
其中,ce
是对象所对应的类的结构,properties
和 properties_table
都是存放对象的属性,只是访问的方式不同。
之前说到对象被存储在对象池中,通过 EG(objects_store)
宏访问。对象池的作用是缓存生成的对象,方便对象的复用,节省内存的开销。对象池的存储结构为zend_objects_store
结构体,如下:
typedef struct _zend_objects_store {
zend_object_store_bucket *object_buckets;
zend_uint top;
zend_uint size;
int free_list_head;
} zend_objects_store;
typedef struct _zend_object_store_bucket {
zend_bool destructor_called;
zend_bool valid;
union _store_bucket {
struct _store_object {
void *object;
zend_objects_store_dtor_t dtor;
zend_objects_free_object_storage_t free_storage;
zend_objects_store_clone_t clone;
const zend_object_handlers *handlers;
zend_uint refcount;
gc_root_buffer *buffered;
} obj;
struct {
int next;
} free_list;
} bucket;
} zend_object_store_bucket;
这个结构体包含了很多信息。其中,_store_object
中的 object
属性就是指向真正对象的指针。
PHP7 的设计,致力于减少重复的引用计数,减少内存使用和减少间接的访问。基于以上思想,zend_object
的设计如下:
struct _zend_object {
zend_refcounted gc;
uint32_t handle;
zend_class_entry *ce;
const zend_object_handlers *handlers;
HashTable *properties;
zval properties_table[1];
};
可以看到,对象不再需要 _zend_object_value
结构体作为访问的外壳了,减少了对内存多次访问。同时,对象池被“架空”了(这里的架空指的是对象池依然存在,但并不在访问对象时使用)。
这里说明两个问题。
第一,为什么对象池没有被移除而是保留下来。因为,在一次请求处理完成后,PHP 会进行 SHUTDOWN 的处理流程,这时,我们需要对所有本次请求中创建但没有主动注销的对象执行析构处理。这时,对象池就提供了一个方便处理的数据集合。
第二,_zend_object
为什么还保留了可以索引到对象池的 handle
。因为对象池依然被使用,创建对象时需要在对象池中创建对象,同时,主动注销对象当然也需要通过 handle
找到并维护对象池中的数据。
总结一下,PHP7 相较之前的设计,减少了重复的引用计数(refcount),并且内存使用更小了(只需要40字节用于存储对象和对象的每个属性16字节的容量),取消了访问对象池,将之前需要4次内存的访问,减少到可以1次内存直接获取到的对象。
结束语
从开题到完成这篇文章,其中断断续续持续了有一个多月的时间。除了最近有些懈怠之外,最大的原因莫过于开始的时候把题目定的过于庞大。
PHP 的变量几乎贯穿于整个 PHP 内核的设计,从变量的数据结构设计,到变量的生命周期,到变量的类型转换,几乎每项内容可以总结出一两篇文字。开篇的时候没有把主旨和结构设计好,导致写的过程感觉不是思路断片,就是整体产生不平衡的感觉。直到这两天,重新整理了一遍思路,才决定只保留变量的数据结构和特殊的类型变量的基础设计内容,旨在将内核中的变量以什么样的方式存在这件事讲明白。至于和变量相关其他的内容,还是放在特定的命题下展开说明比较合适。
另外,上一篇总结 HashTable 的文章,是翻译了国外优秀的文章。虽然,译文也需要思考和考证,也是收获颇丰,但终究不是按照自己的思路探究事物,少了很多自己的思考。所以,这次先确定了架构和问题,然后找寻答案,再把答案消化后转换为文字总结于此。如果其中我的理解有所偏差,欢迎发现问题的你留言交流。
最后的最后,能把这篇文字写完的感觉,就像雾霾过后迎来久别的晴朗。回顾了一下,觉得写得还是极其简陋(笑)。ANYWAY,人生需要里程碑,完成不代表结束,而是一次新的迭代的开始。
参考文献
Internal value representation in PHP 7 - Part 1
Internal value representation in PHP 7 - Part 2
来源:http://joshuais.me/php7-nei-he-fen-xi-bian-liang-de-she-ji/
PHP 内核分析:类与对象
类是什么,什么是对象,相信不需要我在这里解释。本文也不是要说什么OO思想,而是想探究一个问题。作为 PHP 底层的实现 C 语言,是一个面向过程的语言,C 语言是如何构建出可以使用类与对象的 PHP(PHP 绝对称不上是面向对象的语言),这就是本文探讨的重点。为了方便查阅,也方便说明,以下所涉及的源码和实现均来源于 PHP7,后面所有出现的 PHP 均表示 PHP7 版本。
类
通常,我们概念中,类是一种抽象,类似于 C 语言中的结构体,本身称不上是一个实体。但在 PHP 的实现中,为了赋予不同的类特定的行为和数据结构,类不得不作为一个实体存在。
类的数据结构
在 PHP 的源码里,类是承载在 zend_class_entry
数据结构上的。
struct _zend_class_entry {
char type;
zend_string *name;
struct _zend_class_entry *parent;
int refcount;
uint32_t ce_flags;
int default_properties_count;
int default_static_members_count;
zval *default_properties_table;
zval *default_static_members_table;
zval *static_members_table;
HashTable function_table;
HashTable properties_info;
HashTable constants_table;
union _zend_function *constructor;
union _zend_function *destructor;
union _zend_function *clone;
union _zend_function *__get;
union _zend_function *__set;
union _zend_function *__unset;
union _zend_function *__isset;
union _zend_function *__call;
union _zend_function *__callstatic;
union _zend_function *__tostring;
union _zend_function *__debugInfo;
union _zend_function *serialize_func;
union _zend_function *unserialize_func;
zend_class_iterator_funcs iterator_funcs;
/* handlers */
zend_object* (*create_object)(zend_class_entry *class_type);
zend_object_iterator *(*get_iterator)(zend_class_entry *ce, zval *object, int by_ref);
int (*interface_gets_implemented)(zend_class_entry *iface, zend_class_entry *class_type); /* a class implements this interface */
union _zend_function *(*get_static_method)(zend_class_entry *ce, zend_string* method);
/* serializer callbacks */
int (*serialize)(zval *object, unsigned char **buffer, size_t *buf_len, zend_serialize_data *data);
int (*unserialize)(zval *object, zend_class_entry *ce, const unsigned char *buf, size_t buf_len, zend_unserialize_data *data);
uint32_t num_interfaces;
uint32_t num_traits;
zend_class_entry **interfaces;
zend_class_entry **traits;
zend_trait_alias **trait_aliases;
zend_trait_precedence **trait_precedences;
union {
struct {
zend_string *filename;
uint32_t line_start;
uint32_t line_end;
zend_string *doc_comment;
} user;
struct {
const struct _zend_function_entry *builtin_functions;
struct _zend_module_entry *module;
} internal;
} info;
};
可以看到,类的属性繁多,这里我不会一一展开说明。可以看到的是,它会占用不小的内存,具体是多少呢?568字节,这其中还不包括类定义的属性和方法所占用的内存。
因此,PHP 类是一个相对比较重的实体,之所以设计的重,也是为了减少真正的对象实体所消耗的资源,毕竟相对于对象,定义的类的数量要少的多。
接口和类有什么区别
接口、traits、类本质上都是一样的,都是基于上文描述的 zend_class_entry
结构。接口的行为和类很相似,只是在使用上有一些限制。比如,不能定义属性,在编译的阶段特殊处理一下,直接禁止接口使用诸如 static_members_table
这样的字段即可。
因此,接口和 traits 的开销与类差不多,我们可以通过以下的方式来验证:
<?php
$class = <<<'CL'
interface Bar { }
CL;
$m = memory_get_usage();
eval($class);
echo memory_get_usage() - $m . "\n"; /* 912 bytes */
继承是如何实现的
继承提供了一种明确表述共性的方法,是一个新类从现有的类中派生的过程。 继承产生的新类继承了原始类的特性,新类称为原始类的派生类(或子类), 而原始类称为新类的基类(或父类)。
对于 PHP 而言,实现继承的关键点就在于将父类的属性和方法复制给子类。这个过程被放在了编译阶段。具体实现方法可以查看 Zend/zend_inheritance.c 中的zend_do_inheritance()
函数。
ZEND_API void zend_do_inheritance(zend_class_entry *ce, zend_class_entry *parent_ce)
{
zend_property_info *property_info;
zend_function *func;
zend_string *key;
if (UNEXPECTED(ce->ce_flags & ZEND_ACC_INTERFACE)) {
/* Interface can only inherit other interfaces */
if (UNEXPECTED(!(parent_ce->ce_flags & ZEND_ACC_INTERFACE))) {
zend_error_noreturn(E_COMPILE_ERROR, "Interface %s may not inherit from class (%s)", ZSTR_VAL(ce->name), ZSTR_VAL(parent_ce->name));
}
} else if (UNEXPECTED(parent_ce->ce_flags & (ZEND_ACC_INTERFACE|ZEND_ACC_TRAIT|ZEND_ACC_FINAL))) {
/* Class declaration must not extend traits or interfaces */
if (parent_ce->ce_flags & ZEND_ACC_INTERFACE) {
zend_error_noreturn(E_COMPILE_ERROR, "Class %s cannot extend from interface %s", ZSTR_VAL(ce->name), ZSTR_VAL(parent_ce->name));
} else if (parent_ce->ce_flags & ZEND_ACC_TRAIT) {
zend_error_noreturn(E_COMPILE_ERROR, "Class %s cannot extend from trait %s", ZSTR_VAL(ce->name), ZSTR_VAL(parent_ce->name));
}
/* Class must not extend a final class */
if (parent_ce->ce_flags & ZEND_ACC_FINAL) {
zend_error_noreturn(E_COMPILE_ERROR, "Class %s may not inherit from final class (%s)", ZSTR_VAL(ce->name), ZSTR_VAL(parent_ce->name));
}
}
...
}
多态是如何实现的
至于多态,是调用同一个方法,对于不同的对象能够产生不同的行为,往往会定义一个接口规范具体实现类需要实现的方法:
<?php
interface Animal {
public function run();
}
class Dog implements Animal {
public function run() {
echo 'dog run';
}
}
class Cat implements Animal{
public function run() {
echo 'cat run';
}
}
class Context {
private $_animal;
public function __construct(Animal $animal) {
$this->_animal = $animal;
}
public function run() {
$this->_animal->run();
}
}
在 PHP 的实现中,核心点在于传入方法的参数可以通过指定接口或者任意级的父类来限制。在 PHP 的设计中,这个语法称之为类型提示。相较于标准类型,对象的类型提示的判断需要额外处理一些逻辑,实现代码在 Zend/zend_execute.c 中:
static zend_always_inline int zend_verify_arg_type(zend_function *zf, uint32_t arg_num, zval *arg, zval *default_value, void **cache_slot)
{
...
if (UNEXPECTED(!zend_check_type(zf, cur_arg_info, arg, &ce, cache_slot, default_value, 0))) {
zend_verify_arg_error(zf, cur_arg_info, arg_num, ce, arg);
return 0;
}
return 1;
}
static zend_always_inline zend_bool zend_check_type(
const zend_function *zf, const zend_arg_info *arg_info,
zval *arg, zend_class_entry **ce, void **cache_slot,
zval *default_value, zend_bool is_return_type)
{
...
ZVAL_DEREF(arg);
if (EXPECTED(arg_info->type_hint == Z_TYPE_P(arg))) {
if (arg_info->class_name) {
if (EXPECTED(*cache_slot)) {
*ce = (zend_class_entry *) *cache_slot;
} else {
*ce = zend_verify_arg_class_kind(arg_info);
if (UNEXPECTED(!*ce)) {
return 0;
}
*cache_slot = (void *) *ce;
}
if (UNEXPECTED(!instanceof_function(Z_OBJCE_P(arg), *ce))) {
return 0;
}
}
return 1;
}
...
}
可以看到,如果参数类型被定义为类或者接口,则调用 zend_verify_arg_class_kind()
函数获取对应的类数据,然后调用 instanceof_function()
函数。此函数首先会遍历实例所在类的所有接口,递归调用其本身,判断实例的接口是否为指定类的实例。
对象
谈到对象,其实与 PHP 的变量的设计息息相关。在 PHP7 内核分析:变量的设计 一文中已经略有提及,由于篇幅原因,有些问题没有展开说明,那就在本篇里补全吧。
对象的数据结构
还是要把 PHP 对象的数据结构说明一下:
struct _zend_object {
zend_refcounted_h gc;
uint32_t handle;
zend_class_entry *ce;
const zend_object_handlers *handlers;
HashTable *properties;
zval properties_table[1]; /* C struct hack */
};
这个结构体包含以下几个属性:
ce
是指向上一章节我们提到的类数据的指针。handle
在 PHP7 内核分析:变量的设计 的关于对象池的概念中已经说明了,内容比较多,不再次展开说明。properties
用于存储定义在类中的属性。properties_table
用于存储动态定义的属性。(这里还利用了 C 语言的 struct hack 的 trick)handlers
指向了标准对象处理函数std_object_handlers
。gc
记录了当前对象被引用的状态,用于垃圾回收机制处理。
对象是怎样创建的
创建对象的过程分成两个步骤:
<?php
class A {
}
$a = new A();
我们通过 VLD 可以看到,创建对象的操作产生了两个 OPCODE:
Finding entry points
Branch analysis from position: 0
Jump found. Position 1 = -2
filename: /Users/joshua/Programs/local/test/php/opcode/object.php
function name: (null)
number of ops: 5
compiled vars: !0 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > NOP
7 1 NEW $2 :-6
2 DO_FCALL 0
3 ASSIGN !0, $2
4 > RETURN 1
branch: # 0; line: 3- 7; sop: 0; eop: 4; out1: -2
path #1: 0,
Class A: [no user functions]
第一步,初始化对象变量。通过 zend_fetch_class()
获取存储类的变量,然后调用zend_objects_new()
(代码位于 Zend/zend_objects.c) 函数为对象分配内存空间。
ZEND_API zend_object *zend_objects_new(zend_class_entry *ce)
{
zend_object *object = emalloc(sizeof(zend_object) + zend_object_properties_size(ce));
zend_object_std_init(object, ce);
object->handlers = &std_object_handlers;
return object;
}
第二步,调用类定义的构造函数,其调用和其它的函数调用是一样。
对象方法调用的过程是怎样的
我们来看下对象在调用方法的时候的处理逻辑:
<?php
class A {
public function do() {
echo 'test';
}
}
$a = new A();
$a->do();
同样使用 VLD 查看产生的 OPCODE:
Finding entry points
Branch analysis from position: 0
Jump found. Position 1 = -2
filename: /Users/joshua/Programs/local/test/php/opcode/object.php
function name: (null)
number of ops: 7
compiled vars: !0 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > NOP
10 1 NEW $2 :-6
2 DO_FCALL 0
3 ASSIGN !0, $2
11 4 INIT_METHOD_CALL !0, 'do'
5 DO_FCALL 0
6 > RETURN 1
branch: # 0; line: 3- 11; sop: 0; eop: 6; out1: -2
path #1: 0,
Class A:
Function do:
Finding entry points
// 省略调用对象方法产生的 OPCODE
...
可以看到相对于调用函数,在调用对象方法之前,会执行 ZEND_INIT_METHOD_CALL
操作。这个操作主要为了处理 __call()
和 __callStatic()
魔术方法,以及验证方法的调用权限,以及是否为静态调用等。
对象的方法调用与非对象的方法调用基本无异,唯一的区别在于可以使用 $this
变量获取到当前所在对象。因此,在 ZEND_INIT_METHOD_CALL
操作中,最后初始化调用栈的时候会将当前对象传入。
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_METHOD_CALL_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
...
call = zend_vm_stack_push_call_frame(call_info,
fbc, opline->extended_value, called_scope, obj);
call->prev_execute_data = EX(call);
EX(call) = call;
ZEND_VM_NEXT_OPCODE();
}
最终在 Zend/zend_execute.h 文件的 zend_vm_init_call_frame()
的函数被设置到调用上下文数据中,供后续流程使用。
static zend_always_inline void zend_vm_init_call_frame(zend_execute_data *call, uint32_t call_info, zend_function *func, uint32_t num_args, zend_class_entry *called_scope, zend_object *object)
{
call->func = func;
if (object) {
Z_OBJ(call->This) = object;
ZEND_SET_CALL_INFO(call, 1, call_info);
} else {
Z_CE(call->This) = called_scope;
ZEND_SET_CALL_INFO(call, 0, call_info);
}
ZEND_CALL_NUM_ARGS(call) = num_args;
}
结语
PHP 向我们展示了,如何用面向过程的语言实现一门支持类与对象的语言。其设计的思路和方法,大有我们学习和参考的价值,有兴趣的同学可以就感兴趣的问题再深入挖掘,也欢迎大家和我分享交流。
参考文献
Zoom on PHP objects and classes (PHP 5)
来源:http://joshuais.me/php-lei-yu-dui-xiang-2/