PHP内核详解· 内存管理篇(四)· 分配小块内存

一、小块内存的分配过程

在 PHP 的内存管理体系中,小块内存(small block) 指的是大小不超过 ZEND_MM_MAX_SMALL_SIZE(3072 Bytes)的内存请求。这类内存的使用频率极高,因此 Zend 为其设计了一套高效且精巧的分配机制。整体思想是“用少量的空间浪费换取极高的分配效率”。

整个分配流程可分为三步:

  1. 计算需要的 page 数量;
  2. 分配 page,并更新 bitset 地图;
  3. 格式化小块内存列表,形成可复用的链表结构。

目标是在尽量少的系统调用下,快速获得可用的小内存块。

1)计算所需的 page 数量

小块内存的分配逻辑,并不是“一次分配一个”,而是每次批量分配一串小块。原因很简单:小块使用极其频繁,如果每次都直接向操作系统申请,就会造成严重的性能瓶颈。因此 Zend 在启动阶段,就通过 ZEND_MM_BINS_INFO() 预先定义了 30 种小块内存配置。

<table style="width:100%;">
<colgroup>
<col style="width: 16%" />
<col style="width: 16%" />
<col style="width: 14%" />
<col style="width: 15%" />
<col style="width: 17%" />
<col style="width: 17%" />
</colgroup>
<tbody>
<tr>
<td style="text-align: center;"><p><strong>num列</strong></p>
<p><strong>行号</strong></p></td>
<td style="text-align: center;"><p><strong>size列</strong></p>
<p><strong>大小(Bytes)</strong></p></td>
<td style="text-align: center;"><p><strong>elements列</strong></p>
<p><strong>每次分配数量</strong></p></td>
<td style="text-align: center;"><strong>总大小(Bytes)</strong></td>
<td style="text-align: center;"><p><strong>pages列</strong></p>
<p><strong>占用page数</strong></p></td>
<td style="text-align: center;"><strong>page使用率</strong></td>
</tr>
<tr>
<td style="text-align: center;"><strong>0</strong></td>
<td style="text-align: center;"><strong>8</strong></td>
<td style="text-align: center;">512</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>1</strong></td>
<td style="text-align: center;"><strong>16</strong></td>
<td style="text-align: center;">256</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>2</strong></td>
<td style="text-align: center;"><strong>24</strong></td>
<td style="text-align: center;">170</td>
<td style="text-align: center;">4080</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">99.61%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>3</strong></td>
<td style="text-align: center;"><strong>32</strong></td>
<td style="text-align: center;">128</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>4</strong></td>
<td style="text-align: center;"><strong>40</strong></td>
<td style="text-align: center;">102</td>
<td style="text-align: center;">4080</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">99.61%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>5</strong></td>
<td style="text-align: center;"><strong>48</strong></td>
<td style="text-align: center;">85</td>
<td style="text-align: center;">4080</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">99.61%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>6</strong></td>
<td style="text-align: center;"><strong>56</strong></td>
<td style="text-align: center;">73</td>
<td style="text-align: center;">4088</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">99.80%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>7</strong></td>
<td style="text-align: center;"><strong>64</strong></td>
<td style="text-align: center;">64</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>8</strong></td>
<td style="text-align: center;"><strong>80</strong></td>
<td style="text-align: center;">51</td>
<td style="text-align: center;">4080</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">99.61%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>9</strong></td>
<td style="text-align: center;"><strong>96</strong></td>
<td style="text-align: center;">42</td>
<td style="text-align: center;">4032</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>10</strong></td>
<td style="text-align: center;"><strong>112</strong></td>
<td style="text-align: center;">36</td>
<td style="text-align: center;">4032</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>11</strong></td>
<td style="text-align: center;"><strong>128</strong></td>
<td style="text-align: center;">32</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>12</strong></td>
<td style="text-align: center;"><strong>160</strong></td>
<td style="text-align: center;">25</td>
<td style="text-align: center;">4000</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">97.66%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>13</strong></td>
<td style="text-align: center;"><strong>192</strong></td>
<td style="text-align: center;">21</td>
<td style="text-align: center;">4032</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>14</strong></td>
<td style="text-align: center;"><strong>224</strong></td>
<td style="text-align: center;">18</td>
<td style="text-align: center;">4032</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>15</strong></td>
<td style="text-align: center;"><strong>256</strong></td>
<td style="text-align: center;">16</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>16</strong></td>
<td style="text-align: center;"><strong>320</strong></td>
<td style="text-align: center;">64</td>
<td style="text-align: center;">20480</td>
<td style="text-align: center;">5</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>17</strong></td>
<td style="text-align: center;"><strong>384</strong></td>
<td style="text-align: center;">32</td>
<td style="text-align: center;">12288</td>
<td style="text-align: center;">3</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>18</strong></td>
<td style="text-align: center;"><strong>448</strong></td>
<td style="text-align: center;">9</td>
<td style="text-align: center;">4032</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>19</strong></td>
<td style="text-align: center;"><strong>512</strong></td>
<td style="text-align: center;">8</td>
<td style="text-align: center;">4096</td>
<td style="text-align: center;">1</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>20</strong></td>
<td style="text-align: center;"><strong>640</strong></td>
<td style="text-align: center;">32</td>
<td style="text-align: center;">20480</td>
<td style="text-align: center;">5</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>21</strong></td>
<td style="text-align: center;"><strong>768</strong></td>
<td style="text-align: center;">16</td>
<td style="text-align: center;">12288</td>
<td style="text-align: center;">3</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>22</strong></td>
<td style="text-align: center;"><strong>896</strong></td>
<td style="text-align: center;">9</td>
<td style="text-align: center;">8064</td>
<td style="text-align: center;">2</td>
<td style="text-align: center;">98.44%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>23</strong></td>
<td style="text-align: center;"><strong>1024</strong></td>
<td style="text-align: center;">8</td>
<td style="text-align: center;">8192</td>
<td style="text-align: center;">2</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>24</strong></td>
<td style="text-align: center;"><strong>1280</strong></td>
<td style="text-align: center;">16</td>
<td style="text-align: center;">20480</td>
<td style="text-align: center;">5</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>25</strong></td>
<td style="text-align: center;"><strong>1536</strong></td>
<td style="text-align: center;">8</td>
<td style="text-align: center;">12288</td>
<td style="text-align: center;">3</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>26</strong></td>
<td style="text-align: center;"><strong>1792</strong></td>
<td style="text-align: center;">16</td>
<td style="text-align: center;">28672</td>
<td style="text-align: center;">7</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>27</strong></td>
<td style="text-align: center;"><strong>2048</strong></td>
<td style="text-align: center;">8</td>
<td style="text-align: center;">16384</td>
<td style="text-align: center;">4</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>28</strong></td>
<td style="text-align: center;"><strong>2560</strong></td>
<td style="text-align: center;">8</td>
<td style="text-align: center;">20480</td>
<td style="text-align: center;">5</td>
<td style="text-align: center;">100.00%</td>
</tr>
<tr>
<td style="text-align: center;"><strong>29</strong></td>
<td style="text-align: center;"><strong>3072</strong></td>
<td style="text-align: center;">4</td>
<td style="text-align: center;">12288</td>
<td style="text-align: center;">3</td>
<td style="text-align: center;">100.00%</td>
</tr>
</tbody>
</table>

