用弱引用 WeakMap 缓存对象元数据避免循环引用下的内存堆积

业务里给对象挂"额外信息"是很常见的需求:ORM 的实体要记住自己是脏的、事件总线要记录监听器归属、序列化器要给对象打标记、DI 容器要保存单例的构造状态。最直接的做法是在对象上开一个 public $meta 属性,或者用 spl_object_id($obj) 当 key 塞进一个静态数组。前者侵入业务模型,后者在长生命周期进程(如 Swoole / R

业务里给对象挂”额外信息”是很常见的需求:ORM 的实体要记住自己是脏的、事件总线要记录监听器归属、序列化器要给对象打标记、DI 容器要保存单例的构造状态。最直接的做法是在对象上开一个 public $meta 属性,或者用 spl_object_id($obj) 当 key 塞进一个静态数组。前者侵入业务模型,后者在长生命周期进程(如 Swoole / RoadRunner / 队列 worker)里几乎必然泄漏——因为 spl_object_id 在对象被回收后会被复用,而数组里的旧条目永远不会消失。

PHP 8.0 引入的 WeakMap 正是为这类场景设计的:它的 key 是对象,且对 key 只持有弱引用,对象被 GC 回收时对应条目自动移除。但”用上 WeakMap”不等于”没有内存问题”,一旦你在 value 里反过来强引用 key,或者让 WeakMap 本身参与了循环引用,内存照样堆积,而且排查难度比普通数组更高。下面从实际故障出发,讲清楚 WeakMap 的语义边界、循环引用的真实形态、可落地的缓存封装,以及怎么用真实工具把泄漏定位出来。

先还原故障:为什么静态数组缓存会越跑越大

一个典型的错误实现长这样,常见于给对象缓存计算结果的”小工具”:

<?php
final class MetaCache
{
    /** @var array<int, array> */
    private static array $store = [];

    public static function set(object $obj, string $key, mixed $value): void
    {
        self::$store[spl_object_id($obj)][$key] = $value;
    }

    public static function get(object $obj, string $key): mixed
    {
        return self::$store[spl_object_id($obj)][$key] ?? null;
    }
}

这段代码有三个独立的问题:

  • 条目永不释放:对象销毁后 $store 里还留着以它的 spl_object_id 为下标的数组,静态属性随进程存活。
  • id 复用导致串味:PHP 的对象句柄 id 在对象销毁后可被新对象复用。旧条目没清掉,新对象一来就命中别人的元数据,出现”明明是新对象,却带着上次的脏标记”这类诡异 bug。
  • value 可能强引用 key:哪怕换成 WeakMap,只要 value 里存了对象自身(比如缓存了一个闭包,闭包里 $this 指向该对象),弱引用就被”拉直”成强引用,条目无法回收。

第三点最隐蔽。它不报错、不 warning,只在 memory_get_usage() 曲线上表现为缓慢爬升。下面逐个拆。

WeakMap 的语义:弱在哪,不强在哪

先明确三条容易被忽略的规则,这决定了你的缓存是否真的能释放。

1. key 必须是对象,且是弱引用

<?php
$map = new WeakMap();
$obj = new stdClass();
$map[$obj] = 'meta';

var_dump(count($map)); // int(1)
unset($obj);           // 没有任何其他强引用
var_dump(count($map)); // int(0)

key 只能是 object,传字符串、数组、int 会抛 TypeError:

PHP Fatal error:  Uncaught TypeError: WeakMap key must be an object

注意回收时机:条目是在对象被 GC 判定为不可达之后才移除的,不是 unset 的那一刻立即生效。如果对象处在循环引用中,需要等循环垃圾回收器(cycle collector)跑一轮。默认 zend.enable_gc=1,回收器在根缓冲区满 10000 个可能的根时触发。所以在循环引用场景下,count($map) 可能短暂不为 0,这是正常的,不要误判成泄漏。

2. value 是强引用,会反向延长 key 的生命

<?php
$map = new WeakMap();
$obj = new stdClass();
$obj->self = $obj;              // 制造循环
$map[$obj] = $obj;              // value 强引用 key 自己
unset($obj);

gc_collect_cycles();
var_dump(count($map));          // int(1) —— 不会被回收

原因很直白:$map[$obj] = $obj 建立了 WeakMap → value → key 的强引用链。只要 value 活着,key 就被强引用,key 就不会死,条目自然不释放。这是一个由 WeakMap 自身参与的循环引用:key → (无) ,但 map → value → key 让 key 的引用计数始终 ≥ 1。

结论:WeakMap 的 value 里绝对不能出现对 key 的强引用。需要保存”与对象相关的回调”时,要么把回调参数化(不捕获 $this),要么用 WeakReference 包一层,要么干脆把元数据做成不可变的值类型(字符串、数组、纯数据 DTO)。

3. 迭代与序列化能力有限

WeakMap 实现了 IteratorAggregate 与 Countable,可以 foreach、count(),但不能序列化,serialize() 会直接抛异常:

PHP Fatal error:  Uncaught Exception: Serialization of 'WeakMap' is not allowed

也不能 var_export 出可重放的结构(输出是 \WeakMap::__set_state(array()),等于丢数据)。因此在做框架缓存容器时,必须给 WeakMap 留一条”降级”路径:需要持久化的元数据走另一套存储,WeakMap 只做进程内加速层。

循环引用的三种真实形态与对策

“循环引用”在业务代码里通常不是教科书里的 $a->b = $b; $b->a = $a,而是下面三种。它们对 WeakMap 的影响各不相同。

形态一:父子互指(最常见)

<?php
class Node
{
    public array $children = [];
    public ?Node $parent = null;
}

$root = new Node();
$child = new Node();
$child->parent = $root;
$root->children[] = $child;

$meta = new WeakMap();
$meta[$root] = ['dirty' => true];

unset($root, $child);
// 此时 refcount 各自减 1 但都不为 0,需要 GC
gc_collect_cycles();
echo count($meta), PHP_EOL; // 0

这种结构下 WeakMap 表现正常,前提是 value 里没有回指。要注意的是别把 GC 关掉:如果为了”提升性能”设了 zend.enable_gc=0,循环引用就永远不回收,WeakMap 条目也不会消失。这在短生命周期的 FPM 请求里无所谓,在常驻进程里是灾难。

形态二:value 捕获闭包(陷阱重灾区)

<?php
class Listener
{
    public function __construct(public string $event) {}
}

$registry = new WeakMap();
$listener = new Listener('order.paid');

// 错误写法:闭包捕获 $this 之外的变量没问题,但若捕获 $listener 就出事
$registry[$listener] = function () use ($listener) {
    return $listener->event;
};

unset($listener);
gc_collect_cycles();
echo count($registry), PHP_EOL; // 1 —— 泄漏

修复方式有三种,按推荐度排列:

  1. 只存必要数据,不存闭包:$registry[$listener] = $listener->event;
  2. 确实需要回调时,用 WeakReference 持有:WeakReference::create($listener),取值时 ->get() 判空。
  3. 用静态闭包 + 显式参数:static fn (Listener $l) => $l->event,调用时传对象。

形态三:WeakMap 存进全局容器被间接持有

这一种不会泄漏,但会让一些同学误判。把 WeakMap 本身放进容器单例里,容器不释放,WeakMap 就一直存在——这没问题,WeakMap 存在但条目可以为 0。真正的问题在”注册表套注册表”:

<?php
$inner = new WeakMap();
$outer = new WeakMap();
$obj = new stdClass();

$inner[$obj] = 'x';
$outer[$obj] = $inner;   // value 是另一个 WeakMap,不含对 $obj 的引用,安全

unset($obj);
gc_collect_cycles();
echo count($inner), ' ', count($outer), PHP_EOL; // 0 0

只要 value 不沿着引用链回到 key,嵌套 WeakMap 是安全的。反过来,$outer[$obj] = [$obj => 'x'] 这种”value 里再用 key 当数组下标”是强引用(数组的 key 不能是对象,这里只是举例说明思路),等价于循环,会泄漏。

一个可用于生产的 ObjectMeta 实现

下面这份实现解决四个问题:弱引用存储、避免 value 回指、可选的统计与调试、与 spl_object_id 方案的兼容迁移。代码可直接运行(PHP 8.1+)。

<?php
declare(strict_types=1);

/**
 * 以对象为 key 的元数据缓存,依赖 WeakMap 实现自动释放。
 * - value 只允许标量/数组/纯数据对象,禁止出现对 key 的引用
 * - 提供 hit/miss 统计,便于在压测中观察命中率
 */
final class ObjectMeta
{
    private static ?self $instance = null;

    /** @var WeakMap<object, array<string, mixed>> */
    private WeakMap $map;

    private int $hits = 0;
    private int $misses = 0;

    private function __construct()
    {
        $this->map = new WeakMap();
    }

    public static function instance(): self
    {
        return self::$instance ??= new self();
    }

    public function set(object $obj, string $key, mixed $value): void
    {
        $bucket = $this->map[$obj] ?? [];
        $bucket[$key] = $value;
        $this->map[$obj] = $bucket;
    }

    public function get(object $obj, string $key, mixed $default = null): mixed
    {
        $bucket = $this->map[$obj] ?? null;
        if ($bucket === null || !array_key_exists($key, $bucket)) {
            $this->misses++;
            return $default;
        }
        $this->hits++;
        return $bucket[$key];
    }

    public function has(object $obj, string $key): bool
    {
        return isset($this->map[$obj]) && array_key_exists($key, $this->map[$obj]);
    }

    public function forget(object $obj, string $key): void
    {
        if (!isset($this->map[$obj])) {
            return;
        }
        $bucket = $this->map[$obj];
        unset($bucket[$key]);
        if ($bucket === []) {
            unset($this->map[$obj]);
        } else {
            $this->map[$obj] = $bucket;
        }
    }

    /** 记住一次计算结果,命中则直接返回 */
    public function remember(object $obj, string $key, callable $factory): mixed
    {
        if ($this->has($obj, $key)) {
            $this->hits++;
            return $this->map[$obj][$key];
        }
        $this->misses++;
        $value = $factory($obj);
        $this->set($obj, $key, $value);
        return $value;
    }

    public function count(): int
    {
        return count($this->map);
    }

    /** @return array{hits:int, misses:int, size:int} */
    public function stats(): array
    {
        return ['hits' => $this->hits, 'misses' => $this->misses, 'size' => $this->count()];
    }
}

几个设计取舍说明:

决策 原因
单例持有 WeakMap WeakMap 不持有对象强引用,单例存在不影响回收;避免每次 new 弱引用表导致命中率归零
按对象分桶(array 作为 value) 一个对象存多个 key 时减少一次 hash 查找,并且 forget 可精细清理
array_key_exists 而非 isset 允许缓存 null 作为有效结果,避免”缓存了 null 又被当成 miss”的重复计算
remember 里把 $obj 传给 factory 工厂函数以参数形式拿到对象,不通过 use 捕获,天然避免循环引用
不提供 all() 返回全部条目 对外暴露整个数组容易让人把对象拷进别的结构,制造隐性强引用

验证一下回收行为:

<?php
require 'ObjectMeta.php';

$meta = ObjectMeta::instance();

$order = new stdClass();
$order->id = 1001;
$meta->set($order, 'dirty', true);
$meta->remember($order, 'total', fn ($o) => 99.50);
$meta->set($order, 'note', null);

var_dump($meta->get($order, 'dirty'));          // bool(true)
var_dump($meta->has($order, 'note'));           // bool(true)
var_dump($meta->get($order, 'note', 'dflt'));   // NULL(缓存了 null)
var_dump($meta->count());                       // int(1)

unset($order);
gc_collect_cycles();
var_dump($meta->count());                       // int(0)
print_r($meta->stats());

输出末尾的 stats() 会给出类似 ['hits' => 2, 'misses' => 0, 'size' => 0]。压测时如果 size 随请求数单调上升,就是 value 回指的强信号。

迁移实战:把 ORM 的脏检查从属性搬到 WeakMap

一个更完整的落地点。假设原先是实体自己带 private array $changed,想在实体类里去掉这个属性(避免实体序列化时把脏状态写进缓存),改为外部缓存:

<?php
declare(strict_types=1);

final class EntityTracker
{
    private WeakMap $dirty;

    public function __construct()
    {
        $this->dirty = new WeakMap();
    }

    public function mark(object $entity, string $field): void
    {
        $fields = $this->dirty[$entity] ?? [];
        $fields[$field] = true;
        $this->dirty[$entity] = $fields;
    }

    /** @return list<string> */
    public function changed(object $entity): array
    {
        return array_keys($this->dirty[$entity] ?? []);
    }

    public function isDirty(object $entity): bool
    {
        return isset($this->dirty[$entity]) && $this->dirty[$entity] !== [];
    }

    public function clear(object $entity): void
    {
        unset($this->dirty[$entity]);
    }
}

class Product
{
    public function __construct(
        public int $id,
        public string $name,
        public int $price
    ) {}
}

$tracker = new EntityTracker();
$p = new Product(1, 'Keyboard', 29900);
$p->price = 25900;
$tracker->mark($p, 'price');

var_dump($tracker->isDirty($p));      // bool(true)
var_dump($tracker->changed($p));      // array(1) { [0]=> string(5) "price" }

// 模拟 flush 后提交并清理
$tracker->clear($p);
var_dump($tracker->isDirty($p));      // bool(false)

// 对象出作用域,无需手动清理
unset($p);
gc_collect_cycles();

注意 changed() 返回的是新建数组,不是 WeakMap 内部结构的引用,调用方改它不会污染缓存。这一点在老代码里经常被忽略——如果直接 return $this->dirty[$entity]; 返回数组,PHP 数组是值语义,其实也会拷贝,但如果是对象(比如返回 ArrayObject)就会共享,务必小心。

还要给 EntityTracker 定好生命周期:它应该是每请求/每事务一个实例,或者进程内的单例都行,但不要让 EntityTracker 被序列化后跨请求复用——WeakMap 无法序列化。若用 Redis 做二级缓存,只缓存实体本身,脏状态永远只在内存。

用真实工具定位”WeakMap 也泄漏”

遇到常驻进程内存涨,别猜,按顺序做三件事。

第一步:确认是 PHP 层还是扩展层

<?php
// 在 worker 的每个 tick / 每 N 个任务后打印
printf(
    "mem=%.2fMB real=%.2fMB peak=%.2fMB\n",
    memory_get_usage(true) / 1048576,
    memory_get_usage(false) / 1048576,
    memory_get_peak_usage(true) / 1048576
);

memory_get_usage(false)(不带 true)是 Zend 内存管理器分配的字节数,最能反映 PHP 对象层面的堆积;true 是按 2MB 的 chunk 取整后的值,涨得慢但会跳变。两者都不涨而 RSS 在涨,多半是 opcache / 扩展 / 碎片,不是你的 WeakMap。

第二步:用 gc_status() 看循环垃圾

<?php
$s = gc_status();
print_r($s);
// Array
// (
//     [runs] => 120
//     [collected] => 4800
//     [threshold] => 10001
//     [roots] => 17
// )

关键看 roots:它表示”可能的循环根”当前积压数量。如果它长期贴近 threshold(默认 10001),说明循环垃圾产生速度超过回收速度,正常;如果 roots 缓慢但 collected 几乎不涨,说明存在”回收器扫到了但收不掉”的对象——典型就是被 WeakMap 的 value 强引用着的循环,回收器无权处理。这时候可以用一次强制回收再比对 count():

<?php
echo count($meta), PHP_EOL;   // 前
gc_collect_cycles();
echo count($meta), PHP_EOL;   // 后,不降就说明有外部强引用

第三步:用 WeakReference 做存活探针

不要 dump 整个 WeakMap,写个小工具追踪几个关键对象的存活状态:

<?php
$probe = [];
$probe[] = WeakReference::create($someEntity);   // 只记弱引用,不影响回收

// 稍后
foreach ($probe as $i => $ref) {
    echo $i, ': ', $ref->get() === null ? 'collected' : 'alive', PHP_EOL;
}

如果某个实体 alive 一整天,而你确信业务逻辑早就处理完了,就用 debug_zval_dump($obj) 或 Xdebug 的 xdebug_debug_zval 看引用计数(PHP 8 下 debug_zval_dump 仍然可用,输出形如 object(stdClass)#3 (1) refcount(4))。refcount 异常高,就回到本文第二节,检查 value 里回指、全局变量、静态数组、容器单例。

常见真实报错与处理

报错/现象 原因 处理
TypeError: WeakMap key must be an object 用字符串、int 或 spl_object_id 结果当 key 保留对象本身作为 key;若只有 id 需查表,改用普通数组并自行管理 TTL
Uncaught Exception: Serialization of 'WeakMap' is not allowed 把含 WeakMap 的服务对象塞进队列 payload 或 cache 实现 __sleep()/__serialize() 排除该属性,或在构造时重新 new
Undefined offset / 返回 null 对象已被回收,条目自动消失,代码还在用旧的 spl_object_id 逻辑 改为 $map[$obj] ?? $default;不要假设条目一定存在
Cannot use object of type stdClass as array 对已从 WeakMap 取出的 value 按数组索引(value 实为对象) 明确 value 的类型契约,用 DTO 而不是裸 array/object 混用
内存持续上涨且 gc_collect_cycles() 无效 value 强引用 key,形成 map → value → key 链 把闭包/对象引用换成标量或 WeakReference;用 WeakReference::create($obj)->get() 取值

还有一类不是错误的”错误”:有人用 clone 拷贝对象后,期望新对象继承元数据。不会继承,因为 WeakMap 以对象身份为 key,clone 出来的是新对象。如果业务需要,在 __clone() 里显式调用 ObjectMeta::instance()->set($this, ...) 把字段拷过去。

小结

WeakMap 解决的是”spl_object_id 方案既泄漏又串味”的核心痛点,但它的价值只在正确使用的前提下成立。三条硬性纪律:value 不得持有 key 的强引用;不要在缓存里塞闭包;循环引用场景必须允许 GC 工作。工程上再补两条:给 WeakMap 容器加 count()/stats() 便于观测,给它写清楚”不可序列化、不跨请求”的契约。做到这几点,长驻进程里的元数据缓存就能长期稳定在”对象活着才有条目”的健康状态,而不是靠定时重启兜底。

FAQ

Q1:PHP 8.0 以下怎么办?

没有 WeakMap。可选两条路:升级到 8.0+;或用 WeakReference(PHP 7.4 引入)自己封装:以 spl_object_id 为下标存 ['ref' => WeakReference::create($obj), 'meta' => ...],读取时 $entry['ref']->get() 判空,为空则 unset 清理。但注意 7.4 没有 WeakMap 的自动清理,必须配合定时清扫(比如每 N 次写入扫一遍),否则条目仍在数组里,只是值失效。

Q2:WeakMap 里的 key 被回收后,value 里的资源(连接、文件句柄)会怎样?

同样被回收,前提是没有别的强引用。如果 value 是一个持有 PDO 连接的包装对象,条目消失时该对象引用计数归零,析构函数正常执行,连接关闭。但如果你在 value 里把连接塞进了某个全局连接池,池子持有强引用,那连接不会关——这跟 WeakMap 无关,是池的设计问题。

Q3:能用 WeakMap 做对象池(object pool)吗?

不能。对象池需要”对象还在池里”这个状态,本质要求强引用;WeakMap 的语义是对象没人用条目就没了,正好相反。对象池应该用普通数组 + 显式 release()/acquire()。

Q4:foreach 遍历 WeakMap 时修改它会怎样?

不要在遍历中 unset 或新增条目。PHP 内部迭代器在底层哈希表变动时行为不确定,可能跳过条目。需要过滤就先 iterator_to_array($map) 快照(注意这会建立强引用,用完立即 unset),或先收集 key 再逐个处理。

Q5:为什么我 unset($obj) 之后 count($map) 还是 1?

两种可能。一是 $obj 仍在循环引用里,要等 gc_collect_cycles();二是存在你看不见的强引用,最常见的是:在闭包里 use ($obj) 后被某个回调数组保存、被 debug_backtrace() 抓取、或写在某个全局/静态属性里。用 WeakReference 探针确认对象是否真的还活着,再顺引用链找持有者。

Q6:Swoole 协程下用 WeakMap 有坑吗?

WeakMap 是纯 PHP 层面的哈希表,与协程调度无关,跨协程共享同一个单例是安全的(单线程事件循环,不存在并发写)。要注意的是协程间共享 ObjectMeta 单例时,键是对象身份,不同协程操作同一对象会互相覆盖元数据——这是业务语义问题,不是 WeakMap 的问题。需要隔离就按协程 ID 分表:$maps[Coroutine::getCid()] ??= new WeakMap();。