一、释放内存的过程

释放指针所指向、已不再使用的内存,是内存管理闭环中的关键一步。Zend 的释放路径保持了与分配对称的结构:从统一入口入手,根据块规模分派到对应的子流程,确保正确地回收 page 与元信息。

  • 释放的主入口是 zend_alloc.h 中的 efree() 宏,它的调用路径如下:
efree() -> _efree() -> zend_mm_free_heap()
  • _efree() 主要完成入参规范化与少量安全检查;实际释放逻辑集中在 zend_mm_free_heap() 中。
  • zend_mm_free_heap() 会根据被释放块的规模(小块 / 大块 / 巨大块)选择不同的子过程,以保证 map、bitset 与链表状态的一致性。
内存大小调用方法
小块内存zend_mm_free_small()
大块内存zend_mm_free_large()
巨大块内存zend_mm_free_huge()

二、判断内存块大小

释放前必须先识别待释放指针所对应的块类型(small/large/huge),否则无法进入正确的回收分支。Zend 将这一判定收敛在统一入口 zend_mm_free_heap() 中:

// 统一释放入口:只接收指针,不显式传入大小
static zend_always_inline void zend_mm_free_heap(zend_mm_heap *heap, void *ptr)

可以看出,该函数只接收“指针”而非“尺寸”。尺寸与类型由函数内部按规则推断:

  • small 与 large 都占用 chunk 的“非首页”;
  • huge 以“整 chunk”为单位分配,指针与 chunk 起始地址对齐;
  • small 的首个 page 在 map 中带有 ZEND_MM_IS_SRUN 标记;
  • 不带 SRUN 的则视为 large,页数从 LRUN 字段解析。

下面是 zend_mm_free_heap() 的业务逻辑(带注释):

// 计算该指针相对 chunk 起点的对齐偏移(按 ZEND_MM_CHUNK_SIZE 对齐)
size_t page_offset = ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE);

if (UNEXPECTED(page_offset == 0)) {
    // 偏移为 0:说明指针与 chunk 起点对齐 → 视作 huge(整 chunk 分配)
    if (ptr != NULL) {
        zend_mm_free_huge(heap, ptr);      // 释放巨大块(huge)
    }
} else {
    // small / large:都不会占用 chunk 的第一个 page,因此一定“非零偏移”
    // 取回该指针所在的 chunk 基址
    zend_mm_chunk *chunk = (zend_mm_chunk*)ZEND_MM_ALIGNED_BASE(ptr, ZEND_MM_CHUNK_SIZE);

    // 将字节级偏移换算为 page 编号
    int page_num = (int)(page_offset / ZEND_MM_PAGE_SIZE);

    // 读取页级 map 的语义位与携带信息(bin 号 / 连续页数 / 偏移等)
    zend_mm_page_info info = chunk->map[page_num];

    if (EXPECTED(info & ZEND_MM_IS_SRUN)) {
        // SRUN:小块内存的首个 page
        // 从 map 中取出 bin 号,走 small 回收分支(需要 bin 编号以维护空闲链等)
        zend_mm_free_small(heap, ptr, ZEND_MM_SRUN_BIN_NUM(info));
    } else /* if (info & ZEND_MM_IS_LRUN) */ {
        // 非 SRUN:按 large 处理,页数来自 LRUN 段
        int pages_count = ZEND_MM_LRUN_PAGES(info);
        zend_mm_free_large(heap, chunk, page_num, pages_count);
    }
}

三、释放巨大块内存

巨大块(huge)以整块系统映射为单位进行分配与释放。其释放路径精炼而对称:先从“巨大块链表”中摘除对应节点,拿到实际尺寸;再将这段映射交回系统。

核心逻辑在 zend_mm_free_huge() 中,伪代码如下(保留关键注释):

// 从巨大块链表中删除对应节点,返回该块的实际大小(Bytes)
size_t size = zend_mm_del_huge_block(heap, ptr);

