一、释放内存的过程
释放指针所指向、已不再使用的内存,是内存管理闭环中的关键一步。Zend 的释放路径保持了与分配对称的结构:从统一入口入手,根据块规模分派到对应的子流程,确保正确地回收 page 与元信息。
- 释放的主入口是 zend_alloc.h 中的 efree() 宏,它的调用路径如下:
scss
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() 中:
c
// 统一释放入口:只接收指针,不显式传入大小
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 字段解析。
这套判定思路的核心是"就地自描述":不依赖外部参数,而是用对齐关系与页内 map 信息自洽地还原块类型,避免了调用方传错尺寸。
下面是 zend_mm_free_heap() 的业务逻辑(带注释):
c
// 计算该指针相对 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);
}
}
由此可见,类型判定完全依赖:① 指针与 chunk 的对齐关系(是否为 0 偏移);② map 的位标记(SRUN/LRUN)与携带字段(bin 号 / 连续页数)。这种"结构即约束"的设计,让释放路径与分配路径天然对称且低耦合。
三、释放巨大块内存
巨大块(huge)以整块系统映射为单位进行分配与释放。其释放路径精炼而对称:先从"巨大块链表"中摘除对应节点,拿到实际尺寸;再将这段映射交回系统。
核心逻辑在 zend_mm_free_huge() 中,伪代码如下(保留关键注释):
c
// 从巨大块链表中删除对应节点,返回该块的实际大小(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 粒度上的页表维护负担,路径最短,副作用最小。
归纳:huge 的释放遵循"先摘链拿尺寸,再交还映射"的两步法。链表维护确保可追踪、可回收,系统级 unmap 确保干净利落地归还资源。
四、释放大块内存
大块(large)释放以"页串(pages)"为粒度,依据 map/bitset 恢复占用标记,再按策略决定是否将空 chunk 缓存或直接归还系统。核心入口是:
c
// 除 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
);
调用路径:
scss
zend_mm_free_large()
→ zend_mm_free_pages()
→ zend_mm_free_pages_ex()
→ zend_mm_delete_chunk()
→ zend_mm_chunk_free()
→ zend_mm_munmap()
归纳:先"还页"(恢复 bitset/map、推进 free_tail),再"看需不需要还块"(缓存或释放 chunk)。
把需要释放的 page 标记为空闲
zend_mm_free_pages_ex() 负责完成页级回收与必要的边界推进。相比 zend_mm_free_large(),该函数多了一个"是否删除空 chunk"的开关:
c
// 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
)
核心业务(保留关键注释):
c
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
}
要点:free_tail 的回退使未来查找空闲段时能"少扫一截";页级回收完成后才可能触发 chunk 级处置。
删除 chunk 前的优化操作(缓存优先)
zend_mm_delete_chunk() 并不总是立刻释放内存,而是优先将空 chunk 放入 缓存链表,以便后续复用,减少系统调用与序号回退带来的管理成本。
添加缓存 chunk 的条件(满足其一即可)
- 当前使用的 chunk 数 < 平均使用的 chunk 数(heap->avg_chunks_count + 0.1);
- 当前使用的 chunk 数 ≥ 4,且等于上次清理时记录的阈值(heap->last_chunks_delete_boundary)。
序号的特殊处理
若将要删除的 chunk 的序号大于缓存链表头的序号,则先释放原缓存头,再把当前 chunk 放到缓存头。此举可确保"序号最大的 chunk 要么在用,要么在缓存",避免新建时序号回退或重复。
业务逻辑(保留注释,略去非关键边角):
c
// 先把自己从 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; // 当前成为缓存头
}
}
要点:缓存优先、序号靠前释放,有利于"热启动"与序号单调性;avg_chunks_count / last_chunks_delete_* 提供动态阈值,避免频繁抖动。
释放 chunk(真正归还给系统)
当走到 zend_mm_chunk_free() 时,说明已决定不缓存该 chunk。此时会调用 zend_mm_munmap() 将整段映射解除,内存真正回到操作系统控制之下。
归纳:页级回收 → 尝试缓存 →(必要时)系统释放。大块释放路径比 huge 更长,但换来的是更高的复用率与更低的分配抖动。
五、释放小块内存
小块(small)块的释放不涉及系统归还,核心动作是把这块闲置内存挂回对应 bin 的空闲链表头部,以便后续 O(1) 速度复用。入口函数为 zend_mm_free_small(),业务极简:
c
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; // 更新链表头指针到本节点
不难看出,这里只是回收到空闲链表,并未真正释放内存页。
针对小块的精巧管理
-
批量预分配,减少频繁开销
zend_mm_alloc_small_slow() 会按 ZEND_MM_BINS_INFO() 的配置"一次分一串",将若干小块打包准备,显著降低"分配调用次数"。
-
空闲链表管理,已用块分散在各自上下文*
空闲的小块被串成单链挂在 heap->free_slot[bin],已使用的小块则由其所属对象/数组/变量持有指针,二者彼此独立、互不干扰。
-
高频路径保持常数复杂度
从空闲链表弹出 (分配)与向链表头插(回收)都是常数时间,满足热点路径对吞吐的极致要求。
小块回收并不意味着"归还系统"。真正的释放是以 chunk 为单位完成的:当页级与 chunk 级条件满足(例如整串页都空且触发策略),才可能进入缓存或被 munmap 归还操作系统。
六、小结
在 Zend 的内存管理体系中,释放过程与分配过程是严格对称的。每一次释放,既是资源的归还,也是未来复用链条上的一次"再布置"。
- 巨大块(huge) :直接系统级释放,最干净也最简单;
- 大块(large) :以页为单位回收,可能进入缓存以便复用;
- 小块(small) :仅退回空闲链表,不做真正释放,等待后续再利用。
这种分层式的释放策略,使 Zend 内存管理器兼顾了三点:
- 性能------热点路径(small/large)保持 O(1) 操作;
- 可控性------chunk 缓存避免频繁系统调用;
- 稳定性------分配与回收的逻辑对称,数据结构始终保持有序。
从设计哲学上看,这是一种"精细复用优先、系统归还滞后"的理念。 Zend 选择牺牲一点即时性,换取整体运行期的平稳与高效。
如果你对 PHP 内存管理有不同的理解,或者希望我在后续文章中讲解具体的分配策略,欢迎留言讨论~
本文项目地址:github.com/xuewolf/php...