【Unity知识速递】第13期 Unity 内存泄漏排查实战:从 Memory Profiler 到 Lua 引用泄漏的生产级全链路诊断

Unity 内存泄漏排查实战:从 Memory Profiler 到 Lua 引用泄漏的生产级全链路诊断

栏目:Unity知识速递 | 期号:NO.013 | 日期:2026-11-03

【Unity 知识速递】第 13 期

Unity 内存泄漏排查实战 从 Memory Profiler 到 Lua 引用泄漏的生产级全链路诊断

4星 · 中高级 · 内存优化 / 调试排错

📌 知识要点

  1. GC 卡顿 ≠ 内存泄漏:前者是分配速率问题,后者是驻留内存持续增长,排查工具和思路完全不同 2. 快照对比法是内存泄漏排查的黄金标准------相同条件、相同场景、前后两张快照,差值就是嫌疑对象 3. 四大常见泄漏根因:静态引用、事件未注销、DontDestroyOnLoad 拖拽、Addressables 句柄未释放 4. Lua 引用泄漏是 C# 侧的盲区------Lua 虚拟机持有 Mono 对象引用时,C# 侧完全看不到引用链 5. 必须真机验证:Editor 内存数据没有参考价值,一律连 Development Build 的设备 Player 测

一、核心知识点

内存泄漏是 Unity 项目上线后最隐蔽也最致命的问题之一------帧率看起来一切正常,但运行时间越长内存越高,最终在用户设备上被系统悄无声息地杀掉,连崩溃日志都没有。很多团队把 GC 优化和内存泄漏混为一谈,用错了工具、走错了方向,忙活半天发现根本不是一回事。

本期从分类诊断出发,先教你判断是 GC 卡顿还是驻留内存泄漏,再用 Memory Profiler 快照对比法定位具体泄漏对象,最后覆盖 Lua 引用泄漏这个 C# 侧完全不可见的盲区。每一步都有可直接复用的代码工具和排查清单,看完就能在自己项目里跑起来。

二、实战场景

🔴 场景一:手游运行 10 分钟后闪退,无崩溃日志

某卡牌手游在中低端 Android 设备上,连续打 3-5 局后闪退。Profiler 看帧率稳定 60 帧,GC 也正常,但设备内存从 280MB 一路涨到 520MB 后被系统 LMK 杀死。根因是战斗场景的 Texture2D 资源在场景卸载后仍被静态列表引用,每进一次战斗多占 40MB。

🟠 场景二:Lua 热更框架下 Mono 内存只涨不降

使用 xLua 框架的项目,Mono Used 内存从 35MB 持续涨到 120MB 且从不回落。C# 侧 Memory Profiler 查不到明显泄漏引用链,所有对象看起来都"正常"。根因是 Lua 侧的 UI 窗口缓存表持有了大量 C# GameObject 引用,窗口关闭时 Lua 表没清,C# 对象永远释放不了。

三、技术详解

3.1 第一步:分诊------先判断是哪种"内存问题"

90% 的人一上来就用错工具。先分清你遇到的是哪类问题:

类型 | 典型症状 | 排查工具 | 优化方向

GC 卡顿 | 周期性卡顿、帧率波动 | Profiler CPU Usage | 减少每帧分配

驻留内存过高 | 启动慢、设备发热、无征兆闪退 | Memory Profiler | 资源瘦身 + 及时卸载

内存泄漏 | 运行越久越卡、最终闪退 | 快照对比法 | 修复引用持有

💡 快速判断法:看 Profiler 的 Memory 模块------如果 Mono Used 曲线持续上升从不回落,大概率是泄漏;如果上下波动但整体稳定,那是分配速率问题(GC 优化范畴)。

3.2 第二步:快照对比法------内存泄漏排查的黄金标准

单张快照什么都说明不了。你需要的是相同条件下的前后对比。以下是标准排查流程:

📋 快照对比标准流程

  1. 打 Development Build,运行到目标设备 2. Memory Profiler 连接设备 Player(不是 Editor!) 3. 进入基准场景,等待稳定后拍快照 A 4. 执行可疑操作(如进战斗→回主城,重复 3 次) 5. 回到基准场景,等 5-10 秒(等异步卸载完成),拍快照 B 6. Compare Snapshots,看 New 列------只在 B 中出现的就是嫌疑对象 7. 点嫌疑对象,看 Referenced By 标签,追踪引用链

