PHP-----浅谈垃圾回收机制

前言

大多数编程语言都会有自身的垃圾回收机制,php也不例外。经常听很多人说gc,也就是垃圾回收器,全程为Garbage Collection。

在php5.3之前,是不包括垃圾回收机制的,也没有专门的垃圾回收器,实现垃圾回收就是简单判断一下变量的zval的refcount是否为0,是的话就释放。

但是如果这么简单的判断垃圾回收的话,很容易引起程序过程中内存溢出。如果存在"自身指向自身"的情况的话,那么变量将无法回收早成内存泄露,所以从php5.3开始就出现了专门负责清理垃圾数据防止内存泄露的垃圾回收器。

 

引用计数的基本知识

我们要了解GC,那么首先要了解引起垃圾回收的基数是什么。

在php中,每个变量存在一个叫“zval”的变量容器中。一个zval变量容器,除了包含变量的类型和值,还包括另外两个字节的额外信息。第一个是"is_ref"。第二个是"refcount"。

is_ref是一个布尔类型的值,用来标示这个变量是否属于引用集合。通过这个字节,php引擎才能把普通变量和引用变量区分开来,由于php允许用户通过"&"来使用自定义的引用,所以zval中还有一个内部引用计数机制,来进行优化内存。

refcount用来表示这个zval变量容器的变量的个数。所有符号存在一个符号表当中,每个符号都有作用域。

通俗的讲:

  • refcount就是多少个变量是一样的用了相同的值,那么refcount就是这个值
  • is_ref就是当有变量用了&的形式进行赋值,那么is_ref的值就会增加
<?php
$a = "new string";
?>
在上面的代码中,变量a是在当前作用于中生成的,并且生成了类型为String和值为"new string"的变量容器。这个时候is_ref被默认的设置成了false,因为现在没有任何自定义的引用生成。refcount被设置成了1。我们可以用php来看到这些计数的变化,首先需要用到xdebug,所以php没有装上xdebug扩展的需要先装一下。
<?php
$a = "new string";
xdebug_debug_zval('a');

输出:a: (refcount=1, is_ref=0)='new string'
?>
增加zval的引用计数
<?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";
$c = $b = $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);,包含类型和值的这个变量容器就会从内存中删除。

复合类型

当变量的类型为array或object这样的复合类型时,array和object类型的变量把他们的成员或属性存在自己的符号表中。

<?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的refcount和is_ref。下面这张图是从官网上扒下来的:

添加一个已经存在的元素到数组中:

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

输出:
a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=2, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42,
   'life' => (refcount=2, is_ref=0)='life'
)
?>
如下图解释:

从数组中删除一个元素:

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

输出:
a: (refcount=1, is_ref=0)=array (
   'life' => (refcount=1, is_ref=0)='life'
)
?>
删除数组中一个元素,就是类似从作用于中删除一个变量,删除后数组中这个元素所在容器的refcount的值减少,当refcount为0时,这个变量容器就从内存中被删除。 

将数组作为一个元素添加给自身:

<?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同时也是这个数组第二个元素,指向的变量容器中refcount的值为2,上面输出的“...”说明发生了递归操作,意味着"..."指向原始数组。

尽管不再有某个作用域中的任何符号指向这个结构(就是变量容器),由于数组元素“1”仍然指向数组本身,所以这个容器不能被清除 。因为没有另外的符号指向它,用户没有办法清除这个结构,结果就会导致内存泄漏。

 

垃圾回收周期

在5.3之前的版本中,php无法处理循环的引用内存泄露。但是自5.3之后php使用引用计数系统中同步周期回收的同步算法,仅处理这个内存泄露问题。

基本思想是如果一个引用计数增加那么将继续被使用,当然就不再是垃圾。如果引用计数减少到零,所在变量容器将被清除。那么也就是说只有在引用计数减少到非零值时,才会产生垃圾周期。在一个垃圾周期中通过检查引用计数是否减1,并且检查哪些变量容器的引用次数为零,来发现哪些是垃圾。

我们就拿这张图举例(来自php官网)。为了避免不得不检查所有引用计数可能减少的垃圾周期,同步算法将所有可能根放在了根缓冲区(root buffer)中(在图中用紫色来标记,称为疑似垃圾),这样可以同时确保每个可能的垃圾根在缓冲区中只出现一次。仅当根缓冲区满了时,才对缓冲区中所有不同的变量容器执行垃圾回收操作,在图中体现为步骤A。

在步骤B中,模拟删除每个紫色的变量。模拟删除时可能将不是紫色的不同变量引用数减1,如果某个普通变量引用计数变成0时,就对这个普通变量在做一次模拟删除。每个变量只能被模拟删除一次,模拟删除后标记为灰色。

