โป๏ธ Lesson 2.3: Writing Garbage-Free Code
A leak (Lesson 2.2) is memory that never comes back. This lesson is about the opposite failure: memory allocated and thrown away so fast that cleaning it up becomes the thing that stutters your game. Every managed allocation is a future bill from the garbage collector, and when that bill comes due mid-frame you get a hitch. The fix isn't to allocate faster โ it's to allocate nothing on the hot path. Here you'll learn where hidden allocations come from and how to write the tight, garbage-free code that ships in shipping games.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain how Unity's incremental GC works and why a GC spike shows up as a frame hitch
- Recognize the common allocation sources: boxing, closures that capture state, LINQ, string concatenation,
params, and.ToArray()/.ToList() - Choose structs vs classes deliberately to keep small data off the heap
- Build strings with
StringBuilderand use Unity'sNonAllocphysics APIs - Reuse objects with the built-in generic
ObjectPool<T>and cache component lookups - Refactor a real allocating method into a fully garbage-free one
Estimated Time: 70 minutes ยท Prerequisite: Lesson 2.1 (reading GC Alloc in the Profiler) and the pooling you met in Unity Intermediate
In This Lesson
The GC and Why Spikes Hurt
C# is garbage-collected: you new a reference type, use it, and the garbage collector reclaims it once nothing points at it. Convenient โ but not free. Unity historically used a stop-the-world collector: when it ran, your entire game froze while it walked the heap. On a big heap that's a multi-millisecond pause, and a pause is a dropped frame.
Unity 6's default is the incremental GC, which splits that work across several frames so no single frame eats the whole collection. It's a genuine improvement โ but it is not a licence to allocate freely. Incremental collection still costs CPU time every frame it's active, it can still spike when it must finish a collection under pressure, and the more garbage you produce the more often it runs. The winning move is unchanged: produce so little garbage that the GC almost never needs to run.
๐ Definition โ GC Alloc
The Profiler's GC Alloc column (Lesson 2.1) is bytes of managed heap allocated by a call, per frame. The gold standard for a hot path โ anything in Update, FixedUpdate, or a per-frame loop โ is 0 B. Any steady non-zero number in a per-frame method is garbage you're manufacturing on a schedule, and it will trigger collections. Sort Hierarchy by GC Alloc and drive the hot rows to zero.
Here is the causal chain, from the small allocations you sprinkle through Update to the hitch the player feels:
(struct โ object)"] B["Lambda closures
capturing state"] C["LINQ
Where/Select/ToList"] D["String concat
"Score: " + n"] E["params arrays
& .ToArray()/.ToList()"] F["new class[] each frame"] end A --> H["Managed heap grows"] B --> H C --> H D --> H E --> H F --> H H --> G["GC pressure rises
collector runs more often"] G --> S["GC spike mid-frame"] S --> P["๐ฉน Dropped frame / stutter"]
Figure 1: How small, frequent allocations become a visible hitch. Each source feeds the managed heap; a fuller heap makes the collector run more often; a collection under pressure spikes a frame. Cut the sources at the top and the whole chain never fires.
Where Garbage Comes From
Most per-frame garbage is invisible until you learn to see it. These are the usual suspects โ each looks harmless and each allocates every time it runs.
๐ฆ Boxing
Passing a value type (int, enum, struct) where an object is expected boxes it โ wraps it in a heap allocation. Debug.Log("x=" + 5), adding an enum key to a non-generic collection, or a struct in an ArrayList all box silently.
ฮป Closures
A lambda that captures a local or field allocates a hidden class to hold the captured state. list.Sort((a,b) => a.dist - center) where center is a local allocates a closure object each call.
๐ LINQ
Elegant, and allocation-heavy: every Where/Select/OrderBy allocates an iterator, and ToList()/ToArray() allocates the result. Lovely for tools and setup; poison in Update.
๐งต String work
Strings are immutable, so "Score: " + score allocates a new string every time โ and every intermediate in a chain. A per-frame HUD update this way is a steady garbage drip.
โฆ params & copies
params object[] methods allocate an array per call; .ToArray()/.ToList() copy into a fresh collection; foreach over some non-generic or interface types allocates an enumerator.
๐ Per-frame new
Any new of a class (reference type) in a hot loop โ a temporary List, a Vector3[], a throwaway helper object โ is a heap allocation. Move it out of the loop or make it a reused field.
The through-line: allocation happens on new of a reference type, and on the hidden news the compiler inserts for boxing, closures, and iterators. Learn to spot the hidden ones and half the battle is won. The Profiler's GC Alloc column is your ground truth โ if you're unsure whether a line allocates, put a marker around it and read the number.
Structs vs Classes
The cheapest allocation is the one that never touches the heap. A class is a reference type: new-ing one always allocates on the managed heap and creates GC work. A struct is a value type: a local struct lives on the stack (or inline inside its container), costs nothing to "allocate," and never bothers the GC.
So for small, short-lived, data-only bundles โ a grid coordinate, a hit result, a damage packet โ prefer a struct. This is exactly why Unity's own math types (Vector3, Quaternion, float3) are structs: they're created and discarded constantly, and if each were a class every vector math line would allocate.
// CLASS: every 'new GridCoord(...)' allocates on the heap โ GC work.
public class GridCoordClass { public int X, Y; }
// STRUCT: a local lives on the stack, zero GC. Prefer this for small data.
public readonly struct GridCoord
{
public readonly int X, Y;
public GridCoord(int x, int y) { X = x; Y = y; }
}
โ ๏ธ Structs are not a free win โ three traps
- Copy cost. Structs are copied by value on every assignment and method call. A large struct copied constantly can be slower than a class you pass by reference. Keep structs small (a handful of fields); pass big ones with
in/ref. - Boxing undoes it. Store a struct in a non-generic collection, or pass it as
object/an interface, and it boxes โ a heap allocation, defeating the point. Use generic collections (List<GridCoord>) that hold the struct inline. - Mutable-struct surprises. Mutating a struct returned from a property or held in a non-
refway changes a copy, not the original. Preferreadonly structfor value-like data to avoid the footgun.
Strings & StringBuilder
String building is the single most common source of per-frame garbage, because HUDs update text every frame. Since strings are immutable, concatenation allocates a new string for the result and each intermediate:
// โ Allocates several strings EVERY frame the HUD updates.
void Update()
{
scoreLabel.text = "Score: " + score + " / " + target + " pts";
}
Two fixes, in order of preference:
- Don't build the string at all unless it changed. The cheapest string work is the work you skip. Cache the last value and only rebuild when it differs.
- When you must build one, reuse a
StringBuilderโ a mutable buffer youClear()and refill instead of allocating fresh.
readonly System.Text.StringBuilder _sb = new System.Text.StringBuilder(32);
int _lastScore = -1;
void Update()
{
if (score == _lastScore) return; // only rebuild on change
_lastScore = score;
_sb.Clear();
_sb.Append("Score: ").Append(score).Append(" / ")
.Append(target).Append(" pts");
scoreLabel.SetText(_sb); // TMP takes a StringBuilder, no alloc
}
๐ก TextMeshPro'sSetText. Assigning to.texttakes astring(which you had to allocate). TMP'sSetText(StringBuilder)and its numeric overloads write straight from your buffer with no managed allocation โ the idiomatic garbage-free way to update on-screen numbers.
NonAlloc Physics APIs
Physics queries are a sneaky allocator. The convenient forms return a freshly-allocated array every call โ fine once, ruinous in FixedUpdate:
// โ Allocates a new Collider[] every physics step.
void FixedUpdate()
{
Collider[] hits = Physics.OverlapSphere(transform.position, radius);
foreach (var h in hits) Damage(h);
}
Unity provides NonAlloc variants that write into a buffer you own and reuse. You allocate the array once, and each query fills it and returns how many results it wrote:
readonly Collider[] _hitBuffer = new Collider[16]; // allocated ONCE
void FixedUpdate()
{
int count = Physics.OverlapSphereNonAlloc(
transform.position, radius, _hitBuffer);
for (int i = 0; i < count; i++) Damage(_hitBuffer[i]);
}
The same pattern exists across the physics API โ RaycastNonAlloc, SphereCastNonAlloc, OverlapBoxNonAlloc, and 2D equivalents. Size the buffer for your worst realistic case; results beyond its length are simply dropped (the return count tells you if you hit the cap). One reused array replaces an allocation on every physics tick.
โ The pattern behind all of these
Notice the shape repeating: allocate a reusable buffer once as a field, fill it many times. StringBuilder, NonAlloc physics, pooled objects, cached component references โ they're the same idea applied to strings, physics results, GameObjects, and lookups respectively. "Allocate once, reuse" is the entire philosophy of garbage-free code.
Pooling & Caching Lookups
You met object pooling in Unity Intermediate โ instead of Instantiate/Destroying bullets (which allocates and generates garbage), you keep a pool of inactive objects and recycle them. Unity 6 ships a built-in generic pool, ObjectPool<T> (namespace UnityEngine.Pool), so you no longer hand-roll one:
using UnityEngine.Pool;
public class BulletSpawner : MonoBehaviour
{
[SerializeField] Bullet _prefab;
ObjectPool<Bullet> _pool;
void Awake()
{
_pool = new ObjectPool<Bullet>(
createFunc: () => Instantiate(_prefab),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
defaultCapacity: 64, maxSize: 256);
}
public Bullet Fire()
{
var b = _pool.Get(); // reuses an inactive bullet โ no Instantiate
b.OnDone = () => _pool.Release(b); // return it when finished
return b;
}
}
The pool turns a per-shot Instantiate (heap allocation + GC pressure + the cost of building a GameObject) into a cheap activate/deactivate of an object that already exists. UnityEngine.Pool also offers ListPool<T>, DictionaryPool<T>, and friends for pooling the temporary collections that would otherwise allocate in a method.
The other everyday allocation-and-cost sink is repeated component lookups. GetComponent โ and far worse, FindObjectOfType/FindObjectsByType โ are not free, and calling them every frame is pure waste when the answer never changes:
// โ Looks up the Rigidbody every single frame.
void Update() { GetComponent<Rigidbody>().AddForce(Vector3.up); }
// โ
Look it up ONCE, cache the reference, reuse it forever.
Rigidbody _rb;
void Awake() { _rb = GetComponent<Rigidbody>(); }
void Update() { _rb.AddForce(Vector3.up); }
โ ๏ธ FindObjectsByType in Update is a classic killer
Scene-wide searches like FindObjectsByType<Enemy>() scan the whole scene and allocate an array of results. Called once at startup: fine. Called every frame (as we'll see in the Lesson 2.5 stress scene): a catastrophe that shows up huge in both the CPU Timeline and the GC Alloc column. Cache the reference, or better, have objects register themselves into a list on spawn and remove on death.
A Before/After Refactor
Let's put it together. Here's a plausible enemy-AI method that runs every frame and allocates on almost every line โ a poster child for GC pressure. Read it and count the allocations:
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
public class EnemyAI : MonoBehaviour
{
public float senseRadius = 8f;
void Update()
{
// 1) Allocates a new Collider[] every frame.
Collider[] near = Physics.OverlapSphere(transform.position, senseRadius);
// 2) LINQ: Where + OrderBy + ToList โ several allocations + a closure
// capturing 'transform.position'.
List<Collider> players = near
.Where(c => c.CompareTag("Player"))
.OrderBy(c => Vector3.Distance(c.transform.position, transform.position))
.ToList();
if (players.Count > 0)
{
// 3) GetComponent every frame (no caching).
var target = players[0].GetComponent<PlayerHealth>();
// 4) String concatenation โ new string(s) every frame.
Debug.Log("Targeting " + target.name + " at range " + senseRadius);
}
}
}
Four allocation sources, all on the hot path: the physics array, the LINQ chain plus its closure, the uncached GetComponent, and the string concat feeding Debug.Log (which also boxes senseRadius). Now the garbage-free rewrite โ same behaviour, 0 B/frame:
using UnityEngine;
public class EnemyAI : MonoBehaviour
{
public float senseRadius = 8f;
// Allocate ONCE. Reused every frame.
readonly Collider[] _near = new Collider[32];
void Update()
{
// 1) NonAlloc fills our reusable buffer โ no array allocation.
int count = Physics.OverlapSphereNonAlloc(
transform.position, senseRadius, _near);
// 2) Manual nearest-player scan โ no LINQ, no closure, no ToList.
Collider best = null;
float bestSqr = float.MaxValue;
Vector3 self = transform.position;
for (int i = 0; i < count; i++)
{
var c = _near[i];
if (!c.CompareTag("Player")) continue;
// sqrMagnitude avoids the sqrt in Distance โ cheaper, still orders correctly.
float d = (c.transform.position - self).sqrMagnitude;
if (d < bestSqr) { bestSqr = d; best = c; }
}
// 3) TryGetComponent: no allocation, and null-safe.
if (best != null && best.TryGetComponent(out PlayerHealth target))
{
// 4) No per-frame logging on the hot path. If you must log,
// gate it or use a marker instead of building strings.
target.MarkTargeted();
}
}
}
Line for line: the physics buffer is now a reused field; the LINQ chain became a plain for loop with a running best (and sqrMagnitude instead of Distance to skip a square root); TryGetComponent replaces the allocating GetComponent-then-null-check; and the per-frame string log is gone. Profile it and the GC Alloc for this method reads 0 B โ and, because we cut the LINQ and the sqrt too, it's faster on the CPU as well. Garbage-free and cheaper: the two usually travel together.
๐ Don't do this everywhere. This level of hand-tuning belongs on the hot path โ code that runs every frame or thousands of times. In tools, setup, menus, and cold code, LINQ's clarity and a bit of garbage cost you nothing a player will ever feel. Optimize where the Profiler points, and leave readable code readable everywhere else.
Hands-on Challenge
๐๏ธ Exercise 1: Drive a method to 0 B
Objective: Measure and eliminate allocations with the Profiler as your judge.
- Write a
MonoBehaviourwhoseUpdateallocates deliberately: aStringBuilder-free HUD string, a small LINQ query over an array, and anOverlapSphere. - Add a
ProfilerMarker(Lesson 2.1) around theUpdatebody. Enter Play mode, open the Profiler, switch Hierarchy to sort by GC Alloc, and record the bytes/frame. - Refactor: cache the string with a change-check +
StringBuilder, replace LINQ with a loop, and switch toOverlapSphereNonAllocwith a reusable buffer. - Re-measure. Keep going until the GC Alloc for your marker reads 0 B.
โ What you should observe
Each fix drops the GC Alloc number in a visible step: NonAlloc removes the collider-array bytes, the loop removes the LINQ iterator/closure/ToList bytes, and the cached StringBuilder removes the string bytes. When all three are done the marker's GC Alloc is 0 B/frame and, over time, the Memory module's per-frame allocation line goes flat โ meaning the incremental GC has nothing to collect from this code. That flat line is the goal for every hot path.
๐๏ธ Exercise 2: Spot the hidden allocation
For each line, say whether it allocates managed memory and why: (a) Debug.Log("hp=" + hp); where hp is an int; (b) foreach (var c in myList) where myList is a List<Collider>; (c) list.Sort((a,b) => a.score.CompareTo(b.score)); with no captured locals; (d) enemies.Where(e => e.hp < threshold).Count(); where threshold is a local.
โ Answers
(a) Allocates โ string concatenation builds a new string, and the int boxes into the concat. (b) No allocation โ List<T>'s enumerator is a struct, so foreach over it doesn't allocate (foreach over an interface like IEnumerable<T> would). (c) No capture, butโฆ a non-capturing lambda can be cached by the compiler, so it usually doesn't allocate per call โ however List.Sort with a comparer delegate can still allocate a comparer wrapper the first time; safest is a cached comparer. (d) Allocates โ the Where iterator plus a closure capturing threshold. The lesson: concatenation and capturing-LINQ almost always allocate; generic-collection foreach usually doesn't. When unsure, measure with a marker.
๐ฏ Quick Quiz
Question 1: Unity 6 uses an incremental GC. Does that mean per-frame allocations are now fine?
Question 2: Why is a small, short-lived data bundle often better as a struct than a class?
Question 3: What does Physics.OverlapSphereNonAlloc give you over Physics.OverlapSphere?
Question 4: Which is the single best fix for a HUD that rebuilds "Score: " + score every frame?
Summary
๐ Key Takeaways
- Unity 6's incremental GC eases pauses but still costs CPU and can spike โ the goal is near-zero allocation on the hot path (GC Alloc = 0 B).
- Hidden garbage comes from boxing, capturing closures, LINQ, string concatenation,
params, and.ToArray()/.ToList(), plus any per-framenewof a class. - Prefer small
readonly structs for short-lived data to keep it off the heap โ but beware copy cost and boxing. - Build strings with a reused
StringBuilderand rebuild only on change; feed TMP'sSetText. - Use
NonAllocphysics APIs with a caller-owned buffer instead of the array-returning versions. - Reuse objects with
ObjectPool<T>and pooled collections; cache component lookups and neverFindObjectsByTypeinUpdate. - Every technique is one idea โ allocate once, reuse โ and garbage-free code is usually faster code too.
๐ What's Next?
We've squeezed the CPU and the managed heap. But even perfectly garbage-free C# can be throttled by the other side of the frame: how many times the CPU has to tell the GPU to draw something. In Lesson 2.4: Draw Calls & Batching, we move to rendering cost โ what a draw call really is, and how the SRP Batcher, GPU Instancing, and Unity 6's GPU Resident Drawer collapse thousands of them.
๐งน Zero is the target
When the Memory module's per-frame allocation line goes flat, the incremental GC has nothing to do, and your frame times stop hitching. "Allocate once, reuse" on every hot path is how professionals keep it that way.