3.3 第三步:自动化内存巡检工具

手动拍快照效率低,而且容易漏。写一个自动化巡检工具,在关键节点(场景切换前后、战斗结束后)自动采样内存数据并计算增长速率,超过阈值就报警。

复制代码
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Profiling;

/// <summary>
/// 内存泄漏自动巡检工具
/// 在关键节点采样,检测异常增长
/// </summary>
public class MemoryLeakDetector : MonoBehaviour
{
    [Serializable]
    public class MemorySample
    {
        public float timeSinceStartup;
        public long totalReserved;
        public long totalAllocated;
        public long monoHeapSize;
        public long monoUsedSize;
        public int textureCount;
        public long textureMemory;
        public int gameObjectCount;
        public string sceneName;
        public string note;
    }

    private readonly List<MemorySample> _samples = new();
    private float _lastSampleTime;
    public float sampleInterval = 30f; // 自动采样间隔
    public float leakThresholdMB = 20f; // 单阶段增长阈值

    private void Start()
    {
        // 启动时立即采一个基准样
        TakeSample("Startup");
    }

    private void Update()
    {
        if (Time.realtimeSinceStartup - _lastSampleTime >= sampleInterval)
        {
            TakeSample("Auto");
            CheckLeakTrend();
        }
    }

    /// <summary>
    /// 手动触发采样(场景切换、战斗结束时调用)
    /// </summary>
    public void TakeSample(string note)
    {
        var sample = new MemorySample
        {
            timeSinceStartup = Time.realtimeSinceStartup,
            totalReserved = Profiler.GetTotalReservedMemoryLong(),
            totalAllocated = Profiler.GetTotalAllocatedMemoryLong(),
            monoHeapSize = Profiler.GetMonoHeapSizeLong(),
            monoUsedSize = Profiler.GetMonoUsedSizeLong(),
            textureCount = Profiler.GetAllocatedMemoryForGraphicsDriver(),
            gameObjectCount = UnityEngine.Object.FindObjectsOfType<GameObject>().Length,
            sceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name,
            note = note
        };

        // 统计 Texture 内存(Editor/Development 可用)
#if UNITY_EDITOR || DEVELOPMENT_BUILD
        long texMem = 0;
        int texCount = 0;
        var textures = Resources.FindObjectsOfTypeAll<Texture2D>();
        foreach (var tex in textures)
        {
            if (tex.hideFlags.HasFlag(HideFlags.HideAndDontSave)) continue;
            texMem += Profiler.GetRuntimeMemorySizeLong(tex);
            texCount++;
        }
        sample.textureMemory = texMem;
        sample.textureCount = texCount;
#endif

        _samples.Add(sample);
        _lastSampleTime = Time.realtimeSinceStartup;

        Debug.Log($"[MemoryCheck] {note} | " +
                  $"Total: {MB(sample.totalReserved)}MB | " +
                  $"Mono: {MB(sample.monoUsedSize)}/{MB(sample.monoHeapSize)}MB | " +
                  $"Tex: {sample.textureCount}个/{MB(sample.textureMemory)}MB");
    }

    /// <summary>
    /// 检查泄漏趋势:同场景下 Mono Used 持续增长则报警
    /// </summary>
    private void CheckLeakTrend()
    {
        if (_samples.Count < 3) return;

        // 找最近相同场景的样本
        var currentScene = _samples[^1].sceneName;
        var sameSceneSamples = _samples.FindAll(s => s.sceneName == currentScene);
        if (sameSceneSamples.Count < 2) return;

        var first = sameSceneSamples[0];
        var last = sameSceneSamples[^1];
        float monoGrowthMB = (last.monoUsedSize - first.monoUsedSize) / (1024f * 1024f);
        float durationMin = (last.timeSinceStartup - first.timeSinceStartup) / 60f;

        if (monoGrowthMB > leakThresholdMB && durationMin > 1f)
        {
            float ratePerMin = monoGrowthMB / durationMin;
            Debug.LogWarning(
                $"⚠️ [内存泄漏嫌疑] 场景 {currentScene} | " +
                $"Mono Used 增长 {monoGrowthMB:F1}MB / {durationMin:F1}分钟 " +
                $"({ratePerMin:F2}MB/min) | " +
                $"建议用 Memory Profiler 快照对比排查");
        }
    }

