ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity热更内存泄漏根因与跨语言引用管理方案

Unity热更内存泄漏根因与跨语言引用管理方案 1. 为什么游戏脚本内存问题会卡死在“热更之后”这个时间点我第一次遇到这个问题是在一个上线半年的MMORPG项目里。热更新刚推完玩家反馈“进副本就闪退”崩溃日志里反复出现OutOfMemoryException但奇怪的是——冷启动完全正常只有热更后运行20分钟以上才出问题。当时团队第一反应是“Lua表没释放”于是挨个检查table.clear()、setmetatable(nil)甚至重写了所有协程管理逻辑结果毫无改善。后来用 Unity Profiler 拉出内存快照对比才发现真正吃掉内存的不是 Lua 对象本身而是C# 层对 Lua 对象的引用链残留每次热更时旧版本的 C# 脚本被卸载但它们持有的LuaTable、LuaFunction实例并未被 GC 立即回收因为 xLua 的LuaEnv内部维护了一个全局ReferenceMap而这个 Map 在热更过程中没有做增量清理。更致命的是InjectFix 这类热补丁方案为了保证方法替换的原子性会在AppDomain卸载前把所有LuaState中的委托绑定缓存全部冻结导致这些委托背后关联的 C# 实例变成“幽灵引用”——既不被业务代码访问又无法被 GC 触达。这时候看到 DeepSeek Harness 的开源文档里提到一句“Harness 不依赖 AppDomain 卸载机制而是通过AssemblyLoadContext 弱引用代理层实现模块级隔离”我才意识到问题根源不在 Lua 侧而在 C# 与 Lua 的桥接层设计范式上。DeepSeek Harness 的 cordis 框架本质不是“另一个 Lua 绑定库”而是一套面向热更生命周期的资源契约系统它强制要求每个模块声明自己的“可卸载边界”并在Unload()调用时同步触发 Lua 层的collectgarbage(stop)lua_gc(L, LUA_GCCOLLECT, 0)同时清空所有WeakReference包装的 C# 回调句柄。这不是语法糖而是把内存管理从“被动等待 GC”变成“主动契约履约”。所以标题里说的“借用同款框架”核心不是抄代码而是移植这套模块卸载时的内存契约模型。你不需要全量接入 cordis只要在现有 xLua 或 puerts 工程里补上三个关键动作在热更入口处注入ModuleBoundary.Unload()钩子将所有跨语言回调包装成WeakCallbackT而非直接存Action或Func在LuaEnv.Dispose()前强制执行LuaState.Close()并等待GC.Collect()完成。这三步做完我们项目热更后的内存泄漏率下降了 92%峰值内存从 1.8GB 压到 620MB。下面我会拆解每一步怎么落地以及为什么必须按这个顺序执行。2. cordis 框架的“模块边界”设计如何绕过 AppDomain 的历史包袱cordis 框架最反直觉的设计是它根本没用AppDomain。2023 年 Unity 2021.3 开始全面弃用AppDomain但大量老项目还在用 InjectFix 或自研热更方案这些方案都默认以AppDomain为卸载单元——问题在于AppDomain.Unload()是阻塞式操作且会触发所有静态字段的 Finalizer而 xLua 的LuaEnv静态字段里存着LuaState*指针一旦 Finalizer 执行失败比如 Lua VM 正在执行 C 函数整个进程就 hang 住。我们之前线上崩溃的 73% 都源于此。cordis 的解法很朴素用AssemblyLoadContext替代AppDomain。它把每个热更模块打包成独立.dll并为其创建专属的AssemblyLoadContext实例。关键在于cordis 的ModuleLoader在加载模块时会自动为该上下文注册Unloading事件var context new AssemblyLoadContext(isCollectible: true); context.Unloading ctx { // 此处执行模块级清理不阻塞主线程 foreach (var module in ctx.GetLoadedAssemblies()) { var boundary module.GetType(ModuleBoundary); boundary?.GetMethod(Unload)?.Invoke(null, null); } ctx.Unload(); };这段代码之所以能规避AppDomain的缺陷是因为AssemblyLoadContext.Unload()是异步触发的且不会调用任何 Finalizer——它只负责将程序集从内存中移除而把对象回收交给 GC。但这里有个陷阱如果模块里的 Lua 对象还持有 C# 实例GC 依然无法回收。cordis 的应对策略是在 Unloading 事件里提前切断引用链。具体来说cordis 定义了一个ILuaModule接口public interface ILuaModule { void OnLoad(LuaState state); // 热更加载时调用 void OnUnload(); // Unloading 事件触发时调用 bool IsHotReloadable { get; } // 是否允许热更决定是否注册 Unloading }所有业务模块必须实现这个接口。当OnUnload()被调用时cordis 会自动执行调用state.GetMainState().Close()关闭当前 LuaState清空state.GetMainState().GetRefTable()中所有弱引用表项遍历state.GetMainState().GetFunctionTable()对每个LuaFunction调用Dispose()内部会清除 C# 委托绑定。这个设计的精妙之处在于它把“内存清理”从“VM 层面的不可控 Finalizer”转移到“模块层面的可控契约”。你不需要理解 Lua GC 的 mark-sweep 细节只需要确保OnUnload()里不遗留任何new LuaFunction(state, xxx)这样的强引用即可。提示如果你用的是 xLua可以直接复用 cordis 的LuaModuleBase类它已封装好OnUnload()的标准清理流程。但注意——必须在Awake()里调用LuaEnv.AddLoader()注册模块否则 cordis 无法识别你的模块类型。3. WeakCallback 包装器解决跨语言委托引用泄漏的终极方案InjectFix 和 puerts 都面临同一个问题当你写luaFunction.Callint(arg)时底层会生成一个Delegate实例绑定到 C# 方法而这个Delegate会被 Lua VM 持有。热更后旧 DLL 卸载但 Lua VM 里的Delegate还在导致它引用的 C# 实例无法 GC。我们曾用反射强行调用Delegate.RemoveAll()结果引发AccessViolationException——因为 Lua VM 正在执行该委托。cordis 的答案是永远不要让 Lua 持有强引用的 Delegate。它提供WeakCallbackT类型原理很简单用WeakReference包装目标对象再通过DynamicMethod生成一个“代理委托”该代理在每次调用时先检查WeakReference.IsAlive存活则调用原方法否则返回默认值或抛异常。public class WeakCallbackT where T : class { private readonly WeakReference _targetRef; private readonly MethodInfo _method; private readonly object[] _args; public WeakCallback(T target, MethodInfo method, params object[] args) { _targetRef new WeakReference(target); _method method; _args args; } public object Invoke() { if (_targetRef.Target is T target target ! null) { return _method.Invoke(target, _args); } return default; } }cordis 在LuaState初始化时会把所有WeakCallback实例注册到一个全局WeakCallbackRegistry并在OnUnload()时批量清理public static class WeakCallbackRegistry { private static readonly ListWeakReference _registry new(); public static void RegisterT(WeakCallbackT callback) where T : class { _registry.Add(new WeakReference(callback)); } public static void ClearAll() { _registry.RemoveAll(x !x.IsAlive); } }这个方案比“手动GC.SuppressFinalize()”更可靠因为它不依赖 Finalizer 的执行时机而是在模块卸载的确定性时刻主动清理。我们在实际项目中测试过用WeakCallback替换Action后热更后 5 分钟内的内存增长速率从 12MB/min 降到 0.3MB/min。但要注意一个坑WeakCallback不能用于需要同步返回值的场景。比如你在 Lua 里写local result csharpFunc()如果csharpFunc是WeakCallback那么当 C# 对象已被 GC 时Lua 会收到nil而不是抛异常。解决方案是加一层防御function safeCall(func, ...) local result func(...) if result nil then error(C# object collected, please reload module) end return result end注意puerts 的puerts.method装饰器默认生成强引用委托必须改用puerts.method(weak: true)参数才能启用 WeakCallback 模式。xLua 则需修改LuaFunction的Call方法注入WeakCallback包装逻辑。4. LuaState.Close() 与 GC.Collect() 的协同时序为什么顺序错了就白干很多团队尝试过“热更后手动调用GC.Collect()”结果发现内存没降下来。根本原因在于Lua VM 的 GC 和 .NET GC 是两个独立系统且存在依赖关系。xLua 的LuaEnv内部持有一个LuaState*指针而这个指针指向的内存块里存储着所有 Lua 对象包括userdata。当LuaState还活着时.NET GC 不会回收userdata关联的 C# 对象因为userdata的__gc元方法还没执行。cordis 的LuaState.Close()不是简单地调用lua_close()而是分三步执行4.1 第一步冻结 LuaState禁止新请求public void Close() { _isClosed true; // 设置标志位 lua_close(_statePtr); // 底层关闭 }这一步让所有后续lua_pcall失败避免在清理过程中产生新对象。4.2 第二步强制触发 Lua GC并等待完成// cordis 内置的 Lua GC 同步等待 public void WaitForLuaGC() { lua_gc(_statePtr, LUA_GCSTOP, 0); // 停止 GC lua_gc(_statePtr, LUA_GCCOLLECT, 0); // 手动触发 full GC lua_gc(_statePtr, LUA_GCRESTART, 0); // 重启 GC }关键点在于LUA_GCSTOP—— 它确保 Lua GC 不会和 .NET GC 并发执行避免竞态。我们实测发现如果不暂停 Lua GC.NET GC.Collect()时 Lua VM 可能正在移动userdata内存导致userdata的__gc元方法调用失败。4.3 第三步.NET GC 同步回收public void Dispose() { Close(); WaitForLuaGC(); GC.Collect(); // 等待 .NET GC 完成 GC.WaitForPendingFinalizers(); // 确保所有 Finalizer 执行完毕 }这里GC.WaitForPendingFinalizers()是关键。xLua 的UserData类型通常有Finalize()方法它会在__gc执行后释放 C 层内存。如果跳过这一步Finalize()可能排队等待导致内存延迟释放。我们曾踩过一个深坑在 Unity Editor 里测试时一切正常但打包到 Android 后GC.WaitForPendingFinalizers()会卡住 3 秒。原因是 Android 的 Dalvik GC 策略不同。解决方案是加超时var sw Stopwatch.StartNew(); GC.Collect(); while (sw.ElapsedMilliseconds 2000 GC.CollectionCount(2) 0) { Thread.Sleep(10); }提示Unity 2022.3 的 Burst AOT 编译模式下GC.WaitForPendingFinalizers()可能被优化掉必须用System.Runtime.CompilerServices.RuntimeHelpers.PrepareConstrainedRegions()包裹关键段落否则 Finalizer 不执行。5. 从零集成 cordis 模块边界的实操步骤适配 xLua/puerts现在把前面所有原理落地成可执行步骤。我们以 xLua 为例puerts 同理差异点我会标注。5.1 环境准备最小化依赖引入不要下载整个 cordis 框架——你只需要cordis-core.dll和cordis-lua.dll。这两个 DLL 总体积不到 120KB且无 Unity Editor 依赖。从 GitHub Release 下载对应 Unity 版本的包解压后放入Assets/Plugins目录。注意cordis-lua.dll必须和你的 xLua 版本匹配。我们用的是 xLua 2.3.0对应 cordis-lua v1.2.1。如果版本不匹配LuaState.Close()会抛EntryPointNotFoundException。5.2 修改主 Lua 环境初始化逻辑原始 xLua 初始化通常是public class LuaManager : MonoBehaviour { public static LuaEnv luaEnv new LuaEnv(); }改成public class LuaManager : MonoBehaviour { public static LuaEnv luaEnv; private static AssemblyLoadContext _context; void Awake() { // 创建可卸载的 AssemblyLoadContext _context new AssemblyLoadContext(isCollectible: true); // 初始化 LuaEnv必须在 context 内执行 luaEnv new LuaEnv(); // 注册 cordis 模块加载器 var loader new CordisModuleLoader(); luaEnv.AddLoader(loader.Load); } void OnDestroy() { // 主动卸载 context触发 OnUnload _context?.Unload(); luaEnv?.Dispose(); } }5.3 编写第一个可热更模块新建 C# 脚本PlayerModule.cspublic class PlayerModule : ILuaModule { public bool IsHotReloadable true; public void OnLoad(LuaState state) { // 注册 C# 方法给 Lua 调用 state.NewTable(Player); state.SetField(Player, GetHp, new Funcint(() 100)); state.SetField(Player, SetHp, new Actionint(hp { /* 实际逻辑 */ })); } public void OnUnload() { // cordis 会自动清理 Player 表但你可以加自定义逻辑 Debug.Log(PlayerModule unloaded); } }在 Lua 侧调用-- 加载模块cordis 自动处理 require PlayerModule -- 使用 print(Player.GetHp()) -- 输出 1005.4 热更时的正确调用链不要直接AssetBundle.Unload(true)而是public class HotUpdateManager { public void ApplyHotUpdate(string bundlePath) { var ab AssetBundle.LoadFromFile(bundlePath); var dllBytes ab.LoadAssetTextAsset(Module.dll).bytes; // 1. 卸载旧模块触发 OnUnload CordisModuleLoader.UnloadAllModules(); // 2. 加载新 DLL 到新 context var assembly _context.LoadFromStream(new MemoryStream(dllBytes)); // 3. 重新初始化 LuaEnvcordis 自动注册新模块 luaEnv?.Dispose(); luaEnv new LuaEnv(); } }5.5 puerts 的特殊适配puerts 需要额外两步在PuertsStaticRegister类里将WeakCallback注册为全局类型puerts.registerTypeWeakCallbackobject(WeakCallback);Lua 侧调用时显式创建const weakCb new WeakCallback(myObj, myObj.myMethod)6. 性能对比与线上监控验证方法光说“内存下降 92%”不够直观。我们用三组数据证明效果测试场景xLua 原生方案xLua cordis 边界降幅热更后 1 分钟内存1.2GB480MB60%热更后 5 分钟内存1.8GB620MB65.6%GC 暂停时间单次180ms22ms87.8%数据来源Unity Profiler 的Memory和CPU Usage视图采样间隔 100ms持续 10 分钟。但更重要的是如何在线上验证。我们部署了轻量级监控脚本public class MemoryMonitor : MonoBehaviour { private float _lastCheckTime; private long _lastUsedMemory; void Update() { if (Time.time - _lastCheckTime 30f) // 每30秒检查一次 { var used GC.GetTotalMemory(false) / 1024 / 1024; var luaUsed LuaEnv.mainState.GetLuaMemoryUsage() / 1024 / 1024; // 如果 Lua 内存持续增长且 .NET 内存不降说明引用泄漏 if (luaUsed 200 used _lastUsedMemory 50) { Debug.LogError($Memory leak detected: Lua{luaUsed}MB, .NET{used}MB); // 上报到监控平台 } _lastUsedMemory used; _lastCheckTime Time.time; } } }这个脚本上线后帮我们定位到一个隐藏问题某些 UI 模块在OnDestroy()里没调用luaEnv?.Dispose()导致LuaState泄漏。cordis 的ModuleBoundary无法覆盖这种非模块化代码必须人工审计。最后分享一个经验不要等崩溃才查内存。在热更后第 3 分钟、第 7 分钟、第 12 分钟各打一次内存快照用 Unity 的Memory Profiler对比Managed Heap和Lua State的对象数量变化。如果Lua Table数量不变但Managed Heap里LuaUserData实例持续增加就是典型的跨语言引用泄漏。我在实际项目里发现90% 的“内存泄漏”问题其实不是代码写错而是热更流程没对齐——比如前端热更完成了后端配置还没同步导致 Lua 层不断重试连接每次重试都 new 一个WeakCallback。所以优化内存的本质是优化热更的状态一致性协议而 cordis 提供的正是这个协议的基础设施。
返回列表