PHP内存管理器(ZendMM)的内存分配逻辑

1. Zend VM 的内存从哪来?

Zend VM(执行器)运行时需要频繁创建/销毁大量对象(zval、zend_string、HashTable、call frame 等)。如果每一次申请/释放都直接调用系统 malloc/free,性能和碎片都会很难控制。所以 PHP 在引擎层引入了 ZendMM:把“高频小对象”的申请/释放先收拢到一个更适合 PHP 场景的内存管理器里。

1.1 Zend VM 为什么不直接 malloc?

Zend VM(执行器)里大量对象一般不会直接 malloc(),而是走 Zend 引擎统一的 API:

- emalloc() / efree() / erealloc() → ZendMM(zend_alloc)

这意味着:你的 PHP 代码“释放”变量,通常先释放到 ZendMM 的内部池子里,而不是立刻归还给操作系统。

1.2 ZendMM 的设计总览(源码注释最权威)

在源码里,ZendMM 自己也写得很明白:它是一个“更适合 CPU cache 的内存管理器”,思路参考 jemalloc/tcmalloc,并把分配分成三类:Small/Large/Huge。

代码出处(权威总览注释):
- 文件:Zend/zend_alloc.c
- 位置:第 21-52 行
- 链接:https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L21-L52

对应的源码片段(Huge/Large/Small 的定义):

/*
 * zend_alloc is designed to be a modern CPU cache friendly memory manager
 * for PHP. Most ideas are taken from jemalloc and tcmalloc implementations.
 *
 * All allocations are split into 3 categories:
 *
 * Huge  - the size is greater than CHUNK size (~2M by default), allocation is
 *         performed using mmap(). The result is aligned on 2M boundary.
 *
 * Large - a number of 4096K pages inside a CHUNK. Large blocks
 *         are always aligned on page boundary.
 *
 * Small - less than 3/4 of page size. Small sizes are rounded up to nearest
 *         greater predefined small size (there are 30 predefined sizes:
 *         8, 16, 24, 32, ... 3072). Small blocks are allocated from RUNs.
 */
记住一句话:Zend VM 执行期的大部分内存,最终都归 ZendMM 管;ZendMM 再决定要不要跟操作系统要/还内存。

2. ZendMM 的“三段式”分配策略:Small / Large / Huge

为了既快又省,ZendMM 会按“申请大小”走不同路径:小块尽量复用(少跟 OS 交互),大块则直接映射(避免在池里造成巨大碎片)。理解这三类,你就能解释为什么 unset() 后 RSS 不一定下降。

2.1 Huge(超大块):直接向 OS 申请(mmap/VirtualAlloc)

当申请的 size 大于一个 CHUNK(默认约 2MB)时,ZendMM 走 Huge 路径:直接 mmap()(Windows 用 VirtualAlloc)拿一段映射内存。

代码出处(规则描述):
- 文件:Zend/zend_alloc.c
- 位置:第 25-41 行
- 链接:https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L25-L41

2.2 Large(大块):在 CHUNK 内按“页”切(默认 4KB)

Large 是“CHUNK 里的一段连续页”。它不会每次都向 OS 要内存,而是尽量在已拥有的 CHUNK 里找空闲页段;这样下次同类请求能很快复用同一批页。

2.3 Small(小块):固定尺寸档位 + freelist 复用

Small 会把 size 向上取整到固定档位(8/16/24/.../3072 等),然后从 RUN(一个或多个页组成)里切小块,并用链表(freelist)管理回收再利用。

代码出处(规则描述):
- 文件:Zend/zend_alloc.c
- 位置:第 33-38 行
- 链接:https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L33-L38

[warning]关键点:Small/Large 的 free,很多时候只是“回到 ZendMM 的池子里”,不等于“还给 OS”。[/warning]

3. Zend VM 的执行栈(vm_stack)也用 ZendMM

除了你写的变量,Zend VM 自己执行函数、创建 call frame、保存临时值,也需要一块“执行栈内存”。这块内存同样走 efree() 回到 ZendMM,所以它也会影响你看到的 RSS 曲线。

3.1 vm_stack 如何释放?

