PHP内核详解· 内存管理篇(三)· 分配大块内存

一、大块内存的分配过程

分配过程从 zend_mm_alloc_large()函数开始。该函数负责处理介于 **ZEND_MM_MAX_SMALL_SIZE**(3072B)**ZEND_MM_MAX_LARGE_SIZE**(2MB - 512B) 之间的内存请求。这类请求被归类为"大块内存(large block)",它们既不属于小块路径,也未达到巨大块的系统映射阈值。

zend_mm_alloc_large()函数本身是一个中转层,将任务转交给 zend_mm_alloc_large_ex()函数。这样的设计体现出 Zend 内核一贯的工程哲学------逻辑分层清晰,执行动作简洁。

调用路径如下:

scss 复制代码
zend_mm_alloc_large()
 └── zend_mm_alloc_large_ex()
      └── zend_mm_alloc_pages()

zend_mm_alloc_large_ex()函数内部,ZEND_MM_SIZE_TO_NUM()宏负责将字节数转换为页数。随后,分配逻辑进入 zend_mm_alloc_pages()函数,查找 chunk 中可连续分配的页段。这一步开始,分配器几乎在直接操作物理页。

zend_mm_alloc_pages()函数是整个流程的核心部分。其逻辑简洁明晰:

  1. 分段遍历 bitset 地图,计算连续空闲页;

  2. 若当前 chunk 空间不足,则转向下一个 chunk,必要时创建新 chunk;

  3. 找到合适区间后,标记相应页为已用,并返回指针。

这一流程展示了 Zend 对性能与秩序的平衡。它在最小扫描代价下完成最优分配,展现出底层代码的克制与高效。当分配完成时,大块内存已顺利落地。

二、bitset 地图

在 PHP 的内存分配机制中,无论是大块内存还是小块内存,都离不开 bitset 的参与。顾名思义,bitset 是一组二进制位的集合,用来标记一个 chunk 中的每个 page 是否已被使用。

在 Zend 内存管理中,zend_mm_bitset 是无符号整数 zend_ulong 的别名,其相关定义如下:

arduino 复制代码
// 在 32 位操作系统中占 4 字节,在 64 位系统中占 8 字节
typedef zend_ulong zend_mm_bitset;    /* 4-byte or 8-byte integer */

// 每个 BITSET 的长度:32 位系统中为 32,64 位系统中为 64
#define ZEND_MM_BITSET_LEN (sizeof(zend_mm_bitset) * 8)

// PAGE_MAP 的长度:32 位系统为 512/32=16,64 位系统为 512/64=8
#define ZEND_MM_PAGE_MAP_LEN (ZEND_MM_PAGES / ZEND_MM_BITSET_LEN)

// 32 位系统:4B * 16 = 64B
// 64 位系统:8B * 8 = 64B,总计 512 个 bit。
typedef zend_mm_bitset zend_mm_page_map[ZEND_MM_PAGE_MAP_LEN];    /* 64B */

在 32 位和 64 位系统中,zend_mm_page_map 的大小都是 64 字节,共包含 512 个 bit,对应一个 chunk 中的 512 个 page。当某个位为 0 时,表示对应序号的 page 空闲;当为 1 时,表示该 page 已被使用。


bitset 本身是一个非常轻量的结构,但它支撑着整个内存分配系统的精度与性能。Zend 为其定义了一系列操作函数,主要集中在 zend_alloc.c 中:

arduino 复制代码
// 计算 bitset 右侧连续 1 的数量
static zend_always_inline int zend_mm_bitset_nts(zend_mm_bitset bitset);

// 检查某一位是否为 1
static zend_always_inline int zend_mm_bitset_is_set(zend_mm_bitset *bitset, int bit);

// 设置某一位为 1
static zend_always_inline void zend_mm_bitset_set_bit(zend_mm_bitset *bitset, int bit);

// 设置某一位为 0
static zend_always_inline void zend_mm_bitset_reset_bit(zend_mm_bitset *bitset, int bit);

// 设置某一区域为 1
static zend_always_inline void zend_mm_bitset_set_range(zend_mm_bitset *bitset, int start, int len);

