在PHP7中主要通过以下四点来提升性能:

  1. 存储变量的结构体变小,尽量使结构体里成员共用内存空间,减少引用,这样内存占用降低,变量的操作速度得到提升

  2. 字符串结构体的改变,字符串信息和数据本身原来是分成两个独立内存块存放,php7尽量将它们存入同一块内存,提升了cpu缓存命中率

  3. 数组结构的改变,数组元素和hash映射表在php5中会存入多个内存块,php7尽量将它们分配在同一块内存里,降低了内存占用、提升了cpu缓存命中率

  4. 改进了函数的调用机制,通过对参数传递环节的优化,减少一些指令操作,提高了执行效

PHP7 中储存结构体变化

struct _zval_struct {
    zend_value value;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar type,
                zend_uchar type_flags,
                zend_uchar const_flags,
                zend_uchar reserved
            )
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t var_flags;
        uint32_t next;       /* hash collision chain */
        uint32_t cache_slot; /* literal cache slot */
        uint32_t lineno;     /* line number (for ast nodes) */
    } u2;
};

你可以完全不用注意这个结构的定义中的ZEND_ENDIAN_LOHI_4这个宏,它仅仅只是用于表示拥有不同的字节序的机器中的可预测的内存布局情况。

zval结构有三个部分:第一部分是value。zend_value联合体有8个字节,它可以保存任何类型的值,包括整数、字符串、数组等。具体保存什么取决于zval的类型。

第二个部分是4字节的type_info,它包含变量的真正类型(类似于IS_STRING、IS_ARRAY),以及一系列的标志位,用于提供跟类型相关的信息。例如,如果zval保存的是一个对象,那么这些类型标志位会说明它是一个非常量(non-constant)、可引用计数(refcounted)、可垃圾回收(garbage-collectible)、不可复制(non-copying)的类型。

最后一部分占有4个字节,通常情况下不会被用到(它只是用于填充内存,如果不存在的话,编译器也会自动实现)(对于这一点完全不了解的同学,可以自行搜索内存对齐)。然而,在某些特殊情况下,这些空间也会被用于存放一些额外的信息。例如,AST(抽象语法树)的节点使用它来存放行号,VM(虚拟机)常量使用它来存放缓冲槽的索引,以及Hashtable使用它来保存冲突处理链上的下一个元素——这一部分才是我们要重点关注的。

新的zval的实现跟老的比较,最大的一点差别是:没有refcount字段。这是因为新的zval将不会被单独分配,它会被直接嵌入到任何需要存放它的地方(例如,一个hashtable bucket中)。

所以zvals将不再需要使用引用计数(refcounting),复杂数据类型例如字符串、数组、对象和资源(resources)仍需要使用。所以新的zval的设计将引用计数(包括跟垃圾回收相关的信息)从zval转移到了数组/对象/等中。这种方式有很多优点,在此列出几点:

  • 保存简单的值(例如,boolean、integer或者float)的zval将不再需要额外分配内存。所以避免内存分配的头部冗余(allocation header overhead),以及减少不必要的内存分配和内存释放,可以提高缓存的局部性,从而提高性能。
  • 保存简单的值的zval不需要保存refcount和GC的根缓冲区。
  • 避免两次引用计数。例如,以前的对象即使用了zval的引用计数,又使用额外的对象的引用计数,对于支持按对象传递的语义而言,这是必须的。
  • 现在所有的复杂的值都内嵌一个引用计数,它们可以不依赖于zval的机制而进行共享。特别是字符串现在也有可能共享。这对于hashtable的实现也很重要,因为这样就不用再拷贝非interned字符串的键了。

内部类型zend_string

Zend_string是实际存储字符串的结构体,实际的内容会存储在val(char,字符型)中,而val是一个char数组,长度为1(方便成员变量占位)。

结构体最后一个成员变量采用char数组,而不是使用char*,这里有一个小优化技巧,可以降低CPU的cache miss。

