📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does the .NET managed heap allocate objects? Explain allocation contexts, generations, the large object heap and the pinned object heap.

How allocation works on the managed heap (bump-pointer allocation in per-thread contexts), how generations and promotion keep collections cheap, when objects go to the large object heap, why pinning matters and the pinned object heap, and how to measure and reduce allocations.

Mid-Level Theory Intermediate allocation gc generations heap loh memory

🎯 Key points

  • Allocation is a fast pointer bump inside a per-thread allocation context in gen 0; the slow path asks the GC for more space, and exhausting the gen 0 budget triggers a gen 0 GC
  • Generational hypothesis: most objects die young, so gen 0 is collected often and cheaply; collection cost is proportional to survivors, not garbage
  • Survivors are promoted gen 0 -> gen 1 -> gen 2; gen 2 collections mark the whole heap and usually run as background GCs; a card table lets young GCs skip scanning old objects
  • Objects of 85,000 bytes or more go on the large object heap, which is collected with gen 2 and not compacted by default, so short-lived large buffers cause gen 2 GCs and fragmentation
  • Pinned objects cannot move and block compaction; the pinned object heap (GC.AllocateArray(..., pinned: true)) keeps long-lived pinned buffers out of the young generations
  • Current 64-bit runtimes manage the heap as regions that can move between generations and be returned to the OS (the older segments-based GC is still available as a fallback)
  • Objects with finalizers survive at least one extra GC and are processed on the finalizer thread, so implement IDisposable and call GC.SuppressFinalize instead of relying on finalizers
  • Reduce allocation rate and mid-life survivors in hot paths with spans, pooling, streaming and fewer intermediate objects; measure with MemoryDiagnoser, dotnet-counters and gc-verbose traces

⏱ 30-second answer

Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.

🗣 Full answer (senior)

Allocation: the fast path

  • The GC hands each thread an allocation context: a range of pre-zeroed memory in gen 0 (a few KB at a time).
  • new Order() moves a pointer forward by the object's size and writes the header and method table pointer. This takes a few nanoseconds, similar to stack allocation.
  • When the context is exhausted, the slow path asks the GC for more. When the gen 0 budget (sized from cache sizes and recent survival rates) is exhausted, a gen 0 GC is triggered.

Generations

Generation Holds Collected Cost
Gen 0 New objects Very often Cheap: few survivors to copy
Gen 1 Survivors of one GC (a buffer between young and old) Often Cheap to moderate
Gen 2 Long-lived objects (caches, singletons, static data) Rarely Expensive: marks the whole heap; usually done as a background GC concurrently with your threads
LOH Objects ≥ 85,000 bytes (mostly big arrays and strings) With gen 2 Not compacted by default, so it can fragment
POH Objects allocated pinned (GC.AllocateArray(..., pinned: true)) With gen 2 Never moved, by design

The GC finds live objects by tracing from roots (stack slots and registers, statics, GC handles), then compacts survivors to remove gaps and updates references. A card table tracks old objects that point to young ones, so gen 0 GCs don't need to scan all of gen 2.

Large objects

  • Allocating on the LOH is more expensive (memory is zeroed and it may trigger gen 2 work), and short-lived large objects are a classic cause of frequent gen 2 GCs.
  • Common sources: MemoryStream growth, File.ReadAllBytes of big files, large StringBuilder.ToString(), big JSON payloads read into strings, List<T> growing beyond about 10,000 references.
  • Fixes: ArrayPool<T>.Shared, RecyclableMemoryStream, streaming APIs (PipeReader, Stream copy, JsonSerializer.DeserializeAsync on a stream), and right-sized collections.
  • LOH compaction can be requested with GCSettings.LargeObjectHeapCompactionMode = CompactOnce, which you should rarely need.

Pinning

  • Native code and the OS (for example during asynchronous I/O) need buffers that don't move. Pinning tells the GC not to move an object.
  • Many short-lived pinned objects in gen 0 fragment it. The POH solves this for long-lived buffers; sockets and Kestrel use pooled, pinned or native memory internally.

Regions (.NET 7+)

The GC now manages memory as many smaller regions that can be reassigned between generations, instead of a few large segments. This makes it easier to return memory to the OS and handle varied workloads; you rarely need to think about it, but it explains why memory behaviour changed between .NET 6 and later versions.

Production example

An API that returned reports read each upstream response with await response.Content.ReadAsStringAsync() and then deserialised it. Responses were 200–800 KB, so every request allocated large strings on the LOH, and gen 2 GCs ran every few seconds with visible latency spikes. Deserialising directly from the response stream (ReadFromJsonAsync) removed the large strings, and gen 2 GCs dropped to a few per hour.

🔍 What the interviewer is checking

Checks a solid working model of managed memory that underpins performance work.

Green flags: explains bump allocation and allocation contexts, generations and promotion, the 85,000-byte LOH threshold and why it matters, pinning and the POH, and how to measure and reduce allocations.

