字符串的结构

在本小节开始之前,先编写一个简单的 PHP 代码示例,初步介绍 PHP 的字符串主要包含哪些内容。代码示例 1 如下:

<?PHP                                                     1
    $a = 'hello';                                         2
    $b = 'time:'.time();                                  3
?>                                                        4
php

上述代码中的变量名 “a”“b”,常量字符 “hello”“time:”,函数名 “time”,及第 3 行代码执行后返回的临时字符等都属于字符串。在源码中每个字符串都有自己所属的分类,上述代码中的字符串因为从属的类别不相同,在 PHP 执行过程中存在的生命周期也不同。由简到难,我们先从 PHP 字符串的底层存储结构说起。

PHP 7 及 PHP 5.x 底层是如何实现字符串的存储呢?PHP 7 主要依赖 zend_string 结构体来实现字符串存储,而 PHP 5.x 则依赖 zval 结构体实现字符串存储。

  1. PHP5.x 的字符串结构,直接放在 zval 结构体中:

    struct _zval_struct {
        zend_uint refcount__gc;       /*引用计数*/
        ...
        struct {
                char *val;            /*字符串的值存储位置*/
                int len;              /*字符串长度*/
        } str;
        ...
    }
    c
  2. PHP 7 的字符结构是单独的结构体:

    struct _zend_string {
        zend_refcounted_h gc;    /*8字节,内嵌的gc引用计数及字符串类别存储*/
        zend_ulong       h;      /*哈希值,8字节,字符串的哈希值*/
        size_t           len;    /*8字节,字符串的长度*/
        char             val[1]; /*柔性数数组,占1字节,字符串的值存储位置*/
    };
    c

对比两个结构体可以发现两者的相同点是字符串值的存储都是 val 变量,都由一个 len 变量记录字符串的长度,都由一个 refcount 变量记录引用计数,除了相同点,两者之间同样存在很大的不同点,具体如下。

(1)字符串结构的完全改变

PHP 5 的字符串实现是直接嵌入到 zval 结构体中,占用内存大小在 i386:x86-64(下面所说的内存占用都是以它为准)架构下是 24 字节;而 PHP 7 的字符串是单独的 zend_string 结构体,其大小是 32 字节(8 位对齐后),相较于 PHP 5 有上升。

(2)字符串真正存储的 val 字段的实现方式不同

相比于 PHP 5 的指针存储方式(char *), PHP 7 使用了 C 语言新的特性:柔性数组。除了这两点不同外,PHP 7 的字符串增加了哈希值(h 字段)的存储,增加了 PHP 7 统一的 gc 头部,用来支持 gc

下面来详细讨论 PHP 7 字符串的实现。

PHP 7字符串的具体实现

PHP 7 字符串的实现主要依赖的是 zend_string 结构体,那么先来看一下 zend_string 结构体的详细介绍,具体如下:

struct _zend_string {
    zend_refcounted_h gc;   /*8字节,内嵌的gc引用计数及字符串类别存储*/
    zend_ulong       h;     /*哈希值,8字节,字符串的哈希值*/
    size_t           len;   /*8字节,字符串的长度*/
    char             val[1];             /*柔性数数组,占1位,字符串的值存储位置*/
};
typedef struct _zend_refcounted_h {      /*gc,整块占用8字节*/
    uint32_t         refcount;           /*4字节,引用计数的值存储 */
    union {/* 4字节 */
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    type,      /*等同于zval的u1.v.type*/
                zend_uchar   flags,     /*字符串的类型数据*/
                uint16_t      gc_info    /*垃圾回收标识颜色用*/
          )
        } v;
        uint32_t type_info;
    } u;
} zend_refcounted_h;
c
image 2024 06 08 08 47 51 848
Figure 1. 图4-1 zend_string内存占用情况

zend_string 结构体整体占用了 32 字节,包含了 gchlenval 四个字段,每个字段各占 8 字节,zend_string 内存占用情况如图4-1所示。

那么每个字段的作用是什么?为什么和 PHP 5 的字符串结构那么不同呢?请看下面关于每个字段的解释。

(1)gc 字段

