2

PHP变量在内存中的表示

 2 years ago
source link: http://gywbd.github.io/posts/2015/4/php-variable-in-memory.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

PHP变量在内存中的表示

2015-04-01

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

首先我个人表示任何对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的引用。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK