PHP5垃圾回收机制

在了解垃圾回收机制之前我们必须先来看下zval结构。zval是用于保存变量以及常量的。

php5中的zval数据结构:

    typedef struct _zval_struct {
        zvalue_value value;
        zend_uint refcount__gc;
        zend_uchar type;
        zend_uchar is_ref__gc;
    } zval;

zval 包含了一个value、一个type以及两个__gc后缀的字段。value是一个联合体,用于存储不同类型的值。

  1. zval_value value
typedef union _zvalue_value {
    long lval;                 // 用于 bool 类型、整型和资源类型
    double dval;               // 用于浮点类型
    struct {                   // 用于字符串
        char *val;
        int len;
    } str;
    HashTable *ht;             // 用于数组
    zend_object_value obj;     // 用于对象
    zend_ast *ast;             // 用于常量表达式(PHP5.6 才有)
} zvalue_value;
  1. zend_uint refcount__gc
    该值是一个计数器,用来保存有多少个变量(或者符号)指向该zval。在变量生成的时,其refcount=1,典型的赋值操作如: $a=$b,会令zval的refount加1,而unset操作会使操作相应的减1。php5使用引用计数的机制来实现GC,如果一个zval的refcount减少到0,那么zend引擎会认为没有任何变量指向该zval,因此会释放该zval所占的内存空间。但事情有时并不会这么简单,后面我们会看到,单纯的引用计数机制无法GC掉循环引用的zval,即使指向该zval的变量已经被unset,从而导致内存泄露。
  2. zend_uchar type
    该字段用于表明变量的实际类型,我们知道,php包含四种标量类型(bool,int,float,string),两种符合类型(array.object),两种特殊类型(resource 和null)。在zend内部,这些类型对于下面的宏:
#define IS_NULL     0
#define IS_LONG     1
#define IS_DOUBLE   2
#define IS_BOOL     3
#define IS_ARRAY    4
#define IS_OBJECT   5
#define IS_STRING   6
#define IS_RESOURCE 7
#define IS_CONSTANT 8
#define IS_CONSTANT_ARRAY   9
#define IS_CALLABLE 10
  1. is_ref__gc
    这个字段用于标记变量是否是引用变量。对于普通变量,该值为0,而对于引用变量,该值为1.这个变量会影响zval的共享、分离等。refcount__gc 和 is_ref__gc是php的GC机制所需的很重要的两个字段,这两个字段的值可以使用xdebug 等调试工具查看。笔者这里为了测试分别在不同的机器上安装了不同版本的php并且安装了xdebug,这里就不讲解具体的安装过程了。下面先在php5 (笔者这里是php5.6)下做相关的一些测试:
  2. 创建变量时,会创建一个zval
$str = "test zval";
xdebug_debug_zval('str');

输出结果如下:

str: (refcount=1, is_ref=0)='test zval'

当使用$str = "test zval";创建变量时,会在当前作用域的符号表中插入新的符号(str),由于该变量是一个普通变量,因此会生成一个refcount=1且is_ref=0的zval容器,也就是说实际是这样的:


zval容器.png
  1. 变量赋值给另外一个变量时,会增加zval的refcount值
$str = "test zval";
$str2 = $str;
xdebug_debug_zval('str');
xdebug_debug_zval('str2');

输出结果如下:

str: (refcount=2, is_ref=0)='test zval'
str2: (refcount=2, is_ref=0)='test zval'

我们看到str和str2的zval结构是一样的。这里其实是php所做的一个优化,由于str和str2都是普通变量,因而它们指向了同一个zval,而没有为str2开辟单独的zval。这么做在一定程度上可以节约一定的内存。这时str和str2的关系是这样的。


zval.png
  1. 使用unset时,减少相应的refcount值
$str = "test zval";
$str3 = $str2 = $str;
xdebug_debug_zval('str');
xdebug_debug_zval('str2');
xdebug_debug_zval('str3');
unset($str2,$str3);
xdebug_debug_zval('str');
xdebug_debug_zval('str2');
xdebug_debug_zval('str3');

输出结果如下:

str: (refcount=3, is_ref=0)='test zval'
str2: (refcount=3, is_ref=0)='test zval'
str3: (refcount=3, is_ref=0)='test zval'
str: (refcount=1, is_ref=0)='test zval'
str2: no such symbol
str3: no such symbol

从上面的输出结果我们可以知道:str,str2,str3 三个变量同时指向了一个zval容器,unset str2和str3后将这两个变量从符号表中删除了。最终只有一个str指向了该zval结构。


zval.png

现在如果执行unset($str),则由于zval的refcount会减少到0,该zval会从内存中清理。这当然是最理想的情况,但是事情并不总是这么乐观。

  1. 数组变量的zval 结构
$arr = [
    'id' => 1,
    'name' => 'xiaomimg'
];
xdebug_debug_zval($arr);

看下输出结果:

arr: (refcount=1, is_ref=0)=array ('id' => (refcount=1, is_ref=0)=1, 'name' => (refcount=1, is_ref=0)='xiaomimg')

