PHP是一门托管型语言,在PHP编程中程序员不需要手工处理内存资源的分配与释放(使用C编写PHP或Zend扩展除外),这就意味着PHP本身实现了垃圾回收机制(GC,Garbage Collection)。现在如果去PHP官方网站(php.net)可以看到,目前PHP5的两个分支版本PHP5.2和PHP5.3是分别更新的,这是因为许多项目仍然使用5.2版本的PHP,而5.3版本对5.2并不是完全兼容。PHP5.3在PHP5.2的基础上做了诸多改进,其中垃圾回收算法就属于一个比较大的改变。

本文将分别讨论PHP5.2和PHP5.3的垃圾回收机制,并讨论这种演化和改进对于程序员编写PHP的影响以及要注意的问题。

 

PHP变量及关联内存对象的内部表示

垃圾回收机制说到底是对变量及其所关联内存对象的操作,所以在讨论PHP的垃圾回收机制之前,先简要介绍PHP中变量及其内存对象的内部表示(其C源代码中的表示)。

PHP官方文档中将PHP中的变量划分为两类:标量类型和复杂类型。

1)标量类型包括布尔型、整型、浮点型和字符串

2)复杂类型包括数组、对象和资源;

3)还有一个NULL比较特殊,它不划分为任何类型,而是单独成为一类。

所有这些类型,在PHP内部统一用一个叫做zval的结构表示,在PHP源代码中这个结构名称为“_zval_struct”。

zval的具体定义在PHP源代码的“Zend/zend.h”文件中,下面是相关代码的摘录。

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

struct _zval_struct {  
    /* Variable information */ 
    zvalue_value value;    
   
    /* value */ 
    zend_uint refcount__gc;  
    zend_uchar type;    /* active type */ 
    zend_uchar is_ref__gc;  
}; 

如上,联合体“_zvalue_value”用于表示PHP中所有变量的值,这里之所以使用union,是因为一个zval在一个时刻只能表示一种类型的变量。可以看到_zvalue_value中只有5个字段,但是PHP中算上NULL有8种数据类型,那么PHP内部是如何用5个字段表示8种类型呢? 这算是PHP设计比较巧妙的一个地方,它通过复用字段达到了减少字段的目的。例如,在PHP内部布尔型、整型及资源(只要存储资源的标识符即可,类似于对象的引用地址、数组的首地址和长度)都是通过lval字段存储的;dval用于存储浮点型;str存储字符串;ht存储数组(注意PHP中的数组其实是哈希表);而obj存储对象类型;如果所有字段全部置为0或NULL,则表示PHP中的NULL,这样就达到了用5个字段存储8种类型的值。

而当前zval中的value(value的类型即是_zvalue_value)到底表示那种类型,则由“_zval_struct”中的type确定。

_zval_struct即是zval在C语言中的具体实现,每个zval表示一个变量的内存对象。

除了value和type,可以看到_zval_struct中还有两个字段 refcount__gc is_ref__gc ,从其后缀就可以断定这两个家伙与垃圾回收有关。没错,PHP的垃圾回收全靠这俩字段了。其中,refcount__gc表示当前有几个变量引用此zval,而is_ref__gc表示当前zval是否被按引用引用,这话听起来很拗口,这和PHP中zval的“Write-On-Copy”机制有关,由于这个话题不是本文重点,因此这里不再详述,读者只需记住refcount__gc这个字段的作用即可。

 

PHP5.2中的垃圾回收算法:Reference Counting (引用计数)

PHP5.2中使用的内存回收算法是大名鼎鼎的Reference Counting,这个算法中文翻译叫做“引用计数”,其思想非常直观和简洁:为每个内存对象分配一个计数器,当一个内存对象建立时计数器初始化为1 (因为总是有一个变量引用此对象),以后每有一个新变量引用此内存对象,则计数器加1,而每当减少一个引用此内存对象的变量则计数器减1,当垃圾回收机制运作的时候,将所有计数器为0的内存对象销毁并回收其占用的内存。而PHP中内存对象就是zval,而计数器就是refcount__gc。

