内存管理与垃圾回收

PHP 内存管理与垃圾回收实战:Zend 内存管理器(chunk/arena/bin)的分配模型、引用计数与释放时机、循环引用导致的泄漏、GC 根缓冲与标记清除算法、gc_enable/gc_probability/gc_mem_caches 配置、用 memory_get_usage 与 Xdebug 定位泄漏、生成器与 SplFixedArray 降内存、常驻进程内存治理策略。

引言

PHP 号称「自动内存管理」,但这不等于「不会泄漏」。在短生命周期的 FPM 模型里,泄漏会被进程退出抹平;而在 FrankenPHP、RoadRunner、Swoole 这类常驻进程里,每一次泄漏都会累积,最终把 worker 撑爆。要治理内存,先要理解 PHP 的两套机制:引用计数(reference counting)负责绝大部分回收,循环垃圾回收器(cycle GC)负责收拾引用计数搞不定的环。

本文从 Zend 内存管理器的分配模型讲起,解释引用计数何时释放、环何时形成、GC 如何标记清除,再给出用 memory_get_usage() 与 Xdebug 定位泄漏的方法,最后落到「如何写出省内存的 PHP」与「常驻进程怎么治理内存」。

前置阅读:性能调优:OPcache、JIT 与内存 、生成器、Fiber 与协程调度 。


目录


1. Zend 内存管理器的分配模型

1.1 为什么不用 malloc

每次 malloc 都要陷入内核、加锁、维护堆结构,对于「请求内分配成千上万个小对象」的 PHP 来说太慢。Zend 自己实现了一层内存管理器(Zend Memory Manager,简称 Zend MM),预先向系统申请大块内存,再在里面切片分配。

系统堆
  └─ chunk(2MB 对齐的大块,向 OS 申请的单位)
        └─ page(4KB)
              └─ bin(按尺寸分级的空闲链表)
                    └─ 分配给 zval / zend_string / 数组 ...

1.2 按尺寸分级

小对象按大小归类到不同 bin,分配时直接从对应 bin 取,O(1) 且无锁(每进程独立)。大对象(超过阈值)走单独的映射。请求结束时,Zend MM 一次性把整个请求占用的 chunk 归还,不需要逐个 free——这正是 PHP「请求隔离、随用随弃」高效的原因。

分配器适用特点
Zend MM(小对象)zval/string/array无锁、分级、请求结束批量回收
emalloc(大对象)超大缓冲单独映射
系统 malloc扩展内部不受请求生命周期管理

记忆:Zend MM 预申请 chunk、按尺寸分 bin、无锁分配,请求结束整体归还;这解释了为什么 FPM 下「泄漏无所谓」。


2. 引用计数:绝大多数回收靠它

2.1 refcount 的增减

每个复杂类型(string/array/object)的头部有 refcount。赋值、传参、放进数组都会 +1;变量离开作用域、被覆盖、unset 都会 -1。减到 0 时立即释放。

$a = str_repeat('x', 100);   // refcount = 1
$b = $a;                     // refcount = 2
unset($a);                   // refcount = 1(未释放)
unset($b);                   // refcount = 0 → 立即释放

2.2 释放是即时的

引用计数最大的优点是确定性:一旦不再被引用,内存马上归还,不需要等 GC 扫描。这解释了为什么「及时 unset 大变量」确实有用:

$big = file_get_contents('huge.json');   // 占用几十 MB
$data = json_decode($big, true);
unset($big);                             // 立即释放原始字符串

2.3 引用计数搞不定什么

循环引用。当两个对象互相持有对方,即使外部引用都没了,它们的 refcount 也永远不为 0:

class Node { public ?Node $next = null; }
$a = new Node();
$b = new Node();
$a->next = $b;   // $b 的 refcount +1
$b->next = $a;   // $a 的 refcount +1
unset($a, $b);   // refcount 都还是 1 → 永久泄漏(除非 GC 介入)

记忆:引用计数即时、确定,但遇到「互相引用」就失效——这正是需要 cycle GC 的唯一理由。


3. 循环引用与环的形成

3.1 环不只来自对象

数组也能形成环,只是不如对象直观:

$a = [];
$a['self'] = &$a;   // 数组引用自身
unset($a);          // 引用计数无法归零

