UnityUnity3DPerformanceMemory ManagementMemory LeaksOptimizationGame Development

Unity Memory Optimization Part 2: Identifying and Preventing Memory Leaks

November 28, 2022
13 min read
Gonçalo Bastos

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.

This is Part 2 of our Unity memory optimization series. In Part 1, we covered Unity's memory architecture and essential profiling tools. This post covers memory leaks - what they are, how to identify them, and how to prevent them.

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:

csharp
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}
Why this causes leaks: When this 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

Always unsubscribe from events in OnDestroy or OnDisable:
csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

The biggest asset management mistake is using Resources.Load without proper cleanup:
csharp
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:

csharp
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

For new projects, use the Addressables system instead of Resources:
csharp
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

csharp
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

  1. Enable leak detection early in development
  2. Profile regularly during development, not just at the end
  3. Test long play sessions to catch gradual memory growth
  4. Monitor memory during scene transitions
  5. 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.