// 真正的释放发生在这里:把这段映射交还给系统
zend_mm_chunk_free(heap, ptr, size);

zend_mm_del_huge_block() 负责遍历并定位 zend_mm_huge_list 中指向 ptr 的节点(关于该链表与节点结构,可参见“巨大块内存分配”章节)。定位后完成摘链,并把节点里记录的 size 返回。完成这一步之后,zend_mm_free_huge() 才具备“知道要还多少”的充足信息。

随后进入 zend_mm_chunk_free():该函数会调用底层的 zend_mm_munmap(),将这段以系统页为单位建立的映射解除,归还给操作系统。由于 huge 块本就直接来自系统映射,其释放没有 chunk/page 粒度上的页表维护负担,路径最短,副作用最小。


四、释放大块内存

大块(large)释放以“页串(pages)”为粒度,依据 map/bitset 恢复占用标记,再按策略决定是否将空 chunk 缓存或直接归还系统。核心入口是:

// 除 heap 外还需要:所属 chunk、起始页号、页数
static zend_always_inline void zend_mm_free_large(
    zend_mm_heap  *heap,
    zend_mm_chunk *chunk,
    int            page_num,
    int            pages_count
);

调用路径:

zend_mm_free_large()
  → zend_mm_free_pages()
      → zend_mm_free_pages_ex()
          → zend_mm_delete_chunk()
              → zend_mm_chunk_free()
                  → zend_mm_munmap()

把需要释放的 page 标记为空闲

zend_mm_free_pages_ex() 负责完成页级回收与必要的边界推进。相比 zend_mm_free_large(),该函数多了一个“是否删除空 chunk”的开关:

// free_chunk:是否删除空 chunk(1=允许删除;0=仅回收页)
static zend_always_inline void zend_mm_free_pages_ex(
    zend_mm_heap  *heap,
    zend_mm_chunk *chunk,
    uint32_t       page_num,
    uint32_t       pages_count,
    int            free_chunk
)

核心业务(保留关键注释):

chunk->free_pages += pages_count; // 增加可用 page 数

// 更新 bitset 地图,把相应的 page 标记为空闲
zend_mm_bitset_reset_range(chunk->free_map, page_num, pages_count);

// 重置 map 的起始项(后续若是 SRUN/NRUN/LRUN,会在分配时重新写入)
chunk->map[page_num] = 0;

// 如果被删除的页串末尾 == 已用页的末尾(后面全是空闲)
if (chunk->free_tail == page_num + pages_count) {
    // 推进尾指针,使“可跳过区”增大
    chunk->free_tail = page_num;
}

// 若允许删除 chunk,且该 chunk 非 main_chunk 且已完全空闲
if (free_chunk
    && chunk != heap->main_chunk
    && chunk->free_pages == ZEND_MM_PAGES - ZEND_MM_FIRST_PAGE) {
    zend_mm_delete_chunk(heap, chunk); // 尝试删除/缓存该 chunk
}

删除 chunk 前的优化操作(缓存优先)

zend_mm_delete_chunk() 并不总是立刻释放内存,而是优先将空 chunk 放入 缓存链表,以便后续复用,减少系统调用与序号回退带来的管理成本。

添加缓存 chunk 的条件(满足其一即可)

  1. 当前使用的 chunk 数 < 平均使用的 chunk 数(heap->avg_chunks_count + 0.1);
  2. 当前使用的 chunk 数 ≥ 4,且等于上次清理时记录的阈值(heap->last_chunks_delete_boundary)。

序号的特殊处理

若将要删除的 chunk 的序号大于缓存链表头的序号,则先释放原缓存头,再把当前 chunk 放到缓存头。此举可确保“序号最大的 chunk 要么在用,要么在缓存”,避免新建时序号回退或重复。

业务逻辑(保留注释,略去非关键边角):

// 先把自己从 chunk 环上摘掉
chunk->next->prev = chunk->prev;
chunk->prev->next = chunk->next;
heap->chunks_count--; // 正在使用的 chunk 数 -1

