PHP 语法进阶 11|引用、拷贝与内存:为什么你的数组"偷偷"翻倍了

一切的起点:zval 和引用计数

PHP 的每个变量在底层都是一个 zval(Zend value),里面装着值、类型,以及一个引用计数(refcount)。

<?php
$a = str_repeat("x", 1000);   // 一个 1000 字节的字符串
$b = $a;                       // 不复制!

$b = $a 之后,内存里只有一份字符串数据,两个 zval 都指向它,引用计数变成 2。这就是写时复制(Copy-On-Write,COW)。

只有当其中一方被修改时,才会真的复制一份:

<?php
$a = str_repeat("x", 1000);
$b = $a;              // refcount = 2,内存还是 1 份
$b .= "y";            // 此刻才复制,refcount 拆成 2 份数据

这解释了一个常见现象:"我只是把数组传给函数,内存怎么涨了?" —— 如果函数内部修改了它,就是真的复制了。

<?php
function addOne(array $arr): array {
    $arr[] = 1;       // 这里复制了整个数组
    return $arr;
}

$big = range(1, 1000000);                    // 约 32MB
$new = addOne($big);                         // 峰值多出约 32MB

用引用传参就不复制:

<?php
function addOneByRef(array &$arr): void {
    $arr[] = 1;       // 直接改原数组
}

判断标准很简单:只读 → 不用引用;要改 → 才考虑引用。 只读却加 & 反而更慢,因为引用会破坏 COW 优化。

引用到底是什么

& 不是"指针",也不是"别名",它在 PHP 里创建一个引用容器(reference),让两个变量名共享同一个 zval。

<?php
$a = 1;
$b = &$a;
$b = 2;
echo $a;        // 2

unset($b);
echo $a;        // 2 —— unset 只断开绑定,不影响 $a

$a = 3;
$c = &$a;
unset($a);
echo $c;        // 3 —— $c 还持有这个值

几个反直觉的行为:

<?php
// 1) 数组元素引用之后,数组本身不再"Copy-On-Write"
$a = [1, 2, 3];
$b = $a;
$b[0] = &$b[1];       // 一旦有元素是引用,整个数组的 COW 优化失效
<?php
// 2) 函数返回引用需要两边都写 &
function &getRef() {
    static $x = 1;
    return $x;             // 返回的是引用
}
$r = &getRef();            // 调用处也必须 &
$r = 10;
<?php
// 3) foreach 的引用会"粘"在变量上
$arr = [1, 2, 3];
foreach ($arr as &$v) {}
foreach ($arr as $v) {}    // $v 还是引用,会把最后一个元素改掉
unset($v);                 // 必须清理

对象是另一种机制

对象不遵循 COW,因为变量里存的是对象句柄(handle),不是对象本身:

<?php
class Box { public int $n = 0; }

$o1 = new Box();
$o2 = $o1;              // 两个变量指向同一个对象,不复制
$o2->n = 5;
echo $o1->n;            // 5

要复制对象必须 clone,而 clone 是浅拷贝(第 07 篇讲过)。

内存的几个实用函数

<?php
echo memory_get_usage(), PHP_EOL;          // 当前已分配
echo memory_get_peak_usage(), PHP_EOL;     // 峰值(排查内存问题看这个)
echo memory_get_usage(true), PHP_EOL;      // 从系统实际申请的量(含未使用的池)
echo ini_get('memory_limit'), PHP_EOL;     // 上限,如 "128M"

排查内存问题一定看 memory_get_peak_usage(true),因为它反映进程从操作系统真实申请的内存。CLI 脚本偶尔会看到几十 MB 的"虚高",那是 Zend 的内存池,不一定是泄漏。

一个实测比较(数值随环境浮动,看数量级):

<?php
$m0 = memory_get_usage();

$a = range(1, 1000000);          // 100 万个整数
$m1 = memory_get_usage();

$b = $a;                          // 赋值不复制
$m2 = memory_get_usage();

$b[] = 1;                         // 修改触发复制
$m3 = memory_get_usage();

printf("创建: %.1f MB\n", ($m1 - $m0) / 1048576);
printf("赋值: +%.1f MB\n", ($m2 - $m1) / 1048576);
printf("修改: +%.1f MB\n", ($m3 - $m2) / 1048576);

