Mục tiêu: Hiểu tường tận GC hoạt động như thế nào trong Unity, nhận diện được tất cả các "nguồn rác" ẩn trong code hằng ngày, và áp dụng các kỹ thuật cụ thể để giảm thiểu GC Spike — kẻ thủ tiêu FPS thầm lặng.
Không thể tải ảnh bên ngoài
📌 MỞ ĐẦU — KHI NÀO BẠN CẦN ĐỌC BÀI NÀY?
Bạn đang gặp những triệu chứng này:
Game chạy 50-60 FPS bình thường, nhưng cứ sau vài giây lại khựng 1-2 frame rồi tiếp tục.
Trong Unity Profiler, bạn thấy có những frame đột biến tốn 5-15ms cho một thứ gọi là GC.Collect.
Mở Memory Profiler thấy bộ nhớ cứ tăng dần, rồi tụt đột ngột — chu kỳ cứ lặp đi lặp lại.
Tất cả những triệu chứng này đều có cùng một thủ phạm: Garbage Collector (GC).
🔬 PHẦN 1 — GC HOẠT ĐỘNG NHƯ THẾ NÀO?
Hai vùng nhớ cơ bản
Để hiểu GC, trước tiên cần hiểu bộ nhớ được chia thành hai vùng:
Stack (Ngăn xếp)
Stack là vùng nhớ cực kỳ nhanh, hoạt động theo nguyên tắc LIFO (Vào sau ra trước). Khi một hàm được gọi, một "khung" (frame) mới được đẩy lên stack. Khi hàm kết thúc, khung đó bị bật ra ngay lập tức — không cần GC, không cần dọn dẹp.
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 (Đống)
Heap là vùng nhớ lớn hơn, dùng cho các object có kích thước không xác định hoặc cần sống lâu hơn một lần gọi hàm. Khi tạo object trên Heap, bộ nhớ được phân bổ (allocate). Khi không còn ai giữ reference đến nó, object trở thành "rác" — nhưng bộ nhớ không được giải phóng ngay.
Đây là lúc GC làm việc: Nó định kỳ chạy một thuật toán để quét (scan) toàn bộ Heap, tìm những object không còn ai tham chiếu, và giải phóng bộ nhớ của chúng. Quá trình này tốn thời gian — và trong Unity, nó chạy trên main thread, khiến game bị "đóng băng" trong tích tắc.
Reference Type vs Value Type
Đây là phân biệt cốt lõi bạn phải thuộc lòng:
Value Types → Sống trên Stack (hoặc inline trong object) → Không sinh rác GC:
int, float, bool, double, char
struct (Vector3, Color, Quaternion, Ray, ...)
enum
Reference Types → Sống trên Heap → Sinh rác GC khi không còn ai dùng:
class (MonoBehaviour, ScriptableObject, ...)
string
array (mảng int[] cũng sống trên Heap!)
delegate, Action, Func
object
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!
Generations trong GC (.NET)
GC của .NET (mà Unity dùng qua Mono hoặc IL2CPP) chia Heap thành 3 thế hệ (Generations):
Gen 0: Object mới sinh ra. GC thu thập thường xuyên và nhanh.
Gen 1: Object sống sót qua 1 lần GC. Thu thập ít thường xuyên hơn.
Gen 2: Object sống lâu dài (Singleton, Manager). Thu thập hiếm nhưng tốn kém nhất.
Vấn đề ở Unity là Incremental GC (từ Unity 2019+) hay Stop-the-World GC (cũ) — cả hai đều có chi phí. Mục tiêu là giảm thiểu allocation trên Gen 0, không phải tối ưu GC collection.
💣 PHẦN 2 — CÁC NGUỒN RÁC ẨN TRONG CODE HẰNG NGÀY
Đây là phần quan trọng nhất. Nhiều lập trình viên không biết những đoạn code "vô hại" này đang âm thầm tạo rác mỗi frame.
1. Boxing / Unboxing — Bẫy vô hình với Value Type
Boxing xảy ra khi một Value Type bị ép sang kiểuobject. C# phải tạo một wrapper object trên Heap để bọc giá trị đó lại — đây là 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 (Nối chuỗi) — Kẻ tạo rác số 1
String là immutable (bất biến) trong C#. Mỗi khi bạn "nối" chuỗi, một string mới được tạo trên Heap — string cũ trở thành rác.
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 — Tiện nhưng đắt giá
LINQ rất dễ đọc, nhưng mỗi LINQ operator tạo ra enumerator object và intermediate collection trên 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 trong Lambda — Bẫy ẩn khó nhận ra
Khi một lambda expression capture biến từ scope bên ngoài, C# tạo một closure object trên Heap để lưu biến đó.
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 với Collection không phải Array
foreach trên mảng (T[]) không allocate. Nhưng foreach trên List<T>, Dictionary<K,V>, hay bất kỳ IEnumerable<T> nào đều tạo enumerator 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 — Mỗi "yield return" là một object
Mỗi khi bạn tạo Coroutine, Unity phải tạo một state machine object. Thêm vào đó, một số yield return tạo thêm object:
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 — Tốn kém nếu gọi trong Update
Mặc dù đây không phải GC allocation thuần, nhưng cache component là best practice cần nhắc đến:
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");
}
🔬 PHẦN 3 — CÔNG CỤ PHÁT HIỆN RÁC
Unity Profiler — Cửa sổ đầu tiên cần mở
Window → Analysis → Profiler (Ctrl+7)
Những gì cần chú ý:
GC Alloc column: Hiển thị số byte được allocate trong frame đó. Mục tiêu: bằng 0 trong Update-heavy code.
GC.Collect spike: Những frame đột biến tốn nhiều ms — đây là GC đang dọn rác.
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
So sánh 2 snapshot để xem object nào được tạo thêm
Tìm những object bị "rò rỉ" (còn sống nhưng không ai dùng nữa)
Profiler API trong 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"
🛡️ PHẦN 4 — KỸ THUẬT GIẢM THIỂU GC
1. Object Pooling — Tái sử dụng thay vì tạo mới
Thay vì Instantiate (tạo mới) và Destroy (hủy), hãy "cho mượn" và "thu hồi" object:
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 — Tái sử dụng mảng tạm thời
Khi bạn cần mảng tạm thời cho một phép tính, đừng new 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 thay vì Class cho data thuần túy
Nếu một type chỉ chứa dữ liệu, không có polymorphism, không cần reference semantics — hãy dùng struct:
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 — Lưu kết quả thay vì tính lại
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 với dung lượng phù hợp
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. Tránh tạo Delegate mỗi 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; }
Incremental GC chia nhỏ công việc dọn dẹp ra nhiều frame, giảm spike — nhưng vẫn có overhead. Giải pháp thực sự vẫn là giảm allocation về 0 trong 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();
}
}
✅ PHẦN 6 — CHECKLIST THỰC HÀNH
Đây là danh sách bạn nên kiểm tra trước khi ship mỗi feature:
Trong vòng Update / FixedUpdate / LateUpdate:
Không có new Class(), new List<>(), new array[]
Không có string concatenation với +
Không có LINQ query (Where, Select, OrderBy, ToList, ...)
Không có new WaitForSeconds() trong Coroutine
Không có GetComponent<>() (phải cache trong Awake)
Không có lambda tạo closure mới
Khi tạo object thường xuyên:
Viên đạn, hiệu ứng, quái vật → Dùng Object Pool
Mảng tạm thời trong hàm → Dùng ArrayPool
Với String:
Cập nhật UI text → Chỉ update khi giá trị thực sự thay đổi
Nối nhiều chuỗi → Dùng StringBuilder
Với Data Model:
Các struct thuần dữ liệu nhỏ (< 16 byte), không cần inheritance → Dùng struct
Event args → Dùng struct thay vì class
🎯 KẾT LUẬN
Quản lý bộ nhớ trong Unity không phải là tối ưu từng byte — đó là việc loại bỏ các allocation không cần thiết trong hot path (vòng Update) để GC không cần chạy thường xuyên.
Thứ tự ưu tiên khi tối ưu:
Profile trước — đừng đoán mò, mở Profiler và xem thực tế
Xử lý hot path — Update, các vòng lặp tight là nơi cần zero allocation
Pool mọi thứ tạo/hủy thường xuyên — đạn, effect, enemy
Cache component và delegate — tạo 1 lần trong Awake
Dùng struct cho data nhỏ — DamageInfo, EventArgs, ...
Build → Profile trên device thực — kết quả trên Editor và device rất khác nhau
"Premature optimization is the root of all evil — but profiling is always good." — Donald Knuth (phiên bản Unity)
Tham khảo: Unity Memory Management documentation, .NET GC documentation, Unity Blog — "Fixing Performance Problems"