这是一篇很久以前写的文章(大概是两年前),一直没发,可能囿于技术水平,有一些错误,已经草草修正了一些内容,如仍有写歪来的地方,欢迎拍砖。


我们每天都在和变量打交道,PHP的变量足够简单,当静态语言的初学者还在将类型推导(如C++写”auto foo = 1”将自动推导foo为int类型)惊奇不已的时候,动态语言的开发者早已习以为常了。

但天下没有白吃的午餐,变量使用起来越方便,背后的原理就越复杂,你以为你已经能将变量运用自如,但其实你只是个会开车的普通司机,你并不懂车。而你若想要成为PHP世界的专业级赛车手,赛车的每个零部件到组装到核心引擎的运转,你都必须了如指掌。

变量修饰符

说到PHP变量,我们首先会想到那个令开发者们又爱又恨的$符。不管是喜欢它还是讨厌它的开发者,可能都不是很清楚为什么要用$来表示变量。

很多人都知道PHP很多的设计都受到了C的影响,毕竟PHP内核就是用C写的,就比如很多基础的函数名和C是一模一样的。但PHP还有一个很重要的前辈是Perl,开发者们普遍认为PHP用$符修饰变量是从Perl那里学来的,还有诸如”.”连接字符串,用”=>”来设置数组(哈希)键值,”->”访问对象成员等。

早期的PHP还在写模板的时候,$也为程序员带来了很多好处,如直接在字符串内嵌入变量,甚至很多人并不知道它配合花括号还可以这样:

1
2
3
4
5
6
7
$o = new class {
public function greet(string $name): string
{
return "Hello {$name}!";
}
};
echo "{$o->greet('PHP')}";

输出:

1
Hello PHP!

或是可变变量

1
2
3
$foo = 'bar';
$$foo = 'char';
var_dump($bar);

输出:

1
string(4) "char"

此外,$符修饰的变量永远不会和语言关键字冲突。

当然,这只能算是一些冷知识或是奇技淫巧,作为我们本篇的开胃菜。想要对PHP变量有新的认识,我们得先从表象入手,由浅入深,最后深入理解它的原理。

PHP的栈和堆

为了搞清楚变量分配,我们还需要要了解PHP中的栈和堆,同样是内存,划分为栈和堆自然有分配上效率的原因。

我们事先准备好一块足够大的内存,这就是栈,程序运行时大量的变量符号所需的内存都在栈上挨个分配,函数调用时直接入栈出栈无需每次申请内存,这样就非常快。PHP甚至能事先计算好某个函数调用时需要多大的栈内存,内存不足时便会自动进行栈扩容。

我们运行时可能要创建一些字符串或者对象,它们可能会非常大,而且充满不确定性,这时候我们就需要向内存管理器动态地申请一块内存,有时候甚至可能会因为内存限制分配失败,这里所分配的内存就是堆区上的内存。

TIP: PHP的栈区是在操作系统的堆区上,扩容时也可能会由于系统内存不足失败,但这很少发生,当系统内存不足时会直接把占用太多内存的进程强制kill掉。

变量的存储方式

首先我们要了解什么是动态语言,动态语言即是在“运行时”可以根据某些条件改变自身结构,如动态注册甚至替换函数等等;而静态语言一般在编译期就完成了函数定义、类型检查等事情。容易混淆的是,语言的强弱类型和动静态与否并没有关系,区别在于类型检查的时机:静态语言一般在编译期就进行了类型检查,而动态语言需要在运行时才能检查类型。

综上可知,PHP是一门典型的弱类型的动态语言,因为PHP中的变量,不仅是“量值”可变,“量的类型”也是可以随时变化的。PHP的变量无需声明,写即可用,非常方便,但这也造成了PHP内核无法在编译期间推断出所有变量的类型,如某个函数的运行结果依赖了外部数据,它的返回值可能是多种类型的,这样就无法在编译期有针对性地优化,而是在运行时不断地做检查。而PHP8想要实现JIT提升性能,就必须克服这个问题,所以PHP正不断地引入强类型特性,随着类型系统愈发完善,开发者也在有意识地减少动态类型的滥用,代码质量不断提高。