例如下面一段PHP代码演示了PHP5.2计数器的工作原理(计数器值通过xdebug得到):

<?php  
	$val1 = 100; 	// zval(val1).refcount_gc = 1;  
	$val2 = $val1; 	// zval(val1).refcount_gc = 2,zval(val2).refcount_gc = 2 (因为是Write on copy,当前val2与val1共同引用一个zval)  
	$val2 = 200; 	// zval(val1).refcount_gc = 1,zval(val2).refcount_gc = 1 (此处val2新建了一个zval)  
	unset($val1); 	// zval(val1).refcount_gc = 0 ($val1引用的zval再也不可用,会被GC回收)  
?> 

Reference Counting简单直观,实现方便,但却存在一个致命的缺陷,就是容易造成内存泄露

很多朋友可能已经意识到了,如果存在循环引用,那么Reference Counting就可能导致内存泄露。

例如下面的代码:

<?php  
	$a = array();  	// 数组首地址
	$a[] = & $a;  	// 自己引用自己
	unset($a);  	// 删除了一个自己
?> 

这段代码首先建立了数组a,然后让a的第一个元素按引用指向a,这时a的zval的refcount就变为2,然后我们销毁变量a,此时a最初指向的zval的refcount为1,但是我们再也没有办法对其进行操作,因为其形成了一个循环自引用,如下图所示:

上图,灰色部分表示已经不复存在。由于a之前指向的zval的refcount为1(被其HashTable的第一个元素引用),这个zval就不会被GC销毁,这部分内存就泄露了。

这里特别要指出的是,PHP是通过符号表(Symbol Table)存储变量符号的,全局有一个符号表,而每个复杂类型如数组或对象有自己的符号表,因此上面代码中,a和a[0]是两个符号,但是a储存在全局符号表中,而a[0]储存在数组本身的符号表中,且这里a和a[0]引用同一个zval(当然符号a后来被销毁了)。希望读者朋友注意分清符号(Symbol)的zval的关系。

在PHP只用于做动态页面脚本时,这种泄露也许不是很要紧,因为动态页面脚本的生命周期很短,PHP会保证当脚本执行完毕后,释放其所有资源。但是PHP发展到目前已经不仅仅用作动态页面脚本这么简单,如果将PHP用在生命周期较长的场景中,例如自动化测试脚本或deamon进程,那么经过多次循环后积累下来的内存泄露可能就会很严重。这并不是我在耸人听闻,我曾经实习过的一个公司就通过PHP写的deamon进程来与数据存储服务器交互。

由于Reference Counting的这个缺陷,PHP5.3改进了垃圾回收算法。

 

PHP5.3中的垃圾回收算法:Concurrent Cycle Collection in Reference Counted Systems

PHP5.3的垃圾回收算法仍然以引用计数为基础,但是不再是使用简单计数作为回收准则,而是使用了一种同步回收算法,这个算法由IBM的工程师在论文《Concurrent Cycle Collection in Reference Counted Systems》中提出。

这个算法可谓相当复杂,从论文29页的数量我想大家也能看出来,所以我不打算(也没有能力)完整论述此算法,有兴趣的朋友可以阅读上面的提到的论文 (强烈推荐,这篇论文非常精彩,算法精髓,哈哈)。

我在这里,只能大体描述一下此算法的基本思想。

首先,PHP会分配一个固定大小的“根缓冲区”,这个缓冲区用于存放固定数量的zval,这个数量默认是10,000,如果需要修改则需要修改源代码Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然后重新编译。

由上文我们可以知道,一个zval如果有引用,要么被全局符号表中的符号引用,要么被其它表示复杂类型的zval中的符号引用。因此在zval中存在一些可能根(root)。这里我们暂且不讨论PHP是如何发现这些可能根的,这是个很复杂的问题,总之PHP有办法发现这些可能根zval并将它们投入根缓冲区。

当根缓冲区满额时,PHP就会执行垃圾回收,此回收算法如下:

1、对每个根缓冲区中的根zval按照深度优先遍历算法遍历所有能遍历到的zval,并将每个zval的refcount减1,同时为了避免对同一zval多次减1(因为可能不同的根能遍历到同一个zval),每次对某个zval减1后就对其标记为“已减”

2、再次对每个缓冲区中的根zval深度优先遍历,如果某个zval的refcount不为0,则对其加1,否则保持其为0。

3、清空根缓冲区中的所有根(注意是把这些zval从缓冲区中清除而不是销毁它们),然后销毁所有refcount为0的zval,并收回其内存。

 

如果不能完全理解也没有关系,只需记住PHP5.3的垃圾回收算法有以下几点特性:

1、并不是每次refcount减少时都进入回收周期,只有根缓冲区满额后在开始垃圾回收

2、可以解决循环引用问题,因为深度优先遍历了每一个根zval。

3、可以总将内存泄露保持在一个阈值以下。

 

PHP5.2 与 PHP5.3 垃圾回收算法的性能比较

由于我目前条件所限,我就不重新设计试验了,而是直接引用PHP Manual中的实验,关于两者的性能比较请参考PHP Manual中的相关章节:Performance Considerations

首先是内存泄露试验,下面直接引用PHP Manual中的实验代码和试验结果图:

<?php  
	class Foo  
	{  
	    public $var = '3.1415962654';  
	}  

	$baseMemory = memory_get_usage();  

	for ( $i = 0; $i <= 100000; $i++ )  
	{  
	    $a = new Foo;  
	    $a->self = $a;  
	    if ( $i % 500 === 0 )  
	    {  
	        echo sprintf( '%8d: ', $i ), memory_get_usage() - $baseMemory, "\n";  
	    }  
	}  
?> 

可以看到在可能引发累积性内存泄露的场景下,PHP5.2发生持续累积性内存泄露,而PHP5.3则总能将内存泄露控制在一个阈值以下(与根缓冲区大小有关)。

另外是关于性能方面的对比:

<?php  
	class Foo  
	{  
	    public $var = '3.1415962654';  
	}  

	for ( $i = 0; $i <= 1000000; $i++ )  
	{  
	    $a = new Foo;  
	    $a->self = $a;  
	}  
	echo memory_get_peak_usage(), "\n";  
?> 

这个脚本执行1000000次循环,使得延迟时间足够进行对比。

然后使用CLI方式分别在打开内存回收和关闭内存回收的的情况下运行此脚本:

time php -dzend.enable_gc=0 -dmemory_limit=-1 -n example2.php  
# and 
time php -dzend.enable_gc=1 -dmemory_limit=-1 -n example2.php 

在我的机器环境下,运行时间分别为6.4s和7.2s,可以看到PHP5.3的垃圾回收机制会慢一些,但是影响并不大。

 

与垃圾回收算法相关的PHP配置

可以通过修改 php.ini 中的zend.enable_gc 来打开或关闭PHP的垃圾回收机制,也可以通过调用gc_enable()或gc_disable()打开或关闭PHP的垃圾回收机制。

在PHP5.3中即使关闭了垃圾回收机制,PHP仍然会记录可能根到根缓冲区,只是当根缓冲区满额时,PHP不会自动运行垃圾回收,当然,任何时候您都可以通过手工调用 gc_collect_cycles() 函数强制执行内存回收

 

 

PHP 中的 GC机制详解

PHP memory_get_usage()管理内存PHP unset全局变量运用问题详解

PHP unset()函数销毁变量教你快速实现PHP全站权限验证

 

一、PHP 垃圾回收机制(Garbage Collector 简称GC)

在PHP中,没有任何变量指向这个对象时,这个对象就成为垃圾。

PHP会将其在内存中销毁;这是PHP的GC垃圾处理机制,防止内存溢出。

当一个PHP线程结束时,当前占用的所有内存空间都会被销毁,当前程序中所有对象同时被销毁。

GC进程一般都跟着每起一个SESSION而开始运行的.

GC目的是为了在session文件过期以后,自动销毁删除这些文件

 

二、__destruct /unset __destruct()

析构函数,是在垃圾对象被回收时执行。

unset 销毁的是指向对象的变量,而不是这个对象。

 

