9.1 内存管理的意义
内存是计算机系统中重要的基本资源之一。内存管理是指对其分配、使用和回收的管理。在硬件层面,内存管理涉及对RAM等数据存储硬件的管理;在操作系统和应用层面,则是保障各个程序内存的正常分配和回收。PHP 7的内存管理是在C的内存函数库之上做了一层封装,本章将重点讨论在PHP 7中如何管理PHP程序自身运行时的对象和数据结构所用的内存。
计算机系统的运行无时无刻不依赖内存,所以内存管理是否安全、高效也严重影响着系统性能的表现。如前文所述,既然操作系统已经提供了一套内存管理的函数,那为什么PHP 7还要自己实现一套内存管理方案呢?
首先,PHP 7的使用者不需要像C/C++那样手动申请和释放内存。在开发者对内存随用随取(定义对象、数组等)的背后,是PHP 7内核的内存管理提供的支撑,让开发者可以专注于于业务逻辑,而不用关心内存的申请和释放,大大提高了业务支撑的效率。
其次,向操作系统申请内存以及释放内存、回收内存,会产生用户态和内核态的切换,是高耗时的操作。PHP 7内存管理器充当了应用层和操作系统内核的中间人,大大减少应用直接向内核频繁申请小块内存的操作,同时PHP 7内存管理器会择时释放,提升系统的整体性能。
另外很重要的一点是,PHP 7内存管理还会减少内存“碎片化”问题。没有内存管理器,如果PHP程序持续运行、反复申请与释放内存导致连续内存产生大量碎片,会使得内存利用率降低;内存管理器的内存池技术能按块大小分级分配和回收,减少碎片化。图9-1是PHP 7内存管理器示意图。

图9-1 PHP 7内存管理器示意图
如图9-1所示,PHP脚本运行所需的内存空间不是直接从系统申请,而是调用Zend Memory Manager(Zend内存管理器,以下简称MM)提供的一系列接口函数(如zend_mm_alloc_small)申请:如果MM中的可用内存够用,直接分配给PHP程序;如果MM中的可用内存不够,MM再从系统申请。可见这样能有效减少系统调用的次数,并优化内存空间的使用效率。
一般都认为C\C++开发要难于PHP,很大程度上,难度在于内存管理这一块。C\C++开发时,要自己管理动态内存,自己申请,自己释放,申请了却没有释放,会造成内存泄漏,不断浪费内存以致拖慢系统;内存使用越界,会让程序崩溃。虽然C++中的STL库和BOOST库提供了多款智能指针用于管理内存,但是不同的智能指针有不同的应用场景,PHP的内存管理把工程师从内存管理的梦魇中解救出来。
默认的系统分配和释放内存算法自然也考虑了性能,然而,为了应付更复杂、更广泛的情况,这些内存管理算法的通用版本需要做更多的额外工作。而对于某一个具体的应用程序来说,适合自身特定的内存分配释放模式的自定义内存则可以获得更好的性能。
所以我们需要内存池技术,当申请者第一次申请内存时,直接申请一块大块内存(通常是一页),将此次申请需要的内存部分返回给申请者,并将剩下的内部放到池子中,以后申请者再申请内存时,直接在剩下的部分中选取合适的大小返回给申请者。
我们来看下维基百科对内存池的定义:内存池提供了一个更有效率的解决方案,即预先规划一定数量的内存区块,使得整个程序可以在运行期规划(allocate)、使用(access)、归还(free)内存区块。
内在池不仅在用户态应用程序中广泛使用,同时在Linux内核也广泛使用,在内核中有不少地方内存分配不允许失败。所以在这种情况下,为了确保内存能够成功分配,内核开发者创建了一个内存池,只分配给此情况使用,此方法虽然浪费了内存,但是可以从根本上保证系统更加稳定。
从应用角度来看这个问题,内存管理兼顾性能、灵活与安全性,对PHP的扩展性起到了很好的支撑作用。诸如PHP7的重要数据结构——数组和字符串,为扩展开发者提供了便捷的高级变量类型。而在不同生命周期中,这些变量有持久化和非持久化的不同需求。PHP 7的内存管理为不同场景设计了不同的API,开发者可以将更多精力投入上层逻辑,尽量避开“内存泥沼”。
9.2 内存管理的准备知识
据PHP 7核心开发者描述,PHP 7在内存管理上的CPU时间节省达到了21%,提升巨大。接下来,就从源码层面一窥PHP 7的内存管理实现,逐步揭开效率提升的“内幕”。
PHP 7的Zend MM借鉴了jemalloc和tcmalloc这两个成熟的内存管理方案。两者都在业内广泛应用:jemalloc是Firfox浏览器的默认内存管理器,而tcmalloc则是Chrome和Safari的默认内存管理器。抛开jemalloc和tcmalloc的实现细节,MM和“前辈”的内存分配思想是一致的:系统申请大块内存,再按固定的几种规格分割成较小的内存块,由内存池统一管理。当调用方申请内存时,从池子中匹配已经预分配的合适大小的内存块返回。
9.2.1 基本概念
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所示。