这张表格定义了所有小块的分配策略。看似繁琐,但非常直观。

例如:

  • 当程序需要 5 Bytes 内存时,Zend 会选择比它略大的那一档——8 Bytes 档。一次分配 512 个小块,占用 1 个 page。
  • 当程序需要 1025 Bytes 内存时,会选择 1280 Bytes 档。一次分配 16 个小块,占用 5 个 page。

从表格可以直观看到,page 使用率普遍接近 100%,即便浪费最多的 160 Bytes 档也达到了 97.66%。这正体现了 Zend 在空间利用率与分配性能间的极致平衡。

这是典型的“用空间换时间”策略。通过批量分配与结构化管理,Zend 避免了频繁的系统调用,让高频小内存分配几乎不需要锁竞争。

zend_mm_small_size_to_bin() 函数

在分配小块内存前,Zend 需要根据请求大小找到对应的配置行号(bin)。这由以下函数完成:

// 根据内存大小获取配置行号
static zend_always_inline int zend_mm_small_size_to_bin(size_t size)

在运行时,ZEND_MM_BINS_INFO() 表会被拆分成三个全局数组,以便快速查找:

bin_data_size[]  // 存放每档小块的实际大小
bin_elements[]   // 存放每次批量分配的块数
bin_pages[]      // 存放每档占用的页数