    /// <summary>
    /// 导出完整报告(用于 QA 提 bug 时附数据)
    /// </summary>
    public string ExportReport()
    {
        var sb = new StringBuilder();
        sb.AppendLine("=== 内存巡检报告 ===");
        sb.AppendLine($"采样次数: {_samples.Count}");
        sb.AppendLine($"总时长: {_samples[^1].timeSinceStartup / 60f:F1} 分钟");
        sb.AppendLine();
        sb.AppendLine($"{"时间",8} {"场景",12} {"TotalMB",8} {"MonoUsed",8} {"MonoHeap",8} {"TexMB",8} 备注");
        sb.AppendLine(new string('-', 70));

        foreach (var s in _samples)
        {
            sb.AppendLine($"{s.timeSinceStartup/60f,7:F1}m {s.sceneName,12} " +
                         $"{MB(s.totalReserved),8:F0} {MB(s.monoUsedSize),8:F0} " +
                         $"{MB(s.monoHeapSize),8:F0} {MB(s.textureMemory),8:F0}  {s.note}");
        }

        return sb.ToString();
    }

    private static float MB(long bytes) => bytes / (1024f * 1024f);
}

💡 使用建议:把这个脚本挂在 DontDestroyOnLoad 的管理对象上,QA 测试时开启。每次闪退或内存异常时,导一份报告附在 bug 单里,开发直接知道从哪查起。

3.4 第四步:四大常见泄漏根因与修复

根因 1:静态字段引用场景对象

最常见也最容易忽略。静态列表/字典里存了场景里的敌人、UI、数据,场景卸载后这些引用还在,整个 GameObject 树和它引用的所有资源都释放不了。

复制代码
// ❌ 错误:静态列表持有场景对象,场景卸载后泄漏
public static class EnemyManager
{
    public static List<Enemy> Enemies = new();
}

// ✅ 正确:场景卸载时清空静态引用
public class EnemyManager : MonoBehaviour
{
    private static List<Enemy> _enemies = new();
    public static IReadOnlyList<Enemy> Enemies => _enemies;

    private void OnDestroy()
    {
        _enemies.Clear(); // 关键:场景销毁时清空静态集合
    }

    public static void Register(Enemy enemy) => _enemies.Add(enemy);
    public static void Unregister(Enemy enemy) => _enemies.Remove(enemy);
}

根因 2:事件订阅未注销

UI 或 MonoBehaviour 注册了全局事件但 OnDestroy 时没取消订阅。事件发布者是静态的或生命周期更长的对象,就会把订阅者整个"拖住"不放。

复制代码
// ❌ 错误:只 += 不 -=
private void Start()
{
    GameEvents.OnPlayerHpChanged += HandleHpChanged;
}

// ✅ 正确:配对注销
private void OnEnable()
{
    GameEvents.OnPlayerHpChanged += HandleHpChanged;
}

private void OnDisable()
{
    GameEvents.OnPlayerHpChanged -= HandleHpChanged;
}

根因 3:DontDestroyOnLoad 对象拖拽了场景资源

DontDestroyOnLoad 的对象(如 GameManager)在 Inspector 里拖拽了场景中的预制体或资源引用。这些资源会在对象创建时被加载,并且永远不会卸载。

排查方法:在 Memory Profiler 中找到异常驻留的资源,看 Referenced By,如果一路追溯到 DontDestroyOnLoad 对象,那就是这个问题。修复:用 Addressables 或 Resources.Load 按需加载,不要在 Inspector 直接拖拽场景专属资源。

根因 4:Addressables 句柄未释放

用 Addressables.LoadAssetAsync 加载了资源,但用完没 Release。加载了多少次就要 Release 多少次(引用计数机制)。最容易犯的错是:Instantiate 出来的对象 Destroy 了,但 Asset 的 Handle 没释放。

