Garbage Collection
Minh Khoa
Author
**Goal:**Understand in depth GC how it works in Unity, identify all hidden "sources of garbage" in everyday code, and apply specific techniques to minimize GC Spike — the silent eliminator. FPS Uploading...
Không thể tải ảnh bên ngoài
You are experiencing these symptoms:
The game runs
- normally, but every few seconds it50-60 FPSstutters**a frame 1-2 and then continues.**In Unity Profiler, you see sudden expensive frames for
- something called5-15msOpening Memory Profiler shows memory steadily increasing, then dropping abruptly — the cycle repeats over and over.GC.Collect.
- All of these symptoms have the same culprit:
Garbage Collector🔬 PART 1 — (GC).
HOW DOES IT WORK? GC Two basic memory regions
To understand
, first you need to understand that memory is divided into two regions: GCStack
Stack (The stack is an extremely fast memory region that operates on the LIFO principle)
Last in, first out (. When a function is called, a new "frame")frame (is pushed onto the stack. When the function ends, that frame is popped immediately — no) , no cleanup required. GCHeap
csharp
voidCalculateDamage()
{
intbaseDamage =10;// Nằm trên Stack
floatmultiplier =1.5f;// Nằm trên Stack
// Khi hàm kết thúc, baseDamage và multiplier tự động biến mất — không GC
}
Heap (The heap is a larger memory region, used for objects with unknown sizes or that need to live longer than a single function call. When an object is created on the heap, memory is allocated)
allocate (. When no one holds a reference to it anymore, the object becomes "garbage" — but the memory is not freed immediately.)This is when
comes into play: It periodically runs an algorithm to GC scanscan (the entire heap) , find objects that are no longer referenced by anyone, and free their memory. This process takes time — and in Unity, itruns on the main thread**, causing the game to freeze for a split second.**Reference Type vs Value Type
This is the core distinction you must memorize:
Value Types
→ Live onStackor inline within an object (→ Do not generate garbage) Vector3, Color, Quaternion, Ray, ... GC:
int,float,bool,double,charstruct(Reference Types)enum
→ Live onHeap**→ Generate garbage**when no one uses them anymore: GC arrays
class(MonoBehaviour, ScriptableObject, ...)stringarray(also live on the Heapint[]Generations in!)delegate,Action,Funcobject
csharp
// Value Type — không cấp phát Heap
Vector3direction =newVector3(1,0,0);// struct → Stack
// Reference Type — cấp phát Heap → GC phải dọn
EnemyDatadata =newEnemyData();// class → Heap → rác!
int[]scores =newint[10];// array → Heap → rác!
stringname ="Player";// string → Heap → rác!
.NET GC (of .NET)
GC that Unity uses through Mono or (divides the heap into IL2CPP) 3 generationsGenerations (Gen 0:):
- **Newly created objects.**are collected frequently and quickly. GC Gen 1:
- Objects that survive onecollection. Collected less frequently. GCGen 2:
- Long-lived objectsSingleton, Manager (. Collected rarely but at the highest cost.)The problem in Unity is
Incrementalsince Unity 2019.0.0? GC (Incremental+) orStop-the-World GC (old) — both have a cost. The goal isreduce allocationon Gen 0, not optimize GC collection.
💣 PART 2 — HIDDEN SOURCES OF GARBAGE IN EVERYDAY CODE
This is the most important part. Many programmers do not know that these “harmless” code snippets are silently generating garbage every frame.
1. Boxing / Unboxing — The Invisible Trap for Value Types
Boxing happens when aValue Type is forced to type object. C# must create a wrapper object on the Heap to wrap that value — this is an allocation!
csharp
// ❌ Boxing ngầm — int bị đóng gói thành object
intscore =100;
objectboxed =score;// Boxing! Tạo rác trên Heap
// ❌ Cạm bẫy cực kỳ phổ biến: string.Format với value types
voidUpdate()
{
// Mỗi frame: score (int) bị boxing thành object để truyền vào params object[]
Debug.Log("Score: " +score);// Tạo string mới + boxing
string.Format("Score: {0}",score);// Boxing!
Debug.LogFormat("Score: {0}",score);// Boxing!
}
// ❌ Interface trên Struct cũng gây boxing
structMyStruct :IComparable
{
publicintValue;
publicintCompareTo(objectobj)=>0;
}
IComparableboxed =newMyStruct();// Boxing!
// ❌ Dictionary với Enum key (phổ biến trong game!)
Dictionary<SoundType,AudioClip>_sounds =new();// SoundType là enum (Value Type)
_sounds[SoundType.BGM] =clip;// Mỗi lần lookup: Enum bị boxing!
// ✅ Giải pháp: Custom EqualityComparer để tránh boxing với Enum Dictionary
// Dùng EnumComparer<T> hoặc chuyển sang Dictionary<int, AudioClip>
2. String Concatenation (String concatenation) — The #1 garbage generator
String is immutable (immutable) in C#. Every time you “concatenate” strings, a new stringnewis created on the Heap — the old string becomes garbage.
csharp
// ❌ Tạo rác mỗi frame — KINH KHỦNG
voidUpdate()
{
// "Score: " + score tạo 1 object string mới
// + " / " + maxScore tạo thêm 1 object string nữa
// Tổng cộng: 2 allocations mỗi frame = 120 allocations/giây!
_label.text ="Score: " +_score +" / " +_maxScore;
}
// ❌ Tệ hơn trong vòng lặp
stringresult ="";
for (inti =0;i<items.Count;i++)
result +=items[i].Name +", ";// N+1 string objects được tạo ra!
// ✅ Cách 1: String interpolation (đọc đẹp hơn nhưng vẫn allocate)
_label.text =$"Score:{_score} /{_maxScore}";
// ✅ Cách 2: StringBuilder — tái sử dụng bộ đệm
privatereadonlyStringBuilder_sb =newStringBuilder(64);
voidUpdateLabel()
{
_sb.Clear();
_sb.Append("Score: ");
_sb.Append(_score);
_sb.Append(" / ");
_sb.Append(_maxScore);
_label.text =_sb.ToString();// Chỉ 1 allocation cuối cùng
}
// ✅ Cách 3: Với số nguyên đơn giản — chỉ update khi thực sự thay đổi
privateint_lastDisplayedScore = -1;
voidUpdate()
{
if (_score !=_lastDisplayedScore)
{
_label.text =_score.ToString();// Allocate, nhưng chỉ khi cần thiết
_lastDisplayedScore =_score;
}
}
3. LINQ — Convenient but costly
LINQ is very readable, but each LINQ operator creates anenumerator object and intermediate collection on the Heap.
csharp
// ❌ Mỗi frame tạo rác: Where, OrderBy, ToList đều allocate
voidUpdate()
{
varaliveEnemies =_enemies
.Where(e=>e.IsAlive)
.OrderBy(e=>e.Health)
.ToList();// Tệ nhất: tạo List mới mỗi frame!
}
// ✅ Cách 1: Cache kết quả, chỉ tính lại khi cần
privateList<Enemy>_aliveEnemiesCache =newList<Enemy>();
voidRefreshEnemyCache()// Gọi khi có enemy chết/sinh ra
{
_aliveEnemiesCache.Clear();
foreach (varein_enemies)
if (e.IsAlive)_aliveEnemiesCache.Add(e);
_aliveEnemiesCache.Sort((a,b)=>a.Health.CompareTo(b.Health));
}
// ✅ Cách 2: Dùng vòng for/foreach thủ công trong Update-sensitive code
intFindNearestEnemyIndex(Vector3position)
{
intnearest = -1;
floatminDist =float.MaxValue;
for (inti =0;i<_enemies.Count;i++)
{
floatdist = (_enemies[i].Position -position).sqrMagnitude;// sqrMagnitude không sqrt
if (dist<minDist) {minDist =dist;nearest =i; }
}
returnnearest;
}
4. Closure in Lambda — A Hard-to-Notice Hidden Trap
When a lambda expressioncaptures variables from the outside scope, C# creates aclosure objecton the Heap to store that variable.
csharp
// ❌ Mỗi lần gọi hàm này, một closure object được tạo ra
voidScheduleReward(intamount)
{
// Lambda capture biến 'amount' → closure allocation!
Invoke(()=>GiveReward(amount),2f);
}
// ❌ Trong vòng lặp — tệ hơn nữa
for (inti =0;i<buttons.Length;i++)
{
intindex =i;// Capture biến loop
buttons[i].onClick.AddListener(()=>SelectStation(index));// Mỗi iteration 1 closure!
}
// ✅ Giải pháp 1: Cache delegate thay vì tạo mới
privateAction_cachedRewardAction;
voidStart()
{
_cachedRewardAction = ()=>GiveReward(_rewardAmount);// Tạo 1 lần
}
// ✅ Giải pháp 2: Dùng method group thay vì lambda (không tạo closure)
button.onClick.AddListener(OnButtonClicked);// Chỉ allocate 1 lần khi đăng ký
// ✅ Giải pháp 3: Truyền state qua tham số (tránh closure)
// Dùng các API có overload nhận thêm "state" object
5. foreach with Non-Array Collections
foreachon arrays (T[]) does not allocate. ButforeachonList<T>, Dictionary<K,V>or anyIEnumerable<T>also createsenumerator object.
csharp
// ✅ Không allocate: array
foreach (varenemyin_enemyArray) { }
// ❌ Allocate enumerator: List, Dictionary
foreach (varenemyin_enemyList) { }// Allocates!
foreach (varkvin_enemyDict) { }// Allocates!
// ✅ Giải pháp cho List: dùng for thay vì foreach
for (inti =0;i<_enemyList.Count;i++)
{
varenemy =_enemyList[i];
}
// ✅ Dictionary hiện đại (Unity 2021+, .NET Standard 2.1): foreach không allocate nữa
// Nếu dùng .NET Standard 2.0: vẫn phải dùng for hoặc CopyTo
6. Coroutine — Every "yield return" is an object
Every time you create a Coroutine, Unity has to create a state machine object. In addition, someyield returncreate additional objects:
csharp
// ❌ Tệ: tạo WaitForSeconds mới mỗi lần
IEnumeratorSpawnLoop()
{
while (true)
{
yieldreturnnewWaitForSeconds(2f);// New object mỗi lần iterate!
SpawnEnemy();
}
}
// ✅ Cache WaitForSeconds
privatereadonlyWaitForSeconds_spawnDelay =newWaitForSeconds(2f);
IEnumeratorSpawnLoop()
{
while (true)
{
yieldreturn_spawnDelay;// Dùng lại object đã tạo
SpawnEnemy();
}
}
// ✅ Tốt nhất: Dùng UniTask (zero allocation)
asyncUniTaskVoidSpawnLoop(CancellationTokenct)
{
while (!ct.IsCancellationRequested)
{
awaitUniTask.Delay(2000,cancellationToken:ct);
SpawnEnemy();
}
}
7. GetComponent, FindObjectOfType — Expensive if called in Update
Although this is not GC pure allocation, caching components is a best practice worth mentioning:
csharp
// ❌ Gọi trong Update — tìm kiếm lại mỗi frame
voidUpdate()
{
GetComponent<Rigidbody>().AddForce(Vector3.up);
GetComponent<Animator>().Play("Run");
}
// ✅ Cache trong Awake/Start
privateRigidbody_rb;
privateAnimator_animator;
privatevoidAwake()
{
_rb =GetComponent<Rigidbody>();
_animator =GetComponent<Animator>();
}
voidUpdate()
{
_rb.AddForce(Vector3.up);
_animator.Play("Run");
}
🔬 PART 3 — GARBAGE DETECTION TOOLS
Unity Profiler — The first window to open
Window → Analysis → Profiler (Ctrl+7)
What to pay attention to:
- **GC Alloc column:**Shows the number of bytes allocated in that frame. Goal: 0 in Update-heavy code.
- **GC.Collect spike:**Frames with sudden spikes that cost many ms — this is GC performing garbage collection.
Cách đọc Profiler:
1. Record một session game play
2. Tìm frame có GC Alloc > 0B trong section gameplay
3. Click vào dòng đó để xem call stack
4. Trace ngược về hàm nào đang gây allocation
Memory Profiler Package — Deeper analysis
Package Manager → "Memory Profiler" (Unity Technologies)
Allows you to:
- Capture a memory “snapshot” at a point in time
- Compare 2 snapshots to see which objects were additionally created
- Find objects that are "leaking" (still alive but no longer used)
Profiler API in code
csharp
usingUnity.Profiling;
// Tạo marker để đánh dấu vùng code muốn theo dõi
privatestaticreadonlyProfilerMarkers_SpawnMarker =
newProfilerMarker("EnemySpawner.Spawn");
publicvoidSpawnEnemy()
{
s_SpawnMarker.Begin();// Bắt đầu đo
// ... logic spawn
s_SpawnMarker.End();// Kết thúc đo
}
// Kết quả sẽ hiện rõ trong Profiler window với tên "EnemySpawner.Spawn"
🛡️ PART 4 — MITIGATION TECHNIQUES GC
1. Object Pooling — Reuse instead of creating new
Instead ofInstantiate (creating new) andDestroy (destroying), let's "borrow" and "return" objects:
csharp
// Unity 2021+ có sẵn UnityEngine.Pool
usingUnityEngine.Pool;
publicclassBulletSpawner :MonoBehaviour
{
[SerializeField]privateBullet_bulletPrefab;
privateObjectPool<Bullet>_pool;
privatevoidAwake()
{
_pool =newObjectPool<Bullet>(
createFunc: ()=>Instantiate(_bulletPrefab),
actionOnGet:bullet=>bullet.gameObject.SetActive(true),
actionOnRelease:bullet=>bullet.gameObject.SetActive(false),
actionOnDestroy:bullet=>Destroy(bullet.gameObject),
collectionCheck:true,// Check duplicate release (chỉ dùng khi Debug)
defaultCapacity:20,
maxSize:100
);
}
publicvoidShoot(Vector3direction)
{
varbullet =_pool.Get();// Lấy từ pool (không Instantiate)
bullet.Initialize(direction, ()=>_pool.Release(bullet));// Trả về pool khi xong
}
}
publicclassBullet :MonoBehaviour
{
privateAction_returnToPool;
publicvoidInitialize(Vector3dir,ActionreturnCallback)
{
_returnToPool =returnCallback;
// setup bullet...
}
privatevoidOnHitTarget()
{
_returnToPool?.Invoke();// Trả về pool thay vì Destroy
}
}
2. ArrayPool — Reuse temporary arrays
When you need a temporary array for a calculation, don'tnew T[]:
csharp
usingSystem.Buffers;
// ❌ Tạo mảng mới mỗi khi gọi — allocation!
voidFindNearbyTargets()
{
Collider[]hits =newCollider[20];// Rác!
Physics.OverlapSphereNonAlloc(pos,radius,hits);
ProcessTargets(hits,hits.Length);
}
// ✅ Mượn mảng từ pool, trả lại khi xong
voidFindNearbyTargets()
{
varhits =ArrayPool<Collider>.Shared.Rent(20);// Mượn
try
{
intcount =Physics.OverlapSphereNonAlloc(pos,radius,hits);
ProcessTargets(hits,count);
}
finally
{
ArrayPool<Collider>.Shared.Return(hits);// PHẢI trả lại (dùng try/finally)
}
}
3. Struct instead of Class for pure data
If a type only contains data, has no polymorphism, and does not need reference semantics — usestruct:
csharp
// ❌ Class — heap allocation mỗi khi tạo
publicclassDamageInfo
{
publicintAmount;
publicDamageTypeType;
publicVector3Direction;
}
// ✅ Struct — stack allocation, zero GC
publicstructDamageInfo
{
publicintAmount;
publicDamageTypeType;
publicVector3Direction;
}
// Nhược điểm cần biết: Struct được copy khi truyền qua tham số
// Dùng 'ref' hoặc 'in' để tránh copy với struct lớn
voidProcessDamage(inDamageInfoinfo)// 'in' = readonly ref, không copy
{
ApplyDamage(info.Amount);
}
4. Caching — Store results instead of recalculating
csharp
// ❌ Tính lại mỗi frame
voidUpdate()
{
if (Physics.Raycast(transform.position,transform.forward,outRaycastHithit))
{
// transform.position và transform.forward đều access C++ native layer
// Gọi nhiều lần trong một frame là lãng phí
}
}
// ✅ Cache Transform và các giá trị ổn định
privateTransform_transform;// Cache Component
privatevoidAwake() {_transform =transform; }// transform property truy cập native
voidUpdate()
{
Vector3pos =_transform.position;// 1 lần
Vector3fwd =_transform.forward;// 1 lần
if (Physics.Raycast(pos,fwd,outRaycastHithit)) { }
}
5. Pre-allocate Collection with appropriate capacity
csharp
// ❌ List tự resize (mỗi lần resize = tạo array mới lớn gấp đôi)
List<Enemy>enemies =newList<Enemy>();// Bắt đầu với capacity 4
// ✅ Pre-allocate khi biết trước số lượng tối đa
List<Enemy>enemies =newList<Enemy>(100);// Không resize cho đến khi > 100
// ✅ Với Dictionary
Dictionary<int,Station>stations =newDictionary<int,Station>(16);
6. Avoid creating a Delegate every frame
csharp
// ❌ Tạo delegate object mới mỗi khi gọi Invoke
voidUpdate()
{
Invoke(DoSomething,1f);// Delegate object mới mỗi frame? Không — Invoke nhận string
}
// ❌ Vấn đề thực sự: lambda tạo closure allocation
_button.onClick.AddListener(()=> {DoSomething(); });// Chỉ gọi 1 lần nhưng tạo object
// ✅ Dùng method group — tạo delegate 1 lần, reuse
_button.onClick.AddListener(DoSomething);// Không allocate khi gọi
// ✅ Cache delegate trong field
privateAction_cachedCallback;
privatevoidAwake() {_cachedCallback =DoSomething; }
📊 PART 5 — INCREMENTAL GC VS STOP-THE-WORLD GC
Since Unity 2019+you can enableIncremental GC:
Project Settings → Player → Use incremental GC
Stop-the-World GC (old):
Frame 1: ████████ (8ms logic)
Frame 2: ████████ (8ms logic)
Frame 3: ████████████████████ (20ms logic + GC.Collect!) ← SPIKE!
Frame 4: ████████ (8ms logic)
Incremental GC (new):
Frame 1: ████████░ (8ms + 1ms GC slice)
Frame 2: ████████░ (8ms + 1ms GC slice)
Frame 3: ████████░ (8ms + 1ms GC slice)
Frame 4: ████████░ (8ms + 1ms GC slice) — GC xong rồi
Incremental GC splits cleanup work across multiple frames, reducing spikes — butthere is still overhead. The real solution is stillreduce allocation to 0in the hot path.
Manual GC Control
csharp
// Gọi GC thủ công tại thời điểm "an toàn" (loading screen, scene transition)
// thay vì để nó tự chạy bất ngờ giữa gameplay
publicclassSceneLoader :MonoBehaviour
{
publicasyncvoidLoadScene(stringsceneName)
{
ShowLoadingScreen();
// Dọn rác trước khi load scene mới — người dùng không cảm nhận được spike
System.GC.Collect();
System.GC.WaitForPendingFinalizers();
System.GC.Collect();// Gọi 2 lần để đảm bảo dọn finalization queue
awaitSceneManager.LoadSceneAsync(sceneName);
HideLoadingScreen();
}
}
✅ PART 6 — PRACTICAL CHECKLIST
Here is the list you should check before shipping each feature:
Within Update / FixedUpdate / LateUpdate:
- No
new Class(),new List<>(),new array[] - No string concatenation with
+ - No LINQ query (Where, Select, OrderBy, ToList, ...)
- No
new WaitForSeconds()in Coroutine - No
GetComponent<>()(must be cached in Awake) - No lambda creating a new closure
When creating objects frequently:
- Bullets, effects, monsters → Use Object Pool
- Temporary arrays in a function → Use ArrayPool
For String:
- Update UI text → Only update when the value actually changes
- Concatenating multiple strings → Use StringBuilder
For Data Model:
- Small pure data structs (< 16 bytes), no inheritance → Use
struct - Event args → Use
structinstead ofclass
🎯 CONCLUSION
Memory management in Unity is not about optimizing every byte — it is abouteliminating unnecessary allocations in the hot path (Update loop) to GC do not need to run frequently.
Priority order when optimizing:
- Profile first— do not guess, open the Profiler and look at the real data
- Handle the hot path— Update, tight loops are where zero allocation is needed
- Pool everything that is created/destroyed frequently— bullets, effect, enemy
- Cache components and delegates— create once in Awake
- Use struct for small data — DamageInfo, EventArgs, ...
- Build → Profile on a real device— results on the Editor and device are very different
"Premature optimization is the root of all evil — but profiling is always good."— Donald Knuth (Unity version)
Reference: Unity Memory Management documentation, .NET GC documentation, Unity Blog — "Fixing Performance Problems"