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

一、小块内存的分配过程

在 PHP 的内存管理体系中,小块内存(small block) 指的是大小不超过 ZEND_MM_MAX_SMALL_SIZE(3072 Bytes)的内存请求。这类内存的使用频率极高,因此 Zend <math xmlns="http://www.w3.org/1998/Math/MathML"> 为其设计了一套高效且精巧的分配机制 为其设计了一套高效且精巧的分配机制 </math>为其设计了一套高效且精巧的分配机制。整体思想是"用少量的空间浪费换取极高的分配效率"。

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

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

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


1)计算所需的 page 数量

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

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

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

例如:

  • 当程序需要 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)。这由以下函数完成:

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

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

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

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

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


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

分配链路如下:

scss 复制代码
zend_mm_alloc_small() → zend_mm_alloc_small_slow() → zend_mm_alloc_pages()

zend_mm_alloc_small() 函数

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

arduino 复制代码
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() 函数

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

arduino 复制代码
// 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() 分配连续的页,然后更新每一页的地图信息:

ini 复制代码
// 第一个 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 对应一个元素,用于存储该页的"角色标记"。

arduino 复制代码
#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号、偏移量。

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

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

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

对应的宏定义如下:

scss 复制代码
#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 结构体实现的:

arduino 复制代码
// 用于连接空闲小块的链表结构
typedef struct _zend_mm_free_slot zend_mm_free_slot;

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

创建链表的逻辑如下:

scss 复制代码
// 计算链表的首尾地址
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(),它们在执行前会检测是否存在整数溢出风险。

调用链如下:

scss 复制代码
ecalloc() → _ecalloc() → _emalloc() → zend_mm_alloc_heap()
safe_emalloc() → _safe_emalloc() → _emalloc() → zend_mm_alloc_heap()

两者区别:

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

源码如下:

arduino 复制代码
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() 实现:

arduino 复制代码
// 检测乘加是否越界
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 的分配器不是追求"最少分配",而是追求"最少代价"。通过批量预分配、链表复用与安全校验,它让高频内存操作既快又稳。

相关推荐
BingoGo18 小时前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack18 小时前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack4 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理4 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
QQ5110082854 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php
WeiXin_DZbishe4 天前
基于django在线音乐数据采集的设计与实现-计算机毕设 附源码 22647
javascript·spring boot·mysql·django·node.js·php·html5