三、 Session 与PHP垃圾回收机制

由于PHP的工作机制,它并没有一个daemon线程来定期的扫描Session信息并判断其是否失效,当一个有效的请求发生时,PHP 会根据全局变量 session.gc_probability 和 session.gc_pisor 的值,来决定是否启用一个GC, 在默认情况下,session.gc_probability=1, session.gc_pisor =100也就是说有1%的可能性启动GC(也就是说100个请求中只有一个gc会伴随100个中的某个请求而启动).

; Enables or disables the circular reference collector.
; http://php.net/zend.enable-gc
zend.enable_gc = On
session.gc_probability = 1
session.gc_divisor = 1000
session.gc_maxlifetime = 1440

PHP垃圾回收机制的工作就是扫描所有的Session信息,用当前时间减去session最后修改的时间,同session.gc_maxlifetime参数进行比较,如果生存时间超过gc_maxlifetime(默认24分钟),就将该session删除。

但是,如果你Web服务器有多个站点,多个站点时,GC处理session可能会出现意想不到的结果,原因就是:GC在工作时,并不会区分不同站点的session.那么这个时候怎么解决呢?

1. 修改session.save_path,或使用session_save_path()让每个站点的session保存到一个专用目录,

2. 提供GC的启动率,自然,PHP垃圾回收机制的启动率提高,系统的性能也会相应减低,不推荐。

3. 在代码中判断当前session的生存时间,利用session_destroy()删除。

 

引用计数基本知识

每个php变量存在一个叫做"zval"的变量容器中.

一个zval变量容器,除了包含变量的类型和值,还包括两个字节的额外信息.

第一个是"is_ref",是个bool值,用来标识这个变量是否是属于引用集合(reference set).通过这个字节,php引擎才能把普通变量和引用变量区分开。由于php允许用户通过使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。第二个额外字节是"refcount",用来表示指向这个zval变量容器的变量(也称符号即symbol)个数。

当一个变量被赋常量值时,就会生成一个zval变量容器,如下例所示:

<?php
    $a = "new string";
?>

在上例中,新的变量是a,是在当前作用域中生成的.并且生成了类型为string和值为"new string"的变量容器.在额外的两个字节信息中,"is_ref"被默认设置为false,因为没有任何自定义的引用生成."refcount"被设定为1,因为这里只有一个变量使用这个变量容器.调用xdebug查看一下变量内容:

<?php
    $a = "new string";
    xdebug_debug_zval('a');
?>

以上代码会输出:

a: (refcount=1, is_ref=0)='new string'

对变量a增加一个引用计数

<?php
	$a = "new string";
	$b = $a;
	xdebug_debug_zval('a');
?>

以上代码会输出:

a: (refcount=2, is_ref=0)='new string'

这时,引用次数是2,因为同一变量容器被变量a和变量b关联.当没必要时,php不会去复制已生成的变量容器.变量容器在"refcount"变成0时就被销毁.当任何关联到某个变量容易的变量离开它的作用域(比如:函数执行结束),或者对变量调用了unset()函数,"refcount"就会减1,下面例子就能说明:

<?php
	$a = "new string";
	$b = $c = $a;
	xdebug_debug_zval('a');
	unset($b, $c);
	xdebug_debug_zval('a');
?>

以上代码会输出:

a: (refcount=3, is_ref=0)='new string' a: (refcount=1, is_ref=0)='new string'

如果我们现在执行unset($a),$包含的类型和值的这个容器就会从内存删除

 

复合类型(compound types)

当考虑像array和object这样的复合类型时,事情会稍微有些复杂.与标量(scalar)类型的值不同,array和object类型的变量把它们的成员或属性存在自己的符号表中.这意味着下面的例子将生成三个zval变量容器

<?php
	$a = array('meaning' => 'life', 'number' => 42);
	xdebug_debug_zval('a');
?>

以上代码输出:

a: (refcount=1, is_ref=0)=array ('meaning' => (refcount=1, is_ref=0)='life', 'number' => (refcount=1, is_ref=0)=42)