如果使用char数组,当malloc申请上述结构体内存,是申请在同一片区域的,通常是长度是sizeof(_zend_string) + 实际char存储空间。但是,如果使用char*,那个这个位置存储的只是一个指针,真实的存储又在另外一片独立的内存区域内。

使用char[1]char*的内存分配对比:

从逻辑实现的角度来看,两者其实也没有多大区别,效果很类似。而实际上,当这些内存块被载入到CPU的中,就显得非常不一样。前者因为是连续分配在一起的同一块内存,在CPU读取时,通常都可以一同获得(因为会在同一级缓存中)。而后者,因为是两块内存的数据,CPU读取第一块内存的时候,很可能第二块内存数据不在同一级缓存中,使CPU不得不往L2(二级缓存)以下寻找,甚至到内存区域查到想要的第二块内存数据。这里就会引起CPU Cache Miss,而两者的耗时最高可以相差100倍。

另外,在字符串复制的时候,采用引用赋值,zend_string可以避免的内存拷贝。

HashTable 的内存占用

首先让我们总结一下为什么新的实现方式占用的内存更少。先申明下,这里我只会使用64位系统中的数字,而且只考虑每个元素的大小,而忽略Hashtable结构体。

在PHP 5.x中,一个元素需要占用144个字节(很恐怖)。在PHP 7中这个大小下降到36个字节,在packed情况下只需要32个字节。下面是内存变化的详细情况:

  • Zvals不再单独分配,所以这节省了16个字节的内存分配冗余。
  • Buckets也不用再单独分配,所以又节省了16个字节的内存分配冗余。
  • 对于简单类型的值,zval本身就少了16个字节。
  • 保持数组顺序不再需要16个字节用于维持双向链表的链接指针,而且这个顺序是隐式的。
  • 冲突处理链表现在是单链表,这又节省了8个字节。更进一步来看,现在用的是一个索引列表,并且每个索引是嵌入到zval中的,所以这又节省了8个字节。
  • zval是嵌入到bucket中的,没必要再保存一个指向它的指针。如果分析老版本的实现细节的话,我们实际上节省了2个指针,这就又是16个字节了。
  • 键的长度不用再保存在bucket中,又是8个字节。不过,如果键是一个字符串,而不是一个整数的话,它的长度还是需要保存在zend_string结构体中。这种情况下,对内存的影响不可能精确估算,因为zend_string结构体是共享的,这意味着之前的hashtable需要拷贝字符串,如果这个字符串不是interned的话(这一条没怎么看清楚)。
  • 包含冲突列表头部的数组现在是基于索引的,这样每个元素又节省了4个字节。对于packed数组,这个数组根本就不需要,又节省了4个字节。

首先申明一下,上面的总结只是为了说明PHP 7中的新的Hashtable实现在多个方面都比老版本的要好。首先,新Hashtable的实现使用了更多的内嵌结构体(相对于单独分配内存而言)。这会有什么不利的影响呢?

如果你看下文章开头的示例,你会发现在64位系统下,PHP 7中一个含有100000个元素的数组会占用4.00MB的内存。在这个示例中,我们处理的是packed数组,所以我们实际需要的内存使用量是32 * 100000 = 3.05MB。这个差别是因为所有分配的内存的大小都是2的幂次方,所以包含100000个元素数组,nTableSize的大小将是2^17=131072,所以最终分配的内存就是32 * 131072(4MB)。

当然老的Hashtable的实现中分配的内存的大小也必须是2的幂次方。不过它只会分配一个包含bucket指针的数组(每个指针占8个字节),其他的所有东西都是根据具体需求来分配。所以在PHP 7中,我们多分配了3231072(0.95MB)的未使用内存,而在PHP 5.x中只浪费了`831072(0.24MB)`的内存。