// 设置某一区域为 0
static zend_always_inline void zend_mm_bitset_reset_range(zend_mm_bitset *bitset, int start, int len);

// 检查某一区域是否为空闲
static zend_always_inline int zend_mm_bitset_is_free_range(zend_mm_bitset *bitset, int start, int len);

除此之外,还有定义在 zend_portability.h 中的 ZEND_BIT_TEST 宏,它虽然只有一行代码,却考虑到了 32 位和 64 位系统的兼容性:

scss 复制代码
#define ZEND_BIT_TEST(bits, bit) \
    (((bits)[(bit) / (sizeof((bits)[0])*8)] >> ((bit) & (sizeof((bits)[0])*8 - 1))) & 1)

以 64 位系统为例,其逻辑可分解为:

scss 复制代码
(((bits)[页编号] >> (bit & 63)) & 1)

这行代码取出对应页所在整数,并通过右移与按位与操作,判断该 bit 是否被设置。


从这些方法的定义可以看出,bitset 的操作单位是单个 zend_mm_bitset,而非整张 zend_mm_page_map。为了方便理解,可以将 zend_mm_page_map 看作一本"地图册",每个 zend_mm_bitset 则是其中的一页。在操作时,传入第一页的指针即可完成对整张地图的遍历与修改。例如:

scss 复制代码
zend_mm_bitset_is_set(chunk->free_map, i);

系统会自动定位到需要操作的页码。这种设计使得代码简洁高效,不需要额外的偏移计算。


值得注意的是,内存中 bit 的排列顺序与逻辑上的顺序并不完全一致。zend_mm_page_map 中的 bitset 从左到右排列,而单个 zend_mm_bitset 内部的 bit 是"高位在左、低位在右"。因此,在计算或设置位时,通常需要从右向左遍历。例如:

arduino 复制代码
static zend_always_inline void zend_mm_bitset_set_bit(zend_mm_bitset *bitset, int bit) {
    bitset[bit / ZEND_MM_BITSET_LEN] |= (Z_UL(1) << (bit & (ZEND_MM_BITSET_LEN - 1)));
}

以 64 位系统为例,该操作等价于:

arduino 复制代码
bitset[块索引] |= 1 << (bit & 63);

zend_mm_bitset_nts() 函数的逻辑同样体现了这一特性。它计算的是 bitset 右端连续 1 的数量,也就是逻辑上的"开头"部分。这种位序的反向定义虽然看似复杂,却能更高效地映射底层 CPU 的位操作方式。


总的来说,bitset 的设计看似简单,但却是整个内存管理系统的基础。通过 64 字节的地图,就能标记一个 2MB chunk 的 512 个 page 使用状态,极大地提高了空间利用率与访问效率。这样的巧妙结构背后,凝结的是底层计算机体系结构的精妙知识。

三、分段遍历 bitset 地图,查找可用空间

在内存分配的过程中,需要在 chunk 的 bitset 地图中找到一段连续的空闲 page。bitset 是一个二进制位数组,用于标记每个 page 是否被占用。分段遍历是这一过程的核心。

整个过程从遍历 chunk 链表开始,逐一分析每个 bitset 的状态。每个 bitset 都由若干段组成,这些段由连续的 1(已使用)和连续的 0(空闲)交替构成。每一段的末尾一定是 0 或地图的结尾。当系统检测到一段连续的 0 数量大于或等于所需的 page 数量时,就表示找到了可用的空间。

例如,在 32 位系统中,一个 zend_mm_bitset 占 4 字节(32 bit),其存储示意如下:

找到可用空间后,系统会记录其起始位置,作为下一步分配的候选点。

整个过程的主体逻辑上比较简洁,但为提升效率又做了以下几项比较复杂的优化。

1)在所有段中查找最佳可用位置

为了提高空间利用率,系统不会盲目使用第一个符合条件的空闲段,而是会遍历所有可用段,找到最接近目标大小的那一段。这被称为"最佳可用位置"。