Red flags: "the GC is magic"; recommends GC.Collect(); doesn't know about the LOH; thinks every allocation is expensive in itself.

📚 Learn in depth

1. Allocation and a gen 0 GC, drawn

gen 0 region
[ T1 context: obj obj obj | free ]  [ T2 context: obj obj | free ]  [ unused ... ]
                     ^ alloc pointer (bump)

budget exhausted -> gen 0 GC:
  1. suspend managed threads (at safe points)
  2. mark from roots: stacks, registers, statics, handles, card table (old -> young refs)
  3. compact or promote survivors into gen 1
  4. update references, resume threads
  gen 0 is empty again -> fast bump allocation continues

2. Why generational GC is cheap

Cost of a collection is roughly proportional to surviving objects, not garbage. If 95% of gen 0 objects are dead, a gen 0 GC copies 5% and is done in well under a millisecond for typical heaps. Problems start when objects live just long enough to be promoted (for example request-scoped objects held across a slow await), which is called a mid-life crisis: they're expensive to promote and then die in gen 2.

3. LOH threshold details

  • The threshold is 85,000 bytes of object size, including the header. A byte[] of 84,976 bytes or more crosses it on 64-bit.
  • A double[] of 1,000 elements is 8 KB (small object heap); of 10,700 elements it's on the LOH.
  • string of about 42,500 characters is on the LOH (2 bytes per char).

4. Measuring allocations

Tool Use
BenchmarkDotNet [MemoryDiagnoser] Bytes allocated per operation, gen 0/1/2 counts
GC.GetAllocatedBytesForCurrentThread() Quick in-code measurement in tests
dotnet-counters (alloc-rate, gen-0-gc-count, loh-size) Live production view
Visual Studio .NET Object Allocation tool, PerfView, dotnet-trace with the gc-verbose profile Which code allocates what

5. Allocation-reduction toolbox

Pattern Replace with
Substring and split for parsing ReadOnlySpan<char>, MemoryExtensions.Split (.NET 9+), Utf8Parser
Temporary arrays stackalloc (small), ArrayPool<T>.Shared.Rent/Return
String building in loops StringBuilder (pooled), interpolated string handlers, string.Create
LINQ in hot loops Plain loops over spans
Boxing via object/non-generic APIs Generic APIs, [LoggerMessage] source-generated logging
Reading whole payloads into strings Stream-based deserialisation
Many small objects Structs or struct arrays where semantics allow

6. GC modes in one table

Mode Default for Trade-off
Workstation, concurrent Desktop and console apps Lower memory, single heap
Server ASP.NET Core Heap per core, higher throughput and memory; DATAS adapts heap count (default since .NET 9)
Background GC Both (on by default) Gen 2 marking runs concurrently, reducing pauses

7. Try it yourself

  1. Run a benchmark that allocates new byte[80_000] versus new byte[90_000] in a loop with [MemoryDiagnoser] and compare gen 2 counts.
  2. Replace the 90,000-byte allocation with ArrayPool<byte>.Shared.Rent and Return, and compare again.
  3. Watch dotnet-counters for an ASP.NET Core endpoint that reads a 500 KB response as a string, then change it to deserialise from the stream.
  4. Use GC.GetGeneration(obj) before and after GC.Collect() calls in a small test to see promotion.

💻 Code

namespace ManagedHeap;

using System.Buffers;
using System.Net.Http.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class LohBenchmarks
{
    [Benchmark(Baseline = true)]
    public int SmallObjectHeap() => new byte[80_000].Length;      // below the 85,000-byte threshold

    [Benchmark]
    public int LargeObjectHeap() => new byte[90_000].Length;      // LOH: collected with gen 2

    [Benchmark]
    public int Pooled()
    {
        byte[] buffer = ArrayPool<byte>.Shared.Rent(90_000);        // reused, no new allocation
        try { return buffer.Length; }
        finally { ArrayPool<byte>.Shared.Return(buffer); }
    }
}

public static class Program
{
    public static void Main()
    {
        var order = new object();
        Console.WriteLine(GC.GetGeneration(order));   // 0
        GC.Collect();
        Console.WriteLine(GC.GetGeneration(order));   // 1 (promoted)
        GC.Collect();
        Console.WriteLine(GC.GetGeneration(order));   // 2
        GC.KeepAlive(order);

        var pinned = GC.AllocateArray<byte>(64 * 1024, pinned: true);   // pinned object heap
        Console.WriteLine(GC.GetGeneration(pinned));

        BenchmarkRunner.Run<LohBenchmarks>();
    }
}

public sealed record Report(string Id, decimal Total);

public sealed class ReportClient(HttpClient http)
{
    // Deserialise from the stream instead of building a large string first.
    public Task<Report?> GetAsync(string id, CancellationToken ct) =>
        http.GetFromJsonAsync<Report>($"reports/{id}", ct);
}

⚠ Common mistakes

