变量的内部引用和计数

在引擎内部,一个PHP的变量是保存在“zval”结构中,此结构包含了变量的类型和值信息,此结构还有另外两个字段信息,一个是”is_ref”(此字段在5.3.2版本中是is_ref__gc),此字段是一个布尔值,用来标识变量是否是一个引用,通过这个字段,PHP引擎能够区分一般的变量和引用变量。PHP代码中可以通过 & 操作符号来建立一个引用变量,建立的引用变量内部的zval的is_ref字段就为1。zval中还有另外一个字段refcount(此字段在5.3.2版本中是refcount__gc),这个字段是一个计数器,表示有多少个变量名指向这个zval容器,当此字段为0时,表示没有任何变量指向这个zval,那么zval就可以被释放,这是引擎内部对内存的一种优化。考虑如下代码:

代码中有两个变变量$a和$b,通过普通赋值方式将$a赋给$b,这样$b的值和$a相等,对$b的修改不会对$a造成任何影响,那么在这段代码中,如果$a和$b对应两个不同的zval,那么显然是对内存的一种浪费,PHP的开发者也不会让这样的事情发生。所以实际上$a和$b是指向同一个zval。这个zval的类型是STRING,值是”Hello world”,有$a和$b两个变量指向它,所以它的refcount=2, 由于是一个普通赋值,所以is_ref字段为0。 这样就节省了内存开销。

当执行$a = “Hello world”之后,$a对应的zval的信息为:a: (refcount=1, is_ref=0)=”Hello world”

但执行$b=$a之后,$a对应的zval的信息为:a: (refcount=2, is_ref=0)=”Hello world”

下面将之前的代码修改一下:

<?php  
$a = "Hello world";  
$b = &$a;  
?>

这样就通过引用赋值方式将$a赋给$b。

当执行$a = “Hello world”之后,$a对应的zval的信息为:a: (refcount=1, is_ref=0)=”Hello world”

但执行$b=&$a之后,$a对应的zval的信息为:a: (refcount=2, is_ref=1)=”Hello world”

可以发现is_ref字段被设置成1了,这样$a和$b对应的zval就是一个引用。这样我们基本对引擎中变量的引用和计数有了一个基本的了解,下面将介绍变量的分离。

变量的分离 copy on write

考虑前面第一段代码,用普通方式将$a赋给$b,在内部两个变量还是指向同一个zval的,这个时候如果我们将$b的值修改为”new string”,$a变量的值依然是”Hello world”:

<?php  
$a = "Hello world";  
$b = $a;  
$b = "new string";  
echo $a;  
echo $b;  
?>

$a和$b明明是指向同一个zval,为什么修改了$b,$a还能保持不变呢,这就是copy on write(写时复制)技术,简单的说,当重新给$b赋值的时候,会将$b从之前的zval中分离出来。分离之后,$a和$b分别是指向不同的zval了。

写时复制技术的一个比较有名的应用是在unix类操作系统内核中,当一个进程调用fork函数生成一个子进程的时候,父子进程拥有相同的地址空间内容,在老版本的系统中,子进程是在fork的时候就将父进程的地址空间中的内容都拷贝一份,对于规模较大的程序这个过程可能会有着很大的开销,更崩溃的是,很多进程在fork之后,直接在子进程中调用exec执行另外一个程序,这样原来花了大量时间从父进程复制的地址空间都还没来得及碰一下就被新的进程地址空间代替,这显然是对资源的极大浪费,所以在后来的系统中,就使用了写时复制技术,fork之后,子进程的地址空间还是简单的指向父进程的地址空间,只有当子进程需要写地址空间中的内容的时候,才会单独分离一份(一般以内存页为单位)给子进程,这样就算子进程马上调用exec函数也没关系,因为根本就不需要从父进程的地址空间中拷贝内容,这样节约了内存同时又提高了速度。

当$b从$a指向的zval分离出来之后,zval的refcount就要减1,这样由之前的2变成了1,表示这个zval还有一个变量指向它,就是$a。$b变量指向了一个新的zval,新的zval的refcount为1,值为字符串”new string”,大概过程如下:

$a = "Hello world" 	//a: (refcount=1, is_ref=0)="Hello world"
$b = $a       		//a,b: (refcount=2, is_ref=0)="Hello world"
$b = "new string" 	//a: (refcount=1, is_ref=0)="Hello world"   b: (refcount=1, is_ref=0)="new string"(发生分离操作)

