UE 里有三种"弱引用":TWeakObjectPtr、FWeakObjectPtr、TSoftObjectPtr。它们都解决同一个问题——持有对象但不想阻止它被 GC 回收。但实现机制完全不同:TSoftObjectPtr 靠路径字符串惰性解析,TWeakObjectPtr/FWeakObjectPtr 靠对象表的 index 和 serial number。下面基于 UE5.3 把后两者的机制讲透。
FWeakObjectPtr 的结构
FWeakObjectPtr 是底层实现,TWeakObjectPtr 是它的类型安全包装。结构极其精简:
// Runtime/CoreUObject/Public/UObject/WeakObjectPtrTemplates.h
struct FWeakObjectPtr
{
int32 ObjectIndex; // 在 GUObjectArray 中的下标
int32 ObjectSerialNumber; // 分配时的序列号
};
8 字节,不含指针。判断有效性不需要解引用——这是它和原始指针的本质区别。原始指针判断"对象是否还活着"必须访问对象内存,如果对象已经被释放,访问本身就是 UB。FWeakObjectPtr 只需要查表。
过期判定:index 查表 + serial 比对
判定流程:
- 用
ObjectIndex在GUObjectArray中查到FUObjectItem。 - 比对 item 里存的
SerialNumber和弱引用里存的ObjectSerialNumber。 - 相等 → slot 没被复用,再检查对象 flag(是否 PendingKill / Garbage)。
- 不等 → slot 已被新对象占用,旧引用过期。
关键在于 serial number 的机制。对象销毁后,它在 GUObjectArray 里的 slot 会被回收,之后分配给新对象。但 item 里的 SerialNumber 是单调递增的——每次分配新对象,AllocateSerialNumber(UObject/UObjectArray.cpp)生成一个比上次大的值。所以即使 slot 被复用,新旧对象的 serial number 也不同,旧弱引用比对失败,正确判定为过期。
这套机制等价于一个"乐观锁":不需要对象销毁时通知所有引用方(那需要维护反向引用表),引用方在使用时主动查表验证。代价是每次解引用多一次表查找,收益是零回调开销、零额外内存。
TWeakObjectPtr 的语义
TWeakObjectPtr<T> 在 FWeakObjectPtr 基础上加了类型信息和常用接口:
Get():返回T*,过期返回nullptr。内部走FWeakObjectPtr::Get()→ 查表 → 检查 flag。IsValid():等价于Get() != nullptr,但不返回指针。Pin():不存在——这是和TSharedPtr体系的区别。TWeakObjectPtr 没有"升级成强引用"的操作,因为它的目标对象由 GC 管理,不存在"提升到强引用阻止回收"的概念。想阻止回收就得用UPROPERTY()或AddToRoot。
一个常见误用:把 TWeakObjectPtr 存到 UPROPERTY 里。UPROPERTY 支持 TWeakObjectPtr,GC 能正确识别它"不阻止回收"的语义。但如果误写成裸 T* 加 UPROPERTY,GC 会把它当强引用,目标对象永远不会被回收——这是内存泄漏的常见来源。
FWeakObjectPtr vs TSoftObjectPtr vs TLazyObjectPtr
三者的适用场景完全不同:
| 类型 | 存储内容 | 解析时机 | 适用场景 |
|---|---|---|---|
FWeakObjectPtr |
index + serial | 使用时查表 | 运行时对象引用 |
TSoftObjectPtr |
资产路径字符串 | 首次访问时加载 | 资产引用,未加载时不占内存 |
TLazyObjectPtr |
对象指针(已加载) | 直接访问 | 已加载对象的延迟绑定 |
TSoftObjectPtr 是最重的——存的是 /Game/Path/To/Asset.Asset 这样的字符串,访问时走资产注册表解析、可能触发加载。它的价值在于"不加载就不占内存",适合UI图标、音效这类按需加载的资产。
TLazyObjectPtr 介于两者之间,但 UE5 里已经很少用了——它的功能基本被 TObjectPtr + 编辑器懒加载覆盖。
序列化行为
TWeakObjectPtr 序列化时写的是对象引用路径,不是 index。因为 index 是运行时分配的,不同进程间不稳定。保存时把对象转成 FSoftObjectPath 字符串,加载时重新解析成对象、再构造新的 FWeakObjectPtr。这意味着弱引用可以跨存档保存,但加载后指向的是新运行时里的同名对象——如果对象没加载,解析结果就是空。
放进容器的行为差异
TWeakObjectPtr 可以当 TSet/TMap 的 key,但有个坑:它的哈希基于 (ObjectIndex, ObjectSerialNumber),对象销毁后这两个值不变,所以 key 不会因为目标死亡而"失效移除"——集合里会积累一堆指向死对象的 key。遍历时要主动 IsValid() 过滤,或者定期重建容器。相比之下 TMap 的 value 用 TWeakObjectPtr 是安全的,读的时候判空即可。
另一个容器相关的细节:GC 的 TFastReferenceCollector 遍历 UPROPERTY 里的 TSet<TWeakObjectPtr> 时,会把它当弱引用容器处理,不沿它继续追踪——但如果误写成 TSet<UObject*>,整组对象都会被这条引用链保活。
多线程与帧边界
FWeakObjectPtr::Get() 在实现上读 GUObjectArray,这个数组的读路径是线程安全的(分块数组的块指针在初始化后稳定)。但"线程安全地读到指针"不等于"安全地使用指针":GC 在 GameThread 跑,如果 worker 线程拿到指针后 GameThread 恰好把对象回收了,下一次解引用就是野指针。UE 的官方约定是弱引用只在 GameThread 使用;worker 线程需要访问 UObject 时,应该在 GameThread 先取出强引用(或拷贝数据)再下发任务。
几个容易踩的边界
Get(true)(即GetEvenIfUnreachable语义):默认Get()对已标记 Garbage 的对象返回 nullptr;传 true 则只看 serial 匹配、不查 flag。这个参数只在调试工具里有意义,业务代码用了等于放弃悬空保护。- 网络复制里的弱引用:
TWeakObjectPtr属性加Replicated时,走的是对象引用序列化(和 TSoftObjectPtr 同一条FNetSerializeCB路径),同步的是对象身份而不是 index——两端 index 不同但指向同一网络对象。 Reset()和赋 nullptr 等价,都是清成(-1, -1);判断"从未赋值"用IsValid()就行,没必要单独存 bool。
还有一个性能细节:TWeakObjectPtr 的拷贝是 8 字节 POD 拷贝,放进 TArray 扩容时没有额外开销;但 TSoftObjectPtr 内含 FSoftObjectPath(两个 FName 加一个 FString),拷贝成本高得多,大数组里混用两者时要意识到差距。
实战结论
- 缓存场景用
TWeakObjectPtr:比如 AI 系统缓存"上次看到的玩家",玩家死亡被 GC 后缓存自动失效,不需要手动清理。 - 热路径上避免高频
Get():每次调用都是一次表查找。如果一帧内用多次,先T* Ptr = WeakPtr.Get()缓存到局部变量。 - 多线程访问要小心:
GUObjectArray的读是线程安全的(分块数组),但IsValid的结果可能在你用之前就过期——GC 在另一帧跑。UE 的约定是弱引用只在 GameThread 用。 - 调试时用
obj refs看不到弱引用——这正是弱引用的意义。如果obj refs显示有引用但你觉得不该有,检查是不是把TWeakObjectPtr误写成了UPROPERTY裸指针。