图9-2 chunk与page示意图
在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则是欲取消的内存大小,该函数在释放内存的时候使用。
9.2.2 内存对齐
在用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所示。

图9-3 PHP 7内存地址对齐示例
- 申请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内存管理中核心的数据结构。
9.3 内存管理的数据结构
PHP 7内存管理中用到了多个结构体,其中核心的结构体有_zend_mm_heap、_zend_mm_page、_zend_mm_chunk。其中_zend_mm_page最简单,对应的是4KB的char数组,下面对_zend_mm_heap和_zend_mm_chunk进行详细的讨论。
9.3.1 _zend_mm_heap
变量存储在全局变量alloc_globals(对应的宏是AG())中的mm_heap(在多线程模式下,会有多个mm_heap分别进行管理,为了容易理解,这里只介绍单线程模式下的MM)字段所指向的数据中,其类型为struct_zend_mm_heap,初始值为NULL,在MM启动时进行初始化。下面一起来看下其主要数据结构。
struct _zend_mm_heap {
#if ZEND_MM_CUSTOM
int use_custom_heap;
#endif
#if ZEND_MM_STORAGE
zend_mm_storage *storage;
#endif
#if ZEND_MM_STAT
size_t size; /* 当前使用的内存大小 */
size_t peak; /* 内存使用的峰值 */
#endif
zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* 用来存储small内存 */
#if ZEND_MM_STAT || ZEND_MM_LIMIT
size_t real_size; /* 当前真正分配的内存大小 */
#endif#
if ZEND_MM_STAT
size_t real_peak; /* 真正分配的内存大小的峰值 */
#endif
#if ZEND_MM_LIMIT
size_t limit; /* 内存限制的最大值 */
int overflow; /* 内存溢出的标识 */
#endif
zend_mm_huge_list *huge_list; /* huge内存的链表 */
zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks; /* 未使用chunk的链表 */
int chunks_count; /* 分配chunk的个数 */
int peak_chunks_count; /* 分配chunk个数的峰值*/
int cached_chunks_count; /* 缓存chunk的格式 */
double avg_chunks_count; /* 每个请求分配chunk的平均值*/
下面解释下各变量的含义。
-
size/real_size:size代表的是MM当前申请的已使用的内存,real_size还包括申请的未使用的内存;可以通过PHP的函数memory_get_usage来获取,其PHP函数原型如下:
int memory_get_usage ([ bool $real_usage = false ] ) `` $real_usage默认为false,只返回使用的内存大小;对于true的情况,会返回包括没有使用的分配内存的大小。在PHP 7的源码中,有对应的实现: ```c ZEND_API size_t zend_memory_usage(int real_usage) { if (real_usage) { return AG(mm_heap)->real_size; } else { size_t usage = AG(mm_heap)->size; return usage; } return 0; }从源码中,可以看出,当
real_usage为false时,返回的是size;size和real_size会在申请和释放内存时进行修改。
-
peak/real_peak:peak是emalloc上报的内存峰值,而real_peak是MM在本进程申请的内存峰值;可以通过PHP的函数memory_get_peak_usage来获取,其PHP函数原型如下:
int memory_get_peak_usage ([ bool $real_usage = false ] )$real_usage默认为false,只返回emalloc上报的内存峰值大小;对于true的情况,会返回内存分配峰值的大小;在PHP 7的源码中,有对应的实现:
ZEND_API size_t zend_memory_peak_usage(int real_usage) { if (real_usage) { return AG(mm_heap)->real_peak; } else { return AG(mm_heap)->peak; } return 0; }从源码中,可以看出,当
real_usage为false时,返回的是peak;同样peak和real_peak会在申请内存和释放内存时进行修改。
-
free_slot:指针数组,存储30种规格的small内存链表的首地址,会在9.4.5节详细展开。
-
limit:存储对MM可申请内存的最大值,MM每当向系统申请chunk或huge内存时,会判断申请后的内存值是否大于limit,如果大于,则进行垃圾回收。该参数可以通过在php.ini中修改memory_limit配置项设置。
-
overflow:当要申请的内存总数超出MM的limit时,先进行垃圾回收,如果回收失败,则判断overflow是否为1,如果为1,则抛出异常,中断进程(PHP项目中经常遇到的“Allowed memory size of ** bytes exhausted(tried to allocate ** bytes)”就是这样抛出来的)。
-
main_chunk:双向链表,存储使用中的chunk的首地址。
-
cached_chunks:双向链表,缓存的chunk的首地址。
-
chunks_count:使用中的chunk个数,也就是链表main_chunk中的元素个数。
-
peak_chunks_count:此次HTTP请求中申请的chunk个数最大值,初始化为1,且每次请求开始,都会重置为1。
-
cached_chunks_count:缓存中的chunk个数,也就是链表cached_chunks中的元素个数。
-
avg_chunks_count:历次请求使用chunk的个数平均值,初始化为1.0,每次请求结束时,会重新计算此值,置为avg_chunks_count和peak_chunks_count的平均值。
对于chunk相关的变量,会在后续的chunk章节详细展开。
-
huge_list:用以挂载分配的大块内存的单向列表,方便后续MM关闭时释放。
结构体_zend_mm_heap本身是要占内存的,也保存在内存管理申请的内存中,我们来看下zend_mm_heap:
(gdb) p *alloc_globals.mm_heap
$1 = {use_custom_heap = 0, storage = 0x0, size = 384000, peak = 384000, free_slot
= {0x7ffff7c76000,
0x7ffff7c79030, 0x7ffff7c55048, 0x7ffff7c5c700, 0x7ffff7c01528, 0x7ffff7c02210,
0x7ffff7c56230,
0x7ff f f7c621c0, 0x7f f f f7c750a0, 0x7f f f f7c74060, 0x0, 0x0, 0x7f f f f7c7a0a0,
0x7ffff7c77240, 0x0,
0x7ffff7c78300, 0x7ffff7c5d780, 0x0, 0x0, 0x0, 0x7ffff7c63000, 0x0, 0x0,
0x0, 0x7ffff7c68000, 0x0,
0x7ffff7c6d700, 0x0, 0x7ffff7c58400, 0x0}, real_size = 2097152, real_peak =
2097152,
limit = 134217728, overflow = 0, huge_list = 0x0, main_chunk = 0x7ffff7c00000,
cached_chunks = 0x0,
chunks_count = 1, peak_chunks_count = 1, cached_chunks_count = 0, avg_chunks_
count = 1,
custom_heap = {std = {_malloc = 0, _free = 0, _realloc = 0}, debug = {_malloc
= 0, _free = 0,
_realloc = 0}}}
(gdb) p alloc_globals.mm_heap
$2 = (zend_mm_heap *) 0x7ffff7c00040
从结果中看到,alloc_globals.mm_heap的地址为0x7ffff7c00040,而main_chunk的地址为0x7ffff7c00000,可以看出mm_heap其实是在main_chunk上分配的。根据gdb得到的信息,可以画出main_chunk占用内存的情况,如图9-4所示。

图9-4 PHP 7内存地址对齐示例
从图9-4中可以看出,结构体按8对齐后,mm_heap要占376B的内存,通过gdb可以验证:
(gdb) p sizeof(*alloc_globals.mm_heap)
$3 = 376
zend_mm_heap中有一个非常重要的结构——_zend_mm_chunk,下面讨论一下这个结构体。
9.3.2 _zend_mm_chunk
PHP 7的MM是一个多级内存分配器——预先定义内存块级别,按需要分配空间的大小找到对应级别,对齐分配。前文提到,chunk大小为2MB;每个chunk可以切割为512个page,一个page是4KB。在chunk内部,以page为单位进行管理。参考以下宏:
#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 */
一个chunk大小为2MB, MM管理chunk的变量,使用的是结构体_zend_mm_chunk:
struct _zend_mm_chunk {
zend_mm_heap *heap;
zend_mm_chunk *next;
zend_mm_chunk *prev;
uint32_t free_pages; /* free pages的个数*/
uint32_t free_tail; /* 尾部chunk上free pages的个数*/
uint32_t num;
char reserve[64- (sizeof(void*) * 3 + sizeof(int) * 3)];
zend_mm_heap heap_slot; /* 只存在main chunk上 */
zend_mm_page_map free_map; /* 512 bits或 64 bytes */
zend_mm_page_info map[ZEND_MM_PAGES]; /* 2 KB = 512 * 4 */
};
struct _zend_mm_page {
char bytes[ZEND_MM_PAGE_SIZE]; // ZEND_MM_PAGE_SIZE为4KB
};
各变量的含义如下。
-
heap:zend_mm_heap类型的指针,对应的是9.3.1节中AG里面的mm_heap的地址。
-
next:zend_mm_chunk类型的指针,指向下一个chunk。
-
prev:zend_mm_chunk类型的指针,指向上一个chunk。由next/prev可见zend_mm_chunk是双向链表。
-
free_pages:此chunk中可用的page个数,如图9-5所示,此chunk一共使用了9个page,则free_pages为512-9=503。

图9-5 PHP 7内存管理page使用情况示例 -
free_tail:此chunk的最后一块连续可用page的起始编号,主要用于快速查找连续可用page,此值并不准确,但不影响最后结果,如图9-5所示,free_tail应该为363。
-
free_map:在64位机器下,其为8个元素的数组,每个元素为64bit的整型,所以一共有8× 64bit=512bit,对应512个page。已使用的page,对应的bit置为1,灰色部分;未使用(可用)的page,对应的bit置为0,白色部分,如图9-6所示。

图9-6 free_map对应的512bit -
map:512个元素的数组,每个元素为一个32bit的整型,用来记录每个page的使用情况,比较复杂,如图9-7所示。

图9-7 PHP 7内存管理large内存的map使用情况示例高位的2个bit,用于标记此page的使用类型,有4种情况:0x0、0x1、0x2、0x3,其中0x0代表此page未使用,0x1代表此page用于large内存,0x2和0x3均代表此page用于small内存。
当此page用于large内存时,如果低位的10个bit为0,则代表此page被其前面且连续的page一起用于一次申请的内存;如果非0,假定值为page_count,则代表此page开始的连续page_count个page一起用于一次申请的内存,比如图9-6中一次申请了3个连续的page,起始编号为360,那么map[360]、map[361]、map[362]的低10位分别为3、0、0。
当此page用于small内存时,在9.4.5节中介绍此字段。
注意 free_map是8× 8B,也就是8× 8× 8=512bit,这512个bit对应512个page,每个bit只能取0或者1,代表对应page的使用情况。而map是512个uint32_t,也就是512× 4B,每一个uint32_t代表一个page的使用情况。
-
num:代表此chunk在链表main_chunk中的编号,很明显,当申请第一个chunk时,num为0。对于非第一个chunk, num的值为在前一个chunk的num上加1。
-
reserve:保留字段,在C语言开发中的结构体中尤为常见,用于结构体版本升级之类。
-
heap_slot:在MM进行初始化时,会创建第一个chunk,而第一个chunk的此字段,才有意义。其实全局指针alloc_globals.mm_heap指向的便是第一个chunk的heap_slot。
每申请一个chunk,都需要对chunk进行初始化,大致流程如下所示。
- 将此chunk放入环状双向链表main_chunk的最后面。
- 将free_pages置为512-1=511(第0个page被chunk的头信息占用)。
- 将free_tail置为1。
- 将num在上一个元素的计数基础上加1(chunk->prev->num+1)。
- 将free_map[0]标记为1,代表第0个被使用。
- 将map[0]标记为0x40000000 | 0x01,0x40000000代表第0个page使用large内存,0x01代表从第0个page起,连续1个page被使用。
_zend_mm_chunk本身是要占用内存的,我们输出_zend_mm_chunk的size:
(gdb) p sizeof(zend_mm_chunk)
$3 = 2552
这个结构体占了2552B,它存放在chunk的第0个page上,如图9-8所示。

图9-8 PHP 7内存管理chunk和page在MM中的位置
当申请一个chunk时,MM先判断双向链表cached_chunks是否存在chunk,如果不存在,则直接向操作系统申请一个地址以2MB对齐的chunk,添加到main_chunk中,然后返回给申请者;如果cached_chunks中存在chunk,则将头部的chunk摘除,然后添加到main_chunk后,返回给申请者。每次有新的chunk进入main_chunk之前,都需要对此chunk进行初始化,一个chunk被分成512个page,其中511个page可用,第0个page用来存储这个chunk的管理结构体struct_zend_mm_chunk。
释放一个chunk时,MM先将此chunk从main_chunk中移除,并将chunks_count减1。然后判断当前使用的chunk个数chunks_count和缓存中的chunk个数cached_chunks_count之和是否小于历次请求使用的chunk个数平均值avg_chunks_count。如果小于,则将此chunk放入双向链表cached_chunks中;如果不小于,则直接向操作系统释放此块内存。
到此,我们研究了AG里面mm_heap的结构,以及chunk和page结构和相互关系,有了这些准备后,再来看下PHP 7内存管理的详细实现。
9.4 内存管理的详细实现
在PHP 7生命周期中,SAPI调用php_module_startup时,会调用start_memory_manager,继而调用alloc_globals_ctor,然后调用MM的初始化函数zend_mm_init,我们来看一下栈的调用:
(gdb) bt
#0 zend_mm_init () at /home/vagrant/php7/book/php-7.1.0/Zend/zend_alloc.c:1797
#1 0x00000000008ab0ec in alloc_globals_ctor (alloc_globals=0x11fa7d8)
at /home/vagrant/php7/book/php-7.1.0/Zend/zend_alloc.c:2616
#2 0x00000000008ab105 in start_memory_manager ()
at /home/vagrant/php7/book/php-7.1.0/Zend/zend_alloc.c:2631
#3 0x00000000008e9697 in zend_startup (utility_functions=0x7fffffffde30, extensions=0x0)
at /home/vagrant/php7/book/php-7.1.0/Zend/zend.c:662
#4 0x00000000008391a3 in php_module_startup (sf=0x11e1cc0, additional_modules=0x0,
num_additional_modules=0) at /ome/vagrant/php7/book/php-7.1.0/main/main.c:2123
#5 0x0000000000a87129 in php_cli_startup (sapi_module=0x11e1cc0)
at /home/vagrant/php7/book/php-7.1.0/sapi/cli/php_cli.c:424
#6 0x0000000000a8973f in main (argc=2, argv=0x12050d0)
at /home/vagrant/php7/book/php-7.1.0/sapi/cli/php_cli.c:1345
下面讨论MM的初始化,也就是zend_mm_init实现。
9.4.1 内存管理初始化
在对MM的初始化之前,首先判断系统环境变量USE_ZEND_ALLOC是否将MM关闭(如果设置为0,则关闭;如果设置为其他值,则不关闭)。如果没有关闭,则进行MM的初始化。
首先,申请一个chunk,其大小是2MB,将其初始化。然后,使用该chunk的第0个page存放zend_mm_chunk,以管理整个chunk,同时将AG的mm_heap放在heap_slot中,因此整个zend_mm_chunk会占2552B,如图9-10所示的黑色部分。接着,将chunk中的heap_slot地址赋值给alloc_globals.mm_heap,对alloc_globals.mm_heap进行初始化:main_chunk置为刚申请的chunk, cached_chunks初始化为NULL,chunks_count初始化为1, peak_chunks_count初始化为1,cached_chunks_count初始化为0, avg_chunks_count初始化为1.0, real_size初始化为2MB, size和peak均初始化为0。到此,MM初始化完毕,如图9-9所示。

图9-9 PHP 7内存管理初始化
9.4.2 内存申请
PHP 7提供了一组标准宏,用来申请和释放内存,代码在Zend/zend_alloc.h中,代码如下:
2 /* Standard wrapper macros */
3 #define emalloc(size) _emalloc((size) ZEND_FILE_LINE_CC ZEND_FILE_LINE_
EMPTY_CC)
4 #define emalloc_large(size) _emalloc_large((size) ZEND_FILE_LINE_CC ZEND_FILE_
LINE_EMPTY_CC)
5 #define emalloc_huge(size) _emalloc_huge((size) ZEND_FILE_LINE_CC ZEND_FILE_
LINE_EMPTY_CC)
6 ……
以emalloc为例,内存分配过程如图9-10所示。

图9-10 PHP 7内存管理emalloc内存分配过程
参照图9-10,详细的分配过程如下。
- 根据申请内存的size大小,若size>(2MB-4KB),定义为huge内存的分配,调用chunk的分配函数,chunk直接调用mmap向操作系统申请,后面会详细展开。
- 如果size>3072且size≤(2MB-4KB),定义为large内存的分配,分配n× Page,也就是4KB的整数倍,其中n× Page是大于size的最小的Page的整数倍。large类型内存在mm_heap中的chunk上分配。
- 如果size<3072,定义为small内存的分配,PHP 7从8B到3KB建立了30个规格,申请者申请size大小的内存时,MM会找到大于等于size的最小的规格,比如申请14B,会返回16B,在16B的规格中找到可以使用的内存返回;如果找不到,则申请对应规格的内存,并不是只申请一个,而是多申请一些,挂载到mm_heap的free_slot中,方便下次申请时直接返回。
9.4.3 内存管理之huge内存
huge内存的管理比较简单,huge内存并不从chunk和page中申请,而是自己单独申请和释放的。申请是通过zend_mm_alloc_huge函数实现的,申请size大小的内存过程如下:
- 根据内存大小对齐(1个page大小,4KB),将size计算为要申请的内存长度new_size。
- 调用zend_mm_chunk_alloc,申请new_size大小的内存,具体分配方法是调用mmap函数,将申请到的内存按照1个chunk的大小(2MB)对齐。
- 将分配的内存挂载到mm_heap的huge_list上,同时更新peak/size等值的大小,如图9-11所示。

图9-11 PHP 7内存管理huge内存分配过程
huge内存的释放过程也比较简单,调用zend_mm_free_huge函数,具体如下。
- 根据要释放内存的指针,在mm_heap->huge_list中找到对应内存的size,同时将其从huge_list摘除。
- 调用munmap释放内存。
9.4.4 内存管理之large内存
large内存大小是对应的page的整数倍,比如要申请4094B的内存,返回的是一个page大小的内存。large内存分配调用的是zend_mm_alloc_pages函数,向MM申请page_count个连续的page的过程如下。
-
遍历双向链表mm_heap->main_chunk。
-
判断此chunk中剩余空闲page个数free_pages是否小于page_count,如果小于,则跳回1)继续遍历。
-
根据此chunk中page使用情况的free_map,查找最优连续空间page的起始编号page_num,最优的原则如下:
① 连续空闲page个数最少。 ② 连续空间page的起始编号最小。
如果没有可用的连续空间page,则跳回1继续遍历;如果有,则将此chunk的page_num开始的page_count个page对应的free_map中的bit重置为1,且将map[page_num]置为0x40000000 | (page_count<<0) = 0x40000000 |page_count(代表从第page_num起的page_count个page用于large内存),然后用free_pages减掉page_count。
如图9-12所示,假定现在申请连续两个page,现在此chunk有从128开始的连续5个未使用的page,从360开始的连续3个未使用的page,从400开始的连续3个未使用的page,按照最优原则,从360的位置开始,连续两个未使用的page会被申请到,所以申请到的page_num为360。按照刚才所讲的操作,需要将map[360]置为0x40000000 | 2。

图9-12 PHP 7内存管理page使用情况示例 -
如果2和3均满足,则MM返回申请到的首地址,获取方式如下所示:
((void*)(((zend_mm_page*)(chunk)) + (page_num)));
- 如果遍历main_chunk结束,没有找到可用的page,则重新申请一个chunk放入main_chunk中(从缓存的cache_chunk中取或向系统申请)。
下面说一下page的释放:page释放比较简单,例如,释放从page_num起的page_count个page。
- 修改free_pages += pages_count。
- 修改map[page_num] = 0。
- 将free_map中对应的bit置为0。
- 如果free_pages等于511,则释放此chunk。
9.4.5 内存管理之small内存
small内存存在于mm_heap的free_slot上面,free_slot是存有30个链表的数组,如图9-13所示。

图9-13 PHP 7内存管理small内存的结构
MM按照申请内存的大小将small内存分成了30种规格,每一种称为一个RUN,规格表见表9-1。当申请者申请size大小的内存时,MM会在free_slot中找到比size大的最小规格,比如申请6B,则从8B的规格中查找可用内存并返回给申请者。该规格表在zend_alloc_sizes.h文件的宏ZEND_MM_BINS_INFO中对RUN进行了定义,前4列分别为RUN编号、slot的大小、每次申请RUN包含的slot个数、每次申请RUN占用的page个数。

表9-1 small内存的规格表
如何理解这张规格表呢?以第0行为例,规格为size=8B的内存,需要1个page,这1个page可以分为512个size=8B规格的内存;同理,对于第16行,规格为size=320B的内存,需要5个page,可以分为64个size=320B的内存。
下面讨论small内存与page和chunk的关系,如图9-14所示。

图9-14 small内存的结构
以RUN编号23为例,对应的规格大小size为1024B,在未使用的情况下,在chunk上分配了2个连续的page,即2×4096KB,将其分为8个size为1024B的内存,图9-14展示了small内存在MM的结构。每次申请一个编号为23的RUN,会申请连续两个page,然后分成8个slot,1个返回给申请者,7个挂在起始地址为free_slot[23]的链表上,等待下次申请时使用。
如果现在需要申请1000B大小的内存:
-
在规格表中对比发现,1000在896和1024之间,所以申请的规格大小为1024B,RUN的编号为23。
-
如果单向链表free_slot[23]为空,则进行步骤3);如果非空,把第一个slot从链表删除,然后返回给申请方。
-
申请两个连续page。
-
假定现在申请到的page起始编号为360,则设置map[360]=0x80000000|23(0x80000000代表此page被small内存使用,23代表此page一起用于编号为23的small内存)。对于360之后的page,map[361] = 0xc0000000|23|(1<<16)。
如图9-15所示,当map中一个元素高位的两个位为0x10或0x11时,代表此page为small内存,两者区别在于:当其为0x10时,代表当前page为此块small内存的首个page;当其为0x11时,代表当前page为此块small内存的非首个page。区分是否是首个page,主要用于small内存的垃圾回收。

图9-15 small内存的map的使用情况低位的5个bit,记录使用此page的small内存的类型,比如刚才的示例中,类型为23,所以低5位的值为23。
从第16个bit起的9个bit,在small类型的page中,代表此page所属的用于一次性申请到的连续page的编号,比如刚才的示例中,map[360]的此位置为0,map[361]的此位置为1。
另外,在进行垃圾回收时,这9个bit会有另外的应用:如果此page类型为0x10,则说明此page是首个page,则这里的9个bit肯定为0。如果进行垃圾回收,它会用来统计此次垃圾回收中使用此块连续page的free_slot的个数。如果统计结果恰好等于此块连续page所能分配的slot个数,则说明此块连续page处于完全未使用状态,并对此块连续page进行释放。
-
把这个RUN(刚申请的两个连续page)分成8个slot,第1个slot返回给申请方,剩下的7个slot存入heap->free_slot[23]中。
释放一块small内存,比较暴力,可以直接将内存存入对应的链表heap->free_slot中。
9.5 内存回收
内存使用完之后必须要进行释放,通过huge方式和large方式申请的内存的释放比较简单,前面已经讲过。这里主要介绍small内存的释放。
释放small内存的一个slot时,并没有判断此slot所在的连续page中的slot是否已全部释放。因为small内存的申请和释放比较频繁,每次都判断不太划算,但MM在申请chunk失败后,会进行内存整理。如果一次申请small内存申请到的连续page中的slot已全部释放,则释放这几个page,再继续申请chunk。
- 依次遍历30种RUN的单向链表。
- 根据内存地址对齐的相关宏,可以轻松定位到一个slot所在的page(假定page所在chunk的编号为page_num)、chunk。
- chunk->map[page_num]的高2bit如果为0x10,则说明此page为连续page的首个page, chunk->map[page_num]的中间9bit进行+1操作。
- chunk->map[page_num]的高2bit如果为0x11,则说明此page为连续page的非首个page,假定为page_offset个page,则chunk->map[page_num-page_offset]的中间9bit进行+1操作。
- +1操作后,如果发现中间9bit的值与这一次申请此规格的RUN时分配的slot个数相等,则说明此连续page可以进行释放。
- 重新遍历此RUN的单向链表,将可释放page中的slot全部删除,然后释放所占的page,将不可释放的slot所在连续page的首个page中的map信息中的中间9bit置0。
9.6 本章小结
本章主要研究探讨了PHP 7的内存管理的实现,我们将内存管理比作面包店,可以有利于读者形象地理解内存池管理。
假定场景:
① 面包店;② 各种规格大小的面包;③ 成袋的面粉;④ 存储面粉的仓库。
我们向系统申请内存,就好比去面包店购买面包,那么面包店会如何做呢?
我们到面包店要购买一个牛角面包,一种比较差的办法是,面包店的工作人员去仓库里取一袋面粉,然后和面、作模、烘焙、最后出炉,这类似于malloc。顾客需要等待一定的时间,另外每次只烘焙一个面包,效率是很低的。
实际面包店的做法是事先做好不同品种的面包,当顾客购买面包时,选择某种规格的即可。这非常类似于内存管理中的small内存,建立一些规格的内存,当申请者申请时,返回最小的符合申请者要求的内存。那么对于large内存,一个page类似于一整袋面粉,而chunk则相当于仓库里摆放512袋面粉的货架。这样类比起来更容易理解,并且感受到了技术都来源于生活。