在步骤C中,模拟恢复每个紫色变量。当然这个恢复是有条件的,当变量的引用计数大于0时才对其做模拟恢复。同样的每个变量只能恢复一次,恢复后标记为黑色,这样生下一对没能恢复的就是该删除的蓝色节点了,在步骤D中遍历出来真正的删除掉。

在php中垃圾回收机制默认是打开的,在你的php.ini中可以手动设置,通过zend.enable_gc这个属性进行开启或关闭垃圾回收机制。当开启了垃圾回收机制后,每当根缓存区存满时,就会执行上面描述的循环查找算法。根缓存区具有固定的大小,当然你可以通过修改php源码文件Zend/zend_gc.c中常量GC_ROOT_BUFFER_MAX_ENTRIES来修改根缓存区的大小(注意修改后需要重新编译php)。当关闭垃圾回收机制后,这个循环查找算法将不会执行,然而可能根会一直存在于根缓冲区中,不管在配置中是否激活了垃圾回收机制。

当然你也可以通过调用gc_enable()和gc_disable()函数来打开和关闭垃圾回收机制,效果和修改配置项相同。即使根缓冲区还没有满,也能强制执行周期回收。

 

php的内存管理机制

现在我们已经知道了zval是怎么回事了。那么现在我们需要知道php的内存管理机制是怎么一回事。

var_dump(memory_get_usage());
$test = "这是测试啊";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());

输出(php5.6):
/var/www/html/node_test/phptest/phptest.php:51:
int(361896)
/var/www/html/node_test/phptest/phptest.php:53:
int(361928)
/var/www/html/node_test/phptest/phptest.php:55:
int(361896)
过程是:定义变量->内存增加->清除变量->内存恢复
var_dump(memory_get_usage());
$test = "这是测试啊";
var_dump(memory_get_usage());
unset($name);
var_dump(memory_get_usage());

输出(php7.1):
/var/www/html/node_test/phptest/phptest.php:51:
int(361896)
/var/www/html/node_test/phptest/phptest.php:53:
int(361928)
/var/www/html/node_test/phptest/phptest.php:55:
int(361928)
而我在用php7时发现了这个问题,这就要说道php5和php7的内存管理机制和垃圾回收机制的不同了,这里暂且不表。我们继续往下走。

当在执行

$test = "这是测试啊";
内存的分配做了两件事:
  1. 为变量名分配内存,并存入符号表
  2. 为变量值分配内存

我们再看代码:

var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
    $$a = "hello";    
}
var_dump(memory_get_usage());
for($i=0;$i<100;$i++)
{
    $a = "test".$i;
     unset($$a);    
}
var_dump(memory_get_usage());

输出:
/var/www/html/node_test/phptest/phptest.php:57:
int(363520)
/var/www/html/node_test/phptest/phptest.php:63:
int(372384)
/var/www/html/node_test/phptest/phptest.php:69:
int(369216)
为什么内存没有全部收回来呢?

因为php的核心结构Hashtable,在定义的时候不可能一次性分配足够多的内存块,所以初始化的时候只会分配一小块,等不够的时候在进行扩容,而Hashtable只扩容不减少,所以当存入100个变量的时候符号表不够用了就进行一次扩容,当unset()时只是放了为变量值分配的内存,但是为变量名分配的内存还是在符号表中的,符号表并没有缩小,所以没收回来的内存是被符号表占去了。

php并不是只要内存不够就去向OS申请内存,而是先申请一大块内存,然后将其中一部分分给申请者,这样再有逻辑需要申请内存的时候,就不需要再向OS申请内存了,避免了重复申请,只有当一大块内存不够用的时候再去申请。而当释放内存时,php并非把内存还给了OS,而是把内存轨道自己维护的空闲内存列表,以便重复利用。

新版本的php(5.3版本之后)是如何处理垃圾内存的?

刚刚上面我们已经讲了,针对在php中环形引用导致的垃圾,产生了新的同步算法(GC算法),对于官网上的理论,我进行了理解:

如果一个zval的refcount增加,那么表明该变量的zval还在使用,不属于垃圾

如果一个zval的refcount减少到0,那么zval可以被释放掉,可以清除,不是垃圾

如果在经过模拟删除后一个zval的refcount减1,如果该zval的引用次数为是大于0,那么此zval不能被释放,可能是一个垃圾

 

关于垃圾回收的小知识点

unset():unset()只是断开一个变量到一块内存区域的连接,同时将该内存区域的引用计数减1,内存是否回收主要还是看refcount是否到0了。

null:将null赋值给一个变量是直接将该变量指向的数据结构置空,同时将其引用计数归0。

脚本执行结束:该脚本中所有内存都会被释放,无论是否有环引用。

posted @ 2018-10-25 19:36  如是说  阅读(5999)  评论(1编辑  收藏  举报