例如,当需要分配 3 个 page 时,假设段 3 和段 4 都能满足要求,段 3 有 5 个空闲 page,段 4 有 13 个空闲 page,那么段 3 更加合适。这样的选择可以显著提升内存利用率。

在遍历过程中,如果系统找到的空闲段大小正好等于所需 page 数量,就可以立即停止查找;若未命中,则记录所有候选段,并不断更新距离最接近的那个位置。

这是一种典型的"平衡式优化"策略:既追求分配的即时性,又兼顾空间的合理利用。

2)段的切换

在 bitset 遍历中,从一个段切换到下一个段的过程非常频繁。为此,Zend 使用了极为精巧的位运算技巧,只需两条语句即可完成切换:

arduino 复制代码
tmp &= tmp + 1;  // 把右侧连续的 1 变成 0,跳到下一段的起始位置
tmp |= tmp - 1;  // 把右侧连续的 0 变成 1,跳到下一个空闲段的开头

以示例 bitset 为例:

dart 复制代码
// 初始 bitset:11100000 00000000 11110000 01001100
// 第一次循环:段 1(序号 1~2)有 2 个空闲 page
tmp &= tmp + 1; => 11100000 00000000 11110000 01001100
tmp |= tmp - 1; => 11100000 00000000 11110000 01001111
// 下一次循环时:段 2(序号 1~6)有 2 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 11110000 01000000
tmp |= tmp - 1; => 11100000 00000000 11110000 01111111
// 下一次循环时:段 3(序号 1~12)有 5 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 11110000 00000000
tmp |= tmp - 1; => 11100000 00000000 11111111 11111111
// 下一次循环时:段 4(序号 1~30)有 13 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 00000000 00000000
tmp |= tmp - 1; => 11111111111111111111111111111111111
// 下一次循环时:检查到此chunk中没有可用位置,会跳过此chunk到下一chunk。

通过这种跳跃式的位运算方式,系统不再需要逐位扫描,而是"成段"地移动,大幅降低遍历次数。这是对性能的一种极致优化。

3)zend_mm_bitset_nts() 函数

zend_mm_bitset_nts() 函数用于计算一个 bitset 右侧(即开头方向)连续的 1 的数量。其实现代码如下:

c 复制代码
static zend_always_inline int zend_mm_bitset_nts(zend_mm_bitset bitset){
    int n;
    // 如果所有位都是 1,直接返回长度
    if (bitset == (zend_mm_bitset)-1) return ZEND_MM_BITSET_LEN;
    n = 0;
#if SIZEOF_ZEND_LONG == 8 // 如果是 64 位系统
    if (sizeof(zend_mm_bitset) == 8) {
        if ((bitset & 0xffffffff) == 0xffffffff) { n += 32; bitset = bitset >> Z_UL(32); }
    }
#endif
    if ((bitset & 0x0000ffff) == 0x0000ffff) { n += 16; bitset = bitset >> 16; }
    if ((bitset & 0x000000ff) == 0x000000ff) { n += 8; bitset = bitset >> 8; }
    if ((bitset & 0x0000000f) == 0x0000000f) { n += 4; bitset = bitset >> 4; }
    if ((bitset & 0x00000003) == 0x00000003) { n += 2; bitset = bitset >> 2; }
    return n + (bitset & 1);
}

可以看到,Zend 使用了分组掩码与右移结合的方式,实际上是一种"分段二分查找"。32 位系统最多比较 5 次,64 位系统最多比较 6 次即可完成计算。这种方法在性能和代码可读性之间取得了完美的平衡。

而在计算右端连续 0 的数量时,使用 zend_ulong_ntz() 方法,逻辑完全相似。

4)zend_mm_bitset 的切换

在进入新的页时,Zend 通过整数级的判断快速略过无效页:

  • 若页值等于 (zend_mm_bitset)-1,表示该页的所有位均为 1,可直接跳过;
  • 若页值等于 0,表示该页全部空闲,也可整体略过。

这种设计使得系统只需一次整数比较,就能判断 32 或 64 个 bit 的状态,而无需调用任何复杂函数:

scss 复制代码
zend_mm_alloc_small() -> zend_mm_alloc_small_slow() -> zend_mm_alloc_pages()

这种对极限性能的追求,是 Zend 内存分配器最值得称道的部分之一。

5)zend_mm_chunk 结构体中的 free_tail

free_tail 是一个简单但重要的优化字段。它记录了当前 chunk 中最后一段空闲 page 的起始索引。例如:

在遍历 bitset 时,只需扫描到 free_tail 位置即可,后续部分可以直接跳过。这样能有效避免无效的循环与判断,尤其在大块内存频繁分配与回收时,性能提升明显。

当以上这些机制协同工作时,bitset 的遍历速度极快,内存分配的效率几乎达到了物理结构允许的上限。

四、chunk 空间不够时的处理

在 PHP 内存分配过程中,当当前 chunk 中的可用空间不足时,系统会按照优先级分层处理,以最大程度地提升性能并减少系统调用开销。整体策略可以概括为:"先查找、再复用、最后创建"。

1)尝试查找后续 chunk

如果当前 chunk 的 next 指针没有指向 main_chunk,说明当前 chunk 并不是链表中的最后一个节点。此时可以直接切换到下一个 chunk,继续在其中查找可用空间:

ini 复制代码
chunk = chunk->next; // 切换到下一个 chunk
steps++;              // 记录前进 1 步

这种顺序遍历的策略可以在已有的内存块中完成分配,避免频繁的系统调用,从而提升整体分配效率。

2)尝试使用缓存 chunk

next 指针指向 main_chunk 时,说明链表中已没有可用的 chunk。此时系统会尝试从缓存列表中复用已经释放但仍保留在内存中的 chunk。

Zend 内存管理器不会立即释放不再使用的 chunk,而是将其放入缓存列表中,以便后续快速复用。只有当缓存数量超过限制时,才会真正释放多余的 chunk。这是一种典型的"用空间换时间"的策略。

rust 复制代码
if (heap->cached_chunks) {           // 如果有缓存 chunk,优先使用它
    heap->cached_chunks_count--;     // 缓存数量减 1
    chunk = heap->cached_chunks;     // 获取缓存 chunk
    heap->cached_chunks = chunk->next; // 更新缓存链表头指针
}

通过这种机制,系统在高频分配和释放的场景下,能有效降低系统调用次数,减少碎片化和锁竞争,显著提升分配性能。

3)创建并初始化新的 chunk

如果缓存中也没有可复用的 chunk,Zend 会创建一个新的 chunk。整个流程包括:检查内存限制、创建 chunk、初始化结构并加入链表。

在创建之前,系统会先检查当前已分配的内存是否接近上限。如果存在超限风险,则会调用 zend_mm_gc() 进行垃圾回收:

less 复制代码
// 如果可用内存小于 ZEND_MM_CHUNK_SIZE,说明空间不足
if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
    if (zend_mm_gc(heap)) { // 回收空闲内存
        ...
    }
}

如果内存仍然充足,系统会调用 zend_mm_chunk_alloc() 分配一个新的 chunk,其调用路径如下:

scss 复制代码
zend_mm_chunk_alloc() -> zend_mm_chunk_alloc_int()

每个新创建的 chunk 大小为 ZEND_MM_CHUNK_SIZE = 2MB

创建完成后,需要更新 heap 的相关记录:包括当前内存使用量 (real_size) 和 chunk 数量等。随后,调用 zend_mm_chunk_init() 对新 chunk 进行初始化:

ini 复制代码
static zend_always_inline void zend_mm_chunk_init(zend_mm_heap *heap, zend_mm_chunk *chunk) {
    // 绑定所属 heap
    chunk->heap = heap;

    // 将新 chunk 挂载到链表末尾,形成循环结构
    chunk->next = heap->main_chunk;
    chunk->prev = heap->main_chunk->prev;
    chunk->prev->next = chunk;
    chunk->next->prev = chunk;

    // 初始化空闲页信息
    chunk->free_pages = ZEND_MM_PAGES - ZEND_MM_FIRST_PAGE;
    chunk->free_tail = ZEND_MM_FIRST_PAGE; // 初始空闲尾页为 1

    // 为新 chunk 分配编号
    chunk->num = chunk->prev->num + 1;

    // 将第一个 page 标记为已使用
    chunk->free_map[0] = (1L << ZEND_MM_FIRST_PAGE) - 1;

    // 在 map 中添加大块内存标记(LRUN)
    chunk->map[0] = ZEND_MM_LRUN(ZEND_MM_FIRST_PAGE);
}

