PHP变量在内存中的表示

分享于:2017-04-19 11:48:53

当你打开这篇文章的时候,请先思考一个问题: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(联合体)来表示变量的值:

1.png

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变量在内存中表示所需的所有东西:

1.png

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

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


传值和传引用


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

1.png

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

1.png

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

1.png

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

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

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

1.png

这里例子首先调用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的引用计数是怎么变化的:

1.png

你可以调用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的“引用”,需要用到操作符&显示声明)。

1.png

上面的代码中使用引用赋值操作符“=&”,这段代码执行后, $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用于对应到值改变后的变量。

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

1.png

在这个例子中,一开始 $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的内部引用),还有一种特殊的情况,就是循环引用,我们先通过一个示例看看什么是循环引用:

在这里示例中先创建了两个数组 $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_1;

如果再执行unset($b)之后,zval_2的refcount会减1,变成1。而zval_1的refcount仍然是1,并没有因为删除$b受到影响【这是因为zval_2只是变成了1,并没有被销毁。zval_2没有销毁,zval_1的refcount自然还是1】。

zval_1、zval_2引用它俩的变量都已经销毁,由于它们的refcount大于0,这两个zval都不会被销毁,实际上此时我们可以认为这两个zval造成了内存泄漏,它们不会被PHP的垃圾回收机制销毁。这就是循环引用容易引起内存泄露的原因。

对于垃圾回收机制, PHP有专门的文档介绍 ,虽然不是很详细,但是也至少可以从中了解一些大概。


总结


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

源自于:http://gywbd.github.io/posts/2015/4/php-variable-in-memory.html