Gong Yong的Blog

PHP变量在内存中的表示

当你打开这篇文章的时候,请先思考一个问题:PHP中的参数传递到底是传值的,还是传引用的?这是一个基础问题,还有些历史包袱。我们都知道PHP刚被创造的时候并不支持面向对象的特性,所以如果你是一个比较资深的php程序员的话,你肯定听说过PHP是传值的,不过如果你是从Java或者C#转到PHP,而且最开始用的php 5.2(>=)的话,你很可能会认为PHP是传引用的。这个问题就是我们这篇文章要讨论的主题。

zval

首先我个人表示任何对PHP有点追求的人都应该了解zval。它的全称是zend value,PHP的解释器被称为zend engine,所以顾名思义zend value就是zend engine中的value,而在计算机程序设计的世界中,value一般都是通过变量来指代的。PHP中的变量在内存中是以zval结构体的形式存在的,zval包含了变量的值以及其他一些相关的信息。现在我们来看看zval是个什么东西,首先我们要先了解变量的值在PHP内部是怎么表示的。

PHP的内部使用了一个unioin(联合体)来表示变量的值:

typedef union _zvalue_value {
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
} zvalue_value;

union是C语言中的东西,现在谈到的这些东西都是跟C语言相关。因为PHP就是用C语言开发的,所以我们谈论底层的东西时,就必然会谈到C语言的一些东西。不过还好,对于这篇文章而言我们用到的C语言的东西很少(C语言中的概念本来就不多)。这里出现的union跟struct(联合体)类似,从定义来看,都是一组字段的组合,不过union一次只能表示(使用)一个字段,所以如果你定义了一个zvalue_value类型的变量value,如果将其中的lval设置为1,那么你只能使用value.lval。如果你使用其他的字段,例如value.dval,会得到意想不到的结果。这是因为union在内存中的大小是一定的,跟其中最大字段的大小一致(不管你使用哪个字段),当你访问其中某个字段的时候,它实际上只是从内存中读取一块数据,这个内存块的大小就是这个字段的大小,而起始地址就是对应的union的起始地址,然后再把从内存读到的这个数据转换为字段类型所对应的数据值。

因为我们这里只关注php变量在内存中如何表示,所以我就不考虑变量在内存中所占的存储空间的大小,这只会把问题搞得更复杂。从上面的union中我们可以看到,它可以表示PHP中的整型、浮点型、字符串、数组(hashtable)和对象等类型。考虑到resource类型在PHP中只是一个整型值,所以它也会被保存到lval中,它的处理会比较特殊。在PHP中bool类型的值一般用0(表示false)和1(表示true)两个整型数字表示,所以它的值也会保存在lval中的。还有没有提到的类型是NULL类型,因为NULL值没有任何意义,所以不需要任何字段表示,直接使用c语言中的null表示它的值。

我们现在了解了表示变量的值的联合体zvalue_value,下面我们再来看看zval。zval是一个struct(结构体),它包含了一个PHP变量在内存中表示所需的所有东西:

typedef struct _zval_struct {
zvalue_value value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
} zval;

value是上面用于表示值的联合体,type则是变量的类型,php有8种类型的变量,这个上面已经说明了,它用一个1字节的无符号字符型字段表示,这完全是足够的。refcount__gc和is_ref__gc两个字段都有一个后缀__gc,gc的全称是garbage collection,就是我们通常所说的垃圾回收,搞过java的人肯定对这个概念很熟悉,显然它们是跟垃圾回收相关的。

PHP是一个动态类型的语言,在PHP程序中可以给同一个变量赋予不同类型的值。对于不同的类型的值,这个变量的类型也会发生改变,对底层而言,只需要改变zval中的type字段就可以改变它的类型,这就是实现PHP中动态类型的基础。

传值和传引用

首先我想说PHP是传值的,除非你显示声明为传递一个引用(使用&操作符),所以当你把一个变量赋值给另外一个变量,或者通过函数传递参数的时候,这两个赋值和被赋值的变量是不同的,我们先看一个例子:

<?php
$a = 1;
$b = $a;
$a++;
//只有变量$a的值改变了,$b的值没有变
var_dump($a, $b); // int(2), int(1)
function inc($n) {
$n++;
}
$c = 1;
inc($c);
//将$c传递给函数inc后,虽然在这个函数中会将传递给它的参数+1 //但函数调用完之后,$c的值并未变
var_dump($c); // int(1)

