How to Choose and Manage Collections for Performance in Unity
- Unity collections performance directly impacts your game's frame rate and memory usage - choosing between managed collections (List, Array, Dictionary) and native collections (NativeArray, Nativ...
Key Takeaways
- Unity collections performance directly impacts your game's frame rate and memory usage - choosing between managed collections (List, Array, Dictionary) and native collections (NativeArray, NativeList) can mean the difference between 15 FPS and 70 FPS
- Arrays are approximately 5x faster than List
for reads and writes, making them ideal for fixed-size data with 200k+ items per frame, while List works best for dynamic data under 5,000 elements - Unity native collections like NativeArray eliminate garbage collection overhead entirely and enable Job System multithreading, achieving 10-50x performance improvements when combined with Burst compiler
- Garbage collection Unity spikes cause visible frame drops - target zero GC allocations per frame by reusing collections with Clear(), pre-allocating capacity, and avoiding LINQ in Update loops
- Unity Job System with NativeArray Unity enables true multithreading across CPU cores, with real-world examples showing 35x improvements (140ms reduced to 4ms) for parallel operations
- Unity DOTS ECS architecture with DynamicBuffer components enables processing millions of entities efficiently, as demonstrated by games like V Rising and Unity's Megacity demo
- Memory allocators matter: use Allocator.Temp for single-frame data, Allocator.TempJob for job data living ≤4 frames, and Allocator.Persistent sparingly for long-lived data
- All native collections require explicit disposal using Dispose() or try-finally patterns - forgetting disposal causes memory leaks that crash your game
Ready to Start Building Your First Game?
Understanding Unity collections performance is crucial for building games that run smoothly across different devices. Whether you're optimizing a mobile game or building a large-scale multiplayer experience, choosing the right data structures makes all the difference.
Want to go from Unity basics to building professional game experiences? Check out our comprehensive course that takes you through everything from core concepts to advanced optimization techniques. You'll learn by building real projects and solving actual game development challenges.
Why Your Game's Frame Rate Depends on Collections
Here's the thing - I've seen way too many student projects that look amazing but run at 15 FPS because of one simple mistake: using the wrong collection type in Update loops. You spend weeks building beautiful game mechanics, polish your visuals until they shine, and then your game stutters every few seconds because garbage collection decides to kick in during an intense combat sequence.
Been there. Early in my career, I built this enemy wave system that worked perfectly with 10 enemies. Scaled it to 100 enemies for a bigger level, and suddenly the game was unplayable. The culprit? I was creating new List
The difference between a List and a NativeArray isn't just academic. It's the difference between your game running smoothly at 60 FPS or players rage-quitting because of frame drops. Let me show you exactly how to choose and manage collections so your games perform the way they should.
What Makes Collections a Performance Bottleneck
Let me start with something that took me months to figure out when I was learning Unity: collections aren't just containers for your data. They're active participants in your game's performance story, and they can absolutely wreck your frame rate if you choose wrong.
Collections determine three critical things: how your data is stored in memory, whether garbage collection will interrupt your gameplay, and whether you can leverage Unity's modern multithreading systems. That third point is huge - Unity Job System and Unity DOTS ECS only work with specific collection types, so your choice literally determines whether you can use Unity's most powerful performance features.
Unity offers two completely different collection ecosystems. Traditional C# managed collections (List
The key insight I wish someone had told me earlier: understanding when to use each type isn't optional knowledge for "advanced" developers. It's essential for building games that actually run well. Let's dig into how these systems work and when you should use each one.
The Two Collection Ecosystems You Need to Know
Unity gives you two completely separate worlds of collections, and understanding the difference is crucial.
Managed Collections are the traditional .NET types you might already know: List
According to Unity's official documentation, "The Unity runtime engine doesn't return managed memory to the operating system for reuse until the user terminates the application." That means every time you allocate managed memory, you're permanently growing your game's memory footprint until the player quits. This isn't theoretical - I've debugged games where memory just kept climbing because developers were creating new collections in Update loops.
Official Documentation: Understanding Managed Memory
Native Collections live in unmanaged memory outside the garbage collector's reach. Unity.Collections types like NativeArray, NativeList, NativeHashMap use memory you explicitly allocate and must explicitly dispose. This manual management eliminates GC overhead entirely but introduces memory leak risks if you forget disposal.
The magic of native collections is that they're "thread-safe C# wrappers for unmanaged memory" that enable jobs to access shared data with the main thread rather than working with copies. This is what makes Unity Job System actually work - you can pass NativeArray references to worker threads safely.
Official Documentation: Unity.Collections Package Overview
Here's the practical difference: managed collections for prototyping and non-critical systems, native collections for performance-critical gameplay loops and anything using Job System or DOTS.
When Memory Management Becomes Your Problem
Garbage Collection (GC) is Unity's system for automatically reclaiming unused managed memory. Sounds helpful, right? Actually, it's one of the biggest performance pitfalls in Unity development.
Unity uses the Boehm-Demers-Weiser garbage collector, which is a conservative, non-generational collector. The critical detail: it's not generational, meaning it can't efficiently sweep small, frequent temporary allocations. Every time you create a new List in Update() or use LINQ, you're creating work for the GC. When the collector eventually runs, it stops all program execution - this "stop-the-world" behavior is what causes those visible frame rate hitches.
Official Documentation: Garbage Collection Best Practices
The primary optimization strategy Unity recommends is to "avoid triggering the garbage collector frequently." This means minimizing heap allocations, reusing collections via Clear() instead of creating new instances, and preferring native collections in performance-critical scenarios.
Unity 2019+ includes an incremental GC mode that spreads collection work across multiple frames, reducing individual spike magnitude even if total GC work remains unchanged. This helps, but doesn't eliminate the problem.
Memory Allocators for Native Collections
When you create native collections, you choose one of three allocator types that determine lifetime and performance:
Allocator.Temp - "The fastest allocator, for short-lived allocations." Maximum lifetime of one frame. The main thread creates one Temp allocator per frame and deallocates it automatically at frame end. Minimum alignment is 64 bytes. Critical restriction: thread-scoped usage only - you cannot pass main thread Temp allocations to jobs.
Allocator.TempJob - Moderate speed allocator allowing cross-thread usage in jobs. Critical constraint: "you must deallocate TempJob allocations within 4 frames of their creation." Native collections throw exceptions if TempJob allocations exceed this window. Minimum alignment is 16 bytes.
Allocator.Persistent - "The slowest allocator for indefinite lifetime allocations." Supports unlimited lifetime throughout application runtime. "You must deallocate a Persistent allocation when you no longer need it" since safety checks cannot detect lifetime violations. Minimum alignment is 16 bytes.
Official Documentation: Allocator Overview
// Temp - fastest, single frame
NativeArray<int> tempData = new NativeArray<int>(100, Allocator.Temp);
// TempJob - jobs, max 4 frames
NativeArray<int> jobData = new NativeArray<int>(100, Allocator.TempJob);
// Must dispose within 4 frames
// Persistent - long-lived, slowest
NativeArray<int> persistentData = new NativeArray<int>(100, Allocator.Persistent);
// Must dispose manually when done
Choose allocators based on data lifetime: Temp for immediate single-frame use, TempJob for job-scheduled operations spanning up to 4 frames, and Persistent only for truly long-lived data where the performance cost is acceptable.
Traditional C# Collections: Your Starting Point
Unity supports the full range of .NET Standard generic collections: List
Official Documentation: .NET Collections Selection Guide
For prototyping and early development, these collections are perfect. They're convenient, familiar if you know C#, and have rich APIs for manipulation. The problems emerge when you start scaling - more enemies, more projectiles, more data processed per frame.
I always tell students to start with traditional collections. Get your game working first. Then profile to find bottlenecks. Only then should you migrate performance-critical systems to native collections. Premature optimization wastes time on systems that don't matter.
Arrays - The Speed Champion for Fixed Data
Arrays represent Unity's fastest traditional collection type for fixed-size data. Performance benchmarking from Jackson Dunstan shows arrays are approximately 5x faster than List
// Fixed-size array - best performance
int[] fixedData = new int[1000];
// Direct element access - no overhead
for (int i = 0; i < fixedData.Length; i++)
{
fixedData[i] = i * 2;
}
Use Arrays When:
- Element count is known at initialization and doesn't change
- Maximum performance required for iteration (200k+ items per frame)
- Working with tile grids, lookup tables, or pre-allocated buffers
- Not using Job System or DOTS
Performance Characteristics:
- Fastest iteration speed among managed collections
- Fixed capacity - cannot resize after creation
- Contiguous memory layout for cache efficiency
- Zero overhead for element access
- foreach loops on arrays generate no garbage (Unity optimized this)
Limitations:
- Cannot resize dynamically
- No built-in methods for adding/removing elements
- Manual index management required
I use arrays extensively for game grids (tilemap data), lookup tables (damage calculations by level), and any fixed-size buffer that processes every frame. The speed difference vs List is real and measurable.
List - The Flexible Workhorse
List
// Dynamic list with pre-allocated capacity
private List<Enemy> enemies = new List<Enemy>(100);
void ProcessEnemies()
{
// Reuse list - avoid new allocation
enemies.Clear();
// Add enemies
foreach (var enemy in allEnemies)
{
if (enemy.isActive)
enemies.Add(enemy);
}
// Process enemies
for (int i = 0; i < enemies.Count; i++)
{
enemies[i].Update();
}
}
Official Documentation: List Performance Optimization
Use List
- Need dynamic sizing with frequent additions/removals
- Collection size under approximately 5,000 elements
- Working with traditional GameObject-based code
- Implementing object pooling systems
- Not processing every frame or performance-critical
Performance Characteristics:
- Dynamic resizing - capacity doubles when exceeded
- O(1) indexed access by position
- O(n) search without index
- Foreach loops now garbage-free on concrete List
(modern Unity) - For loops 3x faster than foreach due to enumerator overhead
Critical Performance Tip - Capacity Pre-allocation:
When a List exceeds its capacity, it must allocate a new larger array (typically double size), copy all elements, and deallocate the old array. This is expensive. Pre-allocate capacity when size is predictable:
// Bad - starts small, triggers multiple resizes
List<Item> items = new List<Item>(); // Default capacity ~4
for (int i = 0; i < 1000; i++)
{
items.Add(new Item()); // Triggers ~8 resize operations
}
// Good - pre-allocate to avoid resizing
List<Item> items = new List<Item>(1000);
for (int i = 0; i < 1000; i++)
{
items.Add(new Item()); // No resizing needed
}
Collection Reuse Pattern:
Creating new List instances every frame causes continuous GC pressure. Reuse collections via Clear():
// Bad - allocates 40+ bytes per frame
void Update()
{
List<Enemy> nearby = new List<Enemy>();
// populate and use
}
// Good - reuse collection
private List<Enemy> nearbyEnemies = new List<Enemy>(100);
void Update()
{
nearbyEnemies.Clear(); // Capacity remains 100
// populate and use - no allocation
}
This pattern alone eliminated 90% of GC allocations in one of my student projects. Simple change, massive impact.
Dictionary and HashSet - Fast Lookups with Hidden Costs
Dictionary provides O(1) average-case lookup by key but performs 8-10x slower than List when iterating over all elements. The hash table structure introduces memory overhead beyond simple element storage.
// Dictionary for fast key-value lookups
private Dictionary<int, Item> itemsById = new Dictionary<int, Item>(100);
void RegisterItem(Item item)
{
itemsById[item.id] = item; // O(1) insertion
}
Item GetItem(int id)
{
if (itemsById.TryGetValue(id, out Item item))
return item;
return null;
}
Use Dictionary<TKey, TValue> When:
- Need O(1) lookups by non-integer keys (strings, custom types)
- Primarily accessing individual elements rather than iterating all
- Key-value relationships central to data structure design
- Not in per-frame hot paths
Performance Characteristics:
- O(1) average-case lookup by key
- Slower Add operation than List
- 8-10x slower iteration than List
- Higher memory overhead due to hash table
- Initial capacity pre-allocation recommended
Critical Pitfall - Enum Keys:
Using enum keys with Dictionary causes heap allocations on every lookup due to boxing. This is problematic for performance-critical code:
// Bad - boxes enum on every lookup (allocates 24+ bytes)
private Dictionary<EnemyType, int> enemyCounts = new Dictionary<EnemyType, int>();
int count = enemyCounts[EnemyType.Zombie]; // Boxing allocation!
// Good - use enum as integer array index
private int[] enemyCounts = new int[(int)EnemyType.Count];
int count = enemyCounts[(int)EnemyType.Zombie]; // No allocation
I learned this one the hard way during a game jam. My ability cooldown system was boxing enums every Update(), causing continuous allocations. Switching to array indexing made the problem disappear instantly.
HashSet
// HashSet for uniqueness and fast membership testing
private HashSet<int> processedEntityIds = new HashSet<int>();
void ProcessEntity(int entityId)
{
if (!processedEntityIds.Add(entityId))
return; // Already processed - Add returns false for duplicates
// Process entity
}
Use HashSet
- Need automatic uniqueness guarantees
- Fast Contains() checks required
- Performing set operations
- Not iterating frequently
Performance Characteristics:
- O(1) Contains() and Add() operations
- Automatically ensures uniqueness
- Similar memory overhead to Dictionary
- Faster than List.Contains() for large datasets
Queue and Stack - Specialized Ordering
Queue
// Queue for FIFO processing
private Queue<Action> actionQueue = new Queue<Action>();
void EnqueueAction(Action action)
{
actionQueue.Enqueue(action);
}
void ProcessActions()
{
while (actionQueue.Count > 0)
{
Action action = actionQueue.Dequeue();
action.Invoke();
}
}
Use Queue
I use queues for command systems and event processing. Stacks are perfect for undo/redo functionality and certain pathfinding algorithms.
Object Pooling for Collection Reuse
Unity 2021+ includes built-in CollectionPool for reusing List, Dictionary, and HashSet instances to minimize GC pressure:
using UnityEngine.Pool;
// Get pooled list
var list = ListPool<int>.Get();
try
{
// Use list
list.Add(1);
list.Add(2);
}
finally
{
// Return to pool (automatically cleared)
ListPool<int>.Release(list);
}
// Also available:
// DictionaryPool<TKey, TValue>
// HashSetPool<T>
Official Documentation: CollectionPool API
Benchmarking shows object pooling can improve spawning performance by 77% compared to instantiation (~33% average improvement). For scenarios creating/destroying 600+ GameObjects per second, pooling prevents frame rates from dropping to 10-20 fps on mid-range CPUs.
Important Caveat: CollectionPool is not thread-safe and can break domain reload in the editor if used carelessly, as pooled elements persist across editor runs.
Unity Native Collections - Zero Garbage, Maximum Speed
Here's where things get interesting. Unity native collections are the foundation of Unity's high-performance programming stack. They eliminate garbage collection entirely, enable true multithreading through the Job System, and integrate seamlessly with the Unity Burst compiler for extreme optimization.
The Unity.Collections package provides three tiers of collections:
Official Documentation: Collections Package Manual
1. Native Collections (Unity.Collections namespace): Include safety checks ensuring proper disposal and thread-safe usage. Types begin with "Native" prefix (NativeArray, NativeList, NativeHashMap, NativeHashSet). These should be your default choice.
NativeArray<float> data = new NativeArray<float>(1000, Allocator.TempJob);
// Use data in jobs...
data.Dispose(); // Required - manual disposal
Official Documentation: NativeArray API Reference
2. Unsafe Collections (Unity.Collections.LowLevel.Unsafe namespace): Omit safety checks for maximum performance. Types begin with "Unsafe" prefix (UnsafeList, UnsafeHashMap). Use when safety checks verified at higher level or nesting required.
3. Fixed Collections: Stack-allocated types with fixed byte capacity (FixedList32Bytes through FixedList4096Bytes, FixedString32Bytes through FixedString4096Bytes). These require no disposal as they're value types stored on the stack.
A critical architectural constraint: "Native collection types can't contain other Native collections" due to how the safety system functions. For nested structures, use hybrid patterns like NativeList<UnsafeList<T>>.
Official Documentation: Unsafe Collections Namespace
The real power of native collections becomes clear when combined with Job System and Burst. I've seen performance improvements ranging from 10x to 50x just by switching from managed collections in Update() to native collections in jobs. The combination is genuinely transformative.
NativeArray - Your First Step into High Performance
NativeArray Unity is "a fixed-size unmanaged memory buffer accessible from managed code" that enables "data sharing between managed and native code without marshalling costs." It's Unity's foundational collection type for the Job System.
// Create NativeArray with TempJob allocator
NativeArray<float> positions = new NativeArray<float>(1000, Allocator.TempJob);
// Schedule job using the array
var job = new ProcessPositionsJob
{
positions = positions
};
JobHandle handle = job.Schedule();
// Wait for completion
handle.Complete();
// Must manually dispose
positions.Dispose();
Official Documentation: NativeArray API Reference
Use NativeArray
- Using Job System or DOTS
- Burst compilation enabled
- Zero GC allocation critical
- Fixed-size buffer sufficient
- Can manage explicit disposal
- Working with large datasets
Performance Characteristics:
- Fixed capacity set at construction
- Zero garbage collection overhead (unmanaged memory)
- Direct memory access from jobs
- Thread-safe with job system safety checks
- Performance matches or exceeds traditional arrays in builds
- In Editor: ~10x slower than arrays due to safety checks
- In Builds: Safety checks disabled, performance competitive or superior
Real-world benchmark: Unity forum test with 100k points showed regular arrays at 60 FPS while NativeArray achieved 79 FPS in production context, demonstrating NativeArray's superior performance when safety checks disabled.
Type Constraints:
- Only accepts blittable types (structs, primitive types)
- Cannot contain reference types or managed objects
- No classes, strings, or other managed types
API Operations:
// Creation
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
// Element access
data[0] = 5;
int value = data[0];
// Copy operations
int[] managedArray = new int[100];
data.CopyFrom(managedArray);
data.CopyTo(managedArray);
// Read-only variant
NativeArray<int>.ReadOnly readOnly = data.AsReadOnly();
// Check if allocated
bool isValid = data.IsCreated;
// Disposal
data.Dispose();
// Scheduled disposal after job
JobHandle handle = myJob.Schedule();
data.Dispose(handle); // Disposes after job completes
Critical Disposal Pattern:
All native collections must be explicitly disposed. Forgetting disposal causes memory leaks with the error "A Native Collection has not been disposed, resulting in a memory leak." Use try-finally patterns or Dispose(JobHandle) for scheduled jobs:
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
try
{
// Use data
}
finally
{
data.Dispose();
}
I recommend wrapping NativeArray usage in try-finally blocks during development. It's saved me from countless memory leaks when exceptions occurred before reaching disposal code.
NativeList - Dynamic Sizing Without GC Overhead
NativeList
// Create NativeList with initial capacity
NativeList<float> dynamicData = new NativeList<float>(100, Allocator.TempJob);
// Add elements (auto-resizes if needed)
for (int i = 0; i < 200; i++)
{
dynamicData.Add(i * 1.5f);
}
// Access like array
float value = dynamicData[50];
// Use in job
var job = new ProcessListJob
{
data = dynamicData
};
job.Schedule().Complete();
// Dispose
dynamicData.Dispose();
Official Documentation: NativeList API Reference
Use NativeList
- Need dynamic sizing in jobs
- Can predict approximate capacity to minimize reallocations
- Require ParallelWriter for concurrent appends
- Burst compilation benefits justify disposal overhead
Performance Characteristics:
- Dynamic resizing - capacity increases when exceeded
- Access speed identical to NativeArray
- Automatic capacity doubling on overflow
- RemoveAt: O(n) due to shifting elements
- RemoveAtSwapBack: O(1) by swapping with last element
- Supports parallel writes via AsParallelWriter()
Key API Methods:
NativeList<int> list = new NativeList<int>(10, Allocator.TempJob);
// Adding elements
list.Add(5);
list.AddNoResize(10); // Throws if capacity exceeded
list.AddRange(otherArray);
list.AddReplicate(100, 5); // Add value 5 times
// Removing elements
list.RemoveAt(0); // Shifts remaining items - O(n)
list.RemoveAtSwapBack(0); // Swaps with last - O(1)
list.Clear(); // Removes all, keeps capacity
// Capacity management
list.Capacity = 200; // Set capacity
list.TrimExcess(); // Match capacity to length
// Conversion
NativeArray<int> array = list.AsArray(); // View as array
list.Dispose();
Parallel Writing:
[BurstCompile]
struct ParallelAppendJob : IJobParallelFor
{
public NativeList<int>.ParallelWriter results;
public void Execute(int index)
{
results.AddNoResize(index * 2); // Thread-safe append
}
}
NativeList<int> results = new NativeList<int>(1000, Allocator.TempJob);
results.Resize(1000, NativeArrayOptions.UninitializedMemory);
var job = new ParallelAppendJob
{
results = results.AsParallelWriter()
};
job.Schedule(1000, 64).Complete();
results.Dispose();
Important Limitation: NativeList cannot change capacity during parallel jobs. Pre-allocate sufficient capacity before parallel writes, or use AsParallelWriter() with AddNoResize().
NativeHashMap and Parallel Variants
NativeHashMap is "an unordered, expandable associative array" providing key-value storage in unmanaged memory. Both key and value must be unmanaged types with TKey implementing IEquatable
// Create hash map
NativeHashMap<int, float> scoreMap = new NativeHashMap<int, float>(100, Allocator.TempJob);
// Add key-value pairs
scoreMap.Add(1, 100.5f);
// Safe addition (doesn't throw if key exists)
bool added = scoreMap.TryAdd(2, 250.0f);
// Retrieve values
if (scoreMap.TryGetValue(1, out float score))
{
// Use score
}
// Check key existence
bool hasKey = scoreMap.ContainsKey(1);
// Remove entries
bool removed = scoreMap.Remove(1);
// Clear all
scoreMap.Clear();
// Dispose
scoreMap.Dispose();
Official Documentation: NativeHashMap API Reference
Use NativeHashMap<TKey, TValue> When:
- Need key-value lookups in jobs
- Not using Job System (single-threaded)
- Working with DOTS/ECS systems
- Willing to accept 3-5x slower adds vs NativeList
- Can manage explicit disposal
Performance Characteristics:
- O(1) average-case lookup by key
- Unordered - no iteration order guarantee
- Expandable - automatically grows capacity
- Add performance roughly 3-5x slower than NativeList
- Perform about 20-30% faster than Dictionary in read-only scenarios with Job System
- Not suitable for parallel write access - use NativeParallelHashMap instead
NativeParallelHashMap<TKey, TValue>
NativeParallelHashMap extends NativeHashMap with thread-safe parallel write capabilities. The critical difference is the AsParallelWriter() method which "enables concurrent writes across multiple jobs."
// Create parallel hash map
NativeParallelHashMap<int, float> parallelMap =
new NativeParallelHashMap<int, float>(1000, Allocator.TempJob);
// Use in parallel job
[BurstCompile]
struct BuildMapJob : IJobParallelFor
{
public NativeParallelHashMap<int, float>.ParallelWriter map;
public void Execute(int index)
{
map.TryAdd(index, index * 2.5f); // Thread-safe add
}
}
var job = new BuildMapJob
{
map = parallelMap.AsParallelWriter()
};
job.Schedule(1000, 64).Complete();
parallelMap.Dispose();
Official Documentation: NativeParallelHashMap API Reference
Use NativeParallelHashMap<TKey, TValue> When:
- Need concurrent writes from parallel jobs
- Zero GC allocation required
- Working with Burst-compiled code
- Slightly higher memory overhead acceptable
NativeParallelMultiHashMap<TKey, TValue>
NativeParallelMultiHashMap allows multiple values per key. "The keys are not deduplicated: two key-value pairs with the same key are stored as fully separate key-value pairs." This enables one-to-many relationships.
// Create multi hash map
NativeParallelMultiHashMap<int, Entity> cellToEntities =
new NativeParallelMultiHashMap<int, Entity>(1000, Allocator.TempJob);
// Add multiple values for same key
cellToEntities.Add(5, entity1);
cellToEntities.Add(5, entity2);
cellToEntities.Add(5, entity3);
// Iterate all values for a key
if (cellToEntities.TryGetFirstValue(5, out Entity entity, out var iterator))
{
do
{
// Process entity
} while (cellToEntities.TryGetNextValue(out entity, ref iterator));
}
// Count values for key
int count = cellToEntities.CountValuesForKey(5);
cellToEntities.Dispose();
Official Documentation: NativeParallelMultiHashMap API Reference
Use NativeParallelMultiHashMap<TKey, TValue> When:
- Storing multiple related values under single keys
- Implementing one-to-many relationships
- Spatial partitioning (grid cell → entities in cell)
- Grouping entities by properties
I use NativeParallelMultiHashMap extensively for spatial queries in my projects - dividing the world into grid cells and storing which entities are in each cell. It's perfect for optimizing collision detection and AI perception systems.
NativeQueue and NativeStream for Specialized Workflows
NativeQueue
// Create queue
NativeQueue<int> queue = new NativeQueue<int>(Allocator.TempJob);
// Enqueue elements
queue.Enqueue(1);
queue.Enqueue(2);
queue.Enqueue(3);
// Dequeue elements
int first = queue.Dequeue(); // Returns 1
// Safe dequeue
if (queue.TryDequeue(out int value))
{
// Use value
}
// Peek without removing
int front = queue.Peek();
// Check if empty
bool empty = queue.IsEmpty();
queue.Dispose();
Official Documentation: NativeQueue API Reference
Use NativeQueue
- Need FIFO behavior in jobs
- Producer-consumer patterns
- Breadth-first traversal algorithms
- Work queue systems
Performance Note: Count property has O(n) complexity (traverses internal structure) - cache if used repeatedly.
NativeStream provides "a set of untyped, append-only buffers" designed for concurrent operations with variable-length data. The architecture excels because "each individual buffer is written in one thread and read in one thread, multiple threads can read and write the stream concurrently."
Official Documentation: NativeStream API Reference
Unlike traditional dynamic arrays, NativeStream uses "a chain of blocks" for storage. When capacity exceeded, new blocks are appended rather than copying existing data - a significant efficiency advantage for growing datasets.
// Create stream with one buffer per thread
int threadCount = JobsUtility.MaxJobThreadCount;
NativeStream stream = new NativeStream(threadCount, Allocator.TempJob);
// Writing in job
[BurstCompile]
struct WriteStreamJob : IJobParallelFor
{
public NativeStream.Writer writer;
public void Execute(int index)
{
writer.BeginForEachIndex(index);
writer.Write(index);
writer.Write(index * 2.0f);
writer.EndForEachIndex();
}
}
var writeJob = new WriteStreamJob
{
writer = stream.AsWriter()
};
writeJob.Schedule(threadCount, 1).Complete();
// Reading after all writes complete
NativeStream.Reader reader = stream.AsReader();
for (int i = 0; i < threadCount; i++)
{
reader.BeginForEachIndex(i);
while (!reader.EndOfStream())
{
int intValue = reader.Read<int>();
float floatValue = reader.Read<float>();
}
reader.EndForEachIndex();
}
stream.Dispose();
Use NativeStream When:
- Multi-threaded data collection without locks
- Variable-length heterogeneous data storage
- Job-based parallel processing pipelines
- Different amounts of data per thread
Critical Usage Pattern: "All writing to a stream should be completed before the stream is first read. Do not write to a stream after the first read."
FixedList - Stack-Allocated Collections
FixedList variants (FixedList32Bytes, FixedList64Bytes, FixedList128Bytes, FixedList512Bytes, FixedList4096Bytes) are "unmanaged, resizable lists whose content is all stored directly in the struct." This design allows entire lists to reside on the stack rather than requiring heap allocation.
// Stack-allocated list - no heap allocation
FixedList128Bytes<int> smallList = new FixedList128Bytes<int>();
// Add elements
smallList.Add(1);
smallList.Add(2);
smallList.Add(3);
// Access elements
int value = smallList[0];
// Use in ECS component
public struct PathComponent : IComponentData
{
public FixedList128Bytes<float3> waypoints; // No disposal needed
}
// No disposal required - value type
Official Documentation: FixedList32Bytes API Reference
Use FixedList When:
- Small, temporary collections in performance-critical code
- Collections inside ECS components (Native types not allowed)
- Avoiding heap allocations in hot paths
- Burst-compiled code requiring maximum performance
- Collection size has small, known maximum
Performance Characteristics:
- Zero heap allocation - entire structure on stack
- Zero garbage collection pressure
- Extremely fast access and iteration
- Value type semantics (copied by value)
- No disposal required
Size Constraints:
The struct has fixed byte capacity. Actual element capacity depends on sizeof(T):
- FixedList32Bytes: ~30 bytes for elements
- FixedList64Bytes: ~62 bytes for elements
- FixedList128Bytes: ~126 bytes for elements
- FixedList512Bytes: ~510 bytes for elements
- FixedList4096Bytes: ~4094 bytes for elements
Choose size variant based on maximum expected element count. Attempting to copy from larger FixedList variants throws IndexOutOfRangeException if source length exceeds capacity.
NativeReference
NativeReference
// Single value container
NativeReference<int> counter = new NativeReference<int>(0, Allocator.TempJob);
[BurstCompile]
struct IncrementJob : IJob
{
public NativeReference<int> counter;
public void Execute()
{
counter.Value++;
}
}
var job = new IncrementJob { counter = counter };
job.Schedule().Complete();
int result = counter.Value;
counter.Dispose();
Official Documentation: NativeReference API Reference
Use NativeReference
- Need single value accessible across jobs and main thread
- Job output parameters (result values, counters)
- Clearer semantics than single-element arrays
- Maintaining unmanaged memory efficiency
Unsafe Collections
For performance-critical scenarios, Unity provides Unity.Collections.LowLevel.Unsafe types like UnsafeList and UnsafeHashMap. Key characteristics:
- No safety warnings - developers fully responsible
- Almost all Native* containers implemented internally using Unsafe* containers
- In release builds (debug safety disabled), no performance difference between Unsafe* and Native*
- Provide parallel reader and parallel writer instances
- Support nesting (e.g., UnsafeList<UnsafeList
>)
Official Documentation: Unsafe Collections Namespace
When to Use Unsafe Collections:
- Nested collection structures (Native can't contain Native)
- Maximum performance critical paths
- Safety checks verified at higher level
- Advanced scenarios requiring pointer manipulation
// Nested collections require Unsafe variants
NativeList<UnsafeList<int>> nestedLists = new NativeList<UnsafeList<int>>(10, Allocator.TempJob);
UnsafeList<int> innerList = new UnsafeList<int>(10, Allocator.TempJob);
innerList.Add(1);
innerList.Add(2);
nestedLists.Add(innerList);
// Dispose in reverse order
for (int i = 0; i < nestedLists.Length; i++)
{
nestedLists[i].Dispose();
}
nestedLists.Dispose();
ECS Collections - When You're Building at Scale
DOTS (Data-Oriented Technology Stack) is Unity's architecture for building high-performance, scalable applications through data-oriented design principles. The Entities package provides "a data-oriented implementation of the Entity Component System (ECS) architecture" where data and logic are strictly separated.
Official Documentation: Unity Entities Package
Entities are "a unique identifier, like a lightweight unmanaged alternative to a GameObject" - they serve as IDs that associate with components rather than containing code. Components are pure data containers attached to entities that "contain data about the entity" with "entities contain no code." Systems contain all processing logic that iterates over entities with specific component combinations.
The architecture enables archetypes which "group together components" - entities with identical component sets share an archetype. This allows Unity to store entity data in contiguous memory chunks optimized for cache efficiency. The memory layout is the foundation of ECS performance benefits.
Production games using DOTS report CPU performance improvements ranging from 5-50x depending on parallelization effectiveness. Conway's Game of Life implementation using DOTS achieved "almost 200 times" speedup compared to non-DOTS versions. Hardspace: Shipbreaker reduced specific processes from 1 hour to 100 milliseconds (360x improvement).
DynamicBuffer - The ECS Way to Handle Arrays
DynamicBuffer
// Define buffer element
public struct PathPoint : IBufferElementData
{
public float3 position;
}
// Add buffer to entity
Entity pathEntity = entityManager.CreateEntity();
DynamicBuffer<PathPoint> pathBuffer = entityManager.AddBuffer<PathPoint>(pathEntity);
// Add elements
pathBuffer.Add(new PathPoint { position = new float3(0, 0, 0) });
pathBuffer.Add(new PathPoint { position = new float3(1, 0, 0) });
pathBuffer.Add(new PathPoint { position = new float3(2, 0, 0) });
// Access in system
partial class PathFollowSystem : SystemBase
{
protected override void OnUpdate()
{
Entities.ForEach((Entity entity,
ref Translation translation,
in DynamicBuffer<PathPoint> path) =>
{
if (path.Length > 0)
translation.Value = path[0].position;
}).Schedule();
}
}
Official Documentation: Dynamic Buffer Components
Use DynamicBuffer
- Working with ECS entities
- Need dynamic array as a component
- Want ECS to manage lifecycle automatically
- Small-to-medium sized collections (benefit from inline storage)
Performance Characteristics:
- Small buffers (typically 16-32 elements) stored inline within ArchetypeChunk
- Inline storage maximizes cache efficiency alongside other components
- When buffer exceeds internal capacity, ECS allocates external heap memory
- Integrates with ECS archetype system and entity queries
- Automatic lifecycle management (no manual disposal)
DynamicBuffer vs NativeList:
Choose DynamicBuffer when data is attached to entities as components. DynamicBuffer integrates with entity queries and component system. Choose NativeList for temporary collections within jobs not attached to entities.
Internal Capacity Configuration:
// Specify internal capacity (default varies)
[InternalBufferCapacity(64)]
public struct LargePathPoint : IBufferElementData
{
public float3 position;
public float3 velocity;
}
Higher internal capacity stores more elements inline but increases entity size in chunks. Balance between inline storage benefits and chunk Unity memory optimization.
Thread Safety and the Job System Safety Net
Unity's native collections integrate with a sophisticated safety system tracking read/write access across jobs. The safety system enforces deterministic behavior by preventing simultaneous writes to the same container by different jobs, allowing multiple parallel reads from identical data, blocking main thread access until dependent jobs complete, and throwing clear exceptions when violations occur.
Official Documentation: Job System Native Containers
By default, jobs have read and write access to native collections. This prevents Unity from scheduling multiple jobs writing to the same collection simultaneously. The [ReadOnly] attribute enables crucial optimizations.
ReadOnly Attribute
The ReadOnlyAttribute marks native container fields in job structs as read-only, enabling parallel execution of multiple jobs accessing the same data.
[BurstCompile]
public struct ParallelReadJob : IJobParallelFor
{
[ReadOnly]
public NativeArray<float> input; // Multiple jobs can read
public NativeArray<float> output; // Only this job writes
public void Execute(int index)
{
output[index] = math.sqrt(input[index]);
}
}
// Can schedule multiple jobs reading same input
NativeArray<float> sharedInput = new NativeArray<float>(1000, Allocator.TempJob);
NativeArray<float> output1 = new NativeArray<float>(1000, Allocator.TempJob);
NativeArray<float> output2 = new NativeArray<float>(1000, Allocator.TempJob);
var job1 = new ParallelReadJob { input = sharedInput, output = output1 };
var job2 = new ParallelReadJob { input = sharedInput, output = output2 };
// Both jobs can run in parallel - both only read input
JobHandle handle1 = job1.Schedule(1000, 64);
JobHandle handle2 = job2.Schedule(1000, 64);
JobHandle.CombineDependencies(handle1, handle2).Complete();
sharedInput.Dispose();
output1.Dispose();
output2.Dispose();
Official Documentation: ReadOnlyAttribute API Reference
Benefits of [ReadOnly]:
- Main thread can continue reading while jobs with ReadOnly access execute
- Multiple jobs with ReadOnly access run concurrently without dependencies
- Eliminates artificial dependency chains that would otherwise prevent parallelization
Job System and Burst Compiler
The Unity C# Job System enables developers to "write simple and safe multithreaded code so that your application can use all available CPU cores to execute your code." The system automatically manages worker threads and distributes work, eliminating manual thread management. Jobs are implemented as structs containing an Execute() method that runs on worker threads.
Official Documentation: Job System Overview
Two primary job interfaces exist:
IJob - Executes once on a worker thread. "After a job is scheduled, the job's Execute method is invoked on a worker thread." Use for single operations that benefit from running in parallel with the main thread.
[BurstCompile]
public struct MyJob : IJob
{
public NativeArray<float> result;
public void Execute()
{
for (int i = 0; i < result.Length; i++)
{
result[i] = math.sqrt(i);
}
}
}
Official Documentation: IJob Interface
IJobParallelFor - "A job that performs the same independent operation for each element of a native container or for a fixed number of iterations." Unlike IJob, this interface "invokes the Execute method once per item in the data source" with automatic work distribution across CPU cores. Each iteration must be independent. The system employs work stealing: "When a native job completes its batches before others, it steals remaining batches from the other native jobs."
[BurstCompile]
public struct ParallelJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> input;
public NativeArray<float> output;
public void Execute(int index)
{
output[index] = math.sqrt(input[index]);
}
}
Official Documentation: IJobParallelFor Interface
The Unity Burst compiler translates a subset of C# called High-Performance C# (HPC#) into highly-optimized native CPU code. It "uses LLVM to translate .NET Intermediate Language (IL) to code that's optimized for performance on the target CPU architecture." Apply the [BurstCompile] attribute to jobs or static methods to enable compilation. When combined with native collections, Burst can achieve 10x performance improvements through SIMD optimization and native code generation.
Official Documentation: Burst Compiler Package
[BurstCompile]
public struct BurstOptimizedJob : IJobParallelFor
{
public NativeArray<float> data;
public void Execute(int index)
{
// This code Burst-compiled to native instructions
data[index] = math.sqrt(data[index] * 2.0f);
}
}
Real-world performance data shows dramatic improvements: one documented case showed a job going from 7ms to 0.5ms (14x improvement) simply by enabling Burst compilation. The combination of Job System + Burst + Native Collections represents Unity's highest-performance code path.
Job Dependencies
JobHandle is a struct that "uniquely identifies a job scheduled in the job system." Dependencies control execution order when one job relies on another's results.
// Basic dependency chain
JobHandle firstHandle = firstJob.Schedule();
JobHandle secondHandle = secondJob.Schedule(firstHandle); // Waits for first
// Combining multiple dependencies
NativeArray<JobHandle> handles = new NativeArray<JobHandle>(3, Allocator.Temp);
handles[0] = job1.Schedule();
handles[1] = job2.Schedule();
handles[2] = job3.Schedule();
JobHandle combined = JobHandle.CombineDependencies(handles);
finalJob.Schedule(combined).Complete();
handles.Dispose();
Official Documentation: Job Dependencies
Best Practices:
- Always pass dependencies to prevent race conditions
- Use JobHandle.CombineDependencies() to merge multiple dependencies
- Complete() is blocking - delay until results needed
- Schedule jobs early in frame, complete as late as possible
AtomicSafetyHandle System
Each native collection has an AtomicSafetyHandle that locks when a job is scheduled and releases when the job completes. The safety system tracks usage and enforces deterministic behavior.
Official Documentation: NativeContainer Attribute
Safety checks include:
- Read/write access validation
- Memory boundary checks
- Disposal tracking
- Job dependency enforcement
Critical Note: Safety system operates only in Unity Editor and Development Builds. Release builds strip these checks entirely for maximum performance, making safety system a zero-cost abstraction in production.
Real Performance Numbers You Can Expect
Let me show you actual performance data from benchmarks and production games. These aren't theoretical - these are real numbers from real projects.
Managed Collection Performance
Performance testing across multiple sources establishes clear hierarchies:
Array vs List:
- Arrays are approximately 5x faster than List
- List writes are 695% slower than array writes
- List reads are 47% slower than array reads
- For 200k+ items looped every frame, only arrays are viable
- Lists acceptable for ~5,000 items accessed occasionally
Source: Jackson Dunstan - "Array vs. List Performance" (https://www.jacksondunstan.com/articles/3058)
List vs Dictionary Iteration:
- Dictionary is 8-10x slower than List for iterating all elements
- Dictionary excels at O(1) key-value lookups
- Lists beat dictionaries for 16-20x in pure iteration scenarios
Array Performance in Unity:
- foreach loops on arrays generate zero garbage (Unity optimized)
- for loops remain standard practice for maximum control
- Arrays provide best cache locality among managed collections
Native Collection Performance
NativeArray vs Regular Arrays:
In Unity Editor (Development Builds):
- NativeArray can be 10x slower than regular C# arrays due to safety checks
- One benchmark: C# array 0.545 seconds vs NativeArray 5.97 seconds (same computation)
- Safety checks add approximately 10x performance overhead
In Production Builds:
- Safety checks disabled, removing 10x overhead
- Performance becomes competitive with or superior to managed arrays
- Unity forum test with 100k points: regular arrays 60 FPS, NativeArray 79 FPS in production
- Zero GC allocations provide smoother frame times
Source: Medium - "[Unity ECS] Native container performance test vs normal array by 5argon"
With Job System and Burst:
- Native collections provide 10x performance gains when combined with Job System + Burst
- One documented case: job went from 7ms to 0.5ms (14x improvement) by enabling Burst
- Real-world case: RetroStyle Games' Ocean Keeper achieved 35x improvement (140ms to 4ms) using Job System with NativeArray
Source: RetroStyle Games Medium - "Unity Job System in Practice"
NativeHashMap vs Dictionary:
- NativeHashMap performs 20-30% faster than Dictionary in read-only scenarios with Job System
- Add performance roughly 3-5x slower than NativeList due to hash overhead
- Zero GC allocations vs Dictionary's managed heap allocations
NativeList vs List:
- Access performance matches NativeArray (identical internal representation)
- Dynamic resizing more expensive than List due to unmanaged memory management
- Zero GC advantage outweighs resizing cost in job scenarios
DOTS/ECS Performance Gains
Production games using DOTS report dramatic improvements:
Conway's Game of Life:
- Speed increased almost 200 times compared to non-DOTS implementation
- Computing 1 million cells takes no more than 2ms
Hardspace: Shipbreaker:
- Processes that took 1 hour reduced to 100 milliseconds
- Represents 36,000x improvement for specific workflows
General DOTS Performance:
- Unity official documentation states CPU performance improvements range from 5-50x depending on parallelization effectiveness
- 10,000 body gravity simulation: Successfully executed with 100,000,000 force calculations per cycle
- "Without Burst Compiler everything just dies, you can't get it to work"
Source: Medium - "Unity DOTS/ECS Performance: Amazing" by Anton Antich
Garbage Collection Impact
GC Allocation Goals:
- Zero GC allocations per frame is the target for high-performance Unity applications
- At 60 FPS, frame budget is 16.66ms - GC spikes exceed this entirely
Common Allocation Sources:
- LINQ operations: 32-88 bytes per call, almost 10x slower than loops
- String concatenation in Update loops: continuous allocation (strings immutable)
- Dictionary with enum keys: heap allocations on every lookup due to boxing
- foreach loops on interface types: still allocates enumerators (24-40 bytes)
- GetComponent returning null: allocates 50MB+ garbage (generic version)
Source: Jackson Dunstan - "Just How Much Garbage Does LINQ Create?"
Object Pooling Performance:
- Pool Kit benchmarks show up to 77% faster spawning compared to instantiation
- Scenario with 1200 GameObjects created/destroyed every 2 seconds: dropped to 10-20 fps without pooling on mid-range CPUs
- With pooling: maintained 60+ fps
Frame Rate Targets:
- Studies show user engagement drops significantly below 60 FPS
- Potentially losing up to 30% of player base below this threshold
- Mobile VR requires 90+ FPS for comfort
Games That Got It Right (And Their Techniques)
Let me share some real games that nailed collection performance. These examples show how the techniques we've discussed actually work in production.
V Rising - Full DOTS Production at Scale
Game: V Rising by Stunlock Studios What They Built: Open-world multiplayer survival game
V Rising is an open-world multiplayer survival game that used Entity Component System throughout development. The game handles demanding requirements of persistent open-world multiplayer gameplay with hundreds of concurrent entities. Stunlock Studios leveraged native collections (NativeArray, NativeHashMap, DynamicBuffer) for entity data management, enabling efficient multithreading of game systems. The ECS architecture allowed them to process thousands of entities (players, NPCs, items, projectiles) across multiple systems in parallel using the Job System and Burst compiler.
Key Lessons:
- Full production validation of DOTS in AAA multiplayer context
- Native collections essential for zero-GC gameplay loops
- ECS enables efficient network synchronization of massive entity counts
- Sold 1+ million copies in first week, proving technical approach viable
Source: Unity ECS page showcasing shipped games
How to Apply This:
// Entity-based inventory management like V Rising
public struct InventoryItem : IBufferElementData
{
public Entity itemEntity;
public int quantity;
public int slotIndex;
}
// System processes inventories efficiently
[BurstCompile]
partial struct InventorySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// Process thousands of inventories in parallel
foreach (var (inventory, entity) in
SystemAPI.Query<DynamicBuffer<InventoryItem>>()
.WithEntityAccess())
{
// Efficient iteration over player inventories
}
}
}
Ocean Keeper - 35x Performance Win with Job System
Game: Ocean Keeper by RetroStyle Games The Problem: Searching one million spawn points took 140ms
RetroStyle Games needed to spawn many enemies and find spawn points - an expensive operation. Initially, searching one million items took 140ms. By implementing Unity's Job System with NativeArray Unity, they achieved dramatic optimization: searching one million positions took only 4ms, a 35x performance improvement. The team used NativeArray
Key Lessons:
- Job System + NativeArray can achieve 35x improvements for parallel operations
- Native collections enable safe multithreading without complex lock management
- Unity Burst compiler critical - without it, performance gains minimal
- Explicit capacity management important for consistent performance
Source: RetroStyle Games Medium article - "Unity Job System in Practice: How we increased FPS from 15 to 70"
The Code Pattern:
[BurstCompile]
public struct FindSpawnPointsJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> candidatePositions;
[ReadOnly] public NativeArray<float3> obstaclePositions;
public NativeArray<int> validIndices;
public float minDistance;
public void Execute(int index)
{
float3 candidate = candidatePositions[index];
bool isValid = true;
// Check against all obstacles
for (int i = 0; i < obstaclePositions.Length; i++)
{
if (math.distance(candidate, obstaclePositions[i]) < minDistance)
{
isValid = false;
break;
}
}
validIndices[index] = isValid ? 1 : 0;
}
}
// Usage
NativeArray<float3> candidates = new NativeArray<float3>(1000000, Allocator.TempJob);
NativeArray<float3> obstacles = new NativeArray<float3>(500, Allocator.TempJob);
NativeArray<int> valid = new NativeArray<int>(1000000, Allocator.TempJob);
// Fill candidates and obstacles...
var job = new FindSpawnPointsJob
{
candidatePositions = candidates,
obstaclePositions = obstacles,
validIndices = valid,
minDistance = 5.0f
};
job.Schedule(candidates.Length, 64).Complete();
// Process results - took 4ms instead of 140ms!
candidates.Dispose();
obstacles.Dispose();
valid.Dispose();
This is the pattern I recommend to all my students when they're dealing with large-scale spatial queries or collision detection. The performance improvement is genuinely game-changing.
Academia: School Simulator - Turning Reviews Around with DOTS
Game: Academia: School Simulator by Coffee Brain Games The Problem: Poor simulation performance causing mixed Steam reviews
Coffee Brain Games faced poor simulation performance causing mixed Steam reviews. After integrating Unity DOTS ECS for their custom 2D renderer and porting A* pathfinding code to Burst compilation, performance improved dramatically with "wiggle room in the performance budget." They used a hybrid architecture: MonoBehaviour for presentation layer, UI, and high-level logic; DOTS/ECS for simulation, AI pathfinding, and physics-heavy systems. The team used NativeList and NativeMultiHashMap extensively for pathfinding node storage and spatial queries.
Key Lessons:
- Partial DOTS integration viable for existing projects (don't need full rewrite)
- Pathfinding and simulation systems benefit most from DOTS conversion
- Performance improvements changed review scores from Mixed to Very Positive
- Hybrid architecture allows gradual adoption
Source: Coffee Brain Games blog series on Academia development
Pathfinding Pattern:
// Pathfinding with native collections
[BurstCompile]
public struct AStarPathfindingJob : IJob
{
[ReadOnly] public NativeArray<float3> nodes;
[ReadOnly] public NativeMultiHashMap<int, int> nodeConnections;
public NativeList<int> resultPath;
public int startNode;
public int goalNode;
public void Execute()
{
// A* implementation using native collections
NativeList<int> openSet = new NativeList<int>(100, Allocator.Temp);
NativeHashMap<int, float> gScore = new NativeHashMap<int, float>(nodes.Length, Allocator.Temp);
openSet.Add(startNode);
gScore.Add(startNode, 0);
while (openSet.Length > 0)
{
// A* algorithm with native collections
// Burst-compiled for maximum performance
}
openSet.Dispose();
gScore.Dispose();
}
}
Crash Drive 3 - Multi-Platform Optimization
Game: Crash Drive 3 by M2H Challenge: Shipping on 9 platforms simultaneously
M2H shipped Crash Drive 3 on 9 platforms simultaneously using 13+ years of Unity optimization experience. Key collection-related insights include using Unity's incremental garbage collection feature to greatly reduce GC spikes without necessarily reducing allocations, aggressive profiling of memory allocations in Update methods, and collection reuse patterns. The team emphasized that with incremental GC, developers might not need to reduce GC allocations as aggressively since individual spike magnitude becomes manageable.
Key Lessons:
- Incremental GC mode (Unity 2019+) can reduce spike impact
- Still important to minimize allocations for baseline performance
- Profile memory allocations in Update loops specifically
- Multi-platform shipping requires conservative optimization
Source: Game Developer - "Optimizing Crash Drive 3: Battle Tested Plan"
Collection Reuse Pattern:
// Enable Incremental GC (Unity 2019+)
// Player Settings → Configuration → Garbage Collector → Incremental
// Collection reuse pattern for mobile performance
// For comprehensive mobile optimization, check our [mobile build size guide](https://outscal.com/blog/unity-mobile-build-size-optimization)
public class MobileOptimizedManager : MonoBehaviour
{
// Pre-allocated collections
private List<GameObject> activeObjects = new List<GameObject>(100);
private Dictionary<int, Transform> transformCache = new Dictionary<int, Transform>(100);
void Update()
{
// Reuse collections - never allocate new
activeObjects.Clear();
// Populate with current frame data
foreach (var obj in allObjects)
{
if (obj.activeInHierarchy)
activeObjects.Add(obj);
}
// Process without additional allocations
for (int i = 0; i < activeObjects.Count; i++)
{
ProcessObject(activeObjects[i]);
}
}
}
Megacity - Unity's Ultimate DOTS Showcase
Game: Megacity / Megacity Metro by Unity Technologies Scale: 4.5 million mesh renderers, 5,000 dynamic vehicles
Megacity represents Unity's flagship DOTS demonstration, showcasing extreme scale:
- 4.5 million mesh renderers
- 5,000 dynamic vehicles on spline-based traffic lanes
- 200,000 unique building objects
- 100,000 unique audio sources playing simultaneously
The technical implementation uses NativeParallelHashMap for spatial partitioning of entities into grid cells, DynamicBuffer for vehicle spline path data, NativeArray for massive mesh renderer data, and NativeQueue for work distribution across Job System workers. Megacity Metro extends this to multiplayer supporting 128-150+ players using Netcode for Entities.
Key Lessons:
- DOTS enables unprecedented entity counts (millions of active entities)
- Native collections essential for managing massive datasets
- Full source code available on GitHub (Unity-Technologies/Megacity-2019)
- Demonstrates production-ready DOTS capabilities
Source: Unity Technologies - Megacity Demo and accompanying technical presentations
Spatial Partitioning Pattern:
// Spatial partitioning like Megacity
[BurstCompile]
partial struct SpatialPartitionSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// Build spatial hash of all entities
var spatialMap = new NativeParallelHashMap<int, Entity>(10000, Allocator.TempJob);
var buildMapJob = new BuildSpatialMapJob
{
spatialMap = spatialMap.AsParallelWriter()
};
state.Dependency = buildMapJob.ScheduleParallel(state.Dependency);
state.Dependency.Complete();
// Query entities near positions efficiently
// Process queries in parallel using spatialMap
spatialMap.Dispose();
}
}
[BurstCompile]
partial struct BuildSpatialMapJob : IJobEntity
{
public NativeParallelHashMap<int, Entity>.ParallelWriter spatialMap;
void Execute(Entity entity, in Translation translation)
{
int cellKey = GetSpatialHash(translation.Value);
spatialMap.TryAdd(cellKey, entity);
}
int GetSpatialHash(float3 position)
{
// Grid-based spatial hash
int x = (int)(position.x / 10.0f);
int z = (int)(position.z / 10.0f);
return x + z * 1000;
}
}
These games prove that mastering Unity collections performance isn't just academic - it's what separates games that ship successfully from those that get abandoned due to performance issues.
Your Collection Selection Decision Tree
Here's the practical decision tree I use when choosing collections. Follow this systematically and you'll make the right choice every time.
For Fixed-Size Data:
Is data fixed-size at initialization?
├─ Yes → Are you using Job System?
│ ├─ Yes → Use NativeArray<T>
│ └─ No → Use Array
└─ No → (Continue to dynamic sizing)
For Dynamic Data:
Need dynamic sizing?
├─ Using Job System/DOTS?
│ ├─ Yes → Is data attached to entity?
│ │ ├─ Yes → Use DynamicBuffer<T>
│ │ └─ No → Use NativeList<T>
│ └─ No → Is data < 5000 elements?
│ ├─ Yes → Use List<T>
│ └─ No → Use Array with manual resizing
└─ Need key-value lookups?
├─ Using Job System?
│ ├─ Yes → Need parallel writes?
│ │ ├─ Yes → NativeParallelHashMap<TKey, TValue>
│ │ └─ No → NativeHashMap<TKey, TValue>
│ └─ No → Use Dictionary<TKey, TValue>
└─ Keys are enums?
└─ Use Array with enum cast to int
For Small Fixed-Maximum Collections:
Collection inside ECS component?
├─ Yes → Maximum size < 4096 bytes?
│ ├─ Yes → Use FixedList variant
│ └─ No → Use DynamicBuffer<T>
└─ No → Use appropriate type from above
I keep this decision tree printed next to my desk. It's saved me countless hours of second-guessing collection choices.
Memory Management Rules That Actually Matter
Let me give you the practical rules I follow for memory management. These aren't theoretical - they're the patterns that prevent memory leaks and GC spikes in real projects.
1. Always Dispose Native Collections:
// Use try-finally pattern
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
try
{
// Use data
}
finally
{
data.Dispose(); // Guaranteed disposal
}
// Or use Dispose(JobHandle) for jobs
JobHandle handle = myJob.Schedule();
data.Dispose(handle); // Automatically disposes after job completes
Official Documentation: Collections Allocation
2. Choose Appropriate Allocator:
- Use Allocator.Temp for single-frame temporary data
- Use Allocator.TempJob for job data living ≤4 frames
- Use Allocator.Persistent sparingly for truly long-lived data
- Never use Allocator.Temp for data passed to jobs
3. Pre-allocate Collection Capacity:
// Bad - multiple resize operations
List<Item> items = new List<Item>();
for (int i = 0; i < 1000; i++)
{
items.Add(new Item()); // Triggers ~8 resizes
}
// Good - single allocation
List<Item> items = new List<Item>(1000);
for (int i = 0; i < 1000; i++)
{
items.Add(new Item()); // No resizing
}
4. Reuse Collections via Clear():
// Bad - allocates new collection every frame
void Update()
{
List<Enemy> nearby = new List<Enemy>(); // 40+ bytes per frame
}
// Good - reuse collection
private List<Enemy> nearbyEnemies = new List<Enemy>(100);
void Update()
{
nearbyEnemies.Clear(); // Keeps capacity, reuses memory
}
5. Cache Component References:
// Bad - GetComponent every frame (extremely expensive)
void Update()
{
GetComponent<Rigidbody>().AddForce(force);
}
// Good - cache in Start
private Rigidbody rb;
void Start()
{
rb = GetComponent<Rigidbody>();
}
void Update()
{
rb.AddForce(force);
}
Component caching is critical for performance - learn more in our guide to component communication.
Official Documentation: Understanding Performance in Unity
Job System Patterns That Work
Here are the Job System patterns I use consistently in my projects. These maximize performance while keeping code maintainable.
1. Use [ReadOnly] Attribute Extensively:
[BurstCompile]
public struct OptimizedJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> input; // Enables parallel reads
[ReadOnly] public NativeArray<Vector3> positions;
public NativeArray<float> output;
public void Execute(int index)
{
output[index] = math.length(positions[index]) * input[index];
}
}
2. Schedule Jobs Early, Complete Late:
void Update()
{
// Schedule job early in frame
var job = new MyJob { data = nativeData };
JobHandle handle = job.Schedule();
// Do other work...
ProcessUI();
HandleInput();
// Complete only when results needed
handle.Complete();
// Use results
ProcessResults();
}
3. Use Explicit Job Dependencies:
// Chain dependent jobs
JobHandle job1Handle = job1.Schedule();
JobHandle job2Handle = job2.Schedule(job1Handle); // Waits for job1
JobHandle job3Handle = job3.Schedule(job2Handle); // Waits for job2
// Complete final job completes entire chain
job3Handle.Complete();
4. Enable Burst Compilation:
[BurstCompile] // Always add to jobs
public struct MyJob : IJob
{
public NativeArray<float> data;
public void Execute()
{
// Burst optimizes this to SIMD instructions
for (int i = 0; i < data.Length; i++)
{
data[i] = math.sqrt(data[i]);
}
}
}
Official Documentation: Burst Compiler Package
5. Tune Batch Count for IJobParallelFor:
// Start with batch size of 64
int batchSize = 64;
JobHandle handle = job.Schedule(dataLength, batchSize);
// For compute-heavy operations, reduce batch size to 1-16
// For simple operations, increase to 128-256
// Profile to find optimal value
Official Documentation: Parallel For Jobs
Mistakes I've Watched Students Make (And How to Avoid Them)
Let me share the most common mistakes I see students make with collections. I've debugged these issues so many times that I can spot them from across the room.
1. Never Use LINQ in Update/FixedUpdate:
// Bad - LINQ in Update creates 32-88 bytes per frame
void Update()
{
var enemies = allEnemies.Where(e => e.health > 0).ToList();
}
// Good - manual filtering with reused collection
private List<Enemy> aliveEnemies = new List<Enemy>();
void Update()
{
aliveEnemies.Clear();
foreach (var enemy in allEnemies)
{
if (enemy.health > 0)
aliveEnemies.Add(enemy);
}
}
Understanding when to use Update vs FixedUpdate is crucial for proper collection management in your game loop.
Source: Jackson Dunstan - "How Much Garbage Does LINQ Create?"
This mistake alone accounts for probably 50% of the GC issues I see in student projects. LINQ is convenient but deadly for performance.
2. Avoid String Concatenation in Hot Paths:
// Bad - creates garbage every frame
void Update()
{
string debug = "Health: " + health + " Score: " + score; // Boxing + allocation
}
// Better - StringBuilder (but reuse instance!)
private StringBuilder sb = new StringBuilder();
void Update()
{
sb.Clear();
sb.Append("Health: ").Append(health).Append(" Score: ").Append(score);
}
// Best - avoid string operations in Update entirely
3. Replace Enum-Keyed Dictionaries with Arrays:
// Bad - boxes enum on every lookup
private Dictionary<EnemyType, int> enemyCounts = new Dictionary<EnemyType, int>();
int count = enemyCounts[EnemyType.Zombie]; // Allocates 24+ bytes!
// Good - use enum as array index
private int[] enemyCounts = new int[(int)EnemyType.Count];
int count = enemyCounts[(int)EnemyType.Zombie]; // Zero allocation
4. Always Check Native Collection Disposal:
// Enable Full Stack Traces in Project Settings
// Unity shows detailed allocation site for undisposed collections
// Use conditional compilation for disposal checks
#if UNITY_EDITOR
void OnDestroy()
{
if (myNativeArray.IsCreated)
Debug.LogError("Native collection not disposed!");
}
#endif
5. Don't Forget TempJob 4-Frame Limit:
// Bad - TempJob allocation lives beyond 4 frames
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
// If not disposed within 4 frames → Exception!
// Good - dispose promptly
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
// Use data in job
data.Dispose(); // Within 4 frames
I learned this one during a game jam when I was storing TempJob allocations in a field and disposing them "eventually." Unity threw exceptions after exactly 4 frames, and I spent half a day figuring out why my game crashed intermittently. Read the documentation, folks.
How to Profile and Optimize Your Collections
"Before you make any changes, you must profile your application to identify the cause of the problem." Unity emphasizes a profile-first approach to optimization, and I can't stress this enough.
Official Documentation: Graphics Performance Optimization
1. Use CPU Usage Profiler:
The CPU Profiler tracks time spent across eight main categories (Rendering, Scripts, Physics, Animation, GarbageCollector, VSync, Global Illumination, UI, Others) with four view modes:
- Timeline View - Shows timing across all threads with temporal context
- Hierarchy View - Organizes data hierarchically by duration
- Raw Hierarchy - Displays unmerged call stacks
- Inverted Hierarchy - Groups by profiler markers
Official Documentation: CPU Usage Profiler
2. Track GC.Alloc:
The GC.Alloc column shows bytes allocated per frame on the managed heap. Enable Call Stacks for GC.Alloc samples to identify allocation sources:
Unity Profiler → CPU Module → Call Stacks → GC.Alloc
Goal: Make GC.Alloc column "as consistently close to zero as possible."
3. Use Memory Profiler:
The Memory Profiler package provides detailed memory analysis:
- Managed heap usage and fragmentation
- Native collection allocations
- Memory leaks from undisposed collections
- Snapshot comparison
Official Documentation: Memory Profiler Package
4. Profile on Target Devices:
Editor profiling differs significantly from device profiling. Always profile development builds on actual target hardware.
5. Use ProfilerMarker for Custom Sections:
using Unity.Profiling;
public class GameManager : MonoBehaviour
{
static readonly ProfilerMarker s_PrepareMarker = new ProfilerMarker("GameManager.Prepare");
void PrepareGameState()
{
using (s_PrepareMarker.Auto())
{
// Code to profile
}
}
}
Official Documentation: Profiler Markers
Optimization Strategies:
1. Minimize Per-Frame Allocations:
- Target: 0 bytes GC.Alloc per frame
- Reuse collections via Clear()
- Cache frequently-accessed data
- Use object pooling for GameObjects/Components
2. Choose Right Collection Type:
- Arrays for fixed-size, maximum performance
- List
for dynamic traditional code - Native collections for Job System/DOTS
- Consider data access patterns over raw speed
3. Leverage Job System + Burst:
- Move expensive calculations to jobs
- Apply [BurstCompile] to all compatible jobs
- Use native collections for job data
- Expect 5-50x performance improvements
4. Optimize Data Layout:
- Use structs instead of classes when appropriate (avoid heap allocation)
- Keep frequently-accessed data contiguous (arrays better than lists of pointers)
- ECS archetypes automatically optimize layout
5. Reduce Structural Changes in ECS:
- Batch entity creation/destruction
- Use EntityCommandBuffer
- Profile with Entities Structural Changes module
- Consider enableable components instead of add/remove
Official Documentation: Entity Command Buffer
Frame Rate Targets and Budgets:
60 FPS Target:
- Frame budget: 16.66ms
- CPU budget after rendering: typically 10-12ms
- Collection operations must fit within budget
- User engagement drops significantly below 60 FPS
30 FPS Target:
- Frame budget: 33.33ms
- More headroom for complex operations
- Acceptable for some mobile games
- Still requires GC minimization
VR Requirements:
- Minimum 90 FPS (11.11ms budget)
- Strict GC requirements (any pause noticeable)
- Native collections essential
- Consistent frame times critical (no spikes)
Wrapping Up Unity Collections Performance
Choosing and managing Unity collections performance isn't just an optimization detail - it's fundamental to whether your game actually runs well on players' devices. The difference between using a List in Update() and a properly configured NativeArray with Job System can literally be 35x performance improvement, as Ocean Keeper demonstrated.
Start simple with traditional collections while prototyping. Profile early and often to identify real bottlenecks. When you find performance issues, systematically migrate to native collections and Unity Job System. Don't try to optimize everything upfront - profile-guided optimization is always more effective than guessing.
The key principles: target zero garbage collection Unity allocations per frame, choose collection types based on data access patterns and scale, leverage Unity Burst compiler and multithreading for heavy operations, and always dispose native collections explicitly. These aren't optional practices for "advanced" developers - they're essential for shipping games that perform well.
The games we looked at - V Rising, Ocean Keeper, Academia, Crash Drive 3, Megacity - all prove that mastering collections is what separates shipped games from abandoned prototypes. Your choice of Unity List vs Array, when to use NativeArray Unity, and how to structure Unity DOTS ECS systems directly impacts whether players enjoy your game or rage-quit due to stuttering.
Now you have the knowledge and decision trees to choose correctly. Start applying these patterns in your projects, profile the results, and watch your frame rates climb. The performance improvements are genuinely transformative when you get this right.
Common Questions
What is the difference between managed and native collections in Unity?
Managed collections (List, Array, Dictionary) allocate on the garbage-collected heap and are automatically managed by C#'s runtime, but trigger garbage collection cycles that cause frame stutters. Native collections (NativeArray, NativeList, NativeHashMap) use unmanaged memory outside the garbage collector with zero GC overhead, but require explicit manual disposal using Dispose() or they'll cause memory leaks.
When should I use NativeArray instead of a regular array?
Use NativeArray when you're working with the Job System or DOTS, need zero garbage collection overhead, have large datasets requiring parallel processing, or are using Burst compilation. Use regular arrays for prototyping, small datasets under 5,000 elements, or traditional GameObject-based code where convenience outweighs maximum performance.
How do I prevent garbage collection spikes in my Unity game?
Target zero GC allocations per frame by reusing collections with Clear() instead of creating new instances, pre-allocating List capacity when size is predictable, avoiding LINQ in Update/FixedUpdate loops, caching component references instead of calling GetComponent repeatedly, and using native collections for performance-critical code. Enable the CPU Profiler's GC.Alloc tracking to identify allocation sources.
What does Allocator.TempJob mean and when should I use it?
Allocator.TempJob is a moderate-speed memory allocator that allows native collections to be passed between the main thread and job worker threads. You must deallocate TempJob allocations within 4 frames of their creation. Use TempJob for job-scheduled operations spanning up to 4 frames - it's faster than Persistent but allows cross-thread usage unlike Temp.
How does the Unity Job System work with native collections?
The Job System requires native collections because they're thread-safe C# wrappers for unmanaged memory that enable jobs to access shared data without copies. Native collections integrate with Unity's safety system that tracks read/write access, prevents simultaneous writes from different jobs, and enforces proper disposal. Use [ReadOnly] attribute to enable multiple jobs to read the same collection in parallel.
What is DynamicBuffer in ECS and when should I use it?
DynamicBuffer is the ECS-native collection type for dynamic arrays within entities. Unlike separate native collections, DynamicBuffer integrates directly as a component type with ECS managing its lifecycle automatically. Small buffers (typically 16-32 elements) are stored inline within ArchetypeChunks for maximum cache efficiency. Use DynamicBuffer when you need dynamic arrays as entity components and want ECS to handle memory management.
Why is my NativeArray slower than a regular array in the Editor?
In Unity Editor and Development Builds, NativeArray includes safety checks that add approximately 10x performance overhead, making them slower than regular arrays during development. In production builds, these safety checks are completely disabled, making NativeArray performance competitive with or superior to managed arrays while providing zero garbage collection overhead.
How do I properly dispose of native collections?
Always dispose native collections using Dispose() method, ideally in try-finally blocks to guarantee disposal even if exceptions occur. For collections used in jobs, you can call Dispose(JobHandle) to automatically dispose after the job completes. Enable Full Stack Traces in Project Settings to help identify undisposed collections. Forgetting disposal causes "Native Collection has not been disposed" memory leak errors.
What is the Burst compiler and how does it improve performance?
The Burst compiler translates a subset of C# called High-Performance C# (HPC#) into highly-optimized native CPU code using LLVM. Apply the [BurstCompile] attribute to job structs to enable compilation. When combined with Job System and native collections, Burst achieves 10-50x performance improvements through SIMD optimization and native code generation. Real cases show jobs improving from 7ms to 0.5ms (14x) just by enabling Burst.
Should I use Dictionary with enum keys for fast lookups?
No - using enum keys with Dictionary causes heap allocations on every lookup due to boxing, allocating 24+ bytes per access. Instead, use arrays with enum cast to int as the index: int[] data = new int[(int)EnumType.Count]; int value = data[(int)EnumType.Zombie]; This approach is dramatically faster and allocation-free while providing the same functionality.
How do I choose between List and Array for my game data?
Use Array when element count is fixed at initialization, you need maximum performance (200k+ items per frame), or you're working with grids and lookup tables. Use List when you need dynamic sizing with frequent additions/removals, collection size is under 5,000 elements, or you're implementing object pooling. Arrays are approximately 5x faster but cannot resize; List provides flexibility at a moderate performance cost.
What is the performance difference between List and Dictionary iteration?
Dictionary is 8-10x slower than List when iterating over all elements due to hash table structure overhead. Dictionary excels at O(1) key-value lookups but performs poorly for full iteration. Use List when primarily iterating through collections, and Dictionary only when you need fast lookups by non-integer keys and primarily access individual elements rather than iterating all data.