保护私人版权,尊重他人版权。转载请注明出处并附带页面链接
前言
《PHP7底层设计与源码实现》一书,内容不多也不少。看完后,对开发中的优化思路有不少启发,其中最多的,便是有关变量方面的认识,也是我个人认为,在日常开发中,最值得多注意多思考的方面。
在此,个人结合github中最新的php-src,整理一些个人认为应该注意的点以及想法出来,与大家交流。同时,我会对其中的关键结构体在github上php-src源码中的定义的位置标注出来(截止发文时的最新版本)。这一点,是原书中没有标注的,在这里给出,更易于必要时查阅。
请确保具备以下知识基础
- 有C/C++基础
- 确保了解结构体struct和联合体union的定义和使用(特别是联合体的)
- 确保了解C/C++的取地址符&
正文
很多同学应该都晓得php的写时拷贝这个点,但是我还是选择对这方面深入探讨,是因为我发现日常工作中,还是个人与其他同学理依然容易混淆php7与php5的拷贝机制,且网上大多数文章脱离了php源码仅对结论做列举和验证。因此,在这里,我结合github中的源码对该部分做知识梳理。
一个问题
我们先来做个赋值,并获取赋值前后的内存占用,以此抛出我们后续要讨论的问题。
1 | $a = str_repeat('hello', 1); |
接下来,仅在第三行增加引用符号
1 | $a = str_repeat('hello', 1); |
由此,我们可以得到一个简单的结论:取引用符不能帮助我们节省内存空间,反而会增加内存空间使用。学过C\C++的同学,可能会对此比较懵逼(实际上,我并不止一次听到用同学认为『&』与C/C++中的取地址符等效)
接下来,我们带着这点疑问,继续深入探讨,自然就明白了。
引用
先来看一段代码和原理图。
示例代码与原理图
1 | $a = 10; |
实际上,在php7的底层中,存在一个zval结构体,任何的变量底层都是该结构体,且该结构体不存储真实value,其结构体中存在一个value字段,指向真正的存储值的结构体(在这里指向zend_string)。
因此,我们可以看到,php7底层已经做了优化。在赋值后,只会创建表示变量的zval结构体实例,不会创建新的zend_value结构体实例。
那么,什么时候会创建新的zend_value呢?这就是接下来要讲的写时拷贝。
简单的扩展:
- memory_get_usage()仅跟踪底层中emalloc()分配的内存。
- 所有底层定义,我们都可以很方便的在php的github中的源码查阅,具体路径为php-src/Zend/zend_types.h。
- 变量的结构体是zval,实际上他是_zend_struct的宏定义(typedef)。
- zval中,实际存储值的是结构体成员zend_value value(line 197 from zend_types.h),而zend_value是_zend_value的宏定义。
- zend_value通过联合体实现支持存储多种类型的(line 176 from zend_types.h),其中实际存储字符串的是联合体成员zend_string。
- zend_string是结构体_zend_string的宏定义,实际的字符串首地址即为_zend_string.val指向的首地址
写时拷贝
我们再来看一段代码与原理图
1 | $a = str_repeat('hello1', 1); |
通过对比上方两次赋值后的内存使用,可以看到,在第二次的时候,内存使用差值不再是0,因为,在执行$b = str_repeat('hello2', 1);
后,产生了一个新的zend_value,这就是所谓的写时拷贝——仅在变量值被执行写操作时,才拷贝新的zend_value空间,并对该新的zend_value执行相关修改操作。
这样的设计同时满足了两点需求(也是php7较php5的优点):
- 节省空间
- 避免对变量的其中一个引用做修改时,影响了另一变量的值,因为对其中一个变量做修改时,会拷贝新的空间。
- 需要注意的是,不仅对变量执行对某个新的动态值执行
=
赋值操作,-=
、+=
、*=
等变量的写操作都会触发『写时拷贝』
取引用赋值
我们回顾一下,开篇的两段示例代码。
1 | $a = str_repeat('hello', 1); |
1 | $a = str_repeat('hello', 1); |
对于第二段代码,执行后实际上会产生如下效果。
也就是说,产生了一个新的结构体zend_reference实例,那么,我们应该就能明白了,引用赋值后,多出来的空间就是来自这里的。
当执行了引用赋值后,我们不应该将其理解为C/C++中的指向同一地址,而是二者会指向同一引用,而该引用的zval字段的value字段才指向了真正的值存储空间。在引用赋值后,之所以实现了修改其中一个变量,会影响到另一变量的效果,是因为当zend发现当前zval变量的类型zval.u1.type_flag是引用类型IS_REFERENCE时,就知道zval.value是引用类型,此时会继续搜索zval.value.zval,并对该zval类型字段执行相应修改。
再简单点说就是,引用赋值后,通过将两个zval指向同一zend_reference实例,实现与真正的zend_value实例隔离,从而避免了写时拷贝带来的影响。
- zend_reference是_zend_reference的宏定义(line 94 from zend_types.h)。
- _zend_reference里面也有一个zval,此时,zend_reference.zval.value才存储了真正的值。
- 查看联合体_zend_value,我们可以发现,其中存在字段zend_reference *ref,这也是为什么变量底层的zval.value能存储zend_reference的原因。
- 到这里可能有点绕,建议浏览zend_types.h中,
_zval_struct
、_zend_value
、_zend_reference
三者的定义和结构,再结合上下文,应该就很好理解了。
小结
现在,我们可以回到一开始的问题了:在函数传参或赋值过程中,使用取引用符号&
,能减少内存空间占用吗?
这是我在之前重构项目的过程中,为了功能模块解耦,出现同一数据,向下传递多层嵌套函数参数时,想到的一个问题,为了避免数据重复copy,考虑到这样是否能够减少运行时间和内存占用。然而,目前的情况来看,是不行的。取引用符号&
,与C/C++中的取地址符含义不一样,并且,对于只读不写的参数,使用引用传递,反而更浪费内存。
垃圾回收
又一个绕不开的,在这里,期望与最简单的描述,理清垃圾回收机制,而与童鞋们交流。在上文中,我们知道,当执行$b = $a
时,不会创建新的的zend_value的存储空间,那么此时,除了会为$b创建一个zval,并将zval.value指向$a的zval.value以外,还会对zval.value共同指向的zend_string.gc执行增加计数操作(gc实际上是一个结构体,其中有一个字段用于计数)。
需要注意的是,php的整形和浮点型都直接存储在_zend_value中,并不会使用指针指向额外的空间,因此他们的引用计数由_zend_value.counted决定,它是zend_refcounted类型的指针,
而zend_refcounted其实是只有一个zend_refcounted_h类型字段gc的结构体。
由此,我们可以知道,任何类型的变量都存在zend_refcounted_h类型的引用计数器,垃圾回收本质也正是对引用计数为0的zend_value执行回收。但是,它又不会马上回收,而是会先标记,当空间池满时,才会释放被标记的zend_value。
- 实际上,除了zend_string存在zend_refcounted_h类型的引用计数字段gc以外,_zend_array、_zend_object、_zend_resource等结构体都有(建议在zend_types.h文件中自行搜索关键字zend_refcounted_h)。
隐式的类型转换
这点和前文所述内容关系不大,但是一个我认为非常重要的点,当整形数超出了最大值PHP_INT_MAX时,不会产生任何错误,而会自动转化为浮点型。
示例
1 |
|
然而,如果对float继续增加,会导致float不会有任何变化,进一步的,如果对变量赋值2*PHP_FLOAT_MAX
,会导致变量溢出,但此时变量依然不会报错,而是会自动转化为INF(无穷大),同时,INF可比较,这一点可能会导致php在处理大量数据的时候,产生预料意外的情况。
1 | $num = PHP_FLOAT_MAX; |
这个点,严格来讲,书中没有提及,是我在阅览过程中,无意中发现的一个点,我认为我们可能应该意识到并注意,避免可能对我们的程序产生意外的影响。当然,我们有时候也可以此来实现某些超出整形,但不超过浮点型的运算。
关于浮点数运算,还有很多验证性实验可做,由于实际应用很难遇到,这里不一一列举,有兴趣的同学可以对PHP_FLOAT_MAX进行各种运算尝试。
- 在PHP的底层中,数值类型的存储只有
zend_long
和double
两种形式。- zend_long的定义在
php-src/Zend/zend_long.h
中(line 31),它实际上是int64_t的类型宏定义typedef int64_t zend_long;
。
总结
这里我们对上方描述的做个小总结。
- 函数参数传递过程中,使用『&』取引用赋值,在该参数永远不会被改变值的情况下,并不会减少内存空间的使用,反而会增加内存空间(zend_reference结构体)。
- 不能相信php的垃圾收机制,且要注意buffer不满时,垃圾回收机制不会立即释放空间。
- 循环引用不能被回收的原因,正是因为出现了自己对自己的引用,导致引用计数始终不为0,因此垃圾回收机制便始终不生效。
- 数值越界时,存在隐式的类型转换,int -> float -> INF,且INF减去任意值依然是INF,INF大于任何值,我们使用php做大数运算时必须注意这一点(比如他可能造成循环逻辑的死循环)。
参考文献
- [1] 陈雷. PHP7底层设计与源码实现[M]. 机械工业出版社, 2018.
- [2] php-src in github
最后,再讲一下我写这一篇分享的小感受吧。可能是我理解不够透彻的原因,我在看完这本书之后,我又去github看了一下最新版的源码,并尽可能简单的描述它,但最后我发现我讲的还是有点乱了。
因此,如果同学们有任何不懂的或者认为我说的不正确的地方,都欢迎交流指教。