典型结果:创建一个百万整数数组约 32MB(PHP 8 下每个元素约 32 字节,含 bucket 与 zval),赋值几乎为 0,修改再多出 32MB。

这就是为什么"用数组做大批量数据处理"在 PHP 里很快撞到 memory_limit。

循环引用与 GC

PHP 的引用计数有个天然缺陷:循环引用永远不会归零。

<?php
$a = new stdClass();
$b = new stdClass();
$a->other = $b;
$b->other = $a;

unset($a, $b);      // 两者的 refcount 都还有 1(互相指着),不会释放

PHP 5.3 之后加了循环垃圾回收器,会定期扫描可疑的"可能成环"的 zval。手动触发:

<?php
gc_collect_cycles();
echo gc_status()['runs'], PHP_EOL;

要不要手动调用?多数情况下不用,PHP 会在根缓冲区满(默认 10000 个可能根)时自动跑。但在长驻进程(Swoole、Workerman、队列 worker)里,主动调用能避免内存缓慢上涨:

<?php
// 长驻 worker 里的常见做法
if ($jobCount % 100 === 0) {
    gc_collect_cycles();
}

php.ini 里可以关掉它(zend.enable_gc=0),但除非你非常确定没有循环引用,别关。

内存优化:几个真能省下东西的做法

1. 用生成器代替大数组(详见下一篇)

<?php
function lines(string $file): Generator {
    $fp = fopen($file, 'r');
    while (($line = fgets($fp)) !== false) {
        yield $line;
    }
    fclose($fp);
}

foreach (lines("big.log") as $line) {
    // 处理一行,内存里始终只有一行
}

读 2GB 日志文件,用 file() 会直接把内存打爆;用生成器安然无恙。

2. 及时 unset 大变量

<?php
$rows = $pdo->query("SELECT * FROM big_table")->fetchAll();
foreach ($rows as $row) {
    process($row);
}
unset($rows);        // 早一点释放,避免和后面的处理叠加

3. 用 SplFixedArray 存纯数值列表

<?php
$arr = new SplFixedArray(1000000);   // 定长,只存值,内存约为普通数组的 1/3
$arr[0] = 1;

代价是只能用整数下标、不能动态增删。

4. 大数据交给数据库或外部工具

超过几百 MB 的集合,PHP 数组不是合适的数据结构。用 SQL 聚合、用 DuckDB、用流式处理,别硬扛。

引用计数带来的一个"隐形坑":debug_zval_dump

<?php
$a = "x";
$b = $a;
debug_zval_dump($a);   // refcount(2)

这个函数常用于理解 COW,但它自己传参也会增加引用计数,看到数字比预期多 1 是正常的。

memory_limit 该怎么设

memory_limit = 128M      ; 常规 Web 请求
memory_limit = 512M      ; 处理大文件/大报表
memory_limit = -1        ; 不限制(只适合 CLI 脚本,Web 里禁止)

生产环境的经验值:

  • Web 请求:128M~256M。超过就说明有优化空间,而不是该调大。
  • CLI 脚本 / 队列 worker:按任务实际需要,但配 supervisor 的 memory 限制做兜底(例如超过 200M 自动重启 worker)。这是"防内存泄漏"最实用的兜底手段。
  • memory_limit = -1 在 Web 里等于给了个 DoS 入口。

一个真实的内存事故样例

<?php
// 问题代码:循环里对大数组做"只加一个元素"的操作
$result = [];
foreach ($bigList as $item) {
    $result = array_merge($result, [$item]);   // 每次都复制全部
}

// 正确写法
$result = [];
foreach ($bigList as $item) {
    $result[] = $item;                          // 就地追加
}

array_merge 每次调用都返回新数组,在循环里用就是 O(n²) 的内存分配。这个错误在日志里表现为"Allowed memory size exhausted",但根因是写法不是配置。

坑清单

  1. 只读参数加 &,破坏 COW 优化,反而更慢。
  2. 传给函数后被修改的大数组,会整块复制。
  3. foreach 用引用不 unset,污染后续变量。
  4. 数组中一旦出现引用元素,整体 COW 优化失效。
  5. 循环引用不释放,长驻进程必须定期 gc_collect_cycles()。
  6. 循环里 array_merge,O(n²)。
  7. 用 file() 读大文件,直接爆内存。
  8. Web 里设 memory_limit = -1。
  9. memory_get_usage() 和 memory_get_usage(true) 的口径不同,排查时别混用。