字符串的结构
在本小节开始之前,先编写一个简单的 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
结构体实现字符串存储。
-
PHP5.x 的字符串结构,直接放在
zval
结构体中:struct _zval_struct { zend_uint refcount__gc; /*引用计数*/ ... struct { char *val; /*字符串的值存储位置*/ int len; /*字符串长度*/ } str; ... }
c -
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

zend_string
结构体整体占用了 32 字节,包含了 gc
、h
、len
、val
四个字段,每个字段各占 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
,在这里,很多读者应该都会有几个疑问。
-
PHP 7 值存储为何采用的是
char[1]
,而不是延用 PHP 5 的char *
? -
为什么使用
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
代码中 |
从代码中可以看出,在分配字符串内存时,一次申请的内存大小不仅仅是结构体的大小,还要额外加上字符串值的长度 len+1
。至此,柔性数组 val
字段就占用了末尾连续的一块内存,用于存储不定长度的字符串值。这样,struct
中字符串的值与其他成员便存储在同一块连续的空间中,在分配、释放内存时便可将 struct
当成整体处理。
那么对比的 PHP 5 的 char *
存储值有何优势呢?首先看一下 PHP 7 与 PHP 5 的字符串内存分布对比,如图4-2所示。

从图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 所支持的字符串长度有所增加。需要记录字符串的长度具体有以下两个原因:
-
时间换空间的做法,直接记录以避免重复计算字符串的长度。
-
保证了 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 认为 a
和 b
这两个变量的值相等,b
的长度为 3,那么换成 PHP 代码呢?
<?PHP
$a = "aaa\0b"; /*带有二进制数据的字符串*/
$b = "aaa\0c";
var_dump(strcmp($b, $a)); /*输出-1*/
var_dump(strlen($b)); /*输出5*/
?>
php
PHP 认为 a
和 b
这两个变量的值不相等,而且 b
的长度是 5。
通过上面两个示例可以发现,对于 C 语言来说,“\0”
就是字符串的结束符,当读取字符串 “aaa\0b”
时,读到 “\0”
就会默认字符读取已经结束,而抛掉后面的字符 “b”
。
对于 PHP 7 来说,其通过 zend_string
结构体对字符串重新封装,读取的数据长度以自身结构体 len
值为准,不再像 C 语言一样将特殊格式 “\0”
作为字符串结尾,保证了字符串读写的二进制安全。
字符串持久化
IS_STR_PERSISTENT
是用于标记字符串是否为持久性字符串的标志。在 PHP 中,持久性(persistent)字符串是指在内存中存储时间较长,不会随请求结束而被释放的字符串。这种标志主要用于提升性能和节省内存,尤其是在高频率使用的字符串。
持久性字符串的作用和优点
-
内存节省:
-
持久性字符串在 PHP 的持久性内存区域(例如在持久性内存管理器中分配)中分配。这些字符串在脚本的生命周期内保持存在,即使请求结束也不会被释放。因此,如果多个请求共享相同的字符串,持久性字符串可以减少内存的重复分配,从而节省内存。
-
-
性能提升:
-
因为持久性字符串在内存中保持不变,重复使用相同的字符串时,避免了多次分配和释放内存的开销。此外,持久性字符串还可以减少字符串的比较和查找操作,因为相同的字符串可能会在持久性存储中只存在一个实例。
-
-
避免内存碎片:
-
持久性字符串有助于避免内存碎片问题,因为它们通常在专用的内存区域中分配和管理。这使得内存管理更高效,尤其是在长时间运行的 PHP 进程中。
-
实现细节
在 PHP 中,持久性字符串的标志和管理通常涉及以下几个方面:
-
标志位:
-
IS_STR_PERSISTENT
是一个标志位,通常与其他类型信息一起存储在zend_string
结构体的GC_TYPE_INFO
字段中。这个标志用于标识字符串是否是持久性的。
-
-
分配内存:
-
持久性字符串通过
pemalloc
函数进行分配,该函数用于分配持久性内存。与普通内存分配不同,持久性内存会在 PHP 脚本的整个生命周期内保留。
-
-
引用计数:
-
持久性字符串通常具有固定的引用计数,通常为 1,因为它们在整个生命周期内共享和重用,不会被垃圾回收。
-
-
持久性内存区域:
-
持久性字符串存储在 PHP 的持久性内存区域中(如
persistent
堆),与临时内存区域(如emalloc
)分开管理。这使得持久性字符串在内存中保持长时间有效。
-
IS_STR_INTERNED 和 IS_STR_PERSISTENT 区别
在 PHP 的垃圾回收和内存管理中,IS_STR_INTERNED
和 IS_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 进程启动时分配,并在进程结束时释放。
-
区别总结
-
持久性 vs Interned:
-
持久性 (IS_STR_PERSISTENT):表示字符串的生命周期与 PHP 进程的生命周期相同。即,它在整个 PHP 进程期间是持久存在的。
-
Interned (IS_STR_INTERNED):表示字符串是全局唯一的且共享的,用于优化字符串存储和比较。Interned 字符串通常也是持久的,但它们是通过特殊的机制来管理的,以确保全局唯一性。
-
-
标记用途:
-
IS_STR_PERSISTENT:用于标记字符串的持久性,确保在整个 PHP 进程生命周期内保持。
-
IS_STR_INTERNED:用于标记字符串是否是 interned,意味着字符串在内存中是唯一的并且通常是常量的。
-
-
内存管理:
-
Interned 字符串会在全局范围内共享,避免重复分配内存。
-
持久性字符串则在 PHP 进程中保持,虽然它们可能是 interned,但持久性字符串并不一定是 interned 字符串。持久性字符串可能还涉及到其他的内存管理和优化策略。
-
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
接下来介绍几个主要函数。
-
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所示。
Figure 3. 图4-3 字符串内存分布情况 -
-
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。
对于 |
看完源码其实也能得出,二进制安全比较函数其实就是打破原有 C 语言读字符串遇到 “\0”
就返回的惯性,结合字符串长度字段 len
循环读取、比较字符串而已。
因篇幅有限,本节暂时只分析这几个 zend_string
API 函数,感兴趣的读者可以结合文章罗列的函数清单,去源码中查看。