小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
前言
相对于 C、C++、Java 等其他编程语言,PHP 是一个弱类型的语言,意味着当我们要使用一个变量时,不需要去声明它的类型。这个特性给我们带来了很多遍历。同时有时也会带来一些陷阱。那么,PHP 是真的没有数据类型这种说法吗?
当然不是,在 PHP 官方文档中,将 PHP 中的变量分为三类:标量数据类型、复杂数据类型 和 特殊数据类型。
-
4 种标量数据类型:
-
布尔型(Bool)
-
整型(Int)
-
浮点型(Float)
-
字符串类型(String)
-
-
2 种复杂数据类型:
-
数组类型(Array)
-
对象类型(Object)
-
-
2 种特殊数据类型:
-
资源类型(Resource)
-
NULL
-
而 PHP 又是由 C 语言编写,PHP 脚本会经过 Zend 引擎解析为 C 代码再执行。那么 PHP 变量在 C 语言中是如何定义的呢?
答案是 zval,不管是什么类型的变量,在 PHP 源码中统一用一个叫做 zval 的结构表示。zval 可以看做是 PHP 变量在 C 代码中的容器,它存储了这个变量的值、类型等相关信息。
接下来我们就来看看 PHP5 中的 zval 的基本结构
PHP5 中 zval 基本结构
zval
zval 的定义在 zend.h 中,我们以 PHP5.6.30 zend.h 为例,看一下 zval 结构定义:
struct _zval_struct {
/* Variable information */
zvalue_value value; /* 变量值 */
zend_uint refcount__gc; /* 引用计数 */
zend_uchar type; /* 变量类型 */
zend_uchar is_ref__gc; /* 是否被引用 */
};
我们可以看到,在 PHP 源码中,用一个结构体来表示变量,结构体成员有 4 个,分别代表变量的值、引用计数、变量类型、是否被引用。
zvalue_value value
变量值定义如下(使用联合体定义,联合体的特征是一次只有一个成员是有效地并且分配的内存与需要内存最多的成员匹配):
typedef union _zvalue_value {
long lval; /* 用于 bool 类型、整型、资源类型 */
double dval; /* 用于浮点类型 */
struct { /* 用于字符串类型 */
char *val;
int len;
} str;
HashTable *ht; /* 用于数组类型 */
zend_object_value obj; /* 用于对象类型 */
zend_ast *ast; /* 用于常量表达式(PHP5.6 才有) */
} zvalue_value;
虽然 PHP 有八种数据类型,但是在 _zvalue_value 联合体只有 5 种类型,这是为什么呢?
这是因为 bool 类型、整型、资源类型都使用 lval 字段存储,bool 类型用 1、0 来表示是或非,资源类型存储的是资源 ID。PHP 通过复用字段达到了减少字段的目的。
那么第八种 NULL 类型怎么存呢?如果所有字段全部置为 0 或 NULL 则表示 PHP 中的 NULL。
这样我们就用 5 个字段表示了 8 种数据类型。
另外注意一点,PHP 中数组底层数据结构其实是哈希表。
zend_uchar type
zend_uchar type 字段存储的是数据类型,在 zend 内部,对应的是下面定义的宏。
/* data types */
/* All data types <= IS_BOOL have their constructor/destructors skipped */
#define IS_NULL 0
#define IS_LONG 1
#define IS_DOUBLE 2
#define IS_BOOL 3
#define IS_ARRAY 4
#define IS_OBJECT 5
#define IS_STRING 6
#define IS_RESOURCE 7
#define IS_CONSTANT 8
#define IS_CONSTANT_AST 9
#define IS_CALLABLE 10
zend_uint refcount__gc
refcount__gc 存储该 zval 引用计数的数量。
zend_uchar is_ref__gc
is_ref__gc 表示该 zval 是否被引用。
PHP5 中 zval 存在的问题
这部分内容主要来源于 深入理解PHP7之zval。为了能够让大家更好的理解下 php5 中 zval 的一些问题,因此将 深入理解PHP7之zval 文章中的部分内容复制过来。
1. 结构体大小
首先我们来计算一下 zval 结构体的大小。
int 类型的 refcount__gc 占用 4 字节,is_ref__gc 和 type 分别占用 1 字节。
接下来我们再来计算一下 value 字段的大小。联合体占用字节等于其中最大的元素。
其中:
-
str:8 + 4 = 12字节。 -
lval:4字节。 -
dval:8字节。 -
*ht:8字节。 -
*ast:8字节。 -
obj:???
zend_object_value 我们无法直接看出来大小,我们在 Zend/zend_types.h 找到了它的定义,如下:
typedef unsigned int zend_object_handle;
typedef struct _zend_object_value {
zend_object_handle handle;
const zend_object_handlers *handlers;
} zend_object_value;
我们可以得知,zend_object_value 占用 12 字节。
由于 内存对齐原则,联合体 _zvalue_value 占用 16 字节。
因此,zval 占用的字节大小为 16 + 4 + 1 + 1 = 22 字节,由于内存对齐,所以占用 24 字节。
对于一个整型来说,没有必要占用这么多字节。
因此我们可以优化一下结构体,比如可以把 zend_object_value 进行优化,该字段导致 _zvalue_value 占用 16 个字节。
我们可以把它挪出来,用个指针代替,因为毕竟 IS_OBJECT 也不是最最常用的类型。
2. 扩展性
zval 这个结构体中,每个字段都有明确的含义,没有预留任何的自定义字段。导致在 PHP5 时代做很多优化的时候,需要存储一些和 zval 相关信息的时候,不得不采用其他结构体映射,或者外部包装后打补丁的方式来扩充 zval。比如 PHP5.3 的时候新引入专门解决循环引用的 GC,它不得不采用如下的比较 hack 的做法:
/* The following macroses override macroses from zend_alloc.h */
#undef ALLOC_ZVAL
#define ALLOC_ZVAL(z) \
do { \
(z) = (zval*)emalloc(sizeof(zval_gc_info)); \
GC_ZVAL_INIT(z); \
} while (0)
它用 zval_gc_info 劫持了 zval 的分配。
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,所以实际上来说我们在 PHP5 时代申请一个 zval 其实真正的分配了 32 个字节,但其实 GC 只需要关心 IS_ARRAY 和 IS_OBJECT 类型,这样就导致了大量的内存浪费。
3. 引用传递导致的 GC 问题
PHP 的 zval 大部分都是按值传递,写时拷贝的值,但是有俩个例外,就是对象和资源,他们永远都是按引用传递,这样就造成一个问题,对象资源在除了 zval 中的引用计数意外,还需要一个全局的引用计数,这样才能保证内存可以回收,所以在 PHP5 的时代,以对象为例,它有俩套引用计数,一个是 zval 中的,另外一个是 obj 自身的计数:
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;
除了上面提到的两套引用以外,如果我们要获取一个 object,则我们需要通过如下方式:
EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj
经过漫长的多次内存读取,才能获取到真正的 object 对象本身。效率可想而知。
4. 字符串复制、查找
我们知道 PHP 中,大量的计算都是面向字符串的,然而因为引用计数是作用在 zval 的,那么就会导致如果要拷贝一个字符串类型的 zval,我们别无他法只能复制这个字符串。当我们把一个 zval 的字符串作为 key 添加到一个数组里的时候,我们别无他法只能复制这个字符串。虽然在 PHP5.4 的时候,我们引入了 INTERNED STRING,但是还是不能根本解决这个问题。
还有,PHP 中大量的结构体都是基于 Hashtable 实现的,增删改查 Hashtable 的操作占据了大量的 CPU 时间,而字符串要查找首先要求它的 Hash 值,理论上我们完全可以把一个字符串的 Hash 值计算好以后,就存下来,避免再次计算等等。
5. 引用
在 PHP5 中,采用写时分离,但是结合到引用这里就有了一个经典的性能问题:
<?php
function dummy($array) {}
$array = range(1, 100000);
$b = &$array;
dummy($array);
?>
当我们调用 dummy 的时候,本来只是简单的一个传值就行的地方,但是因为 b,所以导致 $array 变成了一个引用,因此此处就会发生分离,导致数组赋值,从而极大的拖慢性能,这里有一个简单的测试:
<?php
$array = range(1, 100000);
function dummy($array) {}
$i = 0;
$start = microtime(true);
while($i++ < 100) {
dummy($array);
}
printf("Used %sS\n", microtime(true) - $start);
$b = &$array; //注意这里, 假设我不小心把这个Array引用给了一个变量
$i = 0;
$start = microtime(true);
while($i++ < 100) {
dummy($array);
}
printf("Used %sS\n", microtime(true) - $start);
?>
我们在 5.6 下运行这个例子,得到如下结果:
$ php-5.6/sapi/cli/php /tmp/1.php
Used 0.00045204162597656S
Used 4.2051479816437S
相差 1 万倍之多,这就造成,如果在一大段代码中,我不小心把一个变量变成了引用(比如 foreach as &$v),那么就有可能触发到这个问题,造成严重的性能问题,然而却又很难排查。
6. MAKE_STD_ZVAL/ALLOC_ZVAL(最重要)
这一点是最重要的一个,为什么说它重要呢?因为这点促成了很大的性能提升,我们习惯了在 PHP5 的时代调用 MAKE_STD_ZVAL 在堆内存上分配一个 zval,然后对他进行操作,最后呢通过 RETURN_ZVAL 把这个 zval 的值“copy”给 return_value,然后又销毁了这个 zval,比如 pathinfo 这个函数:
PHP_FUNCTION(pathinfo)
{
.....
MAKE_STD_ZVAL(tmp);
array_init(tmp);
.....
if (opt == PHP_PATHINFO_ALL) {
RETURN_ZVAL(tmp, 0, 1);
} else {
.....
}
这个 tmp 变量,完全是一个临时变量的作用,我们又何必在堆内存分配给它呢?MAKE_STD_ZVAL/ALLOC_ZVAL 在 PHP5 的时候,到处都有,是一个非常常见的用法,如果我们能把这个变量用栈分配,那无论是内存分配,还是缓存友好,都是非常有利的。