知道了这些以后,我们可以肯定地推测,PHP变量和其它静态语言变量的存储方式是不同的。如我们在C语言中声明一个字符串:

1
const char *string = "hello";

那么显而易见这个字符串占用了6个字节的内存(字符串长度 + \0终止符),这一点你可以用sizeof来验证。

1
printf("%zu", sizeof("hello")); // 输出6

注:PHP中的sizeof是count的别名,和占用内存并没有关系

而由于PHP7增加了很多优化机制,我们并不能在PHP中直观地看见变量和内存的关系(这里指使用memory_get_usage函数,具体的原因我们将在后文讲到)。但我们可以通过分析PHP的内核,即Zend虚拟机的底层源码来推断。

如果你有一点C语言基础,那么你应该知道结构体、联合体和一些数据类型,我们可以来看看PHP变量在C底层的结构定义(因为是Zend引擎实现,所以被称作zval):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct zval {
/* 值 (8字节)*/
union {
zend_long lval; // 对应int类型
double dval; // 对应float类型
zend_string *str; // 对应string类型
zend_array *arr; // 对应array类型
zend_object *obj; // 对应object类型
zend_resource *res; // 对应resource类型
zend_reference *ref; // 对应引用类型
} value;

/* 类型信息 (4字节) */
struct {
zend_uchar type; // 存储了变量的类型,以找到对应的value
zend_uchar type_flags; // 内存管理使用,可忽略
uint16_t extra;
} type_info;

/* 额外信息 (4字节,内存对齐冗余) */
uint32_t extra;
};

这就是PHP的一个变量在底层的内存布局,这里过滤了一些暂时用不到的信息,简化了它的结构,看起来更加清晰,总的来说有三个部分:值、类型、额外信息,构成了PHP变量的容器”zval”。
我们先来计算一下它在64位机器上占用的内存大小:首先value是一个联合体,它的尺寸取决于它们中最大的那个,不管是long、double、还是指针类型,都正好是占用64位即8个字节;其次是类型信息结构体,一个无符号字符(char)1个字节,类型和类型标志加上额外冗余共计4个字节;最后还有一块额外冗余的值,也是4字节,一共加起来是16字节,这就是一个变量本身所占用的内存大小。

不知道为什么有额外信息冗余的可以课外了解一下内存对齐的知识

当一个变量是int或者float类型的时候,它占用的内存就正好是16字节,因为这个值是直接存在变量本身的内存中的,而变量的内存又是在PHP的栈区上;当一个变量是字符串、数组、对象、资源的时候,它的value存储的是一个指针,指针指向了真正变量值的内存地址,它是分配在堆区上的。

而我们所谈论的字符串,即zend_string的结构体又长这样,包含了三个固定属性:引用计数信息、哈希值、长度,在64位系统上一共是24个字节(refcount是4字节的,但是对齐到了8字节),而value的长度是根据字符串长度动态确定的:

1
2
3
4
5
6
struct zend_string {
uint32_t refcount; // 引用计数,表示这个字符串被引用了几次
zend_ulong hash; // 哈希缓存,在字符串对比时能够极大提高速度
size_t length; // 字符串长度,确保了字符串的二进制安全
char value[length]; // 字符串内容,此处表示它的内存和这个结构体的内存是连续的
};

已知PHP的字符串也是zero-termination(零结尾)的,那么我们可以推测出,一个字符串变量,就需要占用 “zval + zend_string + 字符串长度 + 1” 这么多的字节,以“hello”来计算就是46个字节(可能还需要内存对齐),而不是C语言中明明白白简简单单的6个字节。

PHP字符串变量的写时分离

定义一个8字节的int类型,需要占用16字节的内存(PHP5时代甚至高达32字节,感谢PHP7的优化),定义一个长度为5的字符串,却要占用46个字节的内存,你可能不禁感到使用动态语言的内存代价十分昂贵。

事实确实如此,但也不尽然。

首先我们来看一个例子:

1
2
$a = 'hello';
$b = $a;

我们都知道,PHP默认总是传值赋值,那也就是说,当将一个表达式的值赋予一个变量时,整个原始表达式的值被赋值到目标变量。这意味着,当一个变量的值赋予另外一个变量时,改变其中一个变量的值,将不会影响到另外一个变量。被赋值的变量所持有的值,可以称作是副本。
那么把$a赋值给$b,是否就会产生一个$a的副本,一共占用两倍的内存呢?