gc 字段的类型是自定义的结构体 zend_refcounted_h,其主要作用是存放引用计数等。比如引用计数存放在 refcount 字段、类别信息存储在 flags 字段、字符串所属的变量类别则存储在 type 字段。zend_string 结构体中因为加入了 gc 字段,使得其和数组、对象一样可被多个 zval 引用,相较于 PHP 5 的字符串来说更具有独立性及使用效率上的提升。

(2)h 字段

h 字段的作用是缓存字符串的哈希值,它的值只有当字符串需要被作为数组 key 时才会初始化,同一个字符串被多次当作 key 使用时,不会重复计算其对应的哈希值。数组计算 key 对应的索引值时会用到 h 字段,详细使用请看第 5 章。

(3)val 字段

val 字段的作用是存储字符串值,类型是 char,在这里,很多读者应该都会有几个疑问。

  1. PHP 7 值存储为何采用的是 char[1],而不是延用 PHP 5 的 char *

  2. 为什么使用 char val[1] 占位,而不是 char val[] 或者 char val[0] 占位?

对于第 1 个问题,char[1] 称为柔性数组,当结构体中仅有一个变长的字符串且为最后一个字段时,就可采用这种实现方式。那么使用柔性数组的优势是什么呢?

结合 PHP 7 源码字符串初始化内存的 zend_string_alloc 函数,来看看柔性数组的具体使用:

zend_string *zend_string_alloc(size_t len, int persistent){
    zend_string *ret = (zend_string*)pemalloc(ZEND_MM_ALIGNED_SIZE  ((offsetof(zend_string,  val)  +  len  +  1)),persistent);
    return ret;
}
c

代码中 offsetof(zend_string, val) 是用于求结构体中一个成员在该结构体中的偏移量,也就是取到结构体 zend_string 的大小,其后 + len + 1 中 “+1” 的原因是字符串后面需要追加结尾符 “\0”。

从代码中可以看出,在分配字符串内存时,一次申请的内存大小不仅仅是结构体的大小,还要额外加上字符串值的长度 len+1。至此,柔性数组 val 字段就占用了末尾连续的一块内存,用于存储不定长度的字符串值。这样,struct 中字符串的值与其他成员便存储在同一块连续的空间中,在分配、释放内存时便可将 struct 当成整体处理。

那么对比的 PHP 5 的 char * 存储值有何优势呢?首先看一下 PHP 7 与 PHP 5 的字符串内存分布对比,如图4-2所示。

image 2024 06 08 08 56 57 398
Figure 2. 图4-2 PHP 7与PHP 5的字符串内存分布对比

从图4-2可以看出,char[] 的好处很明显,读写字符串值时可以省一次内存读写,假设 val 字段还沿用 PHP5 中的 char*,要去读写 val 时,需访问两块内存。对于第 2 个问题,是因为 val[]val[0] 在 C99 标准中是合法的,这种定义被称为变长数组(variable-length array)。由于下标为空,这里的 val 就像是一个占位符,只有符号意义,但却并不实际占用空间。在 C99 以前的标准中,是不允许变长数组出现的,但支持 val[1], val[1] 会实际占用 1 字节。PHP 7 采用 val[1] 而不用 val[] 占位是为了兼容不同版本的 C 编译器。

(4)len 字段

字符串的长度是通过 len 字段来记录的,类型是 size_t(long unsigned int), len 的类型也决定了一个字符串的最大长度,相较于 PHP 5, PHP 7 所支持的字符串长度有所增加。需要记录字符串的长度具体有以下两个原因:

  1. 时间换空间的做法,直接记录以避免重复计算字符串的长度。

  2. 保证了 PHP 字符串操作二进制安全。对于二进制安全的讲解请见 4.1.2 节。

以上为 PHP 7 底层字符串存储的主要结构体的介绍,依赖这个结构体,基本可满足 PHP 代码中大部分字符串的存储与操作需求。接下来继续介绍字符串的二进制安全。

字符串的二进制安全

