内存管理的准备知识
据 PHP 7 核心开发者描述,PHP 7 在内存管理上的 CPU 时间节省达到了 21%,提升巨大。接下来,就从源码层面一窥 PHP 7 的内存管理实现,逐步揭开效率提升的 “内幕”。
PHP 7 的 Zend MM 借鉴了 jemalloc 和 tcmalloc 这两个成熟的内存管理方案。两者都在业内广泛应用:jemalloc 是 Firfox 浏览器的默认内存管理器,而 tcmalloc 则是 Chrome 和 Safari 的默认内存管理器。抛开 jemalloc 和 tcmalloc 的实现细节,MM 和 “前辈” 的内存分配思想是一致的:系统申请大块内存,再按固定的几种规格分割成较小的内存块,由内存池统一管理。当调用方申请内存时,从池子中匹配已经预分配的合适大小的内存块返回。
基本概念
PHP 7 的 MM 的核心代码在 zend_alloc.c 中实现,它维护了 3 种规格的内存,分别是 chunk、page、slot,其中一个 chunk 大小是 2MB,一个 page 是 4KB,一个 chunk 可以划分成多个 page,而一个 page 又可划分成多个 slot,每种规格的内存的应用场景不同,因此它们的分配方式有所不同,对于 MM 而言,只有 chunk 是通过 malloc 的方式向系统申请内存的。
PHP 是 C 实现的,在堆中运行。Zend MM 也在堆内存运行,它根据完善的运行机制管理着内存的申请和释放。对于堆内存管理而言,chunk 是最小操作单位。从本质上来说,所有类型的 chunk 都是内存中一块连续的区域,一个 chunk 的大小是 2MB。
对应于 PHP 中,在 zend_alloc_sizes.h 中有对 page 和 chunk 大小的定义:
#define ZEND_MM_CHUNK_SIZE (2 * 1024 * 1024)/* 2 MB */
#define ZEND_MM_PAGE_SIZE (4 * 1024) /* 4 KB */
#define ZEND_MM_PAGES (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE) /* 512 */
page 是在 chunk 中分配的,那么一个 chunk 可以分为 2MB/4KB=512 个 page,如图9-2所示。

在 PHP 7 中,对于 chunk 大块内存的申请是使用 mmap 函数实现的,其中 mmap 函数原型如下:
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);
//PHP 7中对应的调用如下:
ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
各参数的含义如下:
-
start:映射区的开始地址,设置为 0 时表示由系统决定映射区的起始地址,PHP 7 中传入的是 NULL,也就是 0。
-
length:映射区的长度,以字节为单位,不足一内存页按一内存页处理。
-
prot:期望的内存保护标志,不能与文件的打开模式冲突。prot 可以是以下的某个值,可以通过 or 运算合理地组合在一起:
-
PROT_EXEC——页内容可以执行;
-
PROT_READ——页内容可以读取;
-
PROT_WRITE——页可以写入;
-
PROT_NONE——页不可访问。
PHP 7 中的内存保护标志位为 PROT_READ| PROT_WRITE,即可以读和写。
-
-
flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个位的组合体,PHP 7 使用的是 MAP_PRIVATE | MAP_ANON,前者是建立一个写入时复制的私有映射,后者表示匿名映射,映射区不与任何文件关联。
-
fd:有效的文件描述词。PHP 7 中设置为 -1,此时需要指定 flags 参数中的 MAP_ANON,表明进行的是匿名映射。
-
off_toffset:被映射对象内容的起点,PHP 7 中设置为 0。
调用 mmap,必须以 PAGE_SIZE(一个 page 的大小)为单位进行映射,而内存也只能以页为单位进行映射,若要映射非 PAGE_SIZE 整数倍的地址范围,要先进行内存对齐,强行以 PAGE_SIZE 的倍数大小进行映射。成功执行时,mmap 返回被映射区的指针。PHP 7 通过调用 mmap 函数,返回一大块内存,一般是 chunk 大小的倍数,后面的内存管理工作在这一大块内存上进行操作。
PHP 7 的 MM 将申请内存按大小分成了 3 类:small 内存、large 内存、huge 内存。
-
small 内存:小于等于 3KB 的内存。
-
large 内存:大于 3KB 且小于等于(2MB-4KB)的内存,可以对应整数倍的 page,之所以要减掉 4KB 一个 page 的大小,后面会详细展开。
-
huge 内存:大于 2MB-4KB 的内存,可以直接对应整数倍的 chunk。
与 mmap 相反的操作是 int munmap(void *start, size_t length),用来取消参数 start 所指的映射内存起始地址,参数 length 则是欲取消的内存大小,该函数在释放内存的时候使用。
内存对齐
在用 C/C++ 进行软件开发、申请内存时,编译器可以帮我们实现内存对齐,虽然看上去浪费了内存,但是提升了 CPU 访问内存的速度。PHP 7 内存大小的对齐,和 C/C++ 编译器的内存对齐作用相近。在 PHP 7 的内存池管理中,比如我们申请 300B 的内存,如果以 256B 对齐,则对齐后的内存应该是 512B(256 的 2 倍)。
PHP 7 中的内存对齐,主要用到下面 3 个宏:
#define ZEND_MM_ALIGNMENT_MASK ~(ZEND_MM_ALIGNMENT - Z_L(1))
#define ZEND_MM_ALIGNED_SIZE(size) (((size) + ZEND_MM_ALIGNMENT - Z_L(1)) & ZEND_
MM_ALIGNMENT_MASK)
#define ZEND_MM_ALIGNED_SIZE_EX(size, alignment) \
(((size) + ((alignment) - Z_L(1))) & ~((alignment) - Z_L(1)))
如何理解这几个宏呢?下面举例来说明一下,假如要申请一个大小为 4KB 的内存,并以 0x1000 对齐,如图9-3所示。

-
申请 0x1000+0x1000-0x0001=0x1fff 的内存(也就是多申请 0xfff 的内存),比如申请到的起始地址为 0x103c60120,结束地址为 0x103c6211f;因为此时的地址不是 0x1000 对齐的(因为 0x103c60120 不是 0x1000 的整数倍),所以要进行对齐操作。
-
为了对齐,先释放 0x103c60120 到 0x103c61000(恰好是起始地址和结束地址区间内 0x1000 的整数倍)的 0xee0 长度的内存,起始保证了起始地址为 0x103c61000,是与 0x1000 对齐的。
-
释放 0x103c62000 到 0x103c6211f 的 0x11f 长度内存(两次释放的内存长度 0xee0+0x11f=0xfff,恰好为多申请的长度)。
-
剩下的即为需要的 0x1000 长度,起始地址为 0x103c61000,结束地址为 0x103c62000 的内存。
从图9-3可以看到,使用此内存时,比如有一内存地址为 0x103c61120,通过宏计算,可以得出,此内存所在的 page 的起始地址为 0x103c61000,在此 page 的偏移量为 0x120,能够快速定位内存地址所在的 page,提高效率。
到此已经介绍了内存管理的基本概念,以及内存对齐的方法,接下来阐述一下 PHP 7 内存管理中核心的数据结构。