其实不然,因为PHP采用了Copy-On-Write(写时复制)的机制,并使用引用计数管理内存

已知zval上的zend_string是一个指针,那么其它zval也可以用指针指向这个字符串。让我们拆解着看:

当我们声明$a并赋值时:

  1. 在栈上符号表中分配了一个$a的zval
  2. 在堆区申请内存,创建一个zend_string(24字节头部信息+5+1字节内容),拷贝”hello”给zend_string->value,设置length为5
  3. 赋值,设置$a的zval->value.str = 刚才创建zend_string

当我们声明$b并将$a赋值给$b的时候,实则发生了以下事情:

  1. 在栈上符号表中分配了一个$b的zval
  2. 拷贝了$a的zval给$b的zval,现在它们指向同一个zend_string
  3. zend_string->refcount(引用计数)加1

这时候我们知道了,字符串赋值并没有产生字符串的内存拷贝,只是拷贝了zval和增加了引用计数,两个变量都指向了同一个字符串。这样赋值的代价就非常小,几乎可以忽略不计。

如果我们修改了$b的字符串值呢?

1
$b .= ' world'; // hello world

那么此时就会发生写时复制,也叫写时分离,即$a$b不再指向同一个zend_string,这时候会创建一个真正的zend_string的副本给$b,而原来zend_string的引用计数减为1。
需要注意的是,如果字符串的内容非常大,那么哪怕只是对$b追加了一个字符,也将会占用双倍于原来以上内存,代价十分昂贵。

PHP引用计数机制

刚才提到了引用计数这个东西,就不得不展开说一下,正如我们所见,zend_string的头部有一个refcount属性,表示这个zend_string被几个zval所引用了,当我们将它赋值给某个zval时,它就加1,当某个持有它的zval不再被用到时,它就减1,当它变为0的时候,表示再也没有zval指向它了,那么PHP内核就会根据zval的类型,调用相应的释放函数来释放它的内存。

PHP中常见的拥有引用计数的类型有:string、array、object,它们的数据结构的头部都是refcount。

其中,object大家都知道有构造、析构函数,当object的引用计数为0时,先会调用析构函数,再释放内存。

基于引用计数的内存管理方式好处显而易见:简单、可靠、实时回收、不会造成程序长时间停顿、还可以清晰地标明每一个变量的生命周期。但它也不是没有缺点,频繁且大量地更新计数也会有一定的开销,原始的引用计数也无法解决循环引用的问题。

PHP数组变量的写时分离

数组和字符串一样,都是在堆区分配内存,并由zval指向一个zend_array,zend_array的头部也有引用计数。

你可以暂且把数组简单看做一堆zval的集合,当数组发生写时分离时,只会拷贝数组本身,也就是产生一个新的zval的集合,所有数组上的有引用计数的zval,其计数都会加1,而不是数组上的每一个元素都会产生写时分离。

PHP数字变量的值拷贝

1
2
$a = 1;
$b = $a;

这个例子里,$a$b的zval.value.lval上都单独存储了一个8字节的0x00000001,而不是像字符串那样另有指向,因为zval只有16字节,且它总是在栈区上分配,无需单独申请一块内存,所以当我们赋值一个数字的时候,总是将整个zval拷贝过去,这样的拷贝非常快代价几乎可以忽略不计,并且省去了引用计数的管理。

PHP无值布尔变量

按照常人的理解,PHP的zval的布尔值设计一定是定义一个type为IS_BOOL,然后value中有一个zend_bool的值,为0时表示false,为1时表示true。

可事实上并不是这样,PHP使用type这一个量来实现布尔值,布尔型的zval对应了两种type:IS_TRUE和IS_FALSE

这样在赋值一个变量为布尔值时,只需要改变zval的type,而不需要去修改zval的value。

PHP的NULL

NULL和布尔值一样,只有type,没有value。这里又有一个常见的误区,即unset变量和设置一个变量为NULL是两种不一样的操作,unset是从符号表或数组中删除某个变量,而赋值null是将变量的type置为IS_NULL,更具体的区别,我们将在后续模块中讲到,这将涉及到一种隐藏类型的变量——UNDEF变量。