Mistake: Thinking allocation itself is slow. Why it is wrong: the fast path is a pointer bump; the real cost is GC work later, especially for objects that survive. Instead: focus on allocation rate and survival in hot paths, not on avoiding every new.

Mistake: Ignoring large temporary buffers. Why it is wrong: short-lived objects of 85,000 bytes or more land on the LOH and drive expensive gen 2 collections. Instead: pool large buffers (ArrayPool<T>, RecyclableMemoryStream), stream data, and avoid building big strings or arrays per request.

Mistake: Adding finalizers "to be safe". Why it is wrong: a finalizable object is put on the finalization queue, survives at least one extra collection (so it is promoted), and its cleanup runs later on a single finalizer thread. Many finalizable objects add CPU, delay memory release and can back up the finalizer thread. Instead: wrap native resources in SafeHandle, implement IDisposable, dispose deterministically with using, and call GC.SuppressFinalize(this) in Dispose when a finalizer exists.

Mistake: Calling GC.Collect() to "free memory". Why it is wrong: it forces expensive full collections, promotes live objects prematurely and usually makes things worse. Instead: fix allocations and lifetimes; use explicit collection only in rare, measured cases (for example after a one-off bulk load in a batch job).

Mistake: Holding request-scoped data in long-lived places. Why it is wrong: caching per-request objects in statics or singletons promotes them to gen 2 and grows the heap. Instead: keep request data request-scoped, and bound caches with size limits and expiry.

Mistake: Pinning buffers for long periods in gen 0. Why it is wrong: long-lived pins block compaction and fragment the young generation. Instead: use the pinned object heap or pooled native memory for long-lived I/O buffers.

Mistake: Tuning GC settings before measuring. Why it is wrong: the defaults are good for most services, and random tuning often trades one problem for another. Instead: measure with counters and traces, fix the allocation pattern, and change one setting at a time.

🏛 Senior insight: trade-offs

Seniors understand the GC well enough to work with it: allocation is cheap, survival is expensive, large and pinned objects are special, and gen 2 is where pauses and memory growth come from.

GC mode Heaps Best for Trade-off
Workstation (concurrent) One Desktop apps, small containers Lower memory, less throughput on many cores
Server One per core (DATAS adapts the count by default in current .NET) ASP.NET Core, high-throughput services More memory; GC threads per heap
Background GC n/a (on by default) Both Gen 2 marking runs concurrently, shorter pauses
Allocation pattern GC effect Habit
Many short-lived small objects Cheap gen 0 GCs Usually fine; reduce only in hot paths
Objects held across slow awaits Promoted then die (mid-life crisis) Shorten lifetimes, avoid capturing large state
Large temporary buffers LOH churn, gen 2 GCs Pool or stream
Unbounded caches Gen 2 growth Size limits, expiry
Finalizable objects Extra GC cycle, finalizer backlog SafeHandle + Dispose

GC configuration is the last lever, not the first; dashboards for allocation rate, gen 2 count, LOH size and % Time in GC show whether a code change worked.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions GC modes or heap limits. Why: Checks how the GC heap fits into total process memory in containers.
    How to answer

    RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.

  2. When: Candidate talks about reducing allocations with spans or structs. Why: Checks what avoids the heap entirely.
    How to answer

    The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.

    The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.

🛠 Real-world problems: how you handle them

Gen 2 GC storms from reading large upstream responses as strings
Situation
A reporting API aggregated data from three upstream services and returned summaries to a web dashboard used by about 2,000 staff. Upstream responses were 200–800 KB of JSON each.
Signals
p99 latency spikes every few seconds, `dotnet-counters` showing gen 2 GCs every 3–5 seconds, a large and fluctuating LOH size, and high `% Time in GC` during business hours.
Investigation
Captured a dotnet-trace with the `gc-verbose` profile and found most LOH allocations came from `ReadAsStringAsync` followed by `JsonSerializer.Deserialize<T>(string)`. Confirmed with a benchmark that each request allocated several large strings.
Resolution
Switched to `ReadFromJsonAsync<T>`/`DeserializeAsync` on the response stream, and pooled a large buffer used for CSV export. Gen 2 GCs dropped to a few per hour and p99 latency fell by about 60%.
Prevention
Dashboard panels for allocation rate, gen 2 count and LOH size; a code-review guideline against reading whole payloads into strings; an allocation-budget benchmark for the hottest endpoint.

How to tell it (STAR)

**Situation:** At [company], our reporting API had latency spikes every few seconds during business hours. **Task:** I was asked to find the cause and remove the spikes. **Action:** dotnet-counters showed gen 2 collections every few seconds and a busy large object heap. A GC trace pointed at reading [200–800 KB] upstream responses into strings before deserialising. I changed the clients to deserialise straight from the response stream and pooled the large export buffer. **Result:** Gen 2 collections fell from every few seconds to [a few per hour], p99 latency dropped by about [60%], and we added allocation metrics to the dashboard.

↩ Usually asked after

More in Memory

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me