Unity Memory Optimization Part 2: Identifying and Preventing Memory Leaks
Unity Memory Optimization Part 2: Identifying and Preventing Memory Leaks
Memory leaks can gradually degrade Unity application performance. Unlike crashes or obvious bugs, memory leaks slowly increase memory usage over time, often going unnoticed until performance becomes problematic. They manifest as gradually increasing memory usage, performance degradation during long play sessions, and eventual out-of-memory crashes on mobile devices.
Understanding Memory Leaks in Unity
A memory leak occurs when your application allocates memory but never releases it, even when that memory is no longer needed. In Unity, this happens in two main ways:
Managed Memory Leaks: Objects in the C# managed heap that can't be garbage collected because something still references them, even though they're no longer logically needed.
Native Memory Leaks: Unity engine objects (textures, meshes, audio clips) that aren't explicitly destroyed, causing their native memory to never be freed.
The insidious nature of memory leaks means they often manifest as:
- Gradually increasing memory usage over time
- Performance degradation during long play sessions
- Eventual out-of-memory crashes on mobile devices
- Garbage collection hitches becoming more frequent and severe
Let's examine the most common types of memory leaks and learn how to identify and fix them.
Event Handler Leaks: The #1 Memory Leak Source
Event handler leaks are by far the most common memory leak in Unity projects. They occur when you subscribe to events but fail to unsubscribe properly, keeping objects alive long after they should be garbage collected.
The Problem
Here's how event handler leaks typically occur:
1public class PlayerController : MonoBehaviour
2{
3 void Start()
4 {
5 // This creates a memory leak!
6 GameManager.OnGameStateChanged += HandleGameStateChange;
7
8 // Lambda expressions are even worse for memory leaks
9 GameManager.OnScoreUpdated += (score) => {
10 Debug.Log($"Player {gameObject.name} sees score: {score}");
11 };
12
13 // Anonymous methods also create hidden references
14 InputManager.OnInputReceived += delegate(InputData data) {
15 ProcessInput(data);
16 };
17 }
18
19 void HandleGameStateChange(GameState newState)
20 {
21 // Handle game state changes
22 }
23
24 void ProcessInput(InputData data)
25 {
26 // Process input
27 }
28
29 // OnDestroy not implemented = MEMORY LEAK
30 // The GameManager still holds references to this object's methods
31}GameObject is destroyed, Unity destroys the GameObject and its components, but the GameManager still holds references to the HandleGameStateChange method and the lambda expression. This prevents the PlayerController from being garbage collected, keeping the entire GameObject alive in memory.The lambda trap: Lambda expressions and anonymous methods are particularly dangerous because they often capture local variables, creating hidden references that are easy to miss.
The Solution: Proper Event Cleanup
OnDestroy or OnDisable:1public class PlayerControllerFixed : MonoBehaviour
2{
3 // Store method references for cleanup
4 private System.Action<GameState> gameStateHandler;
5 private System.Action<int> scoreHandler;
6 private System.Action<InputData> inputHandler;
7
8 void Start()
9 {
10 // Store references to methods for later cleanup
11 gameStateHandler = HandleGameStateChange;
12 scoreHandler = HandleScoreUpdate;
13 inputHandler = ProcessInput;
14
15 // Subscribe using stored references
16 GameManager.OnGameStateChanged += gameStateHandler;
17 GameManager.OnScoreUpdated += scoreHandler;
18 InputManager.OnInputReceived += inputHandler;
19 }
20
21 void HandleGameStateChange(GameState newState)
22 {
23 // Handle game state changes
24 }
25
26 void HandleScoreUpdate(int score)
27 {
28 // No lambda - explicit method for easier cleanup
29 Debug.Log($"Player {gameObject.name} sees score: {score}");
30 }
31
32 void ProcessInput(InputData data)
33 {
34 // Process input
35 }
36
37 void OnDestroy()
38 {
39 // Clean up all event subscriptions
40 // Note: No null checks needed - unsubscribing from null is safe
41 GameManager.OnGameStateChanged -= gameStateHandler;
42 GameManager.OnScoreUpdated -= scoreHandler;
43 InputManager.OnInputReceived -= inputHandler;
44 }
45}Key principles for event cleanup:
- Store method references in member variables
- Avoid lambda expressions and anonymous methods when possible
- Always unsubscribe in OnDestroy or OnDisable
- No null checks needed - unsubscribing from null events is safe
Advanced Event Management
For complex objects with many event subscriptions, consider using a cleanup pattern:
1public class AdvancedEventManager : MonoBehaviour
2{
3 private List<System.Action> cleanupActions = new List<System.Action>();
4
5 void Start()
6 {
7 // Subscribe and register cleanup in one step
8 SubscribeWithCleanup(
9 () => GameManager.OnGameStateChanged += HandleGameStateChange,
10 () => GameManager.OnGameStateChanged -= HandleGameStateChange
11 );
12
13 SubscribeWithCleanup(
14 () => PlayerManager.OnPlayerSpawned += HandlePlayerSpawned,
15 () => PlayerManager.OnPlayerSpawned -= HandlePlayerSpawned
16 );
17
18 SubscribeWithCleanup(
19 () => UIManager.OnMenuOpened += HandleMenuOpened,
20 () => UIManager.OnMenuOpened -= HandleMenuOpened
21 );
22 }
23
24 void SubscribeWithCleanup(System.Action subscribe, System.Action cleanup)
25 {
26 subscribe.Invoke();
27 cleanupActions.Add(cleanup);
28 }
29
30 void OnDestroy()
31 {
32 // Clean up all subscriptions
33 foreach (var cleanup in cleanupActions)
34 {
35 cleanup.Invoke();
36 }
37 cleanupActions.Clear();
38 }
39}Singleton and Static Reference Leaks
Singletons and static variables are common sources of memory leaks because they persist throughout the application lifetime. Any objects they reference can't be garbage collected.
The Problem
Static collections that grow indefinitely are a classic leak pattern:
1// BAD: This creates permanent memory leaks
2public class BadGameManager : MonoBehaviour
3{
4 public static BadGameManager Instance;
5
6 // This list will grow forever and never be cleaned up
7 public static List<GameObject> AllGameObjects = new List<GameObject>();
8 public static Dictionary<string, PlayerData> PlayerCache = new Dictionary<string, PlayerData>();
9
10 void Awake()
11 {
12 Instance = this;
13 }
14
15 public static void RegisterGameObject(GameObject obj)
16 {
17 AllGameObjects.Add(obj); // Added but never removed!
18 }
19
20 public static void CachePlayerData(string playerId, PlayerData data)
21 {
22 PlayerCache[playerId] = data; // Cached forever!
23 }
24}
25
26// Any script that uses this creates a permanent reference
27public class PlayerSpawner : MonoBehaviour
28{
29 void SpawnPlayer()
30 {
31 GameObject player = Instantiate(playerPrefab);
32
33 // This object will never be garbage collected
34 BadGameManager.RegisterGameObject(player);
35 }
36}The Solution: Proper Static Management
Implement proper cleanup for static collections and provide removal methods:
1public class GoodGameManager : MonoBehaviour
2{
3 public static GoodGameManager Instance { get; private set; }
4
5 // Use collections that allow removal
6 private static HashSet<GameObject> trackedObjects = new HashSet<GameObject>();
7 private static Dictionary<string, PlayerData> playerCache = new Dictionary<string, PlayerData>();
8
9 void Awake()
10 {
11 if (Instance != null && Instance != this)
12 {
13 Destroy(gameObject);
14 return;
15 }
16
17 Instance = this;
18 DontDestroyOnLoad(gameObject);
19 }
20
21 public static void RegisterGameObject(GameObject obj)
22 {
23 if (obj != null)
24 {
25 trackedObjects.Add(obj);
26 }
27 }
28
29 public static void UnregisterGameObject(GameObject obj)
30 {
31 trackedObjects.Remove(obj);
32 }
33
34 public static void CachePlayerData(string playerId, PlayerData data)
35 {
36 playerCache[playerId] = data;
37 }
38
39 public static void RemovePlayerData(string playerId)
40 {
41 playerCache.Remove(playerId);
42 }
43
44 public static void ClearAllCaches()
45 {
46 trackedObjects.Clear();
47 playerCache.Clear();
48 }
49
50 void OnApplicationQuit()
51 {
52 // Clean up static references when application quits
53 ClearAllCaches();
54 Instance = null;
55 }
56
57 // Periodic cleanup for objects that might have been destroyed
58 void Update()
59 {
60 if (Time.frameCount % 300 == 0) // Every 5 seconds at 60fps
61 {
62 CleanupDestroyedObjects();
63 }
64 }
65
66 void CleanupDestroyedObjects()
67 {
68 // Remove null references (destroyed objects)
69 trackedObjects.RemoveWhere(obj => obj == null);
70 }
71}
72
73// Proper usage with cleanup
74public class PlayerSpawnerFixed : MonoBehaviour
75{
76 private List<GameObject> spawnedPlayers = new List<GameObject>();
77
78 void SpawnPlayer()
79 {
80 GameObject player = Instantiate(playerPrefab);
81 GoodGameManager.RegisterGameObject(player);
82 spawnedPlayers.Add(player);
83 }
84
85 void OnDestroy()
86 {
87 // Clean up all spawned players from the static manager
88 foreach (var player in spawnedPlayers)
89 {
90 if (player != null)
91 {
92 GoodGameManager.UnregisterGameObject(player);
93 }
94 }
95 }
96}Coroutine Memory Leaks
Coroutines can cause memory leaks when they run indefinitely or aren't properly stopped when their parent object is destroyed.
The Problem
Infinite coroutines that don't check for object destruction:
1public class BadCoroutineExample : MonoBehaviour
2{
3 void Start()
4 {
5 // This coroutine will run forever, even after the GameObject is destroyed
6 StartCoroutine(InfiniteUpdate());
7 StartCoroutine(AutoSave());
8 }
9
10 IEnumerator InfiniteUpdate()
11 {
12 while (true) // DANGER: No exit condition
13 {
14 UpdateSomething();
15 yield return new WaitForSeconds(1f);
16 }
17 }
18
19 IEnumerator AutoSave()
20 {
21 while (true) // DANGER: Will continue after object destruction
22 {
23 SaveGameData();
24 yield return new WaitForSeconds(30f);
25 }
26 }
27
28 void UpdateSomething()
29 {
30 // This method will keep getting called even after the object is destroyed
31 Debug.Log($"Updating {gameObject.name}");
32 }
33
34 void SaveGameData()
35 {
36 // This might access destroyed objects or components
37 Debug.Log("Saving game data...");
38 }
39}The Solution: Proper Coroutine Management
Always store coroutine references and implement proper lifecycle checks:
1public class GoodCoroutineExample : MonoBehaviour
2{
3 private Coroutine updateCoroutine;
4 private Coroutine autoSaveCoroutine;
5
6 void Start()
7 {
8 // Store coroutine references for cleanup
9 updateCoroutine = StartCoroutine(ManagedUpdate());
10 autoSaveCoroutine = StartCoroutine(ManagedAutoSave());
11 }
12
13 IEnumerator ManagedUpdate()
14 {
15 // Check if the object is still active and not destroyed
16 while (this != null && gameObject.activeInHierarchy)
17 {
18 UpdateSomething();
19 yield return new WaitForSeconds(1f);
20 }
21 }
22
23 IEnumerator ManagedAutoSave()
24 {
25 while (this != null && gameObject.activeInHierarchy)
26 {
27 SaveGameData();
28 yield return new WaitForSeconds(30f);
29 }
30 }
31
32 void UpdateSomething()
33 {
34 // Safe to call - we know the object is still valid
35 Debug.Log($"Updating {gameObject.name}");
36 }
37
38 void SaveGameData()
39 {
40 Debug.Log("Saving game data...");
41 }
42
43 void OnDisable()
44 {
45 // Stop coroutines when object is disabled
46 StopManagedCoroutines();
47 }
48
49 void OnDestroy()
50 {
51 // Stop coroutines when object is destroyed
52 StopManagedCoroutines();
53 }
54
55 void StopManagedCoroutines()
56 {
57 if (updateCoroutine != null)
58 {
59 StopCoroutine(updateCoroutine);
60 updateCoroutine = null;
61 }
62
63 if (autoSaveCoroutine != null)
64 {
65 StopCoroutine(autoSaveCoroutine);
66 autoSaveCoroutine = null;
67 }
68 }
69}Advanced Coroutine Pattern: For coroutines that need to survive object destruction, consider using a persistent manager:
1public class CoroutineManager : MonoBehaviour
2{
3 public static CoroutineManager Instance { get; private set; }
4
5 void Awake()
6 {
7 if (Instance == null)
8 {
9 Instance = this;
10 DontDestroyOnLoad(gameObject);
11 }
12 else
13 {
14 Destroy(gameObject);
15 }
16 }
17
18 public static Coroutine StartPersistentCoroutine(IEnumerator routine)
19 {
20 if (Instance != null)
21 {
22 return Instance.StartCoroutine(routine);
23 }
24 return null;
25 }
26
27 public static void StopPersistentCoroutine(Coroutine routine)
28 {
29 if (Instance != null && routine != null)
30 {
31 Instance.StopCoroutine(routine);
32 }
33 }
34}Asset Management Memory Leaks
One of the most expensive types of memory leaks involves Unity assets like textures, meshes, and audio clips. These leaks are particularly dangerous because they consume native memory that doesn't get garbage collected.
The Resources.Load Trap
Resources.Load without proper cleanup:1// BAD: Creates permanent memory leaks
2public class BadResourceManager : MonoBehaviour
3{
4 void LoadPlayerAssets()
5 {
6 // Each call permanently loads assets into memory
7 Texture2D playerIcon = Resources.Load<Texture2D>("UI/PlayerIcon");
8 AudioClip shootSound = Resources.Load<AudioClip>("Sounds/Shoot");
9 GameObject playerPrefab = Resources.Load<GameObject>("Prefabs/Player");
10
11 // Assets stay in memory forever, even after this script is destroyed!
12 }
13
14 void LoadDynamicContent()
15 {
16 // Loading different assets based on player choice
17 string weaponType = GetPlayerWeaponChoice();
18
19 // This loads a new weapon model every time, never unloading the old ones
20 GameObject weaponModel = Resources.Load<GameObject>($"Weapons/{weaponType}");
21
22 // Memory usage grows with each weapon change
23 }
24}The Solution: Proper Asset Lifecycle Management
Track loaded assets and clean them up appropriately:
1public class GoodResourceManager : MonoBehaviour
2{
3 // Track all loaded resources for cleanup
4 private List<UnityEngine.Object> loadedAssets = new List<UnityEngine.Object>();
5 private Dictionary<string, UnityEngine.Object> assetCache = new Dictionary<string, UnityEngine.Object>();
6
7 public T LoadAsset<T>(string path) where T : UnityEngine.Object
8 {
9 // Check cache first
10 if (assetCache.TryGetValue(path, out UnityEngine.Object cachedAsset))
11 {
12 return cachedAsset as T;
13 }
14
15 // Load the asset
16 T asset = Resources.Load<T>(path);
17 if (asset != null)
18 {
19 loadedAssets.Add(asset);
20 assetCache[path] = asset;
21 }
22
23 return asset;
24 }
25
26 public void UnloadAsset(string path)
27 {
28 if (assetCache.TryGetValue(path, out UnityEngine.Object asset))
29 {
30 loadedAssets.Remove(asset);
31 assetCache.Remove(path);
32 Resources.UnloadAsset(asset);
33 }
34 }
35
36 public void UnloadAllAssets()
37 {
38 foreach (var asset in loadedAssets)
39 {
40 if (asset != null)
41 {
42 Resources.UnloadAsset(asset);
43 }
44 }
45
46 loadedAssets.Clear();
47 assetCache.Clear();
48
49 // Force cleanup of unused assets
50 Resources.UnloadUnusedAssets();
51 }
52
53 void OnDestroy()
54 {
55 UnloadAllAssets();
56 }
57}Modern Asset Management with Addressables
Addressables system instead of Resources:1using UnityEngine.AddressableAssets;
2using UnityEngine.ResourceManagement.AsyncOperations;
3
4public class AddressableResourceManager : MonoBehaviour
5{
6 // Track handles for proper cleanup
7 private List<AsyncOperationHandle> activeHandles = new List<AsyncOperationHandle>();
8
9 public async System.Threading.Tasks.Task<T> LoadAssetAsync<T>(string address) where T : UnityEngine.Object
10 {
11 var handle = Addressables.LoadAssetAsync<T>(address);
12 activeHandles.Add(handle);
13
14 T result = await handle.Task;
15 return result;
16 }
17
18 public void ReleaseAsset(string address)
19 {
20 // Find and release the specific handle
21 for (int i = activeHandles.Count - 1; i >= 0; i--)
22 {
23 var handle = activeHandles[i];
24 if (handle.IsValid() && handle.Result is UnityEngine.Object obj)
25 {
26 if (obj.name == address) // Simplified matching
27 {
28 Addressables.Release(handle);
29 activeHandles.RemoveAt(i);
30 break;
31 }
32 }
33 }
34 }
35
36 void OnDestroy()
37 {
38 // Release all handles
39 foreach (var handle in activeHandles)
40 {
41 if (handle.IsValid())
42 {
43 Addressables.Release(handle);
44 }
45 }
46 activeHandles.Clear();
47 }
48}Detecting Memory Leaks in Your Project
Prevention is better than cure, but sometimes leaks slip through. Here's how to build leak detection into your development workflow:
Automated Leak Detection
1public class MemoryLeakDetector : MonoBehaviour
2{
3 [Header("Leak Detection Settings")]
4 public float checkInterval = 10f;
5 public long memoryGrowthThresholdMB = 50;
6 public int consecutiveGrowthLimit = 3;
7
8 private long lastMemoryUsage;
9 private int consecutiveGrowthCount;
10 private List<long> memoryHistory = new List<long>();
11
12 void Start()
13 {
14 lastMemoryUsage = GetCurrentMemoryUsage();
15 InvokeRepeating(nameof(CheckForMemoryLeaks), checkInterval, checkInterval);
16 }
17
18 void CheckForMemoryLeaks()
19 {
20 long currentMemory = GetCurrentMemoryUsage();
21 long growthMB = (currentMemory - lastMemoryUsage) / (1024 * 1024);
22
23 memoryHistory.Add(currentMemory);
24 if (memoryHistory.Count > 10)
25 {
26 memoryHistory.RemoveAt(0);
27 }
28
29 if (growthMB > memoryGrowthThresholdMB)
30 {
31 consecutiveGrowthCount++;
32
33 Debug.LogWarning($"Memory growth detected: +{growthMB}MB " +
34 $"(Total: {currentMemory / (1024 * 1024)}MB)");
35
36 if (consecutiveGrowthCount >= consecutiveGrowthLimit)
37 {
38 Debug.LogError("🚨 MEMORY LEAK SUSPECTED! Consecutive growth detected.");
39 LogDetailedMemoryAnalysis();
40
41 // Optional: Take memory profiler snapshot
42 TakeMemorySnapshot();
43 }
44 }
45 else
46 {
47 consecutiveGrowthCount = 0;
48 }
49
50 lastMemoryUsage = currentMemory;
51 }
52
53 long GetCurrentMemoryUsage()
54 {
55 return UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
56 }
57
58 void LogDetailedMemoryAnalysis()
59 {
60 Debug.Log("=== MEMORY LEAK ANALYSIS ===");
61
62 var allObjects = FindObjectsOfType<UnityEngine.Object>();
63 var objectCounts = new Dictionary<System.Type, int>();
64
65 foreach (var obj in allObjects)
66 {
67 var type = obj.GetType();
68 objectCounts[type] = objectCounts.GetValueOrDefault(type, 0) + 1;
69 }
70
71 Debug.Log($"Total Unity Objects: {allObjects.Length}");
72 Debug.Log("Top object types:");
73
74 foreach (var kvp in objectCounts.OrderByDescending(x => x.Value).Take(10))
75 {
76 Debug.Log($" {kvp.Key.Name}: {kvp.Value} instances");
77 }
78
79 // Memory history trend
80 if (memoryHistory.Count > 1)
81 {
82 long trend = memoryHistory.Last() - memoryHistory.First();
83 Debug.Log($"Memory trend over {memoryHistory.Count} samples: " +
84 $"{trend / (1024 * 1024)}MB");
85 }
86
87 Debug.Log("============================");
88 }
89
90 void TakeMemorySnapshot()
91 {
92 #if UNITY_EDITOR
93 // Take Memory Profiler snapshot if available
94 try
95 {
96 Unity.MemoryProfiler.MemoryProfiler.TakeSnapshot(
97 $"MemoryLeak_{System.DateTime.Now:MMdd_HHmmss}",
98 (path, success) => {
99 if (success) {
100 Debug.Log($"Memory snapshot saved: {path}");
101 }
102 }
103 );
104 }
105 catch (System.Exception e)
106 {
107 Debug.LogWarning($"Could not take memory snapshot: {e.Message}");
108 }
109 #endif
110 }
111}Building Memory-Safe Habits
Preventing memory leaks is about building good habits into your development workflow:
Code Review Checklist
When reviewing code (yours or teammates'), always check for:
- Event subscriptions: Is there a corresponding unsubscribe in
OnDestroy? - Static collections: Do they have removal methods and cleanup logic?
- Coroutines: Are they stored and stopped properly?
- Resource loading: Is there matching cleanup code?
- Lambda expressions: Can they be replaced with explicit methods?
Development Workflow
- Enable leak detection early in development
- Profile regularly during development, not just at the end
- Test long play sessions to catch gradual memory growth
- Monitor memory during scene transitions
- Use development builds for accurate profiling on target platforms
What's Next?
Memory leaks are just one part of memory optimization. In Part 3, we'll cover advanced optimization techniques including:
- Garbage collection optimization and allocation reduction
- Platform-specific memory considerations (mobile vs. web vs. desktop)
- Production memory monitoring and crash prevention
- Advanced asset optimization strategies
Understanding and preventing memory leaks is crucial for creating stable, performant Unity applications. Start implementing these patterns in your current project, and you'll save yourself countless hours of debugging later.
Struggling with memory leaks in your Unity project? Get in touch and let's discuss your specific challenges and optimization strategies.