In Unity development, lambda expressions and anonymous methods are incredibly convenient C# features for implementing event handlers, callbacks, and asynchronous logic (such as UniTask or Coroutines). However, writing them without careful consideration can lead to a silent performance killer: dozens to hundreds of bytes of GC Alloc (Garbage Collection Allocation) generated every single frame. The accumulation of these unnecessary allocations triggers the garbage collector, causing noticeable performance spikes (micro-stutters and frame drops) during gameplay. In this article, we'll dive deep into the compiler-level mechanics of why lambdas cause GC Alloc, show you how to detect them using the Unity Profiler, and walk you through practical refactoring steps to achieve zero allocations.

1. The Root Cause: Why Do Lambdas Cause GC Allocations?

A lambda expression like () => { ... } is syntax sugar. During compilation, the C# compiler translates it into normal classes and methods. Under the hood, memory allocations on the heap occur in two primary scenarios:

① Closure Generation via Variable Capturing

When a lambda expression references a local variable declared outside its immediate scope (e.g., a variable inside the containing method), it "captures" that variable. To extend the lifetime of the captured variable so it remains available when the lambda runs, the C# compiler generates a hidden compiler-generated class (closure) on the heap. Every time the lambda expression is evaluated, this compiler-generated class is instantiated with a new keyword, leading to a heap allocation.

// BAD: Capturing the local variable 'targetId'
int targetId = 10;
var result = enemies.Find(e => e.Id == targetId); // If called every frame, this causes GC Alloc!

② Dynamic Delegate Instantiation

Even if you don't capture local variables, passing a lambda expression directly as an argument that requires a delegate (like Action or Func<T>) can instantiate a new delegate object (new Action(...)) on the heap upon evaluation. Although the C# compiler optimizes static lambdas (those that capture nothing) by caching them in a static field, it cannot perform this cache optimization if the lambda references instance members or this.

// BAD: Accesses the instance field '_status', preventing compiler caching of the delegate
private string _status = "Active";

void Update()
{
    // Allocates a new delegate instance referencing 'this' on every call
    DoProcess(() => Debug.Log(_status)); 
}

2. Real-World Examples: Allocating vs. Allocation-Free Code

Let's look at common scenarios in Unity and compare problematic code to its allocation-free (GC Alloc = 0) counterpart.

Scenario 1: Searching Items in a List or LINQ Query

Using List.Find or List.Exists with a local variable for filtering triggers a closure allocation.

// X: Allocates memory every frame
void Update()
{
    int searchCategory = 3;
    // Captures 'searchCategory', allocating ~32 to 48 bytes on the heap
    var item = itemList.Find(x => x.category == searchCategory);
}

Solution: Avoid LINQ or list search lambdas inside Update loops. Instead, pre-cache query data into a data structure like a Dictionary, or write a manual for loop. A manual loop achieves the exact same result while keeping garbage allocations at zero.

// O: Allocation-Free (Manual loop replacement)
void Update()
{
    int searchCategory = 3;
    Item foundItem = null;
    
    // Garbage-free lookup
    for (int i = 0; i < itemList.Count; i++)
    {
        if (itemList[i].category == searchCategory)
        {
            foundItem = itemList[i];
            break;
        }
    }
}

Scenario 2: Tweening or Asynchronous Callbacks

Passing lambdas that capture state parameters to tweening libraries (e.g., DOTween) or async task handlers is a very common source of memory leaks.

// X: Triggers a closure allocation to pass parameters to the callback
void StartTween(GameObject target, Vector3 destination, int index)
{
    // Captures 'target' and 'index', instantiating a closure object on the heap
    target.transform.DOMove(destination, 1f)
        .OnComplete(() => OnTweenComplete(target, index)); 
}

Solution: Use callback APIs that accept a custom state parameter. Many modern Unity libraries provide overloads allowing you to pass an arbitrary state object. This lets you write static lambdas that receive the state as an argument rather than capturing it from the parent scope.