函数的逻辑非常高效:它会根据 size 快速定位到最合适的档位,然后返回行号。通过这个行号,就能从 bin_pages[bin_num] 得到需要分配的页数。

这一机制是“预计算”思想的体现——配置表虽然复杂,但能换来运行时的常数级查找速度。

2)分配 page 并更新地图信息

分配链路如下:

zend_mm_alloc_small() → zend_mm_alloc_small_slow() → zend_mm_alloc_pages()

zend_mm_alloc_small() 函数

该函数是小块分配的核心入口,逻辑清晰简洁:

static zend_always_inline void *zend_mm_alloc_small(zend_mm_heap *heap, int bin_num) {
    // 如果有空闲的小块,直接取用
    if (EXPECTED(heap->free_slot[bin_num] != NULL)) {
        zend_mm_free_slot *p = heap->free_slot[bin_num]; // 当前空闲块
        heap->free_slot[bin_num] = p->next_free_slot;    // 更新链表头
        return p;
    } else {
        // 否则分配新的 page 串
        return zend_mm_alloc_small_slow(heap, bin_num);
    }
}

这里的 heap->free_slot 相当于一个“库存指针数组”,每个 bin_num 对应一个单独的空闲链表。分配时只需弹出头部元素,操作复杂度为 O(1)。

可以把它想象成内存版的“对象池”,分配与回收的成本几乎为常数。

zend_mm_alloc_small_slow() 函数

当空闲链表为空时,系统会调用慢路径:

// bin_num 是配置行号,由 zend_mm_small_size_to_bin() 计算得到
static zend_never_inline void *zend_mm_alloc_small_slow(
    zend_mm_heap *heap, uint32_t bin_num ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)

在这个函数中,Zend 首先调用 zend_mm_alloc_pages() 分配连续的页,然后更新每一页的地图信息:

// 第一个 page 存放配置信息行号,并添加 SRUN 标记
chunk->map[page_num] = ZEND_MM_SRUN(bin_num);

// 如果需要多个 page
if (bin_pages[bin_num] > 1) {
    uint32_t i = 1;
    do {
        // 后续 page 添加 SRUN + LRUN 双标记
        chunk->map[page_num + i] = ZEND_MM_NRUN(bin_num, i);
        i++;
    } while (i < bin_pages[bin_num]);
}

这些标记是分配器区分不同内存区域的关键,SRUN 表示小块内存的首页,NRUN 表示非首页,LRUN 表示大块内存。它们共同组成 chunk 地图的核心语义层。

每一页的身份都被精确地标注,Zend 能在回收阶段快速判断“这页属于谁”。这正是其高效回收的基础。

3)chunk 中的地图信息(map)

chunk 结构中,除了 bitset 外,还有一个 map 数组。它由 512 个 32 位整数构成,每个 page 对应一个元素,用于存储该页的“角色标记”。

#define ZEND_MM_LRUN_PAGES_MASK        0x000003ff // 低10位:页数或偏移
#define ZEND_MM_SRUN_BIN_NUM_MASK      0x0000001f // 低5位:bin编号
#define ZEND_MM_SRUN_FREE_COUNTER_MASK 0x01ff0000 // [16,24) 位:空闲计数
#define ZEND_MM_NRUN_OFFSET_MASK       0x01ff0000 // NRUN 页偏移

可以看到,这里大量使用位运算掩码,是为了在有限的 32 位空间内高效编码三种信息:标记位、bin号、偏移量。

截屏2025-10-29 21.08.46.png

段1主要用来存放ZEND_MM_IS_LRUN和ZEND_MM_IS_SRUN标记,只使用前两个位。
段2和段3用于存放两个数字,在不同的状态中用法略有不同。

page在使用过程中有的四种状态对应如下:

  1. 空状态:尚未使用。
  2. LRUN 状态:用于大块分配。
  3. SRUN 状态:小块分配的第一页。
  4. NRUN 状态:小块分配的后续页。

对应的宏定义如下:

#define ZEND_MM_LRUN(count)            (0x40000000 | count)           // 大块页
#define ZEND_MM_SRUN(bin_num)          (0x80000000 | bin_num)         // 小块首页
#define ZEND_MM_SRUN_EX(bin_num,count) (0x80000000 | bin_num | count << 16)
#define ZEND_MM_NRUN(bin_num,offset)   (0xC0000000 | bin_num | offset << 16)

