Unity 内存泄漏排查实战:从 Memory Profiler 到 Lua 引用泄漏的生产级全链路诊断
栏目:Unity知识速递 | 期号:NO.013 | 日期:2026-11-03
【Unity 知识速递】第 13 期
Unity 内存泄漏排查实战 从 Memory Profiler 到 Lua 引用泄漏的生产级全链路诊断
4星 · 中高级 · 内存优化 / 调试排错
📌 知识要点
- 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 第二步:快照对比法------内存泄漏排查的黄金标准
单张快照什么都说明不了。你需要的是相同条件下的前后对比。以下是标准排查流程:
📋 快照对比标准流程
- 打 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 泄漏排查方法
-
先确认是不是 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 Listif (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、性能优化、调试排错