内存管理的详细实现
在 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
bash
下面讨论 MM 的初始化,也就是 zend_mm_init 实现。
内存管理的初始化
在对 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所示。

内存申请
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 ……
bash
以 emalloc 为例,内存分配过程如图9-10所示。

参照图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 中,方便下次申请时直接返回。
内存管理之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所示。

huge 内存的释放过程也比较简单,调用 zend_mm_free_huge 函数,具体如下。
-
根据要释放内存的指针,在 mm_heap→huge_list 中找到对应内存的 size,同时将其从 huge_list 摘除。
-
调用 munmap 释放内存。
内存管理之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。
Figure 4. 图9-12 PHP 7内存管理page使用情况示例 -
-
如果2)和3)均满足,则 MM 返回申请到的首地址,获取方式如下所示:
((void*)(((zend_mm_page*)(chunk)) + (page_num)));
bash -
如果遍历 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。
内存管理之small内存
small 内存存在于 mm_heap 的 free_slot 上面,free_slot 是存有 30 个链表的数组,如图9-13所示。

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 个数。

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

以 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 内存的垃圾回收。
Figure 8. 图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 中。