# The Delegate Is Optional. The Display Class Is Not

> A lambda that captures an int allocated 24 bytes on .NET 10.0.12; the same lambda reading a constant allocated none. The delegate was optional, the display class was not.

- Published: 2026-11-01
- Tags: runtime, memory, language
- Source: https://csharpmind.com/blog/closures-captured-variables-and-the-class-the-compiler-invents/
- Language: en-GB
- Author: Callum Reeve

---

## TL;DR

- A lambda that captured one int allocated 24 bytes per call on .NET 10.0.12 whether it went to Enumerable.Count or was invoked locally, and 88 bytes when it was passed to a method the JIT does not inline; the same lambda reading a constant allocated 0 bytes in all three positions.
- Roslyn moves the captured local into a field of a sealed, compiler-generated display class that is created on every call, while a non-capturing lambda is cached once on a singleton and allocates only on its first call.
- The 24 bytes are the display class and the extra 64 bytes of the 88 are the Func<int, bool> delegate, which the JIT removed when the consumer was inlined into the creating method and the call could be bound to the lambda's own method.
- The delegate's removal is a property of one compiled copy rather than of the source, and the display class survived in every capturing shape measured.

---

Capturing one local allocated 24 bytes when the lambda went to `Count` and 88 when it went to a helper. The same lambda reading a constant allocated nothing in either position.

The two lambdas differ by a single token: a local instead of a literal. The measured difference is one object in the first case and two in the second, and the line of C# that allocates is not in the source at all. The cause is a class the compiler writes and a delegate the JIT may or may not remove, and the second half of that is the part the usual advice gets wrong.

The reasoning is the one in [The Box You Pay For Is the One That Escapes](/blog/the-box-you-pay-for-when-a-struct-meets-an-interface/): an allocation the JIT can see from creation to use can vanish, and one that crosses a call boundary cannot. A closure follows the same rule with an exception, because one of its two objects never vanished.

## A captured local stops being a local

The two lambdas, as written, differ in one operand:

```csharp
public static int LinqNonCapturing(int[] data) => data.Count(x => x > 512);

public static int LinqCapturing(int[] data)
{
    var threshold = data.Length * 8;
    return data.Count(x => x > threshold);
}
```

For a 64-element input `threshold` is 512, so both return the same count. Decompiled with `AnonymousMethods = false`, so that the lowering stays visible, the capturing method reads:

```csharp
public static int LinqCapturing(int[] data)
{
    <>c__DisplayClass2_0 <>c__DisplayClass2_ = new <>c__DisplayClass2_0();
    <>c__DisplayClass2_.threshold = data.Length * 8;
    return data.Count(<>c__DisplayClass2_.<LinqCapturing>b__0);
}
```

`<>c__DisplayClass2_0` is a sealed class marked `[CompilerGenerated]` with one field, `public int threshold`, and the lambda body as an instance method. The local no longer exists; it is a field on an object created at `IL_0000` by `newobj`. The `Func<int, bool>` follows with `ldftn` at `IL_0013` and a second `newobj` at `IL_0019`. No cache check precedes either.

The non-capturing method takes the other path. Its body moves onto a singleton `<>c`, and the delegate is kept in a static field:

```csharp
return data.Count(<>c.<>9__1_0 ?? (<>c.<>9__1_0 = <>c.<>9.<LinqNonCapturing>b__1_0));
```

In IL the field is loaded and `brtrue.s` skips the `newobj` when it is set, so the allocation runs on the first call only.

## 0, 24 and 88 bytes from one predicate

Each lambda was measured in three positions. `Enumerable.Count` over the array, a local `Func<int, bool>` invoked in a `foreach` inside the same method, and `CountWhere`, an identical loop marked `[MethodImpl(MethodImplOptions.NoInlining)]`. The input was 64 `int` values from `Random(11)` in the range 0 to 1023, and every shape returned 32. BenchmarkDotNet's `Allocated` column carries the result:

```text
| Method             | Mean     | Error    | StdDev   | Ratio | RatioSD | Gen0   | Code Size | Allocated | Alloc Ratio |
|------------------- |---------:|---------:|---------:|------:|--------:|-------:|----------:|----------:|------------:|
| NoLambda           | 17.35 ns | 0.161 ns | 0.135 ns |  1.00 |    0.01 |      - |        NA |         - |          NA |
| LinqNonCapturing   | 17.29 ns | 0.367 ns | 0.571 ns |  1.00 |    0.03 |      - |     792 B |         - |          NA |
| LinqCapturing      | 18.70 ns | 0.147 ns | 0.131 ns |  1.08 |    0.01 | 0.0057 |     644 B |      24 B |          NA |
| LocalNonCapturing  | 16.15 ns | 0.065 ns | 0.054 ns |  0.93 |    0.01 |      - |        NA |         - |          NA |
| LocalCapturing     | 18.57 ns | 0.042 ns | 0.037 ns |  1.07 |    0.01 | 0.0057 |        NA |      24 B |          NA |
| OpaqueNonCapturing | 16.85 ns | 0.243 ns | 0.216 ns |  0.97 |    0.01 |      - |        NA |         - |          NA |
| OpaqueCapturing    | 26.97 ns | 0.192 ns | 0.180 ns |  1.55 |    0.02 | 0.0210 |        NA |      88 B |          NA |
```

Every non-capturing shape allocated nothing. Both capturing shapes with an inlinable consumer allocated 24 bytes, which is a 16-byte object header and method table plus a 4-byte `int`, rounded up to 8. `OpaqueCapturing` allocated 88, which is the same display class plus a 64-byte `Func<int, bool>` on this runtime. The `NoLambda` row is the control: the loop written by hand, with nothing to allocate.