这些宏通过掩码组合的方式,实现了“页内身份标记”。阅读时可重点关注高位的 0x8、0x4 标志,它们是识别 SRUN 与 LRUN 的关键位。

map 是 bitset 的“语义补充层”。bitset 仅说明“是否被使用”,map 则说明“被谁使用”。

4)格式化小块内存列表

当分配完一串小块后,Zend 会使用链表把它们串起来,以便后续快速取用。这是通过 zend_mm_free_slot 结构体实现的:

// 用于连接空闲小块的链表结构
typedef struct _zend_mm_free_slot zend_mm_free_slot;

struct _zend_mm_free_slot {
    zend_mm_free_slot *next_free_slot; // 指向下一个空闲小块
};

创建链表的逻辑如下:

// 计算链表的首尾地址
end = (zend_mm_free_slot*)((char*)bin + (bin_data_size[bin_num] * (bin_elements[bin_num] - 1)));
// 小块内存链表开头的指针,每个配置一个指针,共30个指针
// heap->free_slot[bin_num] 本身就是第一个元素,所以它里面的指针要指向第二个元素
heap->free_slot[bin_num] = p = (zend_mm_free_slot*)((char*)bin + bin_data_size[bin_num]);

do {
    // 每个元素的 next 指向下一个小块
    p->next_free_slot = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
    p = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
} while (p != end);

p->next_free_slot = NULL; // 最后一个元素终止

这段代码相当于“把一页内的小格子串成一条链”。分配时只需从头部取一个,释放时把块重新挂回链表,操作复杂度均为 O(1)。

这就是内存分配器的“对象池”思想——事先准备好可复用的资源,避免频繁创建销毁。


二、带安全保护的内存分配

除了常规分配方式,Zend 还提供了更安全的内存分配函数 safe_emalloc()ecalloc(),它们在执行前会检测是否存在整数溢出风险。

调用链如下:

ecalloc() → _ecalloc() → _emalloc() → zend_mm_alloc_heap()
safe_emalloc() → _safe_emalloc() → _emalloc() → zend_mm_alloc_heap()

两者区别:

  1. _ecalloc() 会将分配的内存全部置 0;
  2. _safe_emalloc() 多接收一个 offset 参数,用于额外偏移。

源码如下:

ZEND_API void* ZEND_FASTCALL _ecalloc(size_t nmemb, size_t size){
    void *p;
    size = zend_safe_address_guarded(nmemb, size, 0); // 检查溢出
    p = _emalloc(size);                               // 分配内存
    memset(p, 0, size);                               // 初始化为 0
    return p;
}

ZEND_API void* ZEND_FASTCALL _safe_emalloc(size_t nmemb, size_t size, size_t offset){
    // 检测内存是否会溢出,并分配内存
    return _emalloc(zend_safe_address_guarded(nmemb, size, offset));
}

溢出检测通过 zend_safe_address() 实现:

// 检测乘加是否越界
static zend_always_inline size_t zend_safe_address(size_t nmemb, size_t size, size_t offset, bool *overflow){
    size_t res = nmemb * size + offset;              // 整数结果
    double _d = (double)nmemb * (double)size + (double)offset; // 浮点校验
    double _delta = (double)res - _d;               // 误差检测
    if (UNEXPECTED((_d + _delta) != _d)) {
        *overflow = 1;
        return 0;                                   // 溢出则返回 0
    }
    *overflow = 0;
    return res;
}

这里的核心思想是“结果可逆”:如果整数与浮点的计算结果出现差异,说明溢出发生。通过双通道验证,Zend 在 C 语言层面实现了安全防线。

计算机底层的安全性,不仅依赖硬件边界检查,更依赖软件的“主动怀疑精神”。


三、小结

在 Zend 内存分配系统中:

  • 巨大块(Huge) :直接系统调用,简单但慢;
  • 大块(Large) :以 page 为单位分配,适合中等规模对象;
  • 小块(Small) :批量分配 + 链表复用,适合频繁的小对象。

三种机制形成了分层架构:既保证了分配性能,又平衡了内存利用率。

Zend 的分配器不是追求“最少分配”,而是追求“最少代价”。通过批量预分配、链表复用与安全校验,它让高频内存操作既快又稳。

如果你对 PHP 内存管理有不同的理解,或者希望我在后续文章中讲解具体的分配策略,欢迎留言讨论~


本文项目地址:https://github.com/xuewolf/php-kernel-insight

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容