我们发现对于数组的每个元素都有一个zval结构,如下图所示:


zval.png

对于每个zval而言,refcount的递增减规则与普通变量是一样的。比如,我们在数组中添加另外一个元素并把$arr['name'] 赋值给它:

$arr = [
    'id' => 1,
    'name' => 'xiaomimg'
];
$arr['nickname'] = $arr['name'];
xdebug_debug_zval($arr);

输出结果如下:

arr: (refcount=1, is_ref=0)=array (
'id' => (refcount=1, is_ref=0)=1, 
'name' => (refcount=2, is_ref=0)='xiaomimg',
'test' => (refcount=2, is_ref=0)='xiaomimg'
)

和普通变量一样name和test指向了同一个zval:


zval.png

同样的,从数组中移除该元素,会从符号表中删除相应的符号,同时减少对应zval的refcount的值,同样,如果zval的refount值减少到了0,那么就会从内存中删除该zval:

$arr = [
    'id' => 1,
    'name' => 'xiaomimg'
];
$arr['nickname'] = $arr['name'];
unset($arr['nickname'],$arr['name']);
xdebug_debug_zval($arr);

输出结果如下:

arr: (refcount=1, is_ref=0)=array (
'id' => (refcount=1, is_ref=0)=1
)
zval.png
  1. 引用的出现,会令zval变得复杂
