Skip to content
Preptima

.NET and C# fundamentals

The answers a .NET interviewer works through before the hard questions start: the memory model, the type system, LINQ execution, async, ASP.NET Core wiring and the allocation-free types. Fifty-eight items, fourteen of them worked through with code, a table or a diagram.

58 questions

Go deeper on .NET & C#
Standard Sheet View

The runtime and memory model

Where do value types and reference types actually live?

The honest answer is that the distinction is about copying semantics, not about storage, and the common "structs live on the stack" line is wrong often enough to be a trap. A value type is stored inline wherever it is declared: a local goes on the stack, a field of a class goes inside that object on the heap, an element of a Struct[] goes inline in the array on the heap. A reference type always has its object on the heap, with the reference itself stored inline wherever it was declared. What follows is the behaviour that matters: assigning a value type copies the bytes, so the two variables are independent, while assigning a reference copies only the pointer, so both names see the same mutations.

What does the generational garbage collector actually do?

It divides the heap into three generations on the assumption that most objects die young. New allocations go into generation 0, which is small and collected frequently and cheaply; survivors are promoted to generation 1, and survivors of that to generation 2, which is collected rarely because a full pass is expensive. The consequence for your code is that short-lived garbage is nearly free, so a per-request object graph costs very little, while an object that survives long enough to reach generation 2 is expensive to reclaim and expensive to keep. That is why the damaging allocation pattern is not "lots of objects" but "objects that live just long enough to be promoted" — a cache with a short-but-not-short-enough lifetime is worse than either extreme.

What is the large object heap, and why does it matter?

Allocations of roughly 85,000 bytes or more go on a separate large object heap, which is collected as part of a generation 2 collection and — historically — is not compacted, because moving multi-megabyte blocks is expensive. Two things follow. Any large allocation implicitly costs you a generation 2 collection eventually, so a request path that allocates a big array per call triggers the most expensive kind of collection at a rate proportional to traffic. And an uncompacted heap fragments, so the process can hold plenty of free memory and still fail to satisfy a large contiguous request. This is the concrete argument for ArrayPool<T> and for streaming rather than buffering: not that allocation is slow, but that this particular allocation is charged to the wrong budget.

What is boxing, and what does it cost?

Boxing is the runtime wrapping a value type in a heap-allocated object so it can be treated as a reference — which happens whenever a value is assigned to object, to an interface it implements, or to a non-generic API. Unboxing is the reverse, with a type check. The cost is an allocation plus a copy per box, and the reason it is dangerous is that it is invisible: no cast appears in the source, so a hot loop can allocate millions of objects with nothing in the code suggesting it. Generics exist largely to remove it — List<int> stores ints inline where the old ArrayList boxed every one — and the modern residue is mostly interpolated strings, params object[] logging calls, and comparing enums through non-generic interfaces.

Show me boxing in a loop, with the allocation count.

The code below contains no new and allocates ten million objects. It does contain two (int) casts, and they are worth looking at closely, because they are unboxing conversions rather than the thing doing the allocating — the boxing itself is written nowhere.

// 1. Assignment to `object` boxes. `object` is a reference type, so every int
//    has to be wrapped on the heap before it can be stored in that variable.
//    The visible (int) cast is the unboxing on the way back out; the boxing on
//    the way in has no syntax at all.
long Sum(int[] values)
{
    object total = 0;                      // one box
    for (int i = 0; i < values.Length; i++)
    {
        total = (int)total + values[i];     // unbox, add, box again: 10M boxes
    }
    return (int)total;
}

// 2. The logging call boxes, once per argument, on every iteration -
//    including when the log level is disabled and the string is never built.
for (int i = 0; i < 10_000_000; i++)
{
    logger.LogDebug("processed {Index} of {Total}", i, total);   // 2 boxes each
}

// 3. Enum comparison through the non-generic path.
if (status.Equals(OrderStatus.Placed)) { }   // boxes both sides
Method            Allocated
----------------  -----------
Sum, boxed        240,000,024 B   ~10M boxes at 24 bytes each
Sum, int total              0 B

Two hundred and forty megabytes of generation 0 garbage to add up an array. The collector handles it — that is what generation 0 is for — but the allocation itself is not free, the collections are not free, and the cache behaviour is ruinous because every logical integer is a pointer chase to a separate object.

The fixes are all the same shape: keep the value in a type the compiler knows statically. Use long total rather than object. Where you are comparing or sorting through an interface, put a generic constraint on it — where T : IComparable<T> — so the call resolves to the value type's own implementation without boxing, which is the same fix in a different costume. Check logger.IsEnabled before a hot debug call, or use a source-generated logging method, which avoids both the boxing and the format parse. And use == on enums rather than Equals.

The detail candidates miss is that boxing shows up in a memory profile and never in a CPU profile of your own methods — the time is spread across the collector, which is somebody else's stack frame.

Why does a .NET service get faster after a few minutes?

Because IL is compiled to machine code by the JIT on first call, and the tiered compilation system deliberately compiles quickly and badly first. Tier 0 produces unoptimised code fast, so start-up is quick; methods called often enough are recompiled at tier 1 with full optimisation, inlining and better register allocation. So the first few hundred calls to a path run code that is several times slower than what you shipped. The practical consequences are that a benchmark without a warm-up phase measures the wrong code entirely, that a latency graph should be expected to improve for the first minute after a deploy, and that ReadyToRun or Native AOT exist precisely to trade some peak throughput for predictable start-up.