// O: Using state-passing overloads to eliminate closures
void StartTween(GameObject target, Vector3 destination, int index)
{
    // Use library overloads that pass state parameters. 
    // This allows passing a static delegate that doesn't capture scope variables.
}

3. Identifying Lambdas in the Unity Profiler

When investigating micro-stutters, you can pinpoint lambda allocations using the Unity Profiler:

  1. Open the Profiler via Window > Analysis > Profiler.
  2. Enable Deep Profiler Support in the Profiler window to gather detailed call stacks (Note: this adds overhead during editor run).
  3. Run your game and pause the Profiler on a frame where a performance spike occurs.
  4. Select the CPU Usage module and switch the detail view from "Timeline" to Hierarchy.
  5. Click the GC Alloc column header to sort allocations in descending order.
  6. Look for compiled anonymous methods or class wrappers with names containing <MethodName>b__0 or <>c__DisplayClass....
  7. Highlight the entry and check the Show Calls / Call Stacks pane at the bottom to find the exact line in your code triggering the allocation.

4. Three Techniques to Eliminate Lambda GC Allocations

Technique 1: Pre-Caching Delegates

If a lambda references instance members (e.g., fields of a MonoBehavior) but no local variables, you can eliminate runtime allocations by instantiating the delegate once during initialization (e.g., in Awake or Start) and reusing the reference.

// Caching delegate instances to prevent garbage collection
private Action _cachedCallback;
private int _score;

void Awake()
{
    // Instantiate the delegate object once on startup
    _cachedCallback = UpdateScoreDisplay; 
}

void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // Reuses the cached delegate. GC Alloc = 0
        ExecuteCallback(_cachedCallback); 
    }
}

void UpdateScoreDisplay()
{
    Debug.Log($"Score: {_score}");
}

Technique 2: Leveraging C# 9.0 'static' Lambdas

If you are using Unity 2021.2 or newer, you can prepend the static keyword to lambdas. A static lambda is prevented by the C# compiler from capturing any local variables or instance fields (such as this). This guarantees that you won't accidentally introduce closure allocations at compile time.

// Preventing capturing using static lambdas
void ProcessData()
{
    int limit = 50;
    
    // COMPILE ERROR: Cannot capture local variable 'limit' inside static lambda
    // DoAction(static () => Debug.Log(limit)); 
    
    // OK: The compiler caches this lambda statically. GC Alloc = 0
    DoAction(static () => Debug.Log("No capture possible")); 
}

Technique 3: Writing State-Passing APIs

When designing custom managers, utilities, or callback systems, build them to accept state parameters. By providing generic TState overloads, consumers of your API can pass values into callbacks via arguments instead of scope captures.

// Designing an allocation-free callback API
public class EventNotifier : MonoBehaviour
{
    public delegate void NotifyAction<TState>(TState state);

    // Accept both the delegate and the state object
    public void RegisterNotification<TState>(NotifyAction<TState> action, TState state)
    {
        // Invoke the delegate, passing the state to it
        action(state);
    }
}

// How to use the API
void Start()
{
    var notifier = GetComponent<EventNotifier>();
    int currentId = 123;
    
    // The lambda is static (captures nothing) and receives the state parameter.
    // This completely bypasses closure allocation.
    notifier.RegisterNotification(
        static (id) => Debug.Log($"Received ID: {id}"), 
        currentId
    );
}

5. Summary: Adopting Garbage-Free Coding Habits

While lambda expressions make your C# code clean and readable, they can become silent performance killers in critical game loops, update methods, or physics updates.

  • Avoid lambdas entirely inside high-frequency updates like Update() or FixedUpdate().
  • Cache delegates into member fields during initialization if they need instance references.
  • Use C# 9.0 static lambdas to enforce zero-capture rules at the compiler level.
  • Design custom callback systems to accept state parameters to prevent closures.

By profiling regularly and adhering to these practices, you can build smooth, micro-stutter-free Unity experiences.