PHP变量引用

我们已知zval只是一个变量的容器,它在管理字符串、数组、对象、资源的时候,都是采用指针指向的方式,而我们又常说,object(对象)总是传引用的,但这并不代表存储object的zval是一个引用变量。

结合我们上述分析,可以推出,对象在传递的时候,同样会拷贝zval,但是任我们如何操作对象,也永远不会发生写时分离产生新的对象,如此简单就实现了对象永远是”传引用”的机制了。

resource类型(资源)也是一样,略有不同的就是,资源都是向操作系统申请的,它无法被clone,即无法生成副本。

PHP引用变量

引用变量是相对罕见的,它会引入一些复杂性,有时候难以拿捏。如最常见的错误就是将对象类型的变量进行引用赋值或引用传递,显而易见的多此一举。

引用变量和对象传引用不同,引用是符号表别名,它不是和object那样多个zval指向同一个对象的指针,而是引用变量的zval指向了被引用的zval,所有的修改都相当于对被引用的zval操作,唯一的例外是对引用变量使用unset,它不会删除被引用zval的值,而是解除了对其的引用。

由于写时拷贝的存在和PHP的一些优化措施,引用变量显得有些鸡肋,我们通常也不建议使用引用,让我们来看两个例子:

1
2
3
4
function foo(string $a): string
{
return $a . 'bar';
}
1
2
3
4
function foo(string &$a)
{
$a .= 'bar';
}

这两种写法会有什么性能差异吗?有一定C++基础的人或许不难看出,第二种写法在C++中的同等例子或许可以减少一次对象构造和内存拷贝,但在PHP中并不成立。

  1. 当传入的$a引用计数为1时,在例一中,PHP会将字符串的改动直接作用于传入变量本身并返回,不会发生写时分离,效果和例二没有区别(这就是引用计数的优点之一,可以实时判断变量的生命周期状态,减少内存拷贝)。
  2. 当传入的$a引用计数大于1时,对于字符串的修改又引发了写时分离,例1和例2都会产生一个新的字符串副本。

那么问题又来了,为什么PHP有些内置函数参数是引用的呢?比如非常常见的sort系列函数——那是因为这些函数的出现早于PHP4实现写时复制的版本,那时候的PHP还称不上严格意义上的语言。

所以在PHP中,随意滥用引用是不好的,不要期望引用能够提高性能,它多数时候只会惹是生非

但引用肯定也有用武之地,那么什么时候我们才该使用引用呢?

1
2
3
4
5
6
foreach ($array as &$value) {
$value += 1;
}
foreach ($array as $index => $value) {
$array[$index] += 1;
}

如果你尝试拿一个大的关联数组做一下性能测试,就可以发现第一种的情况的运行速度优于第二种,为什么非得是关联数组呢?因为第二种方式每次增加键值时,都会多一次哈希查找的步骤。但如果不是关联数组,又有什么区别呢?这里有个新的知识点,我们留到后续数组的章节再来讨论。
此外,类似array_walk这样的数组遍历函数,当我们想修改数组内的值时,callback定义的参数通常也是加引用符的,若只是只读地访问变量,我们永远都不需要加引用

PHP变量的循环引用

当我们已经初步了解了上述知识以后,我们就可以来思考这样一个问题,如果一个变量自己引用了自己,那么会发生什么?

1
2
3
$foo = [];
$foo[] = &$foo;
var_dump($foo);

输出

1
2
3
4
5
6
7
array(1) {
[0]=>
&array(1) {
[0]=>
*RECURSION*
}
}

可以由RECURSION看出来foo变量循环引用了自身,如果无限制递归地打印,将会变成死循环输出。
而当foo变量不再被用到时,它的引用计数减一,但由于它的内部自己引用了自己,它将永远保持最低为1的引用计数,将无法被释放。在PHP5.3以前,这种情况没有解决方案,只能依靠FPM模型下的重启VM解决,如果是常驻内存的应用,这种情况将会产生持续的内存泄漏。这也是前文提到的原始引用计数下无法解决的问题之一。

