PHP7源码分析初探PHP字符串类型中的引用计数

16次阅读

共计 3411 个字符,预计需要花费 9 分钟才能阅读完成。

作者:王澍

背景介绍

  • 字符串类型也是我们平时常用的类型,由于字符串的特性,为了节省内存通常相同字符串变量会共用一块内存空间,通过引用计数来标记有多变量使用这块内存。
  • 但是,经过 GDB 追踪发现,并不是所有字符串都是正常在操作引用计数,有正常累加的,有时候为 0,又有时候为 1。为了一探究竟,于是简单分析了一下各种赋值情况。

环境情况

  • 系统版本:Ubuntu 16.04.3 LTS
  • PHP 版本:PHP 7.1.0
  • gdb 版本:GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1

一、基础变量

PHP 中 zval 是所有变量的基础。(zend_type.h 121 行)

其中 zend_value 存储了具体数据,结构如图: (zend_type.h 101 行)

  • zend_value 为一个联合体,整体占用 8 字节。
  • u1 为一个联合体,存储了类型所需要的必要数据,占用 4 字节。
  • u2 位一个联合体,存储了一些额外数据,比如 hash 碰撞时的 next,占用 4 字节。

整个 zval 结构体,占用 16 字节,就支持了 php 所有类型。

PHP7 中用如此简单而巧妙的 zval 存储了所有类型数据,那么一个不确定长度的字符串又如何能存储在一个 16 字节的 zval 中呢?

二、字符串变量

<?php
$a = "hello world";
echo $a;

通过 GDB 调试可以看到:

type = 6,对照类型的定义,可以看到类型是IS_STRING (zend_type.h 303 行)

由于我们的字符串长度不一定,所以单靠 zval 的 16 个字节是无法直接存储的,于是通过 value 中的 str 指向真正存储字符串的内存地址。通过打印我们可以看到,这个地址的类型是zend_string

1、zend_string 结构体

先看一下它的数据结构,如图 (zend_type.h 169 行)

zend_string 结构体中的 gc
头部先是 gc,可以看一下其他复杂类型,头部都有一个 gc,它的作用是什么?
看看 gc 的数据结构,如图:

  • 第一个是 refcount,记录了被引用的次数。
  • 第二个 u 是一个联合体, 可以看到与 zval 的 u1 很像,关键是记录了 type。

那么作用就比较好猜测了,在程序执行 gc 或其他操作的时候,对于任意一个复杂类型,指针头部就是 gc,里面不光有引用计数,并且能通过 u.v.type 确定该复杂类型的真正类型。

zend_string 结构体中的 h
从名字可以猜测,这是字符串的 hash,空间换时间的思想,把计算好的 hash 保存下来,提高性能。

zend_string 中的 len
比较明显,它存储了字符串的长度。

zend_string 中的 val[1]
这种写法是 c 里面的柔性数组,这里存储了整个字符串,通过这个方式保证字符串所在的内存地址是与该结构体内存地址紧密相连的,减少了去另外一个块内存取值的时间。
(ps: 留个小疑问,gdb 就可以追踪到。柔性数组是否占内存空间?zend_string 的对齐后是什么结构? 整体占多大?)

2、zend_string 实际内容

了解过结构本身,可以打印一下内容来看看,如图

该地址内存储的确实是 hello world,为什么 gc 中的 refcount 是 0 呢?

原因是这样的:

  • 常量字符串,在 PHP 代码中的固定字符串,在编译阶段存储在全局变量表中,又叫做字面量表,请求结束后才会被销毁,所以 refcount 一直为 0。
  • 临时字符串,发生在虚拟机执行 opcode 时计算出来的字符串,存储在临时变量区,有正常的 refcount。

修改一下代码,看一下临时字符串

<?php
$a = "hello world".time();
echo $a;

打印一下这个变量的 zval,refcount 为 1,如图

三、字符串的引用计数

1. 临时字符串直接赋值

对于临时字符串,应该是每有一个被赋值的变量时,该 zend_string 中的引用计数 +1,并且在引用计数为 0 时,释放这块内存。
<?php