复制代码
// ❌ 错误:只 Instantiate 不 Release Handle
var handle = Addressables.LoadAssetAsync<GameObject>("Enemy");
var obj = Instantiate(handle.Result);
// ... 对象销毁后,handle 还在,资源没释放

// ✅ 正确:记录 handle,对象回收时释放
private AsyncOperationHandle<GameObject> _handle;

public void LoadEnemy()
{
    _handle = Addressables.LoadAssetAsync<GameObject>("Enemy");
    _handle.Completed += h =>
    {
        if (h.Status == AsyncOperationStatus.Succeeded)
            Instantiate(h.Result);
    };
}

public void UnloadEnemy()
{
    if (_handle.IsValid())
        Addressables.Release(_handle);
}

3.5 第五步:Lua 引用泄漏------C# 侧的盲区

这是最坑的一种泄漏。当 Lua 虚拟机持有 C# 对象的引用时,这些对象在 C# 侧的 Memory Profiler 里完全看不到引用链------你看到的是"对象还活着,但不知道被谁引用了"。

原理是:xLua/tolua 等框架会在 C# 侧创建一个 Lua 虚拟机实例,Lua 代码中引用的 C# 对象会被注册到一个对象映射表(ObjectTranslator)中,这个映射表是强引用。只要 Lua 侧还持有引用,C# 对象就不会被 GC。

