引言
PHP 号称「自动内存管理」,但这不等于「不会泄漏」。在短生命周期的 FPM 模型里,泄漏会被进程退出抹平;而在 FrankenPHP、RoadRunner、Swoole 这类常驻进程里,每一次泄漏都会累积,最终把 worker 撑爆。要治理内存,先要理解 PHP 的两套机制:引用计数(reference counting)负责绝大部分回收,循环垃圾回收器(cycle GC)负责收拾引用计数搞不定的环。
本文从 Zend 内存管理器的分配模型讲起,解释引用计数何时释放、环何时形成、GC 如何标记清除,再给出用 memory_get_usage() 与 Xdebug 定位泄漏的方法,最后落到「如何写出省内存的 PHP」与「常驻进程怎么治理内存」。
前置阅读:性能调优:OPcache、JIT 与内存 、生成器、Fiber 与协程调度 。
目录
- 1. Zend 内存管理器的分配模型
- 2. 引用计数:绝大多数回收靠它
- 3. 循环引用与环的形成
- 4. GC 算法:根缓冲与标记清除
- 5. GC 配置与手动控制
- 6. 定位内存泄漏
- 7. 写出省内存的 PHP
- 8. 常驻进程的内存治理
- 延伸阅读
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 时:
- 从根出发,标记所有可达对象(把 refcount 临时「染色」做标记);
- 扫描后仍然「疑似可达但计数异常」的,即认定为环;
- 清除这些环,释放内存,并修正其他对象的引用计数。
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 下,内存治理要分三层:
- 预防:每请求清理静态缓存、注销事件监听、用
WeakMap切断环; - 检测:每请求记录
memory_get_usage(true),超阈值告警; - 兜底:设置内存上限,超限让 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,配合可观测性体系做趋势告警,才能在泄漏演变成事故前发现它。
记忆:常驻进程内存治理 = 预防(清理状态)+ 检测(指标告警)+ 兜底(超限重启)三线并行。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。