C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含 “\0”(空字符),否则字符串中的 “\0” 将被误认为是字符串结束符,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。而 PHP 的字符串则不同,其支持二进制数据的存储,PHP 在处理带二进制字符的字符串时,程序不会对其中的数据做任何限制、过滤或者假设,数据在写入时是什么样的,它被读取时就是什么样,这种能力称为字符串的二进制安全。

举例说明,C 代码如下:

main(){
    char a[] = "aaa\0b";            /*带有二进制数据的字符串*/
    char b[] = "aaa\0c";
    printf("%d\n", strcmp(a, b));  /*输出0*/
    printf("%d\n", strlen(b));     /*输出3*/
}
php

C 认为 ab 这两个变量的值相等,b 的长度为 3,那么换成 PHP 代码呢?

<?PHP
$a = "aaa\0b";                   /*带有二进制数据的字符串*/
$b = "aaa\0c";
var_dump(strcmp($b, $a));        /*输出-1*/
var_dump(strlen($b));            /*输出5*/
?>
php

PHP 认为 ab 这两个变量的值不相等,而且 b 的长度是 5。

通过上面两个示例可以发现,对于 C 语言来说,“\0” 就是字符串的结束符,当读取字符串 “aaa\0b” 时,读到 “\0” 就会默认字符读取已经结束,而抛掉后面的字符 “b”

对于 PHP 7 来说,其通过 zend_string 结构体对字符串重新封装,读取的数据长度以自身结构体 len 值为准,不再像 C 语言一样将特殊格式 “\0” 作为字符串结尾,保证了字符串读写的二进制安全。

字符串持久化

IS_STR_PERSISTENT 是用于标记字符串是否为持久性字符串的标志。在 PHP 中,持久性(persistent)字符串是指在内存中存储时间较长,不会随请求结束而被释放的字符串。这种标志主要用于提升性能和节省内存,尤其是在高频率使用的字符串。

持久性字符串的作用和优点

  1. 内存节省:

    • 持久性字符串在 PHP 的持久性内存区域(例如在持久性内存管理器中分配)中分配。这些字符串在脚本的生命周期内保持存在,即使请求结束也不会被释放。因此,如果多个请求共享相同的字符串,持久性字符串可以减少内存的重复分配,从而节省内存。

  2. 性能提升:

    • 因为持久性字符串在内存中保持不变,重复使用相同的字符串时,避免了多次分配和释放内存的开销。此外,持久性字符串还可以减少字符串的比较和查找操作,因为相同的字符串可能会在持久性存储中只存在一个实例。

  3. 避免内存碎片:

    • 持久性字符串有助于避免内存碎片问题,因为它们通常在专用的内存区域中分配和管理。这使得内存管理更高效,尤其是在长时间运行的 PHP 进程中。

实现细节

在 PHP 中,持久性字符串的标志和管理通常涉及以下几个方面:

  • 标志位:

    • IS_STR_PERSISTENT 是一个标志位,通常与其他类型信息一起存储在 zend_string 结构体的 GC_TYPE_INFO 字段中。这个标志用于标识字符串是否是持久性的。

  • 分配内存:

    • 持久性字符串通过 pemalloc 函数进行分配,该函数用于分配持久性内存。与普通内存分配不同,持久性内存会在 PHP 脚本的整个生命周期内保留。

  • 引用计数:

    • 持久性字符串通常具有固定的引用计数,通常为 1,因为它们在整个生命周期内共享和重用,不会被垃圾回收。

  • 持久性内存区域:

    • 持久性字符串存储在 PHP 的持久性内存区域中(如 persistent 堆),与临时内存区域(如 emalloc)分开管理。这使得持久性字符串在内存中保持长时间有效。

代码示例

在字符串分配函数中,IS_STR_PERSISTENT 标志用于设置字符串的持久性属性。例如:

GC_TYPE_INFO(ret) = IS_STRING | ((persistent ? IS_STR_PERSISTENT : 0) << 8);
c

在这段代码中:

  • IS_STRING 表示对象的类型是字符串。

  • persistent ? IS_STR_PERSISTENT : 0 根据 persistent 参数决定是否设置持久性标志。如果 persistent 为真,IS_STR_PERSISTENT 将被设置;否则,持久性标志位为 0

