PHP内存管理器(ZendMM)的内存分配逻辑
- PHP笔记
- 3天前
- 25热度
- 0评论
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.
*/
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
(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
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]