另外一种需要考虑的情况是,如果数组中所保存元素的值并非都不同,这又会有什么不同?简单起见,我们考察下数组中所有元素的值都相同的情况。所以将第一个示例中的函数range替换为array_fill:

$startMemory = memory_get_usage();
$array = array_fill(0, 100000, 42);
echo memory_get_usage() - $startMemory, " bytes\n";

最终运行的结果为:

32 bit 64 bit
PHP 5.6 4.70 MiB 9.39 MiB
PHP 7.0 3.00 MiB 4.00 MiB

从这个结果可以看到PHP 7中内存的占用量没有变,每个元素都独自使用一个zval,这样也没有变的理由。不过PHP 5.x中的内存占用量就降低了不少,这是因为只有一个zval用于表示所有的值。尽管如此,PHP 7的内存占用量还是要由于PHP 5.x,虽然差别已经小了一些。

当键为字符串(可能是共享的或者是interned)或者为复杂值的时候,事情就会变得更复杂。当然不管怎样,在这些情况下,最终PHP 7所占用的内存都远小于PHP 5.x,也许上面所列出的数据在某些情况下有些乐观了。

通过宏定义和内联函数(inline),让编译器提前完成部分工作

C语言的宏定义会被在预处理阶段(编译阶段)执行,提前将部分工作完成,无需在程序运行时分配内存,能够实现类似函数的功能,却没有函数调用的压栈、弹栈开销,效率会比较高。内联函数也类似,在预处理阶段,将程序中的函数替换为函数体,真实运行的程序执行到这里,就不会产生函数调用的开销。

PHP7在这方面做了不少的优化,将不少需要在运行阶段要执行的工作,放到了编译阶段。例如参数类型的判断(Parameters Parsing),因为这里涉及的都是固定的字符常量,因此,可以放到到编译阶段来完成,进而提升后续的执行效率。

例如下图中处理传递参数类型的方式,从左边的写法,优化为右边宏的写法。

抽象树语法

PHP是一种解释性语言,通过解析器来执行。

那么首先来看一下编译器与解释器的区别:读入源语言后,解释器和编译器都要进行词法分析、语法分析和语义分析,之后,二者开始有所分别。

解析器与编译器的区别:

  • 解释器在语义分析后选择了直接执行语句;
  • 编译器在语义分析后选择将将语义存储成某一种中间语言,之后通过不同的后端翻译成不同的机器语言(可执行程序)。其存在一个预编译的过程。如下图所示:

PHP7之前的版本,代码解释过程:

  • PHP代码在语法解析阶段直接生成ZendVM指令,即在zend_language_parser.y中直接生成opline指令,使得编译器与执行器耦合在一起。

  • 编译生成的指令再供执行引擎使用,该指令是在语法指令直接生成的,若要更换执行引擎,怎需要修改语法解析规则;若PHP语法变化,但没有修改执行引擎,仍需要修改语法解析规则。其代码解析过程如下图:

PHP7的代码解析过程:

Native TLS

PHP5.x版本扩展中,有TSRM_CC、TSRM_DC宏,用于线程安全。

PHP中有很多变量需要在不同函数间共享,多线程的环境下不能简单地通过全局变量来实现,为了适应线程的应用环境,PHP提供了一个线程安全资源管理器,将全局资源进行线程隔离,不同的线程之间互不干扰。

使用全局资源需要先获取本线程的资源池,这个过程比较占用时间,因此,PHP5.x通过参数传递的方式将本线程的资源池传递给其他函数,避免重复查找。这种方式需要所有函数接受资源池的参数(TSRM_DC宏所加的参数),这些参数传递不仅易遗漏参数,还是得代码不优雅。

PHP7使用Native TLS(线程局部存储)来保存线程的资源池,简单来说就是通过__thread标识一个全局变量,这样这个全局变量就是线程独享的了,不同线程的修改不会相互影响。

参考

PHP 7中新的Hashtable实现和性能改进
PHP7革新与性能优化
PHP7变量结构分析