这里例子可以很明显地说明PHP是传值的。这里我们看到的是普通类型的变量,或者被称为标量(scala),我们再看看传递对象的情况:

<?php
$obj = (object) ['value' => 1];
function fnByVal($val) {
$val = 100;
}
function fnByRef(&$ref) {
$ref = 100;
}
//fnByVal是传值的,所以这个函数调用后,$obj并未改变,而fnByRef是传的是引用,调用后$obj也改变了
fnByVal($obj);
var_dump($obj); // stdClass(value => 1)
fnByRef($obj);
var_dump($obj); // int(100)

上面的示例也可以看到当传递的变量是对象,它也是传值的,所以当我们以传值的方式把一个变量赋值给另外一个新的变量(函数的参数传递也是一种变量赋值),如果我们会改变这个新的变量,之前的变量并不会改变。不过有一种不同的情况:

<?php
$obj = (object) ['value' => 1];
function changeObj($o) {
$o->value = 100;
}
changeObj($obj);
var_dump($obj); // stdClass(value => 100)

上面的代码中的对象$obj在调用changeObj之后被改变了,这看起来像是传引用的。事实上并非如此的,我们从上面的表示变量的值的union中可以看到表示对象的值的类型为zend_object_value,这是一个结构体,它其中有一个long型的字段,它表示对象的ID。当要使用这个对象的时候,PHP会查找这个ID对应的真正的对象在内存中的表示,然后再对这块内存进行操作,所以上面的代码中的$obj和函数的参数$o都包含同一个对象的ID,而当$ochangeObj中被当做对象使用的时候,它所对应的对象跟变量$obj是同一个对象,所以改变这个对象中的value字段的值,就改变了保存在这个对象中的数据。resource类型的数据也有类似的行为,我们就不深究了。

对于引用很好理解,PHP中都是显示使用&操作符来表示变量是否是引用。我们现在已经看过一个传递引用的例子,这篇文章也不会详细讨论引用的应用,PHP有专门的文档来介绍怎么使用引用。不过有一点需要说明,引用跟C语言中的指针并不相同。在PHP中声明的每个变量在内存中都有一个对应的zval,如果把一个变量通过引用操作符赋值给另外一个变量,最终这两个变量都对应同一个zval,这类似于两个指针变量指向同一个地址。但是不同的是指针变量可以任意改变它的指向,而不会影响另外一个变量的指向,但是PHP中的引用则不是,采用引用赋值之后,不论这两个变量怎么改变,它们永远都对应同一个zval。

我们现在已经搞清楚了传值和传引用的特点,以及PHP就是传值的,所以文章开头的问题也已经有了答案了,资深派获胜。且慢!我们先看下面一个例子:

<?php
$s = memory_get_usage();
$arr = range(1,10000);
echo memory_get_usage() - $s; //1491520
$arr2 = $arr;
echo memory_get_usage() - $s; //1491640

这里例子首先调用range函数生成了一个包含10000个整数的数组,然后输出这个数组占用的内存的大小为1491520个字节,大约为1.42M(我的php版本是5.5.14),然后把这个数组赋值给另外一个变量,这个时候的内存消耗为1491640,约为1.42M,基本上没有变化。

按照传值的理论,$arr2和$arr是两个不同的变量,在内存中分别对应不同的zval,如果第一个$arr对应的zval占用1.42M的内存,那么第二个$arr2也应该占用这么多的内存啊,但是赋值之后总的内存空间的大小依旧为1.42M,为什么会这样呢?

在回答这个问题之前,我想先啰嗦两句,如果PHP的传值被设计成上面说的那样,PHP就不会存在了,这样的话每次赋值和函数调用都会分配一块新的内存,而且如果传递的变量占用的内存很大,要分配的内存也会相应的很大,这样内存的消耗会非常恐怖!

写时拷贝(copy-on-write)和引用计数(refcount)

上面问题的答案就是PHP使用了一种叫做写时拷贝的技术,这个技术类似于延迟加载,在需要用到的时候才会新建一个zval。在PHP中,有时候我们把一个变量对应的zval叫做一个拷贝(copy),写时拷贝就是指在需要向变量写入数据的时候才创建一个新的拷贝,所以有时候我们把PHP中的参数传递方式称为“传拷贝”。