// 满足“加入缓存”的任一条件
if (heap->chunks_count + heap->cached_chunks_count < heap->avg_chunks_count + 0.1
 || (heap->chunks_count == heap->last_chunks_delete_boundary
  && heap->last_chunks_delete_count >= 4)) {

    heap->cached_chunks_count++;       // 缓存计数 +1
    chunk->next = heap->cached_chunks; // 头插到缓存链表
    heap->cached_chunks = chunk;

} else {
    // 不满足缓存条件,考虑直接释放
    heap->real_size -= ZEND_MM_CHUNK_SIZE;

    if (!heap->cached_chunks) {
        // 维护“上次清理阈值/计数”
        if (heap->chunks_count != heap->last_chunks_delete_boundary) {
            heap->last_chunks_delete_boundary = heap->chunks_count;
            heap->last_chunks_delete_count = 0;
        } else {
            heap->last_chunks_delete_count++;
        }
    }

    // 若当前 chunk 序号更大,则直接释放当前;否则释放缓存头,把当前放到缓存头
    if (!heap->cached_chunks || chunk->num > heap->cached_chunks->num) {
        zend_mm_chunk_free(heap, chunk, ZEND_MM_CHUNK_SIZE); // 直接释放
    } else {
        chunk->next = heap->cached_chunks->next; // 接到缓存第二个
        zend_mm_chunk_free(heap, heap->cached_chunks, ZEND_MM_CHUNK_SIZE); // 释放原缓存头
        heap->cached_chunks = chunk; // 当前成为缓存头
    }
}

释放 chunk(真正归还给系统)

当走到 zend_mm_chunk_free() 时,说明已决定不缓存该 chunk。此时会调用 zend_mm_munmap() 将整段映射解除,内存真正回到操作系统控制之下。


五、释放小块内存

小块(small)块的释放不涉及系统归还,核心动作是把这块闲置内存挂回对应 bin 的空闲链表头部,以便后续 O(1) 速度复用。入口函数为 zend_mm_free_small(),业务极简:

zend_mm_free_slot *p;

p = (zend_mm_free_slot*)ptr;                 // 指针转成 zend_mm_free_slot(单链节点)
p->next_free_slot = heap->free_slot[bin_num]; // 新节点指向当前空闲链表的头
heap->free_slot[bin_num] = p;                 // 更新链表头指针到本节点

不难看出,这里只是回收到空闲链表,并未真正释放内存页。

针对小块的精巧管理

  1. 批量预分配,减少频繁开销

    zend_mm_alloc_small_slow() 会按 ZEND_MM_BINS_INFO() 的配置“一次分一串”,将若干小块打包准备,显著降低“分配调用次数”。

  2. 空闲链表管理,已用块分散在各自上下文*

    空闲的小块被串成单链挂在 heap->free_slot[bin],已使用的小块则由其所属对象/数组/变量持有指针,二者彼此独立、互不干扰。

  3. 高频路径保持常数复杂度

    从空闲链表弹出(分配)与向链表头插(回收)都是常数时间,满足热点路径对吞吐的极致要求。


六、小结

在 Zend 的内存管理体系中,释放过程与分配过程是严格对称的。每一次释放,既是资源的归还,也是未来复用链条上的一次“再布置”。

  • 巨大块(huge) :直接系统级释放,最干净也最简单;
  • 大块(large) :以页为单位回收,可能进入缓存以便复用;
  • 小块(small) :仅退回空闲链表,不做真正释放,等待后续再利用。

这种分层式的释放策略,使 Zend 内存管理器兼顾了三点:

  1. 性能——热点路径(small/large)保持 O(1) 操作;
  2. 可控性——chunk 缓存避免频繁系统调用;
  3. 稳定性——分配与回收的逻辑对称,数据结构始终保持有序。

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


本文项目地址:github.com/xuewolf/php…

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]