UE × UnLua 内存协同机制:两套 GC 怎么共处?
零、问题来自哪里
UE 和 Lua 是两个完全独立的运行时,各自带着一套内存管理规则:
- UE 这边 :
UObject由引擎的 GC 管。一个对象只要还能被某个UPROPERTY()、根集(Root Set)或FGCObject引用到,就活着;一旦没有任何引用,下一轮GarbageCollect就会把它干掉。 - Lua 这边 :所有
table、userdata、function、string由 Lua 的 GC 管。只要 Lua 端还能找到引用(通过栈、上值、注册表等),它就活着;找不到了,Lua 自己会回收。
矛盾就出现在两个世界共享同一个 UObject 的时候:
┌──────────────┐ ┌──────────────┐
│ UE 世界 │ │ Lua 世界 │
│ │ 绑定关系 │ │
│APlayerCharacter◄────────────────►│ INSTANCE 表 │
│ (UObject) │ │ (含 userdata)│
└──────────────┘ └──────────────┘
↑ ↑
UE GC 管这里 Lua GC 管这里
如果两边各自为政、不通气,会出两种事故:
- Lua 端拿着一个野指针 :UE 已经把 UObject 删了,但 Lua 里那个 INSTANCE 表还在,它里面
.Object字段指向的内存已经是无效的,再去用就崩。 - UE 端意外把对象回收掉了:明明 Lua 里还在拿着这个对象,但 UE 那边没人持有它的强引用,下一轮 GC 就给收走了,Lua 后续访问就崩。
UnLua 的内存管理本质上就是在解决这两件事。它的整体思路可以用一句话概括:
Lua 持有 UObject 时,默认走"弱引用 + UE 删除回调"的模式;只有特定情形(比如 Lua 创建 Delegate 代理)才升级为强引用挂到 UE 的 GC 根集上。
下面我们一层层把这个机制拆开看。
一、整体流程:一次 Lua 拿到 UObject 时发生的事
为了不让概念太空,我们先用一个具体的例子作引子。
假设 C++ 端创建了一个 APlayerCharacter,这个类绑定了 PlayerCharacter.lua 模块。在 Lua 里我们写:
lua
function M:OnHurt(Damage)
print(self:GetName(), self.Health)
end
self 是什么?它是 UnLua 在绑定时建的一个 Lua INSTANCE 表 ,表里有个字段 .Object 指向 APlayerCharacter 对应的 userdata,这个 userdata 内部包着一个 TWeakObjectPtr<UObject>。
整条生命周期链路大致是这样的:
① C++ 新建 APlayerCharacter
↓
② UnLua 监听器收到 NotifyUObjectCreated
↓
③ TryBind 检查类是否实现 IUnLuaInterface
↓
④ FObjectRegistry::Bind 建立 INSTANCE 表 + userdata
userdata 内部 = TWeakObjectPtr<APlayerCharacter>
↓
⑤ 在 Lua 注册表的弱值表中保存 (Object 指针 → INSTANCE 表)
同时 luaL_ref 拿到一个引用 ID 存进 ObjectRefs
↓
┌────────── 此后 Lua 和 UE 都能访问到 APlayerCharacter ─────────┐
│ │
│ Lua 改字段、调函数 → 通过 userdata.Get() 拿到 UObject* │
│ UE 删除 UObject → 通过 NotifyUObjectDeleted 通知 UnLua │
│ │
└──────────────────────────────────────────────────────────────┘
↓
⑥ UE 销毁 APlayerCharacter
↓
⑦ FLuaEnv::NotifyUObjectDeleted → ObjectRegistry::Unbind
↓
⑧ 释放 luaL_ref、把 userdata 里的 TWeakObjectPtr 主动 Reset 为空
↓
⑨ Lua 后续访问该 userdata 时 IsReleasedPtr 检查会拦截,报错而不是崩溃
接下来我们把这个流程里的几个关键部位放大讲。
二、Lua 端怎么"指向"一个 UObject?------ userdata + 弱引用指针
这一步是整套机制的物理基础。Lua 不能直接存一个 C++ 裸指针,它需要包装成 userdata。UnLua 的关键决策是:包装成 TWeakObjectPtr,而不是裸指针,也不是 TStrongObjectPtr。
来看实际代码(LuaCore.cpp):
cpp
void* NewUserdataWithWeakObjectPtr( lua_State* L, UObject* Object )
{
auto Size = sizeof(TWeakObjectPtr<UObject>);
void* Userdata = NewUserdataWithDesc( L, Size,
( BIT_VARIANT_TAG | BIT_UOBJECT_WEAK_PTR ), 0 );
new(Userdata) TWeakObjectPtr<UObject>( Object );
return Userdata;
}
每当 UE 这边要把一个 UObject 推到 Lua 栈上时,最终都会走到 PushObjectCore,里面调的就是上面这个 NewUserdataWithWeakObjectPtr:
cpp
void PushObjectCore(lua_State *L, UObjectBaseUtility *Object)
{
// ... 取元表名 ...
NewUserdataWithWeakObjectPtr(L, (UObject*)Object); // 关键这一行
TryToSetMetatable(L, TCHAR_TO_UTF8(*MetatableName));
}
为什么是弱引用?这有两层考虑:
- 避免 Lua 反过来"绑架" UE :如果 userdata 里塞的是强引用(比如往 GC 根集里挂),那么只要 Lua 还引用这个 userdata,UObject 就永远死不了。这会让 UE 的对象生命周期变得非常难控制------明明在 C++ 里调了
Destroy,但因为 Lua 还在持有,对象就是不走 GC,行为上很反直觉。 - 保留"已失效"的信号 :
TWeakObjectPtr的Get()在对象被销毁后会返回nullptr。这就给了 Lua 端一次"检查再用"的机会,而不是直接踩到野指针。
备注(合理推论):选择
TWeakObjectPtr而不是裸指针的另一个好处是,UE 的TWeakObjectPtr自带"序列号校验",能识别指针被复用的情况------同一段内存可能先后是两个不同 UObject,弱指针能准确判断"我指的那个还在不在"。
每个 userdata 还带一个变体标签 (variant tag) ,里面有 BIT_UOBJECT_WEAK_PTR、BIT_TWOLEVEL_PTR、BIT_RELEASED_TAG 这些位(LuaCore.cpp 的 GetUserdataFast),后面"加固防御"那一节会用到。
三、绑定时的"对象映射表"------ 让 Lua 端的"替身"唯一
光把 userdata 推到栈上还不够。如果同一个 UObject 每次访问都新建一个 INSTANCE 表,那不仅浪费内存,更要命的是:你在 Lua 里给 self.SomeFlag = true 之后,下次拿到的 self 就丢了这个 flag------明显不合理。
UnLua 的做法是在 Lua 注册表里维护两张表,由 FObjectRegistry 负责(ObjectRegistry.cpp):
UnLua_ObjectMap:键是 UObject 指针(lightuserdata),值是对应的 INSTANCE 表。这张表是弱值表。ObjectRefs(C++ 端的TMap<UObject*, int32>):记录每个 UObject 在 Lua 注册表里通过luaL_ref拿到的引用 ID。
构造函数里就把这两张弱值表建好了:
cpp
FObjectRegistry::FObjectRegistry(FLuaEnv* Env) : Env(Env)
{
const auto L = Env->GetMainState();
lua_pushstring(L, REGISTRY_KEY); // "UnLua_ObjectMap"
LowLevel::CreateWeakValueTable(L); // 弱值表
lua_rawset(L, LUA_REGISTRYINDEX);
// ... 同样的方式建 ManualRefProxyMap ...
}
3.1 弱值表是关键设计点
为什么要用弱值表?这里要分清两个层次:
- 弱值表是 Lua 层面的概念:表里的"值"是弱引用,如果这个值(INSTANCE 表)没有别处引用,Lua GC 就会把它收走,同时连带把这个 entry 从弱值表里抹掉。
- 这正好对应"如果 Lua 业务代码已经不再持有这个 UObject 的 INSTANCE 表,那 UnLua 也没必要替它留着"。
但有个例外:Bind 出来的 INSTANCE 表,UnLua 用 luaL_ref 在注册表里固定了一份强引用 (存到 ObjectRefs 里)。这是为了让 C++ 端反过来调 Lua 时(比如蓝图事件覆写、Delegate 回调),随时能根据 UObject 找到对应的 Lua 实例。否则 Lua GC 一收,C++ 调过来就找不到 self 了。
cpp
int FObjectRegistry::Bind(UObject* Object)
{
// ... 构建 INSTANCE 表,串好三层元表链 ...
lua_pushvalue(L, -1);
const auto Ret = luaL_ref(L, LUA_REGISTRYINDEX); // 强引用,防止 Lua GC
ObjectRefs.Add(Object, Ret);
// ...
}
这里要留意一个不对称:Bind 上来的对象,Lua 端这一份是强引用;但 C++ 端这边并没有对 UObject 加 GC 根。也就是说,如果 UE 这边没有别人引用这个 UObject,UE GC 照样会回收它------Lua 只是"挂着一份替身",并不能阻止 C++ 端销毁它。
3.2 没 Bind 但被 Push 上来的 UObject
不是所有进 Lua 的 UObject 都会触发 Bind(比如它没实现 IUnLuaInterface)。这种情况走 Push:
cpp
void FObjectRegistry::Push(lua_State* L, UObject* Object)
{
// 在 ObjectMap 里找
const auto Type = lua_rawget(L, -2);
if (Type == LUA_TNIL)
{
// 没找到,新建 userdata
PushObjectCore(L, Object);
// 写入 ObjectMap (弱值)
ObjectRefs.Add(Object, LUA_NOREF); // 注意:是 LUA_NOREF
}
// ...
}
注意 LUA_NOREF------意味着 UnLua 不在注册表里强引用它 。这种 userdata 完全交由 Lua GC 管:业务代码不再用它,Lua 自然就把它清了,弱值表里那一项也跟着没了。ObjectRefs 里这条记录虽然还在,但等到 UE 删除 UObject 时会被一起清理(见下一节)。
四、UE 销毁 UObject 时:怎么避免 Lua 端的野指针
这是整套机制最容易出事的地方。UnLua 的应对方式是主动监听 + 主动失效化。
4.1 监听 UE 的删除事件
FLuaEnv 自己继承了 FUObjectArray::FUObjectDeleteListener:
cpp
class UNLUA_API FLuaEnv
: public FUObjectArray::FUObjectDeleteListener
{
virtual void NotifyUObjectDeleted(const UObjectBase* ObjectBase, int32 Index) override;
// ...
};
UE 的对象数组在销毁任一 UObject 时都会广播 NotifyUObjectDeleted。UnLua 收到通知后,做了一连串清理:
cpp
void FLuaEnv::NotifyUObjectDeleted(const UObjectBase* ObjectBase, int32 Index)
{
UObject* Object = (UObject*)ObjectBase;
PropertyRegistry->NotifyUObjectDeleted(Object);
FunctionRegistry->NotifyUObjectDeleted(Object);
if (Manager) Manager->NotifyUObjectDeleted(Object);
ObjectRegistry->NotifyUObjectDeleted(Object);
ClassRegistry->NotifyUObjectDeleted(Object);
EnumRegistry->NotifyUObjectDeleted(Object);
// ...
}
我们重点看 ObjectRegistry::NotifyUObjectDeleted,它实际就是调 Unbind:
cpp
void FObjectRegistry::Unbind(UObject* Object)
{
int32 Ref;
if (!ObjectRefs.RemoveAndCopyValue(Object, Ref)) return;
// 1) 把 ObjectMap 里这个对象对应的项摘掉,并把 INSTANCE 表压栈
RemoveFromObjectMapAndPushToStack(Object);
if (Ref == LUA_NOREF)
{
// 走 Push 路径上来的,没有 INSTANCE 表,只有 userdata
// 直接把 TWeakObjectPtr 主动 Reset 掉
((TWeakObjectPtr<UObject>*)Userdata)->Reset();
return;
}
// 2) 释放注册表里的强引用 ref
luaL_unref(L, LUA_REGISTRYINDEX, Ref);
// 3) 把 INSTANCE.Object 指向的 userdata 里的弱指针也 Reset
lua_pushstring(L, "Object");
lua_rawget(L, -2);
void* Userdata = GetUserdataFast(L, -1, nullptr, &bWeakObject);
((TWeakObjectPtr<UObject>*)Userdata)->Reset();
}
这里有一个看起来有点多此一举但其实非常关键的动作:主动 Reset userdata 里的 TWeakObjectPtr 。TWeakObjectPtr 不是已经能自动识别失效了吗?为什么还要手动 reset?
这是合理推论:手动 reset 是一道确定性的失效化操作 ,它让
Get()立刻返回 nullptr,而不依赖于 UE 内部弱指针的序列号机制(那个机制在跨帧、跨 GC 阶段的边角情况下行为不那么直观)。它给了 UnLua 一个简单的判定标准:IsReleasedPtr(ptr) == (ptr == nullptr)。
4.2 Lua 端访问已失效对象时的兜底
光 reset 还不够------Lua 业务代码不会主动去检查"我手上的 self 还活着吗"。所以 UnLua 在所有从 Lua 读 / 写属性、调函数的入口都做了 IsReleasedPtr 检查。比如读属性时(UnLuaLib.cpp):
cpp
if (UnLua::LowLevel::IsReleasedPtr(Self))
{
lua_pushnil(L);
return luaL_error(L, ...
TEXT("attempt to get ue property '%s' on released object"), ...);
}
效果是:原本会崩溃的野指针访问,现在变成一条Lua 错误。Lua 错误是可恢复的(pcall 能接住),不会带崩整个进程。这是用很小的代价换很大的稳定性。
4.3 容器里的"过期 UObject"
还有一种情况比较隐蔽:Lua 里持有一个 TArray<UObject*>,数组里某些元素被 UE 销毁了,但数组本身没有清理。Lua 拿到这个数组遍历时会怎样?
FObjectRegistry::Push 在最前面就有这道闸:
cpp
if (!UnLua::IsUObjectValid(Object))
{
luaL_error(L, "attempt to read invalid uobject ptr from lua, "
"maybe from containers like TArray.");
return;
}
这条注释直接点明了它防的就是容器里的过期指针。这种情况下 Object 不是 nullptr(指针还在数组里躺着),但它指向的 UObject 已经被销毁了------IsUObjectValid 通过 UE 的对象数组校验拦下来。
五、Lua 销毁了它持有的 UObject userdata:UE 这边怎么知道?
刚才讲的是 UE → Lua 方向的同步。反过来呢?如果 Lua 这边把一个 userdata 给 GC 了(比如某个临时变量出作用域),UE 端要不要知道?要知道的话,怎么知道?
UnLua 的做法是给每个 userdata 挂一个 __gc 元方法。当 Lua GC 回收这个 userdata 时,会自动调它,从而把信号传回 C++ 端。
5.1 元表上的 __gc
ClassRegistry 在为每个 UClass 创建元表时会注册:
cpp
lua_pushstring(L, "__gc");
lua_pushcfunction(L, UObject_Delete);
lua_rawset(L, -3);
UObject_Delete 的实现(LuaLib_Object.cpp):
cpp
int32 UObject_Delete(lua_State* L)
{
bool bTwoLvlPtr = false;
bool bClassMetatable = false;
bool bWeakObject = false;
void* Userdata = GetUserdata(L, 1, &bTwoLvlPtr, &bClassMetatable, &bWeakObject);
if (!Userdata) return 0;
if (bClassMetatable) return 0; // 元表本身被 gc,不是实例
if (!bWeakObject) return 0;
UObject* Object = ((TWeakObjectPtr<UObject>*)Userdata)->Get();
if (UnLua::LowLevel::IsReleasedPtr(Object)) return 0;
UnLua::FLuaEnv::FindEnvChecked(L)
.GetObjectRegistry()->NotifyUObjectLuaGC(Object);
return 0;
}
注意它只通知一下,没有真的销毁 UObject。理由很简单:UObject 的生命周期归 UE 管,Lua 这边的 userdata 被回收只代表"Lua 不再用了",不能据此推断 UObject 也该死。
5.2 NotifyUObjectLuaGC 实际做了什么?
cpp
void FObjectRegistry::NotifyUObjectLuaGC(UObject* Object)
{
Env->AutoObjectReference.Remove(Object);
}
它从 AutoObjectReference 里把这个 UObject 移除------也就是取消对它的强引用。这就引出下一个话题:那这个强引用一开始是谁加的?
六、AutoObjectReference 与 ManualObjectReference:把对象挂上 UE GC 根
前面一直在说"Lua 默认对 UObject 是弱引用"。但有些场景必须升级为强引用,否则 UE GC 会先一步把对象收走。UnLua 用两个 FObjectReferencer 来管理这种情况(ObjectReferencer.h):
cpp
class FObjectReferencer : public FGCObject
{
public:
void Add(UObject* Object) { ReferencedObjects.Add(Object); }
void Remove(UObject* Object) { ReferencedObjects.Remove(Object); }
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
Collector.AddReferencedObjects(ReferencedObjects);
}
private:
TSet<UObject*> ReferencedObjects;
};
它继承自 FGCObject------这意味着 UE 的 GC 在 mark 阶段会调 AddReferencedObjects,把这个集合里所有对象当成"被引用",从而不会被回收。
FLuaEnv 持有两个实例:
cpp
FObjectReferencer AutoObjectReference; // "UnLua_AutoReference"
FObjectReferencer ManualObjectReference; // "UnLua_ManualReference"
6.1 AutoObjectReference ------ 主要给 Delegate Handler 用
在 UnLua 这份代码里,AutoObjectReference.Add 的调用点全部位于 DelegateRegistry.cpp:
cpp
const auto Handler = CreateHandler(Ref, Info.Owner.Get(), SelfObject);
Handler->BindTo(Delegate);
Env->AutoObjectReference.Add(Handler);
这里要做一个事实层面的纠正(代码事实):在某些版本的 UnLua 文档里会说"Lua 里 NewObject 的对象会被 AutoObjectReference 自动接管",但就这份 UnLua 代码而言,
Global_NewObject里只是调了PushUObject推到 Lua 栈,并没有调用AutoObjectReference.Add。也就是说,Lua 里UE.NewObject(...)出来的 UObject,如果 C++ 那边没人把它接住(比如赋给某个UPROPERTY()),它会被 UE GC 当作无主对象回收掉。这是使用时容易踩坑的地方。
回到 Delegate 这条主线。为什么 Delegate Handler 必须挂 GC 根?因为 ULuaDelegateHandler 是 UnLua 用 NewObject 临时建的代理对象,UE 端没有任何 UPROPERTY() 持有它。如果不主动加根,下一次 GC 就会把它收掉,结果挂在原 Delegate 上的回调就成了无效引用。
挂根之后什么时候摘?两条路径:
-
Lua 端的
__gc:当 Lua 里持有这个 Handler 引用的 userdata 被 GC 时,UObject_Delete→NotifyUObjectLuaGC→AutoObjectReference.Remove(Object)。 -
DelegateRegistry自己的清理 :在PostGarbageCollect回调里检查Owner是否已失效,是的话主动 Remove:cppif (Pair.Key.SelfObject.IsStale()) { ToRemove.Add(Pair.Key); Env->AutoObjectReference.Remove(Pair.Value.Get()); }
6.2 ManualObjectReference ------ Lua 主动声明"我要握住这个对象"
这是给业务方留的一个出口:当 Lua 里就是想确保某个 UObject 不被 UE 回收,比如它要异步用一段时间、又没有合适的 C++ 持有者时,可以主动加一个手动引用。
这条路径由 FObjectRegistry::AddManualRef 实现:
cpp
void FObjectRegistry::AddManualRef(lua_State* L, UObject* Object)
{
// 检查 ManualRefProxyMap 里是不是已经有了
if (lua_rawget(L, -2) == LUA_TNIL)
{
Env->AddManualObjectReference(Object); // 加到 GC 根
auto Proxy = new(...) FManualRefProxy;
Proxy->Object = Object; // 内部是 TWeakObjectPtr
luaL_getmetatable(L, "UnLuaManualRefProxy");
lua_setmetatable(L, -2);
// 写入 ManualRefProxyMap
}
}
它返回一个 FManualRefProxy 给 Lua。业务持有这个 proxy 期间,UObject 就活着 ;一旦 Lua 把 proxy 丢了,proxy 被 GC,会触发 ReleaseManualRef:
cpp
static int ReleaseManualRef(lua_State* L)
{
auto Proxy = (FManualRefProxy*)lua_touserdata(L, 1);
auto Object = Proxy->Object.Get();
// 检查 ManualRefProxyMap 里这个 entry 是否已经空
if (lua_rawget(L, -2) == LUA_TNIL)
Env.RemoveManualObjectReference(Object);
return 0;
}
这套设计很优雅的地方在于:Lua 业务方不用记得"什么时候释放强引用",只要不再持有 proxy,Lua GC 自然会触发 __gc,强引用就被自动撤掉。
6.3 业务层的入口:UnLua.Ref / UnLua.Unref
上一节讲的 AddManualRef / RemoveManualRef 是 C++ 端的 API。Lua 业务方不会直接调它们------UnLua 在 UnLuaLib.cpp 里把它包了一层,注册成两个 Lua 全局函数:UnLua.Ref 和 UnLua.Unref,这是业务真正用到的入口。
6.3.1 源码上长什么样
实现极其简单,就是把调用透传到 FObjectRegistry:
cpp
static int Ref(lua_State* L)
{
const auto Object = GetUObject(L, -1);
if (!Object)
return luaL_error(L, "invalid UObject");
const auto& Env = FLuaEnv::FindEnvChecked(L);
Env.GetObjectRegistry()->AddManualRef(L, Object);
return 1; // 返回 1 个值:FManualRefProxy userdata
}
static int Unref(lua_State* L)
{
const auto Object = GetUObject(L, -1);
if (!Object)
return luaL_error(L, "invalid UObject");
const auto& Env = FLuaEnv::FindEnvChecked(L);
Env.GetObjectRegistry()->RemoveManualRef(Object);
return 0;
}
对应的 Lua 签名(来自 Content/IntelliSense/UnLua.lua):
lua
---Add or find a manual reference to specified UObject. This prevents the object from UE GC.
---@param Object UObject
---@return userdata @Proxy object of the reference.
--- The reference of UObject will be removed when proxy object deleted by lua GC.
function UnLua.Ref(Object) end
---Force remove all manual reference to specified UObject.
---@param Object UObject
function UnLua.Unref(Object) end
6.3.2 用法语义
可以这样理解:
UnLua.Ref(obj)------ "把这个对象挂到 UE GC 根上,并给我一个 proxy 拿着"。只要 proxy 没死,对象就不会被 UE GC 回收。UnLua.Unref(obj)------ "立即、强制把这个手动引用摘掉",不管 Lua 端 proxy 还在不在。
两者一个柔和一个刚性。多数业务场景用 Ref + 让 proxy 自然死亡的 RAII 模式就够了;只有特殊情况(比如想立刻让对象可以被回收,不等 Lua GC)才显式调 Unref。
6.3.3 典型业务场景
场景一:异步资源加载完成后立即保活
加载完成回调里拿到的 UObject,C++ 端往往没有强引用------如果不立即 Ref 就可能被下一轮 GC 收掉:
lua
function ResObject:OnSuccess(Request, Object)
self.LoadedAsset = Object
self.LoadedAssetRef = UnLua.Ref(Object) -- 立即保活
end
场景二:UE.NewObject 创建的对象需要保活
前面提到过,UE.NewObject 出来的对象 UnLua 不会自动加 GC 根,业务必须自己接住:
lua
local Obj = UE.NewObject(SomeClass, Owner)
self.ObjRef = UnLua.Ref(Obj) -- 不加这一行的话,下次 GC 时 Obj 可能就没了
场景三:跨帧/跨异步流程持有 UObject
lua
-- 拿到一个对象,但要在几秒后才真正用
local target = GetSomeUObject()
self.targetRef = UnLua.Ref(target)
DelayManager:DelaySeconds(5, function()
if UE.UObject.IsValid(self.targetRef) then
-- ... 安全使用 target ...
end
self.targetRef = nil -- 丢掉 proxy,Lua GC 之后会自动 Unref
end)
注意一个细节:就算调了 UnLua.Ref,使用前依然要 IsValid 检查 。Ref 只能防住"UE GC 主动收回"这条路径,对于"对象被显式 Destroy"、"关卡切换强制销毁"这些场景挡不住------它们会绕过 GC 直接让对象失效。
场景四:显式释放(用 Unref 而不是依赖 proxy GC)
lua
function SomeView:OnDestruct()
if self.PhotoTextureRef and UE.UObject.IsValid(self.PhotoTextureRef) then
UnLua.Unref(self.PhotoTextureRef) -- 立刻摘掉,不等 Lua GC
end
self.PhotoTextureRef = nil
end
UI 控件销毁时常用这种模式:业务上很明确"现在就要让 UE 可以回收它",不依赖 Lua GC 的时机。
6.3.4 易混点:proxy 是什么、能拿来干什么
UnLua.Ref(obj) 返回的不是 obj 本身,而是一个 FManualRefProxy userdata。这一点在用法上有几个隐含约束:
- proxy 不能当 UObject 使用------它只是个"持有凭证",调不了 UObject 的方法。如果要用对象,得继续用原来那个 obj 变量。
- 多次调
UnLua.Ref(同一个 obj)不会重复加根 :AddManualRef内部先在ManualRefProxyMap里查,已存在的话直接返回原 proxy。 - proxy 的生死直接决定保活的生死 :把 proxy 字段置 nil 是最常见的"主动释放"写法,Lua GC 触发 proxy 的
__gc时会自动调ReleaseManualRef。
6.4 两个 Referencer 的对比
| 维度 | AutoObjectReference |
ManualObjectReference |
|---|---|---|
| 谁来加 | UnLua 内部(主要是 Delegate) | 业务代码显式调用 UnLua.Ref / AddManualRef |
| 谁来减 | Lua 的 __gc + DelegateRegistry 周期性清理 |
proxy 被 Lua GC 时自动减,或显式 UnLua.Unref |
| 用途 | 临时代理对象的保活 | 业务级别的"我要这个对象别死" |
| 业务感知 | 透明 | 需要持有返回的 proxy |
七、加固防御:当多套机制对不齐时
理想状态下前面几节已经能覆盖大部分情况,但实际工程里总会有边角问题。UnLua 还加了一些额外防御:
7.1 Released 标记位
GetUserdataFast 在拆 userdata 时会检查 BIT_RELEASED_TAG:
cpp
if (UserdataDesc->tag & BIT_RELEASED_TAG)
Userdata = nullptr;
这个 tag 在 LuaDanglingCheck 里被设置------这是一套额外加的悬空指针防护,在某些场景把 userdata 主动标记为已释放,让所有读取都退化成 nullptr。
这是经验推测:
LuaDanglingCheck的命名和实现表明它是在标准 UnLua 之上的扩展机制,目的是抢在__gc之前就让 userdata "失效",避免某些异步路径下 Lua 还能拿到旧引用。
7.2 Lua 环境销毁时的清理
FLuaEnv::~FLuaEnv 会调:
cpp
AutoObjectReference.Clear();
ManualObjectReference.Clear();
这两步会把所有挂在 GC 根上的 UObject 全部释放,让它们在下一轮 UE GC 时被正常回收。同时 lua_close(L) 会触发所有 userdata 的 __gc,但因为 Env 已经在销毁中,那些 NotifyUObjectLuaGC 的副作用其实也无所谓了。
八、一张图把整套机制串起来
┌────────────────────────────────────┐
│ FLuaEnv │
│ │
│ ┌──────────────────────────────┐ │
│ │ AutoObjectReference │ │ → 挂 UE GC 根
│ │ (主要给 Delegate Handler) │ │ (FGCObject)
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ ManualObjectReference │ │ → 挂 UE GC 根
│ │ (业务主动 AddManualRef) │ │ (FGCObject)
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ FObjectRegistry │ │
│ │ ObjectRefs (UObject* → ref)│ │
│ │ UnLua_ObjectMap (弱值表) │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
UE 销毁回调 Lua __gc 业务主动调 AddManualRef
NotifyUObjectDeleted UObject_Delete │
│ │ │
▼ ▼ ▼
主动 Reset AutoRef.Remove 挂 / 摘 ManualRef
userdata 弱指针
│
▼
后续 IsReleasedPtr
兜底拦截
九、总结:怎么管理两个世界共享的对象?
回到最初的问题:UE 和 Lua 各有各的 GC,怎么协同?UnLua 给出的答案可以拆成五条原则:
- 默认弱引用 :Lua 持有 UObject 时用
TWeakObjectPtruserdata,不强行延寿。这避免了"Lua 反向绑架 UE GC"。 - 删除事件强同步 :注册 UE 的
FUObjectDeleteListener,UE 一旦销毁对象,立刻 Reset Lua 端的弱指针并清理映射表。Lua 不会拿到野指针。 - 入口处兜底 :所有从 Lua 访问 UObject 属性 / 函数的入口都加
IsReleasedPtr检查,把潜在崩溃降级成 Lua 错误。 - 必要时升级强引用 :通过
AutoObjectReference(UnLua 内部用)和ManualObjectReference(业务可用)两个FGCObject,在确实需要保活时把对象挂到 UE GC 根集。 - Lua GC 信号反向同步 :每个 userdata 挂
__gc元方法,Lua GC 触发时通知 C++ 端撤销保活引用。配合 proxy + 弱值表,让"业务释放引用 → 强引用自动撤销"这条链路全自动。
本质上 UnLua 选择了一种**"UE 主导生命周期 + Lua 端做投影"**的设计:UObject 还是由 UE 管,Lua 只在自己这边维护一份会自动失效的"映像",并通过显式机制(ManualRef)允许业务在必要时反向影响 UE 的生命周期。这种不对称的设计虽然牺牲了一些"对称的优雅",但避免了两套 GC 互相牵制可能产生的引用环和不可预测行为,是一个相当务实的工程取舍。
对应到日常开发,有几条经验性的注意事项:
- Lua 里
UE.NewObject出来的对象 ------就这份 UnLua 代码而言,UnLua 不会自动给它加根(这一点在不同版本的 UnLua 之间有差异),最好立即把它赋给某个UPROPERTY()字段、或者用UnLua.Ref显式保活,否则下一轮 UE GC 就没了。 - Lua 里订阅 Delegate ------不用担心 Handler 被回收,UnLua 自己会用
AutoObjectReference兜住。 - 跨帧异步使用 UObject ------必要时显式
UnLua.Ref(obj)把 proxy 存起来,用完释放(最简单:proxy 字段置 nil,让 Lua GC 自然撤引用;或显式UnLua.Unref(obj))。 - 看到
attempt to xxx on released object这类报错------别慌,这是 UnLua 的兜底机制在工作,意味着你拿了一个已经被 UE 销毁的 UObject,去顺着调用栈查谁持有了过期引用即可。