好在PHP5.3后引入了垃圾回收机制,通过一种同步回收算法,定量地深度度优先遍历疑似垃圾,进行模拟删除和模拟恢复,以此筛选得出循环引用的垃圾,然后进行内存回收,解决了这个问题。

但我们在开发中仍需重视循环引用的问题,降低垃圾回收的负担。

PHP变量的隐式转换、整数溢出

前文我们已经说了PHP的变量是动态弱类型的,那么就意味着它允许变量之间的隐式转换。

所谓隐式转换,就是指当你将A类型的变量当作B类型来操作时,A类型将会自动转换为B类型,而不是产生一个类型错误

PHP底层定义了一系列convert方法来进行这样的转换,convert系列方法会先switch判断zval里存储的type,跳跃到对应的处理流程进行转换。

比较常见的隐式转换就是数字和字符串之间的转换,这里PHP巧妙地用“.”符号来表示字符串拼接,“+”符号来表示加法运算,在某些场景下很好地避免了错误的隐式转换类型。

此外,PHP的标量类型尽管在引入了函数类型定义的情况下,仍允许隐式转换,如当你将一个string类型的变量传给了一个限制了int类型的参数,string将会自动转为int,除非你在文件开头定义”declare(strict_types=1);”,这也是部分高质量开源库的硬性要求,这种做法在未来的PHP中将有很大受益。

类似PHP这样的动态类型语言还有一个通病就是不方便解决整数溢出问题,PHP的int型是有符号整数,比无符号的范围要小很多,大部分语言的解决方法就是在溢出时将数值转换成浮点型,PHP也是这么做的:

1
2
3
4
5
6
$foo = PHP_INT_MAX;
var_dump($foo); // 输出 int(9223372036854775807)
$foo++;
var_dump($foo); // 输出 float(9.2233720368548E+18)
$foo -= PHP_INT_MAX;
var_dump($foo); // 输出 float(0) 出现丢失

但我们都知道,浮点型的精度有限,所以在某些时候,我们可能需要借助bcmath扩展来处理大数字。
此外值得一提的是,从数据库取大的整型数据这样的场景中,超出范围的整型变量PHP底层将会将其变成字符串型,以确保不会发生信息丢失。

PHP变量的比较

作为一个有经验的PHP程序员,不可能不知道强等于(===)和弱等于(==)的区别。合格的PHPer大都首选强等于,加之PHP类型系统的不断完善,“declare(strict_types=1);”甚至也成了必选。

但很多开发者并没有注意到, PHP中还存在着使用松散比较的函数,如最常用的”in_array”,需要设定第三个参数为true,甚至最基础的switch语句使用的也是松散比较,稍不注意,就会陷入变量松散比较的陷阱中。

以下返回结果都是true,你所忽视的变量松散比较正在破坏着你的程序逻辑

1
2
3
var_dump(in_array('foo', [0]));
var_dump(in_array(0, ['bar']));
var_dump(in_array(null, [[]]));

这里还是涉及到了类型转换的知识,当两个不同类型的变量进行松散比较时,PHP内核总是按照特定规则将它们转为同一类型的变量,再进行比较。
但在PHP8中,某些不安全的比较行为可能会得到校正,如字符串总是等于0,这一改动会从语言的根本导致向下不兼容,但这是一个正确的方向,PHP8应该有这样的勇气去除糟粕,才能成大事。

相关RFC: https://wiki.php.net/rfc/string_to_number_comparison

而强类型比较则是在弱比较的基础上,还判断了两个变量是否是相同类型的,因此它不会引发任何隐式的类型转换。

除此之外,”==”的语义自然是”equal”,而“===”的语义实际上是”identical”,也就是“同一的“,对于PHP的一些基本类型,如数字、字符串、数组等,两个变量的值相等即可,但对于对象类型的比较,则要求是”同一个对象“,如:

1
var_dump(new stdClass === new stdClass);

这个例子中虽然两个对象别无二致,但由于不是同一个对象,将会返回false。

结语

PHP的变量说简单,是真的简单,无需声明,想用就用,或许很多开发者长久以来从未思考过变量背后的运作原理,只是一味地使用。实际上zva的设计十分精妙,用繁浩的底层代码来隐藏了编程的复杂性,让PHP开发者享受到了快乐开发的乐趣。