值得注意的是,新 chunk 的第一个 page 会被保留,用作系统标识或管理用途,并不会参与实际分配。这保证了 chunk 的结构完整性和后续管理的一致性。

通过以上三步处理流程,Zend 内存管理器能在几乎所有情况下保持分配过程的高效与平稳。它避免了频繁的系统调用,降低了内存碎片率,也让整个分配器在面对复杂、高并发的 PHP 应用时,依然能稳定运行。

五、在 chunk 中分配需要的 page

当可分配的 chunk 已经确定后,接下来的任务是在其中拿到一串连续的 page 并完成状态更新。策略很朴素:尽量把高频的小块分配放在链表前端,随后一次性更新 bitmap 与元数据,最后返回首个 page 的地址。

首先做一个微优化:如果本次需要的 page 数少于 8,说明属于小块分配场景。为了降低后续查找开销,应将当前 chunk 移至链表前端,使其紧随 main_chunk。小块分配在典型业务中出现频率最高,把"热" chunk 放在最前面更容易命中,提高整体吞吐。

随后进行收尾更新。核心动作包括三件事:

  • 调整空闲页计数;
  • 在 free_map 上批量标记这段 page 已被占用;
  • 在 map 中写入"大块占用条目"的元信息,并维护 free_tail 指针(若命中尾段)。

实现片段如下(示意):

scss 复制代码
// 更新剩余 page 数量
chunk->free_pages -= pages_count;

// 在 free_map 上将 [page_num, page_num + pages_count) 标记为占用
zend_mm_bitset_set_range(chunk->free_map, page_num, pages_count); // 函数

// 在 map 中登记一条大块占用记录(记录 run 长度)
chunk->map[page_num] = ZEND_MM_LRUN(pages_count); // 宏

// 若恰好从尾段开头分配,则推进尾指针
if (page_num == chunk->free_tail) {
    chunk->free_tail = page_num + pages_count;
}

// 计算并返回首个 page 的地址
return ZEND_MM_PAGE_ADDR(chunk, page_num); // 宏

至此,一次"大块内存"分配闭环完成:从定位 chunk、选段、批量标记到返回可用指针,路径短、修改点集中,便于后续统计与回收。

整个分配机制依旧遵循"先粗后细"的策略------先定位大的空间(chunk),再确认页级位置,最后在页内完成分配。这正是 Zend 的工程美学:结构清晰,逻辑自洽,冷静而优雅。

相关推荐
星光一影3 小时前
快递比价寄件系统技术解析:基于PHP+Vue+小程序的高效聚合配送解决方案
vue.js·mysql·小程序·php
JaguarJack5 小时前
开发者必看的 15 个困惑的 Git 术语(以及它们的真正含义)
后端·php·laravel
落落鱼201314 小时前
Dompdf库html生成pdf时editor编辑器中文本长度被截断不会自动换行问题处理
pdf·编辑器·php·html生成pdf
苏琢玉19 小时前
收藏版:Phinx 数据库迁移完全指南
数据库·mysql·php
m0_748240251 天前
C++仿Muduo库Server服务器模块实现 基于Reactor模式的高性
服务器·c++·php
坐吃山猪1 天前
第2章-类加载子系统
开发语言·php
JaguarJack1 天前
2025 年必须尝试的 5 个 Laravel 新特性
后端·php·laravel
zorro_z2 天前
ThinkPHP8学习篇(十):模型(二)
php
kali-Myon2 天前
NewStarCTF2025-Week3-Web
sql·安全·web安全·php·ctf