$a = ['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)=...)

其中,... 表示指向原始的数组,因而这是一个循环引用,如下图所示:


zval.png

调用unset(a),会删除a这个符号,且它指向的变量容器中的引用次数也减1,所以,如果我们在执行上面的代码后对变量

a 调用了unset,那么变量
a 和数组元素“1” 所指向的变量容器的引用次数减1:

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

如下图所示:


zval.png

尽管不再有某个作用域中的任何符号执行这个结构(就是变量容器),由于数组元素“1” 仍然指向数组本身,所以这个容器不能被清除。因为没有另外的符号指向它,用户没有办法清除这个结构,结果就导致内存泄露。庆幸的是,php将在脚本执行结束时清除这个数据结构,但是在php清除之前,将会消耗不少内存。当然,同样的情况也会发生在对象上,实际上对象更有可能出现这种情况,因为对象总是隐式的被引用。不过需要注意的是:5.3以后引入了新的垃圾回收算法来对付循环引用计数的问题。下面来简单了解下php5.3之后的版本是如何解决这个问题的:
php5.3之后的版本引入了根换成机制,即php启动时默认设置指定zval数量的根缓冲区(默认是10000),当php发现有存在循环引用的zval时,就会把其投入到根缓冲区,当根缓冲区达到配置文件中指定的数量(默认为10000)后,就会进行垃圾回收,以此解决循环引用导致的内存泄露问题。
确认为垃圾的准则:
1、如果引用计数减少到零,所在变量容器将被清除(free),不属于垃圾
2、如果一个zval的引用计数减少后还大于0,那么它会进入垃圾周期。其次,在一个垃圾周期中,通过检查引用计数是否减1,并且哪些变量容器的引用次数是零,来发现哪部分是垃圾。

  1. zval 分离
    前面我们已经介绍过,在变量赋值的过程中例如b=a,为了节省内存,并不会为a和b都开辟单独的zval,而是使用共享zval,那么当其中一个变量修改了,如何处理zval共享的问题?
$a = 'test zval';
$b= $a;
xdebug_debug_zval('a');
xdebug_debug_zval('b');

$b = 'modify';
echo 'after write '.PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');

输出结果:

a: (refcount=2, is_ref=0)='test zval'
b: (refcount=2, is_ref=0)='test zval'
after write 
a: (refcount=1, is_ref=0)='test zval'
b: (refcount=1, is_ref=0)='modify'

最初,为了节约内存变量a和变量b同时指向同一个zval,而后变量b发生变化,Zend会检查b指向的zval的refcount是否为1,如果是1,那么说明只有一个符号指向该zval,则直接更改zval。否则,说 明这是一个共享的zval,需要将该zval分离出去,以保证单独变化互不影响,这种机制叫做COW –Copy on write。在很多场景下,COW都是一种比较高效的策略。
那么对于引用变量呢?

$a = 'test';
$b = &$a;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
$b=12;
echo 'after change '.PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
unset($b);
echo 'after unset'.PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');

输出结果如下:

a: (refcount=2, is_ref=1)='test'
b: (refcount=2, is_ref=1)='test'
after change 
a: (refcount=2, is_ref=1)=12
b: (refcount=2, is_ref=1)=12
after unset
a: (refcount=1, is_ref=0)=12
b: no such symbol

可以看出,在改变变量b的值之后,zend会检查zval的is_ref 是否是引用变量,如果是则直接更改,否则需要分离zval。由于a和b是引用变量。因而更改共享的zval实际也间接更改了a的值。而在unset(b),后,变量b从符号表中删除了。
这也说明一个问题,unset并不是清除zval,而只是从符号表中删除相应的符号。

PHP5 zval结构的缺点

  1. 这个结构体的大小是(在64位系统)24个字节,其中zend_object_value是最大的长板它导致整个value需要16个字节,这个应该是很容易可以优化掉的,比如把它移出来,用个指针代替,因为毕竟IS_OBJECT也不是最常用的类型。
  2. 这个结构体的每一个字段都有明确的含义定义,没有预留任何的自定义字段,导致PHP5时代做很多的优化的时候,需要存储一些和zval相关的信息的时候,不得不采用其他的结构体映射,或者外部包装后打补丁的方式来扩充zval,比如5.3的时候新引入专门解决循环引用GC,它不得不采用比较hack的做法:
/* The following macroses override macroses from zend_alloc.h */
#undef  ALLOC_ZVAL
#define ALLOC_ZVAL(z)                                   \
do {                                                \
(z) = (zval*)emalloc(sizeof(zval_gc_info));     \
 GC_ZVAL_INIT(z);                                \
} while (0)
它用zval_gc_info劫持了zval的分配:
typedef struct _zval_gc_info {
 zval z;
 union {
       gc_root_buffer       *buffered;
       struct _zval_gc_info *next;
  } u;
} zval_gc_info;

然后用zval_gc_info来扩充了zval,所以实际上来说我们在PHP5时代申请分配一个zval其实真正分配的是32个字节,但其实GC只需要关心IS_ARRAY和IS_OBJECT类型,这样就导致了大量的内存浪费。

  1. php的zval大部分是按值传递(复制一个zval),写时拷贝的值,但是有两个例外,就是对象和资源,它们永远都是按引用传递(传递的是内存的地址),这样就会导致一个问题,对象和资源在除了zval中引用计数之外,还需要一个全局的引用计数,这样才能保证内存可以回收。所以在php5的时代,以对象为例,它有两套引用计数,一个是zval中的,另一个是obj自身的计数。
typedef struct _zend_object_store_bucket {
    zend_bool destructor_called;
    zend_bool valid;
    union _store_bucket {
         struct _store_object {
            void *object;
             zend_objects_store_dtor_t dtor;
             zend_objects_free_object_storage_t free_storage;
             zend_objects_store_clone_t clone;
             const zend_object_handlers *handlers;
             zend_uint refcount;
             gc_root_buffer *buffered;
      } obj;
      struct {
          int next;
      } free_list;
   } bucket;
} zend_object_store_bucket;

除了上面提到了两套引用以外,如果我们要获取一个object,则我们需要通过如下方式:

EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj

经过漫长的多次内存读取才能获取真正的object对象本身,效率可想而知。这是因为Zend引擎在最初设计的时候,并没有考虑到后来的对象。

  1. 我们知道php中,大量的计算都是面向字符串的,然而因为引用计数是作用在zval的,那么就会导致如果要拷贝一个字符串类型的zval,我们别无他法只能复制这个字符串,当我们把一个zval的字符串作为key添加到一个数组里的时候,我们别无他法只能复制这个字符串。虽然在php5.4的时候,我们引入了INTERNED STRING,但是还是不能从根本上解决这个问题。
    还比如,php中打量的结构体都是基于HashTable实现的,增删改查占据了大量的CPU时间,而字符串要查找首先要求它的Hash值,理论上我们完全可以把一个字符串的Hash值计算好以后保存起来,避免再次计算等等。
  2. php5的时代,我们采用写时分离,但是结合引用这里就有了一个经典的性能问题:
<?php
 function dummy($array) {}
 $array = range(1, 100000);
 $b = &$array;
 dummy($array);
?>

当我们调用dummy的时候,本来只是简单的一个传值就行的地方,但是因为变量array曾经引用赋值给了b,所以导致变量array变成了一个引用,于是此处就会发生分离。从而极大的拖慢了性能,小伙伴可以简单的测试下,加上 变量b = &$array;这句和没加上这句的运行效率是相差很多的。

  1. 这点也是最重要的一点,为什么说它重要呢?因为这点促成了很大的性能提升,我们习惯在php5时代调用MAKE_STD_ZVAL在堆内存上分配一个zval,然后对他进行操作,最后呢通过RETURN_ZVAL把这个zval的值“copy”给return_value,然后又销毁了这个zval,比如pathinfo这个函数。
PHP_FUNCTION(pathinfo)

{

.....

     MAKE_STD_ZVAL(tmp);

     array_init(tmp);

.....

  

    if (opt == PHP_PATHINFO_ALL) {

        RETURN_ZVAL(tmp, 0, 1);

    } else {

.....

}

这个tmp变量完全是一个临时变量的作用,我们又何必在堆内存分配它呢?
MAKE_STD_ZVAL/ALLOC_ZVAL在PHP5的时候,到处都有,是一个非常常见的用法,如果我们把这个变量用栈分配,那无论是内存分配,还是缓存友好,都是非常有利的。
还有很多这里就不一一列举了。
后面会接着来讲解php7的zval结构。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270