这个分离逻辑可以表叙为:对一个一般变量a(isref=0)进行一般赋值操作,如果a所指向的zval的计数refcount大于1,那么需要为a重新分配一个新的zval,并且把之前的zval的计数refcount减少1。

以上为普通赋值的情况,如果是引用赋值,我们看看这个变化过程:

$a = "Hello world" 	//a: (refcount=1, is_ref=0)="Hello world"
$b = &$a       	//a,b: (refcount=2, is_ref=1)="Hello world"
$b = "new string" 	//a,b: (refcount=2, is_ref=1)="new string"

可以看出来,对一个引用类型的zval进行赋值是不会进行分离操作的,实际上我们再产生一个引用变量的时候是可能出现一个分离操作的,只是时机有些不同:

  1. 在普通赋值的情况下,分离操作发生在$b=”new string”这一步,也就是在对变量赋新的值的时候,才会进行zval分离操作
  2. 在引用赋值的情况下,分离操作有可能发生在$b = &$a这一步,也就是在生成引用变量的时候

情况1就不多解释了,情况2中强调是有可能发生分离,以前面的这代码为例子,是否进行分离与$a当前指向的zval的refcount有关系,代码中$b = &$a 的时候, $a指向的zval的refcount=1,这个时候不需要进行分离操作,但是如果refcount=2,那么就需要分离一个zval出来。比如如下代码:

<?php  
$a = "Hello world";  
$c = $a;  
$b = &$a;  
$b = "new string";  
?>

在执行引用赋值的时候,$a指向的zval的refcount=2,因为$a和$c同时指向了这个zval,所以在$b=&$a的时候,就需要进行一个分离操作,这个分离操作生成了一个ref=1的zval,并且计数为2,因为$a,$b两个变量指向分离出来的zval,原来的zval的refcount减少1,所以最终只有$c指向一个值为”Hello world”,ref=0的zval1, $a和$b指向一个值为”Hello world”,ref=1的zval2。 这样我们对$c的修改时在操作zval1,对$a和$b的修改都是在操作zval2,这样就符合引用的特性了。

此过程大致如下:

$a = "Hello world";	//a: (refcount=1, is_ref=0)="Hello world"
$c  = $a;       	// a,c: (refcount=2, is_ref=0)="Hello world"
$b = &$a;       	// c: (refcount=1, is_ref=0)="Hello world" a,b: (refcount=2, is_ref=1)="Hello world" (发生分离操作)
$b = "new string"; 	// c: (refcount=1, is_ref=0)="Hello world" a,b: (refcount=2, is_ref=1)="new string"

试想一下如果不进行这个分离会有什么后果?如果不进行分离,$a,$b,$c都指向了同一个zval,对$b的修改也会影响到$c,这显然是不符合PHP语言特性的。

这个分离逻辑可以表述为:将一个一般变量a(isref=0)的引用赋给另外一个变量b的时候,如果a的refcount大于1,那么需要对a进行一次分离操作,分离之后的zval的isref等于1,refcount等于2

通过以上的一些知识和分离逻辑读者应该可以很容易分析其它的一些情况。比如将一个引用变量a(isref=1)的引用赋给一般变量b的时候,需要将b之前指向的zval的refcount减少1,然后将b指向a的zval,a的zval的refcount加1,没有任何分离操作

这些理论结合实际代码会让你更容易理解这个过程。

unset的作用

unset()并非一个函数,而是一种语言结构,这个可以通过查看编译生成的opcode看到区别,unset对应的不是一个函数调用的opcode。那么unset到底做了什么? 在unset对应的opcode的handler中可以看到相关内容,主要的操作时从当前符号表中删除参数中的符号,比如在全局代码中执行unset($a),那么将会在全局符号表中删除a这个符号。全局符号表是一张哈希表,建立这张表的时候会提供一个表中的项的析构函数,当我们从符号表中删除a的时候,会对符号a指向的项(这里是zval的指针)调用这个析构函数,这个析构函数的主要功能是将a对应的zval的refcount减1,如果refcount变成了0,那么释放这个zval。所以当我们调用unset的时候,不一定能释放变量所占的内存空间,只有当这个变量对应的zval没有别的变量指向它的时候,才会释放掉zval,否则只是对refcount进行减1操作。