更常见的是隐式环:

  • 父对象 ↔ 子对象双向持有(树结构忘了用弱引用);
  • 事件监听器持有 $this,而对象又持有监听器列表;
  • 闭包捕获了 $this,对象又存了这个闭包;
  • 全局注册表持有对象,对象又引用了注册表。

3.2 弱引用

PHP 7.4 的 WeakReference 与 WeakMap 正是为切断隐式环而生:

class Registry {
    private array $items = [];
    public function set(string $k, object $v): void {
        $this->items[$k] = WeakReference::create($v);  // 不增加 refcount
    }
    public function get(string $k): ?object {
        return ($r = $this->items[$k] ?? null)?->get();
    }
}

WeakMap 更直接:键是对象、值为任意数据,键对象被回收时条目自动消失,天然适合「给对象挂附加数据」的场景。

记忆:环多来自「双向持有」与「回调/闭包捕获」;WeakReference/WeakMap 是不加引用计数的正解。


4. GC 算法:根缓冲与标记清除

4.1 根缓冲

为了不每次都全量扫描,PHP 的 GC 维护一个根缓冲(root buffer):每当一个数组或对象的 refcount 减少但未归零(可能形成环)时,把它登记为「疑似根」。缓冲满了(默认 10000 个根)或显式调用时才真正运行 GC。

4.2 标记清除

运行 GC 时:

  1. 从根出发,标记所有可达对象(把 refcount 临时「染色」做标记);
  2. 扫描后仍然「疑似可达但计数异常」的,即认定为环;
  3. 清除这些环,释放内存,并修正其他对象的引用计数。
gc_enable();
echo gc_status()['runs'];    // GC 运行次数
echo gc_status()['collected']; // 已回收的环数量
echo gc_status()['roots'];     // 当前根缓冲中的根数

4.3 开销

GC 是增量的(分批处理根,不一次扫全堆),但仍有成本。对「短命请求、无环」的典型 Web 应用,GC 收益有限;对「常驻进程、大量长生命周期对象」的场景,GC 才是必需的。

记忆:GC = 根缓冲收集「疑似环」+ 标记清除释放真环;它只在引用计数失效时工作,是兜底而非主力。


5. GC 配置与手动控制

5.1 相关 ini

zend.enable_gc=1              ; 总开关(默认开)
zend.gc_probability=1         ; 触发概率分子
zend.gc_divisor=100           ; 分母 → 1/100 概率在根缓冲满时触发
zend.gc_mem_caches=1          ; 请求间复用内存缓存,常驻模式有用

gc_probability / gc_divisor 决定「根缓冲满时自动跑 GC 的概率」。设为 0/1 即关闭自动 GC,完全手动控制。

5.2 手动控制

gc_disable();          // 关闭自动 GC(批处理里省开销)
// ... 大量无环的密集计算 ...
gc_enable();
gc_collect_cycles();   // 主动回收一次,返回回收的环数

在常驻进程里,每个请求处理完主动跑一次 gc_collect_cycles() 是常见做法,避免环跨请求累积。

5.3 什么时候该关

  • 确定不产生环的短脚本:关掉自动 GC 省微秒级开销;
  • 有大量长生命周期对象的常驻服务:保持开启并配合主动回收;
  • 内存敏感的批处理:分批处理后手动 gc_collect_cycles()。

记忆:默认开自动 GC;无环脚本可关、常驻服务必须开并每请求主动 gc_collect_cycles()。


6. 定位内存泄漏

6.1 三把尺子

memory_get_usage();        // 当前 PHP 已用(不含未归还给系统的空闲)
memory_get_usage(true);    // 向系统申请的总量(含 Zend MM 保留的 chunk)
memory_get_peak_usage(true); // 峰值

判断泄漏看 memory_get_usage(true) 是否随请求数单调上升——单次波动不算,持续增长才是。

6.2 逐请求采样

for ($i = 0; $i < 10000; $i++) {
    handle_one_request($i);
    if ($i % 100 === 0) {
        error_log(sprintf('iter=%d cur=%.1fMB peak=%.1fMB roots=%d',
            $i,
            memory_get_usage(true) / 1048576,
            memory_get_peak_usage(true) / 1048576,
            gc_status()['roots']));
    }
}

若 cur 阶梯式上升且 gc_collect_cycles() 后不回落,说明存在被引用的对象(非环),要从「谁还持有它」入手。