Time is secondary. The capturing shapes with an inlinable consumer ran 1.07 to 1.08 times the control in this run and 1.07 to 1.16 across the two runs; `OpaqueCapturing` ran 1.55 times, and 1.55 to 1.69 across both. The non-capturing and `NoLambda` means spread over 1.20 ns here and 0.72 ns in the reproduction, so no ordering among them is claimed.

A `GC.GetAllocatedBytesForCurrentThread()` loop of 1,000,000 calls per shape gave 0.00 for every non-capturing shape, 24.00 for `LocalCapturing` and 88.00 for `OpaqueCapturing`. It gave 87.94 for `LinqCapturing`, which BenchmarkDotNet had measured at 24.

## The delegate goes only when it stays in the method and the JIT can bind the call

The difference between 24 and 88 is explained by the JIT listing, captured with `DOTNET_JitDisasm` in the benchmark processes. Counting `CORINFO_HELP_NEWSFAST` calls, the allocation helper, on each shape's per-call path gave 0 for every non-capturing shape, 1 for `LinqCapturing`, 1 for `LocalCapturing` and 2 for `OpaqueCapturing`. The non-capturing shapes contain one such call each, behind the empty-cache branch.

In `LinqCapturing`, `Count` over an `int[]` is inlined into the creating method together with the lambda body. The body reads `threshold` straight from the display class, and no delegate object exists. The JIT saw creation, consumer and call in one method, bound the call to the lambda's own method and removed the `Func`. In `OpaqueCapturing` both objects are allocated before a tail call to `CountWhere`. The delegate crosses a `NoInlining` boundary, so it has to exist; inside `CountWhere` the JIT still guards on the delegate's method pointer and inlines the lambda body behind the check, but that does not take back the allocation.

The 87.94 is the same rule failing in a different compiled copy. In the cross-check's on-stack-replacement copy of the measuring loop, seen in a diagnostic run of the cross-check with `DOTNET_JitDisasm` on an earlier build without the opaque shapes, guarded devirtualisation predicted the non-capturing lambda as the target. The guard failed on every element, the call stayed indirect, and the `Func` had to be allocated: 88 bytes. The shortfall from 88 comes from the first 1,000 iterations of each measured loop, which run in Tier0 and call the standalone Tier1 method that allocates 24. Why the guard predicted the wrong lambda was not tested; the profile `Count` shares with the non-capturing variant is the likeliest reading.

So the delegate's removal is an outcome of one compiled copy, not a guarantee of the language. The display class has no such dependence. It stayed on the heap at 24 bytes in every capturing shape, and the capturing lambda never reached 0.

## What this changes

1. Read the lambda body for any local of the enclosing method. In these measurements a constant gave 0 bytes and a captured `int` cost at least the 24-byte display class whatever the consumer did, and 88 bytes when the delegate survived.
2. Do not read the delegate's allocation off the source. The same lambda measured 24 bytes when the consumer was inlined and 88 when it was not, and one cross-check copy of the same code measured 87.94.
3. Confirm in a decompiler instead of guessing. A nested type named `<>c__DisplayClass…` means a per-call object, and a static `<>c.<>9__…` field means a cached delegate. With a single captured `int`, one object per call in `Allocated` or in the `NEWSFAST` count is the display class with the delegate removed, and two is the delegate surviving as well.

---

**Measured on** .NET SDK 10.0.401 / runtime 10.0.12, Linux (Ubuntu 24.04.5 LTS) arm64 inside Docker with `--network none --cap-drop ALL --security-opt no-new-privileges --user 501:20 --pids-limit 512` and no resource limits (`nproc` 12, `cpu.max` `max 100000`, `memory.max` `max`). Timing and the `Allocated` column come from BenchmarkDotNet 0.15.8 with `[MemoryDiagnoser]` on a job that is the default job plus the JIT environment variables; the byte counts were cross-checked with `GC.GetAllocatedBytesForCurrentThread()` over 1,000,000 calls per shape after five warm-up rounds, which sees only the calling thread. The lowered view comes from ICSharpCode.Decompiler 9.1.0.7988. BenchmarkDotNet's disassembly diagnoser exported assembly for two of seven shapes on Arm64, so the per-shape allocation sites come from `DOTNET_JitDisasm`. What this harness cannot see: the Docker Desktop VM's own CPU and memory allocation, the host CPU model (BenchmarkDotNet reports that line as `-`), x64 code, other runtime versions, inputs other than 64 `int` values, a captured type other than `int`, and a delegate stored in a field, returned, or passed to a virtual or interface consumer.

---

## Frequently asked

### Does a lambda that captures a local always allocate two objects?

No. On .NET 10.0.12 it always allocated the display class, 24 bytes for one captured int, but the delegate was allocated only when it escaped the creating method or the JIT could not bind the call to the lambda's own method. Passed to Enumerable.Count on an int array it measured 24 bytes; passed to a non-inlined method it measured 88.

### How can a lambda that captures a variable be told apart from one that does not?

A lambda that reads a local of the enclosing method gets a display class, which shows up in a decompiled build as a nested type named like c__DisplayClass2_0 with the local as a public field. A lambda that reads nothing from the enclosing method is cached in a static field on the nested type named c, and allocated 0 bytes after its first call.

### Can the JIT remove the allocation of a capturing lambda entirely?

Not in the shapes measured here. The JIT removed the delegate when the consumer was inlined into the creating method, but the display class stayed on the heap at 24 bytes, so a capturing lambda never reached 0 bytes.