总结

  • IS_STR_PERSISTENT 是用于标记字符串是否为持久性字符串的标志。

  • 持久性字符串在 PHP 的持久性内存区域中分配,避免了重复分配和释放内存的开销,并且在请求结束后仍然存在。

  • 这种机制有助于提升性能、节省内存,并避免内存碎片。

IS_STR_INTERNED 和 IS_STR_PERSISTENT 区别

在 PHP 的垃圾回收和内存管理中,IS_STR_INTERNEDIS_STR_PERSISTENT 是用于标记字符串状态的标志。虽然它们看起来相似,但它们有不同的用途和意义。

IS_STR_INTERNED

  • 定义:这个标志用于标记一个字符串是否是 interned(常量的、全局共享的)字符串。即,它是一个持久性字符串,并且在整个 PHP 进程中是唯一的实例。

  • 作用:

    • IS_STR_INTERNED 用于确定一个字符串是否被 interned,这样 PHP 就可以在内部优化字符串的存储和比较。

    • Interned 字符串是全局唯一的,这意味着在整个运行时环境中,每个 interned 字符串只会有一个实例。

    • Interned 字符串在 PHP 的内存管理中是特别处理的,因为它们不会被垃圾回收器回收,通常是在 PHP 启动时初始化,并在整个脚本执行过程中保持不变。

IS_STR_PERSISTENT

  • 定义:这个标志表示一个字符串是否是 persistent(持久性的)。持久性字符串指的是在整个 PHP 进程生命周期内都需要保持的字符串。

  • 作用:

    • IS_STR_PERSISTENT 表示一个字符串是持久的,通常用于存储需要在整个 PHP 执行期间都保留的字符串。

    • 持久性字符串在 PHP 内存管理中会被特殊处理,以确保在脚本执行期间不会被回收。

    • 这些字符串在 PHP 进程启动时分配,并在进程结束时释放。

区别总结

  1. 持久性 vs Interned:

    • 持久性 (IS_STR_PERSISTENT):表示字符串的生命周期与 PHP 进程的生命周期相同。即,它在整个 PHP 进程期间是持久存在的。

    • Interned (IS_STR_INTERNED):表示字符串是全局唯一的且共享的,用于优化字符串存储和比较。Interned 字符串通常也是持久的,但它们是通过特殊的机制来管理的,以确保全局唯一性。

  2. 标记用途:

    • IS_STR_PERSISTENT:用于标记字符串的持久性,确保在整个 PHP 进程生命周期内保持。

    • IS_STR_INTERNED:用于标记字符串是否是 interned,意味着字符串在内存中是唯一的并且通常是常量的。

  3. 内存管理:

    • Interned 字符串会在全局范围内共享,避免重复分配内存。

    • 持久性字符串则在 PHP 进程中保持,虽然它们可能是 interned,但持久性字符串并不一定是 interned 字符串。持久性字符串可能还涉及到其他的内存管理和优化策略。

示例

  • Interned 字符串:通常是 PHP 内部定义的一些关键字或常量,比如 'true'、'false'、'null' 等,这些在整个 PHP 进程中都是唯一的,并且会被 interned。

  • Persistent 字符串:可能包括常量字符串或在 PHP 启动时加载的全局字符串。

这两个标志在 PHP 内部处理字符串时都有其特定的作用,确保字符串的高效管理和优化。

zend_string API

看完前面几节,想必读者对 zend_string 结构体有了一个较初步的理解,本节会对 zend_string API 集合进行简要的讲解,让读者能更清晰地了解 zend_string 结构体在源码中是如何被应用的。

zend_string API 是基于 zend_string 结构体封装的各类字符串操作函数集合,主要有字符串的扩容、截断、初始化、销毁、判等、计算哈希值等函数,本节在这里会做具体的函数功能注释,也会对重点的几个方法做简要的代码分析。

zend_string API 的全部函数及注释如下。