不过如果想完全搞明白什么叫写时拷贝,我们必须得先搞清楚什么是引用计数。我们在zval这个结构体中已经看到过引用计数,refcount_gc这个字段就是保存zval的引用计数的。所谓引用计数,就是指有多少个变量跟这个zval对应。我觉得很多时候我们误解了引用计数的含义,引用计数是针对zval而言的,而不是针对于变量的。我们通过一个简单的例子看看变量对应的zval的引用计数是怎么变化的:

<?php
$a = 1; // $a = zval_1(value=1, refcount=1)
$b = $a; // $a = $b = zval_1(value=1, refcount=2)
$c = $b; // $a = $b = $c = zval_1(value=1, refcount=3)
$a++; // $b = $c = zval_1(value=1, refcount=2)
// $a = zval_2(value=2, refcount=1)
unset($b); // $c = zval_1(value=1, refcount=1)
// $a = zval_2(value=2, refcount=1)
unset($c); // zval_1已被销毁,因为它的refcount=0
// $a = zval_2(value=2, refcount=1)

你可以调用xdebug提供的xdebug_debug_zval函数在代码中输出变量的引用计数,这个方法只会输出变量的值和引用计数,而不会输出使用的是哪一个zval。为了讲解方便,我们在这篇文章的示例中给出了每个变量对应的zval。从上面的示例中可以看到在$a++这个语句执行之前,变量$a$b$c都对应同一个zval,理论上一个zval对应多少个变量,那么它的refcount的值就是多少,所以此时zval_1的refcount的值为3。当$a++执行后,$a会对应一个新的zval,我们把它命名为zval_2,它的refcount为1,而$b$c对应的zval_1的refcount变成了2,减少了一个,这是因为$a不再对应到zval_1上了。

后面当unset($b)执行后,zval_1中的refcount再次减一,因为现在只有$c与它对应了,最后unset($c)执行后,zval_1的refcount减为0,此时它会被PHP中的底层函数销毁,这里注意一下,这个zval并不是被垃圾回收销毁,而是被PHP内部的内存管理函数销毁的,通过调用C语言中的free函数完成的,到此一个变量的生命周期也就结束了。

通过这个示例我们可以得出一个结论:每个PHP中的变量都会对应一个zval,当把这个变量赋值给其他变量的时候,无论是传值还是传引用(等会会看到传引用的情况),我们认为zval的引用增加了(这里说的引用是指对zval的引用,而不是使用&符号显示声明的变量引用),所以它的引用计数会加一;当这些引用了同一个zval的变量中的某一个的值发生改变,这个zval的引用就会减少,它的引用计数就会减一。当zval的引用计数减为0时,它就会被销毁。

我们再来看下使用PHP的变量引用的情况(我们可以把引用计数中的“引用”理解为PHP的内部引用,实际上是对zval的引用,而通常我们说的变量“引用”,可以说是PHP的“引用”,需要用到操作符&显示声明)。

<?php
$a = 1; // $a = zval_1(value=1, refcount=1, is_ref=0)
$b =& $a; // $a = $b = zval_1(value=1, refcount=2, is_ref=1)
$b++; // $a = $b = zval_1(value=2, refcount=2, is_ref=1)
// 对于is_ref=1的情况,PHP会直接改变zval的值,而不会创建一个新的zval的拷贝

上面的代码中使用引用赋值操作符“=&”,这段代码执行后,$a$b都对应同一个zval,这个zval中的is_ref字段的值为1,表示这个变量是一个PHP的引用,refcount的值为2,表示有两个PHP变量引用了这个zval。然后执行$b++操作,这个时候除了zval_1的值发生了变化外,refcount和is_ref都没变,而且$a$b依旧都引用同一个zval。这实际就是我上面说的,对于PHP中的引用变量,自从它们被创建出来之后它们会一直引用(对应)同一个zval,它们其中任何一个的值发生改变,只会改变这个zval的值,而不会改变它们的引用关系,这就是所谓的传引用吧。因为按照copy-on-write的策略,当一个变量被赋值为另外一个变量时,这两个变量会引用同一个zval,但是当其中某个变量的值发生改变,则会新建一个zval用于对应到值改变后的变量。

我们再看一个既有普通赋值,又有引用赋值的例子:

<?php
$a = 1; // $a = zval_1(value=1, refcount=1, is_ref=0)
$b = $a; // $a = $b = zval_1(value=1, refcount=2, is_ref=0)
$c = $b // $a = $b = $c = zval_1(value=1, refcount=3, is_ref=0)
$d =& $c; // $a = $b = zval_1(value=1, refcount=2, is_ref=0)
// $c = $d = zval_2(value=1, refcount=2, is_ref=1)
// $d是对$c的引用, 不是$a和$b的,所以此时会发生拷贝 // 创建一个新的zval,就是上面的zval_2
//
//
$d++; // $a = $b = zval_1(value=1, refcount=2, is_ref=0)
// $c = $d = zval_2(value=2, refcount=2, is_ref=1)
// $a和$b对应的zval跟$c和$d对应的zval不同,所以$d改变后,$a和$b不会改变
// $c和$d是引用关系,所以它们的值会发生改变,并且它们还是对应同一个zval

在这个例子中,一开始$a$b$c都引用zval_1,所以zval_1的引用计数为3,当把$c的引用赋值给变量$d之后,这个时候创建了一个新的zval(zval_2),$c$d现在引用这个zval,而zval_1只被$a$b引用,所以它的引用计数会减一变成2。在此我们可以发生,我们使用普通的赋值不会导致copy的发生(新建一个zval),而使用引用赋值则会导致copy的发生,write操作没有出现就产生了copy操作,也就是说copy-on-write对于引用赋值无效,所以在PHP中不建议随便使用引用赋值,或者是将函数参数设为引用,这会导致在赋值的时候就发生copy,影响性能。

对于zval的引用(PHP的内部引用),还有一种特殊的情况,就是循环引用,我们先通过一个示例看看什么是循环引用:

<?php
$a = []; // $a = zval_1(value=[], refcount=1)
$b = []; // $b = zval_2(value=[], refcount=1)
$a[0] = $b; // $a = zval_1(value=[0 => zval_2], refcount=1)
// $b = zval_2(value=[], refcount=2)
// zval_2的引用计数增加了,是因为zval_1对应的数组中的元素使用了它
$b[0] = $a; // $a = zval_1(value=[0 => zval_2], refcount=2)
// $b = zval_2(value=[0 => zval_1], refcount=2)
// zval_1的引用计数增加了,是因为zval_2对应的数组中的元素使用了它
unset($a); // zval_1(value=[0 => zval_2], refcount=1)
// $b = zval_2(value=[0 => zval_1], refcount=2)
// unset($a)之后zval_1的引用计数减一,但是zval_1依旧存在于内存中 // 因为它还在被zval_2使用,并且它的refcount=1

unset($b); // zval_1(value=[0 => zval_2], refcount=1)
// zval_2(value=[0 => zval_1], refcount=1)
// unset($b)之后,zval_2的引用计数也会减一 // 同时因为它也被zval_1使用,它也不会被销毁,它的refcount=1

在这里示例中先创建了两个数组$a$b,它们分别对应zval_1和zval_2,refcount都为1,然后将$a[0]赋值为$b,此时$a[0]就引用了zval_2,$b对应的zval_2的引用计数会加1,变成2。然后再将$b[0]赋值为$a,这个时候$b[0]会引用$a$a对应的zval_1的refcount也会加1,变成2。这个时候zval_1和zval_2就形成了一个循环引用。当我们执行unset($a)之后$a对应的zval_1的refcount减1,变成1,这个时候变量$a已经不存在了,但是zval_1依旧存在,因为$b[0]引用了这个zval;如果再unset($b)之后,通过zval_2的refcount会减1,变成1,这样zval_1和zval_2的refcount都为1,但是引用它们的变量都已经销毁,由于它们的refcount大于0,这两个zval都不会被销毁,实际上此时我们可以认为这两个zval造成了内存泄漏,它们会被PHP的垃圾回收机制销毁。对于垃圾回收机制,PHP有专门的文档介绍,虽然不是很详细,但是也至少可以从中了解一些大概。

总结

最后对于开头提出的问题我可以得到的答案是:PHP即不是传值的,也不是传引用的,而是使用了一种写时拷贝的机制(copy-on-write),我们可以把它称为“传拷贝”。某种意义上这是一种语言优势,完全传值必然会造成性能损失,而如果完全传引用的话又有一些历史的包袱(实际上就是兼容性问题),而且我们在写程序的时候也应该尽量避免拷贝的发生,例如尽量不要使用PHP的引用。