这三个zval变量容器是:a,meaning,number.增加和减少refcount的规则和上面提到的一样

特例,添加数组本身作为数组元素时:

<?php
	$a = array('one');
	$a[] = &$a;
	xdebug_debug_zval('a');
?>

以上代码输出的结果:

a: (refcount=2, is_ref=1)=array (0 => (refcount=1, is_ref=0)='one', 1 => (refcount=2, is_ref=1)=...)

可以看到数组a和数组本身元素a[1]指向的变量容器refcount为2

当对数组$a调用unset函数时,$a的refcount变为1,发生了内存泄漏

 

清理变量容器的问题

尽管不再有某个作用域中的任何符号指向这个结构(就是变量容器),由于数组元素"1"仍然指向数组本身,所以这个容器不能被消除.因为没有另外的符号指向它,用户没有办法清除这个结构,结果就会导致内存泄漏.庆幸的是,php将在请求结束时清除这个数据结构,但是php清除前,将耗费不少内存空间

回收周期

5.3.0PHP使用了新的同步周期回收算法,来处理上面所说的内存泄漏问题

首先,我们先要建立一些基本规则:

如果一个引用计数增加,它将继续被使用,当然就不再垃圾中.如果引用技术减少到零,所在的变量容器将被清除(free).就是说,仅仅在引用计数减少到非零值时,才会产生垃圾周期(grabage cycle).其次,在一个垃圾周期中,通过检查引用计数是否减1,并且检查哪些变量容器的引用次数是零,来发现哪部分是垃圾

为避免不得不检查所有引用计数可能减少的垃圾周期,这个算法把所有可能根(possible roots 都是zval变量容器),放在根缓冲区(root buffer)中(用紫色标记),这样可以同时确保每个可能的垃圾根(possible garbage root)在缓冲区只出现一次.仅仅在根缓冲区满了时,才对缓冲区内部所有不同的变量容器执行垃圾回收操作。

 

vim /usr/local/php/lib/php.ini  (摘录 GC 部分)

; Enables or disables the circular reference collector.
; http://php.net/zend.enable-gc
zend.enable_gc = On

; Defines the probability that the 'garbage collection' process is started
; on every session initialization. The probability is calculated by using
; gc_probability/gc_divisor. Where session.gc_probability is the numerator
; and gc_divisor is the denominator in the equation. Setting this value to 1
; when the session.gc_divisor value is 100 will give you approximately a 1% chance
; the gc will run on any give request.
; Default Value: 1
; Development Value: 1
; Production Value: 1
; http://php.net/session.gc-probability
session.gc_probability = 1

; Defines the probability that the 'garbage collection' process is started on every
; session initialization. The probability is calculated by using the following equation:
; gc_probability/gc_divisor. Where session.gc_probability is the numerator and
; session.gc_divisor is the denominator in the equation. Setting this value to 1
; when the session.gc_divisor value is 100 will give you approximately a 1% chance
; the gc will run on any give request. Increasing this value to 1000 will give you
; a 0.1% chance the gc will run on any give request. For high volume production servers,
; this is a more efficient approach.
; Default Value: 100
; Development Value: 1000
; Production Value: 1000
; http://php.net/session.gc-divisor
session.gc_divisor = 1000

; After this number of seconds, stored data will be seen as 'garbage' and
; cleaned up by the garbage collection process.
; http://php.net/session.gc-maxlifetime
session.gc_maxlifetime = 1440

; NOTE: If you are using the subdirectory option for storing session files
;       (see session.save_path above), then garbage collection does *not*
;       happen automatically.  You will need to do your own garbage
;       collection through a shell script, cron entry, or some other method.
;       For example, the following script would is the equivalent of
;       setting session.gc_maxlifetime to 1440 (1440 seconds = 24 minutes):
;          find /path/to/sessions -cmin +24 -type f | xargs rm

 

 

参考推荐:

PHP GC 垃圾回收机制深入了解

PHP 资源收集整理大全

PHP 实现定时任务的五种方法

PHP Session 与 Cookie 详解

Android内存分析工具

10门最常用的编程语言和应用场景