/*初始化CG(interned_strings)内部字符串存储哈希表,并把PHP的关键字等字符串写进去*/
zend_interned_strings_init
/* 把一个zend_string写入CG(interned_strings)哈希表中,若已存在数据,则返回它,否则写入当前数据并返回*/
zend_new_interned_string_int
/* 把CG(interned_strings)哈希表中的字符串全部标识成永久字符串,注意,标识的时候只有PHP关键字、内部函数名、内部方法名等*/
zend_interned_strings_snapshot_int
/* 销毁CG(interned_strings)哈希表中字符类型为非永久字符串的值,在php_request_shutdown阶段释放*/
zend_interned_strings_restore_int
/*销毁整个CG(interned_strings)哈希表,在PHP_module_shutdow阶段触发*/
zend_interned_strings_dtor
zend_string_hash_val              /*得到字符串的哈希值,若没有,则实时计算并存储*/
zend_string_forget_hash_val       /*哈希值置为0/*
zend_string_refcount              /*读取字符串的引用计数*/
zend_string_addref                /*引用计数+1*/
zend_string_delref                /*引用计数-1*/
zend_string_alloc                 /*分配内存及初始化字符串的值*/
zend_string_safe_alloc
zend_string_init                  /*初始化字符串并赋值追加“\0” */
zend_string_cop                   /*使用引用计数方式复制字符串*/
zend_string_dup                   /*直接复制一个字符串*/
zend_string_realloc               /*更安全的内存分配,能在内存分配失败时处理错误*/
zend_string_extend                /*扩容到len,保留原来的值*/
zend_string_truncate              /*截断到len,并保留开头到len的值*/
zend_string_free                  /*释放字符串内存,当GC_REFCOUNT≤1时*/
zend_string_release               /*GC_REFCOUNT--、直到为0时释放内存*/
zend_string_equals                /*普通判等*/
zend_string_equals_ci(s1, s2)     /*基于二进制安全,两个zend_string类型的字符串判等 */
zend_string_equals_literal_ci(str, c)/*基于二进制安全,zend_string类型和char*字符串判等*/
zend_inline_hash_func             /*计算字符串的哈希值*/
zend_intern_known_strings         /*往zend_intern_known_strings全局数组中写入str */
c

接下来介绍几个主要函数。

  1. zend_string_init 函数

    zend_string_init 函数把一个普通字符串初始化成 zend_string。比如 PHP 的词法解析器在解析 PHP 源码时,会把扫描遇到的每个字符串初始化成 zend_string 结构存储,然后关联到 AST 的 zval 节点,初始化的过程就是调用 zend_string_init 函数。zend_string_init 函数的源码如下。

    为了避免粘贴的源码过多,影响整体的阅读质量,笔者对源码都会做必要的删减,对于函数完整的源码,可直接根据函数名称在 PHP 7 的源码中搜索。

    FOO = zend_string_init("foo", strlen("foo"), 0);
    zend_string_init(const char *str, size_t len, int persistent){
        zend_string ret = zend_string_alloc(len, persistent); /*申请内存* /
        memcpy(ZSTR_VAL(ret), str, len); /*拷贝str到zend_string.val中*/
        ZSTR_VAL(ret)[len] = '\0';   /*zend_string.val末尾追加\0*/
        return ret;
    }
    c

    整体步骤大致如下。

    • 第 1 步:申请一块连续的内存,内存的大小的计算公式为:实际申请大小 = 结构体的大小(24) + 字符串的长度(len)+1,实际申请大小是按照 8 字节对齐的,不一定等于实际计算的结果。

    • 第 2 步:指针偏移到 val 字段所在位置,进行字符串内容拷贝。

    • 第 3 步:追加结束符 “\0”

    经过这 3 步后,一个完整的字符串就初始化完成了,初始化完成后,该字符串的内存分布情况如图4-3所示。

    image 2024 06 08 09 27 24 362
    Figure 3. 图4-3 字符串内存分布情况
  2. zend_string_extend 函数

    zend_string_extend 函数可以对字符串进行扩容。之所以会介绍这个函数,是因为它除了实现了扩容之外还能很好地体现写时分离的思想,zend_string_extend 函数的源码如下:

    zend_string_extend(zend_string s, size_t len, int persistent)/*扩容到len,并保留原来的字符串值*/
    {
        if (! ZSTR_IS_INTERNED(s)) {/* 内部字符串不考虑gc*/
            if (EXPECTED(GC_REFCOUNT(s) == 1)) {
                ret = (zend_string *)perealloc(s,ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(len)), persistent);
                return ret;
            } else {
                GC_REFCOUNT(s)--;
            }
        }
        ret = zend_string_alloc(len, persistent);
        memcpy(ZSTR_VAL(ret), ZSTR_VAL(s), ZSTR_LEN(s) + 1);
        return ret;
    }
    c

    扩容的步骤如下。

    • 第 1 步:当需扩容的字符串是普通字符串且 refcount 等于 1 时,直接调用 perealloc 函数分配内存,扩容一步到位。

      perealloc 函数,当参数 persistent=1 时调用系统函数 realloc 申请内存;当 persistent!=1 时调用 PHP 的内存池的 erealloc 函数申请内存。两者实现的功能相似,以 realloc 函数的作用为例,它会先判断当前的指针是否有足够的连续空间。如果有,扩大 mem_address 指向的地址,并且将 mem_address 返回,如果空间不够,先按照 newsize 指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来 mem_address 所指内存区域(注意:原来指针是自动释放的,不需要使用 free 函数),同时返回新分配的内存区域的首地址。

    • 第 2 步:当需扩容的字符串引用计数大于 1 或类型为内部字符串时,则调用 zend_string_alloc 函数申请一块新内存,并把原值拷贝进去。对于普通字符串还需要对老字符串进行 refcout-- 操作。

    经过上面两个步骤就完成了字符串的扩容,而第 2 步中的扩容实际就是分离过程,当 refcount>1,通过申请新内存及拷贝值等操作,生成两份不关联的字符串数据。

