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

53 阅读7分钟

一、内存的分配过程

PHP 的内存分配机制主要通过 zend_alloc.h 中的 emalloc() 宏和 safe_emalloc() 宏实现。

emalloc() 宏的调用路径如下:

emalloc()
 └── _emalloc()
      └── zend_mm_alloc_heap()

safe_emalloc() 宏的调用路径如下:

safe_emalloc()
 └── _safe_emalloc()
      └── _emalloc()
           └── zend_mm_alloc_heap()

它们的区别在于:safe_emalloc() 宏在执行前会进行内存溢出检测(见“带安全保护的内存分配”章节)。无论哪种方式,最终都调用 zend_mm_alloc_heap() 函数完成内存分配。

zend_mm_alloc_heap() 函数根据分配的内存大小调用不同的内部函数:

内存大小调用函数
小于等于 ZEND_MM_MAX_SMALL_SIZE(3072B)zend_mm_alloc_small() 函数
小于等于 ZEND_MM_MAX_LARGE_SIZE(2MB - 512B)zend_mm_alloc_large() 函数
大于 ZEND_MM_MAX_LARGE_SIZE(2MB - 512B)zend_mm_alloc_huge() 函数

zend_mm_alloc_heap() 函数的代码如下:

static zend_always_inline void *zend_mm_alloc_heap(zend_mm_heap *heap, size_t size) {
    void *ptr;
    // 小块内存(<= 3072B)
    if (EXPECTED(size <= ZEND_MM_MAX_SMALL_SIZE)) {
        ptr = zend_mm_alloc_small(heap, ZEND_MM_SMALL_SIZE_TO_BIN(size));
        return ptr;
    } else if (EXPECTED(size <= ZEND_MM_MAX_LARGE_SIZE)) {
        ptr = zend_mm_alloc_large(heap, size);
        return ptr;
    } else {
        return zend_mm_alloc_huge(heap, size);
    }
}

如上所示,大部分与内存管理相关的函数都以 zend_mm_heap *heap 作为首个参数。一般情况下,全局只维护一个 zend_mm_heap 实例。


二、内存大小的对齐

为了提高查找性能,内存管理器通常会将分配大小向上对齐到 2 的幂次方(如 4KB、2MB 等)。

例如:

  • 若对齐数为 4KB,需要分配 3KB 的内存,系统会调整为 4KB。
  • 若需要分配 13KB,则调整为 16KB。

用于对齐的核心宏有两个:

  • ZEND_MM_ALIGNED_OFFSET() 宏:在分配和释放时常用。
  • ZEND_MM_ALIGNED_SIZE_EX() 宏:用于 zend_mm_alloc_huge() 函数修正所需内存空间大小。

小块内存分配相对复杂,而巨大块(huge block)的逻辑最为直观。以下从巨大块分配开始介绍。


三、分配巨大块内存

当内存请求超过 ZEND_MM_MAX_LARGE_SIZE(2MB - 512B)时,会调用 zend_mm_alloc_huge() 函数分配巨大块:

// 分配内存时,需要传入 heap 指针和分配的内存大小(size)两个参数
static void *zend_mm_alloc_huge(zend_mm_heap *heap, size_t size)

分配内存前,先调用 ZEND_MM_ALIGNED_SIZE_EX() 宏来修正所需空间大小,使其向上取整到 2MB 并检测内存空间是否足够:

size_t new_size = ZEND_MM_ALIGNED_SIZE_EX(size, alignment);

随后进入核心分配流程:

zend_mm_alloc_huge()
 ├── zend_mm_chunk_alloc()
 │    └── zend_mm_chunk_alloc_int()
 │         └── zend_mm_mmap()
 │
 └── zend_mm_add_huge_block()
  • zend_mm_chunk_alloc() 函数:负责 chunk 的分配。
  • zend_mm_mmap() 函数:向操作系统申请物理内存;在 Windows 下调用 VirtualAlloc() 函数,在 Unix/Linux 下调用 mmap() 函数。

作为跨平台抽象层,zend_mm_mmap() 函数屏蔽了底层系统调用差异,统一了分配路径的接口与语义。


四、矫正内存指针

为了在运行时以位运算快速判定任意指针的归属并简化页索引,zend_alloc 需要将 chunk 起始地址对齐到 2MB 边界。