6.3 Xdebug 与外部工具

# 记录每步内存,生成可分析的跟踪
php -d xdebug.mode=develop \
    -d xdebug.start_with_request=yes \
    -d xdebug.collect_assignments=1 script.php

在常驻进程里,也可以用 Xdebug 的函数跟踪,或直接对 PHP 进程用 valgrind --tool=massif(对原生扩展泄漏更有效)。

工具定位能力
memory_get_usage(true) 采样判断「是否泄漏、多快」
gc_status()判断「是环还是强引用」
Xdebug develop 模式定位「哪一步分配最多」
valgrind massif定位「扩展/原生层泄漏」

记忆:先采样看趋势,再 gc_collect_cycles() 判断是环还是强引用,最后用 Xdebug 定位分配点。


7. 写出省内存的 PHP

7.1 用生成器替代大数组

读大文件、遍历大数据集时,生成器把内存从 O(n) 降到 O(1):

// 坏:一次性把百万行读进数组
$lines = file('huge.log');            // 全部进内存
foreach ($lines as $l) { process($l); }

// 好:逐行产出
function readLines(string $f): Generator {
    $h = fopen($f, 'r');
    try { while (($l = fgets($h)) !== false) { yield $l; } }
    finally { fclose($h); }
}
foreach (readLines('huge.log') as $l) { process($l); }

7.2 定长数组

元素数量已知且类型单一时,SplFixedArray 比普通数组省内存(无哈希表开销):

$arr = new SplFixedArray(1_000_000);   // 比同规模普通数组省约 30~50%
$arr[0] = 1;

7.3 其它技巧

做法效果
及时 unset 不再用的大变量立即释放
用 yield 流式处理内存 O(1)
SplFixedArray 替代定长数组省 30~50%
避免无谓的字符串拼接少分配中间串
大数据用 cursor() 而非 get()逐行取,不全部加载
图片/文件用流式(fopen/fpassthru)不整块读入

数据库层面,Eloquent 的 cursor()、lazy()、chunk() 都是把「全量加载」改成「分批流式」的手段。

7.4 慎用引用赋值

& 引用会让 zval 被打上 is_ref 标记,破坏写时复制,导致本该共享的数据被强制分离,反而更占内存:

$a = range(1, 100000);
$b = $a;              // 共享,几乎不额外占内存
$c = &$a;             // 引用:$a 被标记 is_ref,后续写入可能复制
foreach ($a as &$v) { $v *= 2; }   // 常见写法,但会留下悬挂引用
unset($v);            // 必须 unset,否则最后一个元素仍被引用

在遍历时用引用(foreach ($arr as &$v))忘记 unset($v) 是经典 bug:循环结束后 $v 仍指向最后一个元素,后续对该变量的赋值会悄悄改写数组末元素。除非确实需要原地修改,否则优先用「生成器 + 返回值」而非引用。

记忆:省内存的三板斧 = 生成器流式、SplFixedArray、及时 unset;本质都是「别把不必要的数据同时留在内存里」;引用会破坏写时复制,非必要不用。


8. 常驻进程的内存治理

8.1 三条防线

在 FrankenPHP/RoadRunner/Swoole 下,内存治理要分三层:

  1. 预防:每请求清理静态缓存、注销事件监听、用 WeakMap 切断环;
  2. 检测:每请求记录 memory_get_usage(true),超阈值告警;
  3. 兜底:设置内存上限,超限让 worker 处理完当前请求后重启。
// 兜底:内存超限时优雅退出,由进程管理器重启
if (memory_get_usage(true) > 200 * 1024 * 1024) {
    error_log('worker memory limit reached, restarting');
    exit(0);
}

8.2 每请求清理钩子

register_shutdown_function(function () {
    gc_collect_cycles();                 // 回收本请求产生的环
    // 清理自定义的静态缓存 / 容器单例
});

8.3 与监控结合

把 memory_get_usage(true)、gc_status() 的指标打到日志或 Prometheus,配合可观测性体系做趋势告警,才能在泄漏演变成事故前发现它。

记忆:常驻进程内存治理 = 预防(清理状态)+ 检测(指标告警)+ 兜底(超限重启)三线并行。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 图像与文档处理
  2. 流封装与文件系统
  3. gRPC 与 Protobuf 服务