$a = "hello world".time();
echo $a;
$b = $a;
echo $b;
当为 $a 赋值完成时,$a, 在栈上第一个位置,类型为 6,IS_STRING,取 value 中的 str,地址为:**0x7ffff4402c30**,可以看到内容,zend_string 引用计数为 1。

当为 $b 赋值完成时,$b 在栈上第二个位置,类型为 6,IS_STRING,取 value 中的 str 同样地址为:**0x7ffff4402c30**,zend_string 引用计数为 2。

大致引用情况可以画出:

2. 引用赋值

对于变量直接赋值,上面已经画出了引用关系,那么如果是引用类型呢?
<?php

$a = "hello world".time();
echo $a;
$b = &$a;
echo $b;
当为 $a 赋值完成时,$a, 在栈上第一个位置,类型为 6,IS_STRING,取 value 中的 str,地址为:**0x7ffff4402c30**,可以看到内容,zend_string 引用计数为 1。

当 $b 赋值为引用类型时,$b 在栈上第二个位置, 类型为 10 , IS_REFERENCE, 取 value 中的 ref 可以看到内容。

这时 $a 的类型是否发生改变?是否还是字符串类型?直接打印 $a 看一下。这时 $a 的类型变成了 10,IS_REFERENCE, 打印 value 中的 ref,地址与 $b 的 ref 相同!

在 $b 引用 $a 的时候,$a 与 $b 都变成引用类型,该引用类型指向了中有一个 zval,类型为 6,IS_STRING,value 中的 str 指向了一个 zend_string,并且 zend_string 引用计数为 1.

大致引用情况如图:

四、字符串变量特殊值

<?php
$a =“string”;
$b =“double”;
 
echo $a;
echo $b;

以我们上面的结论,$a 与 $b 都属于常量字符串。

打印 $a 的 zend_string,如图

打印 $b 的 zend_string,如图

可见 $b 符合预期,但是 $a 颠覆了以上的理论。
那问题出在了哪?

经过 GDB 的追踪,可以看到 a 和 b 都在栈上,并且都是 string 类型。
但是,其中 value 中的 str 地址有很大不一样。
首先看变量 a
在栈的第一个位置,str 的值是 0x11522c0

其次看变量 b
在栈上第二个位置,str 的值是 0x7ffff4401880

了解 PHP 的内存分配的话,可以看出 b 的字符串,分配在了 0x7ffff440000 这个 chunk 上,属于第一个 page 页,0x7ffff4401000

而 a 的字符串很显然不是这个规则,他没有分配在 chunk 上,而是很特殊的一个地址。
所以 string 这个字符串,不是_emalloc 分配的。

那么采用个笨办法,我把 0x11522c0 强转 (zend_string *)0x11522c0 , 然后看里的值什么时候放进去的。

PHP 版本 7.1.0
第一个节点: php_cli.c 中的 1345 行

sapi_module->startup(sapi_module)

第二个节点: php_cli.c 中的 424 行

php_module_startup(sapi_module, NULL, 0)

第三个节点: main.c 中的 2123 行

zend_startup(&zuf, NULL);

第四个节点: zend.c 中的 768 行

zend_interned_strings_init();

很接近了哦
第五个节点: zend_string.c 中的 103

zend_intern_known_strings(known_strings, (sizeof(known_strings)

在这里打印一下,know_strings,这里可以看到,file,line,function,class,object 等等,以及 string 在这里就初始化了!

对应声明的地址在 zend_string.h 383 行

这里还没有初始化字面量,于是这些字符串与字面量的情况有些不一样。

小结

同样是字符串在 PHP 中有很多种不同情况。

  • 1. 代码中直接硬编码的字符串,在字面量表中,引用计数一直为 0,直到整个脚本执行完毕后,才会销毁。
  • 2. 在执行阶段计算出来的字符串,临时字符串,引用计数正常计算,每个引用都会加 1。在引用计数为 0 时回收内存。
  • 3. 引用类型的字符串,多个变量引用计数计算在引用类型 (zend_reference) 上。字符串被 zend_reference 引用,引用计数为 1。
  • 4. 特殊的字符串,在 PHP 初始化时创建的,整个脚本执行完毕后才会销毁,引用计数一直为 1。

正文完
 0