Zend VM 的执行栈是按“栈页”扩展的:不够就新分配一页;销毁时会把每一页 efree()。

代码出处:
- 文件:Zend/zend_execute.c
- 函数:zend_vm_stack_destroy()
- 位置:第 210-218 行
- 链接:https://github.com/php/php-src/blob/master/Zend/zend_execute.c#L210-L218

对应的源码片段:

ZEND_API void zend_vm_stack_destroy(void)
{
    zend_vm_stack stack = EG(vm_stack);

    while (stack != NULL) {
        zend_vm_stack p = stack->prev;
        efree(stack);
        stack = p;
    }
}

3.2 为什么栈销毁了,RSS 仍可能不降?

因为 efree() 的语义是“归还给 ZendMM”,而 ZendMM 会保留一定内存做缓存复用:这能显著提升下一次请求的速度,但也会让你看到 RSS 不一定同步下降。

4. 什么时候内存会“真正归还给操作系统”?(核心)

判断“是否归还 OS”的最直接方式就是看:是否触发了 munmap()/VirtualFree 这种“解除映射”的系统调用。只要没有走到这一步,很多释放都只是发生在用户态内存管理器(ZendMM 或系统分配器)的内部。

4.1 判断标准:是否触发 zend_mm_munmap

ZendMM 把“归还 OS”封装成 zend_mm_munmap():

- 文件:Zend/zend_alloc.c
- 函数:zend_mm_munmap()
- 位置:第 439-478 行
- 链接:https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L439-L478

对应的源码片段:

static void zend_mm_munmap(void *addr, size_t size)
{
#ifdef _WIN32
    VirtualFree(addr, 0, MEM_RELEASE);
#else
    munmap(addr, size);
#endif
}

4.2 归还 OS 的 4 类常见场景(从最容易发生到最不容易)

(1)Huge 块释放(通常直接 munmap)
- Zend/zend_alloc.c #L2480-L2487
https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L2480-L2487

单次分配超过 ~2MB 的“大块”,更容易在 free 时立刻归还给 OS。

(2)某个 CHUNK “整块空了”且策略决定不缓存
- 触发点:Zend/zend_alloc.c #L1179-L1190
https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L1179-L1190
- 删除策略:Zend/zend_alloc.c #L1141-L1176
https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L1141-L1176

[warning]这就是为什么 unset() 很多变量,RSS 仍可能不降:内存分散在多个 CHUNK,或 CHUNK 空了也被缓存。[/warning]

(3)请求结束(RSHUTDOWN):部分归还,但会保留缓存
- main/main.c #L2053-L2058
https://github.com/php/php-src/blob/master/main/main.c#L2053-L2058
- Zend/zend_alloc.c #L2489-L2526
https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L2489-L2526

(4)进程退出/模块关闭(full shutdown):尽可能全部归还
- main/main.c #L2536-L2539
https://github.com/php/php-src/blob/master/main/main.c#L2536-L2539
- Zend/zend_alloc.c #L2500-L2509
https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L2500-L2509

如果你期待“内存一定归还 OS”,最可靠的时机是:进程退出(或重启 worker)。请求级别不保证。

5. 为什么 memory_get_usage() 和 RSS 不是一回事?(结尾总结)

RSS 是 OS 视角的进程占用;而 memory_get_usage() 是 PHP/Zend 视角的统计,它们天然不会完全一致。你只要记住:ZendMM 和系统分配器都会缓存/复用,就很容易理解“代码释放了但 RSS 不降”。

5.1 两个 memory_get_usage 的差别

- memory_get_usage(false):更像“脚本实际占用(ZendMM 已分配给用户态对象的量)”
- memory_get_usage(true):更接近“ZendMM 从 OS 要来的总量(含缓存 chunk)”

5.2 结论一口气背下来

- unset() / GC 让对象析构,通常只表示“归还给 ZendMM/分配器”
- RSS 下降往往需要触发 munmap() 或类似“归还内核页”的行为
- 所以:脚本里看到的“内存下降”,不代表 RSS 下降

[warning]判断是否“真的还给 OS”,最可靠的视角是:是否发生 munmap/VirtualFree,以及进程 Private_Dirty 是否下降。[/warning]