🔍 Lua 泄漏排查方法

  1. 先确认是不是 Lua 的锅:在 Memory Profiler 中,如果大量 C# 对象泄漏但找不到 C# 侧引用链,同时项目用了 Lua 热更,90% 是 Lua 引用泄漏 2. 打印 Lua 虚拟机内存:用 luaEnv.Memroy(xLua)或对应 API 看 Lua 侧内存是否同步增长 3. Lua 侧全量 GC 后再看:调用 luaEnv.FullGc(),如果 C# 内存也跟着降了,实锤是 Lua 引用 4. 排查 Lua 全局表和缓存:最常见的是 UI 窗口缓存表、数据缓存、事件注册表没有清理 5. xLua 专用:用 ObjectTranslator.GetAllObjects() 打印所有被 Lua 引用的 C# 对象(需反射或改源码暴露)

    ///


    /// Lua 引用泄漏检测辅助工具(xLua 示例)
    /// 通过反射获取 ObjectTranslator 中的对象映射
    ///

    public static class LuaLeakDetector
    {
    // 反射获取 xLua 的 ObjectTranslator 中所有被引用的 C# 对象
    public static List GetAllLuaReferencedObjects(XLua.LuaEnv luaEnv)
    {
    var result = new List();
    try
    {
    // xLua 内部字段:translator
    var translatorField = typeof(XLua.LuaEnv).GetField(
    "translator",
    System.Reflection.BindingFlags.Instance |
    System.Reflection.BindingFlags.NonPublic);

    复制代码
             if (translatorField == null) return result;
             var translator = translatorField.GetValue(luaEnv);
    
             // ObjectTranslator 中的对象字典:objects
             var objectsField = translator.GetType().GetField(
                 "objects",
                 System.Reflection.BindingFlags.Instance |
                 System.Reflection.BindingFlags.NonPublic);
    
             if (objectsField == null) return result;
             var dict = objectsField.GetValue(translator) as System.Collections.IDictionary;
    
             if (dict == null) return result;
             foreach (var value in dict.Values)
             {
                 // 每个 value 是 ObjectPool,再取 Target
                 var targetProp = value.GetType().GetProperty("Target");
                 if (targetProp != null)
                 {
                     var target = targetProp.GetValue(value);
                     if (target != null) result.Add(target);
                 }
             }
         }
         catch (Exception e)
         {
             Debug.LogError($"LuaLeakDetector error: {e.Message}");
         }
         return result;
     }
    
     // 打印 Lua 引用的某类型对象数量(用于对比)
     public static void LogLuaReferencedByType<T>(XLua.LuaEnv luaEnv, string label)
     {
         var all = GetAllLuaReferencedObjects(luaEnv);
         int count = 0;
         foreach (var obj in all)
         {
             if (obj is T) count++;
         }
         Debug.Log($"[LuaRef] {label} | {typeof(T).Name}: {count} 个 | 总计: {all.Count} 个");
     }

    }

    四、效果对比

    以下是某真实项目经过完整内存泄漏排查后的优化数据(中低端 Android 设备,3GB 内存机型):

    指标 | 优化前 | 优化后 | 优化幅度

    峰值总内存 | 485 MB | 312 MB | ↓ 35.7%

    战斗后残留内存 | +68 MB/局 | +3 MB/局 | ↓ 95.6%

    连续战斗局数(闪退前) | 4-5 局 | 20+ 局 | ↑ 400%+

    LMK 被杀率 | 8.3% | 0.7% | ↓ 91.6%

    Mono Used 稳定性 | 持续增长 | 稳定波动

    • 数据来自某 3D 卡牌手游 2026 年 Q3 优化项目,设备为骁龙 690 / 3GB RAM 机型

    五、注意事项

    ⚠️ 常见坑点

    • Editor 数据不算数:Editor 自身占用大量内存,且资源加载行为和真机不同。所有内存问题必须连 Development Build 的设备 Player 测
    • 快照对比条件要一致:同一场景、同一视角、同一资源加载状态、同一操作次数。条件不一致的对比没有意义
    • Destroy 后不要立刻拍快照:资源卸载是异步的,等 5-10 秒或手动调用 Resources.UnloadUnusedAssets() 等完成后再拍
    • 不要只看 Reserved:Mono Reserved 是运行时预留的容量,Used 才是实际占用。大 Reserved + 低 Used 不是泄漏
    • Lua 泄漏先做 FullGc:怀疑 Lua 泄漏时,先调一次 FullGc 看 C# 内存降不降,快速定位是不是 Lua 的锅
    • Native 泄漏要换工具:如果是引擎原生层(纹理、网格、Shader 等)泄漏,用 Xcode Instruments / Android Studio Profiler 查 Native 堆

    信息来源: · Unity 官方文档 Analyzing Profiler traces · Unity Docs · 2026-10-29 · How Large Should a Unity Mono Heap Be Before You Treat It as a Problem? · GameOptim · 2026-10-30 · Hunting 'Still Heavy After Loading': Memory Profiler and Snapshot Diffs · UhiyamaLab · 2026-07-16 · How do I find memory leaks on iOS? · Unity Support Help Center · 2026-10-01 · Memory in Unity Web · Unity Docs · 2026-10-29


    标签:Unity、知识速递、游戏开发、C#、内存泄漏、MemoryProfiler、性能优化、调试排错

    相关推荐
    用户37215742613517 小时前
    C# 提取 PDF 附件:包括文档附件和页面注释附件
    c#
    寂寞的船18 小时前
    【Unity Bug挑战】第12期 Awaitable 二次 await 死锁之谜 —— 为什么同一个异步操作等两次就卡死?
    unity·c#·游戏引擎·bug
    探求虚无2 天前
    用 AI 在 Unity 里做一款 RTS:最难的不是生成代码,而是证明它真的能玩
    人工智能·unity·游戏引擎
    huaqianzkh2 天前
    WinForm C#:循环修改控件UI闪烁卡顿?SuspendLayout / ResumeLayout 与 BeginUpdate 优化
    开发语言·ui·c#
    用户3721574261353 天前
    C# 实现 Excel 单元格保护:锁定公式并允许编辑指定区域
    c#·ai编程·编程语言
    努力努力再努力wz3 天前
    【Lua 进阶系列】从 C++ 对象到 Lua 变量:一文理解 userdata、Lua 栈与元表绑定机制
    c++·junit·lua
    17岁的勇气3 天前
    Unity Shader unity文档学习笔记(二十二):shader变体
    笔记·学习·unity
    公子小六3 天前
    基于.NET的Windows窗体编程之WinForms数据持久化(1)
    windows·microsoft·c#·.net·winforms
    阿卡好可爱3 天前
    Lua 热更新不生效:改完文件线上仍是旧逻辑,package.loaded 与 upvalue 重绑定详解
    数据结构·分布式·后端·架构·lua