3.二进制安全比较函数

笔者前文中介绍字符串的结构时提到过,PHP 字符串的处理实现了二进制安全,那么字符串的比较是如何做到二进制安全的呢?zend_string API 也提供实现了二进制安全的字符串比较函数 zend_binary_strcasecmp,该函数是通过宏 zend_string_equals_ci(s1, s2) 去调用,具体源码如下:

#define zend_string_equals_ci(s1, s2) \ /*宏定义*/
(ZSTR_LEN(s1)  ==  ZSTR_LEN(s2)  &&  ! zend_binary_strcasecmp(ZSTR_VAL(s1),  ZSTR_LEN(s1), ZSTR_VAL(s2), ZSTR_LEN(s2)))
/* 核心对比函数*/
zend_binary_strcasecmp(const char *s1, size_t len1, const char *s2, size_t len2)
{
    len = MIN(len1, len2);
    while (len--) {/*遍历最短的一个字符数组*/
        c1 = zend_tolower_ascii(*(unsigned char )s1++); /*取出该字节ASC码*/
        c2 = zend_tolower_ascii(*(unsigned char *)s2++);
        if (c1 ! = c2) {/*不相等,则返回
            return c1- c2;
        }
    }
    return (int)(len1- len2); /*长度是否相等*/
}
c

字符串的二进制安全比较函数的实现过程如下。

  • 第 1 步:判断两个字符串的长度是否相等(即 ZSTR_LEN(s1) == ZSTR_LEN(s2))。

  • 第 2 步:调用核心对比函数 zend_binary_strcasecmp 来判断两个字符串的 val 字符数组值是否相等。

  • 第 3 步:核心对比函数 zend_binary_strcasecmp 则是循环地把字符数组中每个字节取出,转换成 ASCII 码来判等,其中一位不相等,则返回差值,跳出循环。

  • 第 4 步:循环完毕,若没找到不匹配的字符,则直接返回两个字符串数组的长度差。若相等,则返回值为 0。

对于 zend_binary_strcasecmp 的返回值,相等时返回 0,其他返回值为不相等,不要混淆。

看完源码其实也能得出,二进制安全比较函数其实就是打破原有 C 语言读字符串遇到 “\0” 就返回的惯性,结合字符串长度字段 len 循环读取、比较字符串而已。

因篇幅有限,本节暂时只分析这几个 zend_string API 函数,感兴趣的读者可以结合文章罗列的函数清单,去源码中查看。