Memory and Allocation
By the end of this lesson
Describe stack and heap allocation and what garbage collection does for you.
Your program has two places to put things, and which one is used depends on the type.
The stack is a small, fast region tied to a single method call. When a method is entered, space is reserved for its parameters and local variables. When it returns, that space is released by moving a pointer — no bookkeeping, no cleanup. It is fast because it is strictly ordered: the last thing in is the first thing out.
The heap is a larger region for objects whose lifetime is not tied to one method. When you write new Order(), the object goes on the heap and your variable holds a reference to it. That reference can be returned, stored in a list, or handed to another thread, and the object lives on until nothing points at it any more.
You do not choose between them directly. The type decides, and the rules are worth knowing because they explain several behaviours that otherwise look arbitrary.
Value types and reference types differ in what a variable holds, and everything else follows from that:
| Value types | Reference types | |
|---|---|---|
| Which types | int, double, decimal, bool, char, DateTime, Guid, any struct, any enum | class, record class, interface, delegate, string, array, List<T> |
| What the variable holds | The value itself | A reference to an object stored elsewhere |
| Where it lives | With its owner: on the stack as a local, or inside the object that contains it | The object is on the heap; the reference can be on the stack |
| Assignment copies | The whole value. Two independent copies | The reference. Two names for one object |
| Default when uninitialised | Zero-filled — 0, false, DateTime.MinValue | null |
| Equality by default | Compares the contents field by field | Compares identity — is it the same object? (string and records override this) |
| Who cleans it up | Nobody. The stack unwinds, or the containing object is collected | The garbage collector, once nothing references it |
public struct Money // value type
{
public decimal Amount { get; set; }
public string Currency { get; set; }
}
public class Order // reference type
{
public string Reference { get; set; } = "";
public decimal Total { get; set; }
}
// Value type: assignment copies.
Money first = new() { Amount = 100m, Currency = "GBP" };
Money second = first;
second.Amount = 250m;
Console.WriteLine(first.Amount); // 100 — unaffected
// Reference type: assignment copies the reference.
Order a = new() { Reference = "SO-91", Total = 100m };
Order b = a;
b.Total = 250m;
Console.WriteLine(a.Total); // 250 — same object
// Boxing: a value type stored where an object is expected.
object boxed = first; // allocates on the heap and copies into it
Money unboxed = (Money)boxed; // copies back out
// The same thing, hidden inside an innocent-looking call.
List<object> auditTrail = new();
for (int i = 0; i < 1_000_000; i++)
{
auditTrail.Add(i); // one million boxed ints, one million allocations
}
// No boxing: the list holds ints directly.
List<int> counts = new();
for (int i = 0; i < 1_000_000; i++)
{
counts.Add(i);
}- Money is a struct, so second = first copies all of it. Changing second leaves first alone. This is the behaviour people find surprising until they know the type is a value type, and it is why mutable structs cause confusion.
- Order is a class, so b = a copies only the reference. There is one Order, and both names point at it. Changing it through either name changes the one object.
- object boxed = first is boxing. object is a reference type, so the value cannot be stored as-is: the runtime allocates a small heap object, copies the value into it, and your reference points at that. The cast back copies the value out again, which is unboxing.
- The auditTrail loop is boxing a million times without saying so. Every int added to a List<object> becomes a separate heap allocation, and every one of them is rubbish the moment nothing references it.
- The counts loop does the same work with no boxing at all, because List<int> stores ints directly. This is the practical argument for generics from the memory side: List<object> would have boxed every element.
- Neither loop is slow because of arithmetic. The difference is allocation, and the clean-up work it creates later.
What the garbage collector does
You never free heap memory in C#. The garbage collector does it, and knowing roughly how helps you understand what allocation costs:
It works out what is still reachable
Starting from roots — local variables in running methods, static fields, and a few others — it follows every reference. Anything it can reach is in use. Anything it cannot reach is rubbish, whether or not you meant to abandon it. There is no reference counting and no cycle problem: two objects pointing only at each other are unreachable and both go.
It reclaims the unreachable objects and compacts what is left
Survivors are moved together so free space stays contiguous, which keeps the next allocation cheap — usually little more than advancing a pointer. This is why allocating in .NET is fast; the cost of an object is paid mostly at collection time, not creation time.
It sorts objects into generations, because most die young
New objects go into generation 0. A generation 0 collection looks at only the newest objects, so it is quick and happens often. Objects that survive are promoted to generation 1, and survivors of that to generation 2. Generation 2 collections examine everything and are far more expensive, so the goal is for objects to die in generation 0.
Very large objects are handled separately
Objects over roughly 85,000 bytes — big arrays, large buffers, the backing array of a list that has grown a great deal — go on the large object heap, which is collected with generation 2 and by default is not compacted. Repeatedly allocating large short-lived arrays is therefore more expensive than the size alone suggests, and is what array pooling exists to avoid.
Collections pause your code, briefly
Some phases of a collection need application threads stopped. Generation 0 pauses are typically under a millisecond and you will not notice them. Generation 2 pauses on a large heap can reach tens of milliseconds, which is enough to show up in the tail of your latency figures. Fewer promoted objects means fewer of those.
Unmanaged resources are not its job
The collector manages memory, not file handles, sockets or database connections. Those are released by Dispose, which is what using statements call. An object holding an unmanaged resource can be collected without the resource ever being released, which is why using matters regardless of garbage collection.
Summary
- The stack holds locals for one method call and is released when the method returns; the heap holds objects with longer lives
- Value types hold their value and copy on assignment; reference types hold a reference and share one object
- The garbage collector reclaims whatever is no longer reachable, and generations exist because most objects die young
- Boxing stores a value type on the heap, so value types in object or non-generic collections allocate
- Collection frees memory, Dispose frees resources — using is still required for files, sockets and connections
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Think about it
A web service runs comfortably for the first hour after a restart and then slows steadily. Memory climbs through the day and never drops, even overnight when there is no traffic. Restarting it resets everything for another hour.
Garbage collection is working — collections are happening, and frequently. So what could be growing, and why does the collector not help?
Show solution
Something is keeping objects reachable, and the collector is doing its job correctly by not touching them. It does not collect objects that are still referenced; it has no way to know a reference is one you no longer want.
The usual suspects are all long-lived holders. A static collection or a singleton's field that is only ever added to. A cache with no size limit and no expiry. An event where handlers subscribe per request and never unsubscribe, so the publisher holds every subscriber that ever existed. A captured DbContext of the kind the dependency injection lesson described, accumulating tracked entities.
The pattern in the symptoms is informative. Memory that never drops overnight rules out a load-related spike, because idle time would have let a transient peak be collected. A rising floor means growth, not pressure.
Frequent collections plus growing memory is a recognisable signature. The collector keeps running, keeps finding almost nothing to reclaim because everything is reachable, and promotes survivors up the generations. Generation 2 then grows, full collections get more expensive, and that is where the gradual slowdown comes from — the memory growth and the slowdown are the same problem.
Diagnosing it means looking at what is on the heap, not at the collection counts. A memory snapshot taken an hour apart, compared by type, shows which type is growing. Then the question is which long-lived object holds those instances, and the answer is usually a static field, a cache or an event.
Try it yourself
Try it yourself
You have a method that builds a list of order references as object values, then formats them into a report line. It allocates far more than expected for a few hundred orders.
Find every allocation in it and decide which ones are worth removing. Do not assume all of them are.
Show solution
Three sources of allocation are worth naming. First, boxing: every decimal and int added to a List<object> becomes its own heap object. Second, string concatenation in a loop: each += creates a new string and abandons the previous one, so 300 orders produce 300 increasingly large strings. Third, the list itself, which reallocates its backing array as it grows.
The boxing and the concatenation are worth fixing, and both fixes make the code clearer rather than more obscure. A strongly typed list removes the boxing and restores compile-time checking at the same time. StringBuilder removes the intermediate strings and expresses the intent better.
Presizing the list is worth doing only when you know the count, as you do here, and it is genuinely marginal — a handful of array copies for 300 items. It is included to show the difference between an allocation that matters and one that does not.
What is not worth changing: the orders themselves, the final string, the StringBuilder. Those are allocations the work requires. Removing necessary allocations is not the goal; removing the ones that produce nothing is.
For a few hundred orders, none of this is measurable. The version to write is the second one regardless, because it is also the clearer of the two — and that is the honest reason to prefer it. If this ran per request on a busy service, the measurement would then justify it on its own terms.
// Before: boxing on every value, a new string on every iteration.
public string BuildReportBefore(IReadOnlyList<Order> orders)
{
List<object> values = new();
string line = "";
foreach (Order order in orders)
{
values.Add(order.Reference); // reference type, no boxing
values.Add(order.Total); // decimal boxed
values.Add(order.ItemCount); // int boxed
line += order.Reference + "," + order.Total + ";"; // new string each time
}
return line;
}
// After: no boxing, one buffer, and the compiler checking the types.
public string BuildReportAfter(IReadOnlyList<Order> orders)
{
// Capacity known up front, so the backing array is allocated once.
List<(string Reference, decimal Total, int ItemCount)> values = new(orders.Count);
StringBuilder line = new();
foreach (Order order in orders)
{
values.Add((order.Reference, order.Total, order.ItemCount));
line.Append(order.Reference)
.Append(',')
.Append(order.Total)
.Append(';');
}
return line.ToString();
}Saved in this browser only.