业务里给对象挂”额外信息”是很常见的需求: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 —— 泄漏
修复方式有三种,按推荐度排列:
- 只存必要数据,不存闭包:
$registry[$listener] = $listener->event; - 确实需要回调时,用
WeakReference持有:WeakReference::create($listener),取值时->get()判空。 - 用静态闭包 + 显式参数:
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();。