๐พ Lesson 2.2: Hunting Memory โ The Memory Profiler
The CPU Profiler tells you how fast your frame is. It says almost nothing about the slow, silent failure mode that crashes shipped games: memory that grows and never comes back. A texture that's loaded twice, an event handler that pins a whole scene, a static list that only ever appends โ none of these spike the frame graph, but any of them will eventually exhaust a device and force an out-of-memory kill. This lesson is about the dedicated Memory Profiler: capturing snapshots of your entire heap, reading them, and comparing two of them to catch a leak red-handed.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish managed memory (the C# / GC heap) from native memory (engine objects, textures, meshes)
- Install the Memory Profiler package and capture a snapshot of a running game
- Read the tree map to see which categories dominate memory
- Compare two snapshots to isolate exactly what grew between them โ the core leak-hunting technique
- Explain what keeps an object alive: references, static fields, and events left subscribed
- Recognize the cost of textures, meshes, and assets sitting resident in memory
Estimated Time: 65 minutes ยท Prerequisite: Lesson 2.1 (the Profiler and its Memory module) & comfort with C# events/delegates
In This Lesson
Two Kinds of Memory
Before you can read a memory snapshot, you need to know that a Unity game holds memory in two very different places, and confusing them will send you hunting in the wrong one.
๐งต Managed memory
The C# heap the garbage collector owns. Every class instance, array, closure, and boxed value lives here. The GC frees it automatically โ when nothing references it. Managed leaks are really "objects the GC can't collect because something still points at them." This is where your code's own footprint lives.
โ๏ธ Native memory
Memory the engine (C++) side owns: textures, meshes, audio clips, render targets, the scene graph, physics data. A managed Texture2D object is a tiny handle in the C# heap; the actual pixel data is native. This is usually where the big numbers are, and it's freed by unloading assets, not by the GC.
The relationship matters: a Texture2D you created in code is a small managed wrapper pointing at a large native buffer. Drop the last managed reference and the GC eventually collects the wrapper โ but the native buffer only frees when Unity unloads that asset (via Resources.UnloadUnusedAssets, Addressables.Release, or Destroy for a runtime-created one). Forget that, and you leak megabytes of native memory while the managed heap looks innocent.
๐ Definition โ a "leak" in a garbage-collected engine
Unity doesn't leak the way C++ does (a forgotten free). A managed leak is an object you're done with that the GC still can't collect, because a live reference somewhere โ a static field, a subscribed event, a cached list โ keeps pointing at it. The memory is "reachable," so the GC keeps it, forever. The Memory Profiler's job is to show you that reference chain.
The Memory Profiler Package
The Memory module you met in Lesson 2.1 gives you live totals and per-frame allocation โ a smoke alarm. To actually investigate what is in memory, you install the separate Memory Profiler package: Window โ Package Manager โ Unity Registry โ Memory Profiler โ Install. It adds a new window under Window โ Analysis โ Memory Profiler.
Where the CPU Profiler streams continuous data, the Memory Profiler works in snapshots: a full, frozen dump of every managed and native object in memory at the instant you press capture, complete with sizes and โ crucially โ the references between them. A snapshot is heavy to take (the game hitches for a moment) and that's fine; you take a handful deliberately, not continuously.
๐ก Snapshot, don't stream. Memory problems are about accumulation over time, not one bad frame. The right rhythm is: capture a snapshot at a known-good baseline, do something (load a level, open and close a menu, play a wave), capture again, and diff. The difference between two snapshots is the story.
Capturing a Snapshot
With the window open and your game in Play mode (or, better, a Development Build connected โ the same on-device rule from Lesson 2.1 applies), press Capture. After a moment the snapshot appears in the left-hand list with a timestamp and total size. Each snapshot is saved to disk in your project, so you can reopen and compare them across sessions.
You can capture from either a connected player (real device memory โ what you trust) or the editor (convenient, but inflated by editor overhead exactly as CPU timings were). For any decision that ships, capture from the build on device.
Once captured, a snapshot opens into several views. The most useful two are the Tree Map (what's using memory, by category and size) and the All Of Memory / object table (every object, searchable, with its references). Let's read the tree map first.
Reading the Tree Map
The tree map is a set of nested rectangles where area is proportional to bytes. Big categories are big rectangles; you find your heaviest memory users literally by looking for the largest boxes. Typical top-level categories are Native Objects (broken down into Texture2D, Mesh, AudioClip, RenderTextureโฆ), the Managed Heap, Graphics driver memory, and executable/other. Click a box to drill in.
Here is the Memory Profiler reconstructed โ a tree map on the left, and on the right the snapshot-comparison table that turns it into a leak-finding tool.
Texture2D data dominates, as it usually does. Right: the snapshot comparison of "menu open" (A) versus "menu closed again" (B). Counts should have returned to baseline; instead 40 EnemyViews and 40 delegates survived. The reference-path panel names the culprit: a static event still holds a delegate whose target is each view, so the GC can never collect them.๐ก Why a diagram, not a screenshot? Like the Profiler, the Memory Profiler is a UI Toolkit window that can't be captured cleanly, so it's reconstructed from the real layout, real category names, and a real reference path. The numbers illustrate the exact pattern you'll hunt: a comparison where things that should have gone away didn't.
Comparing Snapshots to Find a Leak
The single most valuable thing the Memory Profiler does is diff two snapshots. One snapshot tells you what's in memory now โ useful, but you can't tell a legitimate 148 MB of textures from a leak. Two snapshots taken around a repeatable action tell you what changed, and change is where leaks live.
The technique โ call it the bracket test โ is:
- Get to a stable baseline (e.g. main gameplay, nothing transient open). Capture A.
- Do a complete round-trip that should end where it started: open a menu and close it, load a level and return, spawn a wave and clear it.
- Capture B.
- Switch the window to Compare mode with A and B. Sort by ฮ Count or ฮ Size.
If the round-trip was truly balanced, most deltas are zero. Any type with a positive delta that shouldn't have grown is your suspect โ exactly the red EnemyView +40 row in Figure 1. Repeat the round-trip several times before capturing B and the leak compounds (+40 becomes +120), making it unmistakable and proving it's not just one-time initialization.
โ Balanced round-trips are the whole trick
The power of the bracket test comes from choosing an action that should net to zero. "Open and close the pause menu ten times" must leave memory where it started; if it doesn't, the delta is the leak, with no ambiguity. Design your test so the correct answer is "no change," and any change indicts itself.
What Keeps an Object Alive
Once the diff names a leaking type, the question becomes why won't the GC collect it? The GC frees anything not reachable from a root (a static field, a live local, a running thread). So a leak is always: "there's still a reference path from some root to this object." The Memory Profiler shows you that path (the reference panel in Figure 1). Three sources cause the overwhelming majority of managed leaks:
๐ Lingering references
A cache, list, or dictionary that you add to but never remove from. The classic: a static List<Enemy> _all you Add on spawn but forget to Remove on death. Every enemy ever spawned stays alive forever.
๐ Static fields
Statics are GC roots that live for the whole program. Anything a static field points at โ directly or through a chain โ can never be collected. A single misplaced static can pin an entire scene's worth of objects.
๐ก Events not unsubscribed
The most common Unity leak. When object B does someEvent += B.Handler, the event's delegate holds a reference to B. Destroy B's GameObject and the C# object still can't be collected, because the (often static or long-lived) event still points at it. You must -= in OnDestroy/OnDisable.
๐ The "destroyed but alive" trap.Destroy(gameObject)tears down the native Unity object, but the managed C# object behind it only dies when the GC can prove it's unreachable. If an event still holds it, you get a zombie: the GameObject is gone from the scene, yet itsMonoBehaviourinstance sits in the heap forever, itsUpdatequietly not running but its memory never freed. This is why "I destroyed it, why is it still in the snapshot?" is one of the most common questions in memory work โ and the answer is nearly always a missing-=.
A Real Leak and Its Fix
Here is the exact bug behind Figure 1. An EnemyView subscribes to a static score event so it can flash when the player scores. It subscribes in OnEnable but never unsubscribes โ so when the enemy is destroyed, the static event keeps the view alive:
// A long-lived (often static) event bus โ survives the whole session.
public static class GameEvents
{
public static event System.Action<int> OnScore;
public static void RaiseScore(int total) => OnScore?.Invoke(total);
}
public class EnemyView : MonoBehaviour
{
void OnEnable()
{
// Subscribing makes GameEvents.OnScore hold a delegate
// whose target is THIS instance...
GameEvents.OnScore += HandleScore;
}
// ...but we never unsubscribe. When this GameObject is destroyed,
// the static event still references us, so the GC can never
// collect this EnemyView. Every enemy ever spawned leaks.
void HandleScore(int total) { /* flash the UI */ }
}
The snapshot diff caught it: after opening and closing the enemy view forty times, forty EnemyView instances and forty delegates survived. The fix is one line โ unsubscribe in the mirror-image callback:
public class EnemyView : MonoBehaviour
{
void OnEnable()
{
GameEvents.OnScore += HandleScore;
}
void OnDisable()
{
// Mirror every subscription with an unsubscription.
// Now nothing long-lived references us; the GC can collect us.
GameEvents.OnScore -= HandleScore;
}
void HandleScore(int total) { /* flash the UI */ }
}
Re-run the bracket test after the fix and the EnemyView delta drops to zero โ proof, not hope. The discipline generalizes: every += needs a matching -=, and the natural place is the paired lifecycle callback (OnEnable/OnDisable, or Awake-region subscribe / OnDestroy unsubscribe). The same rule applies to manual C# events, UnityEvent listeners added in code, and input-action callbacks.
โ ๏ธ Textures and assets: the native side of leaking
Managed leaks pin small objects; the megabytes usually leak on the native side. Loading the same texture through Resources.Load or Addressables repeatedly without releasing, instantiating materials (which clones them into memory) each frame, or never calling Addressables.Release on a handle all grow native memory that the GC can't touch. Watch the Texture2D and Material boxes in the tree map across a bracket test the same way you watch managed types โ the icon-atlas +8 MB row in Figure 1 is exactly this. We cover disciplined asset lifetime in the Addressables lessons (Module 7).
Hands-on Challenge
๐๏ธ Exercise 1: Plant a leak, then catch it
Objective: Run the full bracket test end to end on a leak you created.
- Create the leaking
EnemyViewabove (subscribe inOnEnable, noOnDisable). Spawn and immediately destroy a handful of them behind a button press. - Install the Memory Profiler package. Enter Play mode and Capture A.
- Press your button several times to spawn/destroy a batch. Capture B.
- Compare A and B, sort by ฮ Count, and find the
EnemyViewrow that grew when it should be zero. - Add the
OnDisableunsubscribe, repeat, and confirm the delta is now zero.
โ What you should observe
Before the fix, EnemyView (and a matching Action/delegate) count climbs with every batch and never falls โ the diff shows a growing positive delta. Inspecting one leaked instance's references leads back to GameEvents.OnScore, a static root. After adding OnDisable() { GameEvents.OnScore -= HandleScore; }, the same test nets to zero delta. You've reproduced the entire professional workflow: suspect via diff, confirm via reference path, fix, re-measure.
๐๏ธ Exercise 2: Managed or native?
For each item, decide whether the leaked bytes live in managed or native memory: (a) a static List<Transform> you never clear; (b) 200 MB of duplicate Texture2D pixel data from re-loading an atlas; (c) a lambda you subscribed to an event and never removed; (d) instantiated Material clones you never destroy.
โ Answers
(a) Managed โ the List and its references are on the C# heap (though the Transforms it pins are native handles). (b) Native โ texture pixel data is engine-owned. (c) Managed โ the delegate/closure lives on the C# heap. (d) Native โ material instances are engine objects; instantiating clones them into native memory. The lesson: managed diffs catch counts that grow (leaked wrappers, subscriptions), tree-map boxes catch bytes that grow (textures, meshes, materials). Use both.
๐ฏ Quick Quiz
Question 1: A Texture2D's tiny C# object is on the managed heap. Where do its actual pixels live?
Question 2: What is the core technique for finding a leak with the Memory Profiler?
Question 3: You Destroy(gameObject) an enemy, but its MonoBehaviour is still in the snapshot. What's the most likely reason?
Question 4: Which pairing correctly prevents an event-subscription leak on a MonoBehaviour?
Summary
๐ Key Takeaways
- Unity holds memory in two places: managed (the GC-owned C# heap) and native (engine-owned textures, meshes, audio) โ the big bytes are usually native.
- The Memory Profiler package works in snapshots: full, frozen dumps of every object and the references between them.
- The tree map shows what's using memory by area = bytes; drill into the biggest boxes first.
- The core skill is the bracket test: capture around a balanced round-trip that should net to zero, then compare โ any unexpected positive delta is the leak.
- An object leaks when it's still reachable from a root; the top three causes are lingering references, static fields, and events not unsubscribed.
- Mirror every
+=with a-=in the paired lifecycle callback; verify the fix by re-running the bracket test to a zero delta.
๐ What's Next?
Leaks are memory that never comes back. The other memory problem is churn: memory allocated and freed so fast that the garbage collector itself becomes a frame-time spike. In Lesson 2.3: Writing Garbage-Free Code, we hunt down every common allocation source โ boxing, closures, LINQ, string building, hidden array copies โ and refactor a method from allocating to completely garbage-free.
๐ต๏ธ You can catch a leak now
Snapshot, act, snapshot, diff, follow the reference path to the root, fix, re-measure. That loop turns "the game slowly dies after twenty minutes" from a mystery into a two-minute investigation.