zend_mm_chunk_alloc_int() 函数在第一次分配后,会使用 ZEND_MM_ALIGNED_OFFSET() 宏检查内存指针是否对齐到 2MB。如果未对齐,会执行以下操作:

  1. 释放该块内存;
  2. 重新分配一块更大的内存,比原始块多出 2MB - 4KB
// 这是第二次调用 zend_mm_mmap() 函数来分配内存
ptr = zend_mm_mmap(size + alignment - REAL_PAGE_SIZE);
  1. 在新分配的内存中,将指针向右偏移至第一个对齐到 2MB 的位置,作为新内存块的起始地址;
  2. 保留所需大小的内存块,将前后多余部分释放。

这整个过程称为 矫正内存指针(Memory Pointer Correction),是内存分配中至关重要的一步。其目的在于保证每个 chunk 起始地址与 2MB 边界对齐,便于快速计算 chunk 所属范围和映射索引。

矫正后的每个 chunk 的起始与结束地址都是 2MB 的倍数。此时,任意指针只需调用 ZEND_MM_ALIGNED_BASE()宏即可定位到其所属 chunk 的起始地址,无需在结构体中额外存储该信息。

同理,对于 page 也是如此。由于 chunk 的起始地址对齐到 2MB,内部的每个 page 也自然对齐到 4KB,从而简化了 page 内存索引。

在上述四个步骤中,后两个步骤在不同操作系统上有不同的实现方式。

Windows 平台

在 Windows 操作系统中,第二次调用 zend_mm_mmap() 函数分配的内存不会直接使用,而是立即释放。这次分配只是为了确定一块可用的内存空间。随后系统根据偏移量调整指针位置,使指针对齐到正确位置,并通过 zend_mm_mmap() 函数进行第三次分配:

zend_mm_munmap(ptr, size + alignment - REAL_PAGE_SIZE); // 释放第一次分配的内存
ptr = zend_mm_mmap_fixed((void*)((char*)ptr + offset), size); // 指针偏移到正确的位置
ptr = zend_mm_mmap(size + alignment - REAL_PAGE_SIZE);// 第三次分配

经过三次分配后,最终获得的内存块位置对齐且大小正确,可直接使用。这种多次映射方式是为了兼容 Windows 内存管理特性,性能开销可忽略。

非 Windows 平台

在非 Windows 操作系统中,不会释放第二次分配的内存,而是释放开头未对齐部分与尾部多余部分,仅保留中间对齐区域:

offset = alignment - offset; // 计算偏移差值
zend_mm_munmap(ptr, offset); // 释放这块内存开头没有对齐的部分
ptr = (char*)ptr + offset; // 把指针移动到对齐的位置上
...
zend_mm_munmap((char*)ptr + size, alignment - REAL_PAGE_SIZE); // 释放结尾多出来的部分

这种方式的实现更加直接,但两者在逻辑上是等价的。

把内存大小和起始位置指针都对齐到 2MB 以后,可以简化查找逻辑和数据结构,减少内存开销,带来优异的查找性能。


五、更新巨大块内存链表

分配完内存后,通过 zend_mm_add_huge_block() 函数将其添加到 heap 的链表中,这一链表不仅用于记录所有巨大块的分配情况,也在内存回收阶段发挥关键作用,用于追踪并释放对应的物理内存,从而形成完整的内存管理闭环。

// 创建一个链节点
zend_mm_huge_list *list = (zend_mm_huge_list*) zend_mm_alloc_heap(heap, sizeof(zend_mm_huge_list));
list->ptr = ptr; // 巨大块内存指针
list->size = size; // 内存大小
list->next = heap->huge_list; // next指针关联到原列表开头
heap->huge_list = list; // 本块(的指针封装)作为huge块链表开头

zend_mm_huge_list 结构体定义如下:

typedef struct  _zend_mm_huge_list zend_mm_huge_list; // 别名

// 巨大块内存链表元素
typedef struct _zend_mm_huge_list {
    void *ptr; // 巨大块内存的指针
    size_t size; // 指向内存的大小
    struct _zend_mm_huge_list *next; // 指向下一个同类元素
};

通过这种方式,所有巨大块被串联为单向链表,便于统一管理与回收。至此,巨大块分配的流程已完整闭环。


小结

本章系统梳理了 PHP 内存分配的整体流程,重点阐述巨大块(huge block)的分配与对齐原理。巨大块通过直接与操作系统交互进行内存映射,兼具高效与低开销的特点。下一篇文章将进一步分析大块(large block)与小块(small block)的分配逻辑,以及它们在 zend_mm_heap 内部的运作机制。

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


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