Memory and the Garbage Collector
The GC system only cleans up objects that nothing points to anymore. New objects are in generation 0, and most of them die soon. A memory leak in .NET means a forgotten reference that keeps an object alive.
Author: bezzad
The problem: memory that only goes up
The shopping cart service of our shop runs in Kubernetes. On the monitoring dashboard we see this:
- The service memory goes up slowly and steadily over a few days.
- In the end, the pod restarts with the OOMKilled status.
- After the restart, the same cycle starts again.
A coworker says: “.NET has a GC. A memory leak is not possible. Let us just raise the memory limit.”
This is wrong. To understand why, we must know how memory works in .NET.
Stack and Heap
A .NET program has two main places for data:
Stack
- Each thread has its own Stack.
- The local variables of a method are here.
- When the method ends, its space is freed automatically. No GC is needed.
- It is very fast, but it is small.
Heap
- It is shared between all threads.
- Every object made with new from a class is here.
- An object can stay alive even after the method ends.
- The GC system cleans it up.
Value types and reference types
- Reference types (class, record, string, array). The variable is only an address to the object on the Heap. If you give the variable to another variable, both point to the same one object.
- Value types (int, decimal, Guid, struct). The variable is the value itself. If you give it to another variable, a copy is made.
public readonly record struct Money(decimal Amount, string Currency); // value type
public sealed class Cart // reference type, lives on the heap
{
private readonly List<CartLine> _lines = [];
public Money Total { get; private set; } // stored inside the Cart object
}
void AddToCart(Cart cart, int quantity) // quantity is a local on the stack
{
var copy = cart; // copies the reference: same Cart object
}
How does the GC find garbage?
The GC system starts from the roots (GC Roots). Roots are things the program surely has access to:
- The local variables of methods that are running right now.
- Static fields.
- Things we reach from these, like Singleton services inside the container.
Then, like following a map, it follows all the references. Every object it reaches is alive. The rest are garbage, and their space is freed.
This small difference is the reason for memory leaks. If an object is not useful anymore, but there is still a path to it from a root, the GC does not clean it up.
Generations: why is the GC fast?
If the GC searched the whole Heap every time, it would be very slow. So .NET uses a simple fact: most objects die very young. Objects inside a request are garbage after a few milliseconds.
Because of this, the Heap has three generations:
- Generation 0 (Gen0). Every new object is created here. It is small and fills up quickly. Its GC is fast.
- Generation 1 (Gen1). Objects that survived one GC. A layer between young and old.
- Generation 2 (Gen2). Long-lived objects, like caches and Singleton services. A GC of this generation means a full GC. It has a lot of work and is expensive.
We also have a separate part: the Large Object Heap, or LOH. Objects of 85 thousand bytes and bigger (usually big arrays) go directly here. This part is collected only together with a full GC.
Each request makes a few short-lived objects. Sometimes a long-lived object is also added to the cache. A filled square means the object is alive. An empty square means garbage.
Memory leaks in .NET
Now let us go back to the shopping cart service. We find this code:
public sealed class PriceFeed // registered as Singleton
{
public event EventHandler<PriceChanged>? PriceChanged;
}
public sealed class CartPriceWatcher // registered as Scoped
{
public CartPriceWatcher(PriceFeed feed)
{
feed.PriceChanged += OnPriceChanged; // never removed
}
private void OnPriceChanged(object? sender, PriceChanged e) { /* ... */ }
}
Why is this a leak? Step by step:
- Each event keeps a reference to all its subscribers. So PriceFeed points to every CartPriceWatcher.
- The PriceFeed service is a Singleton. This means there is a path to it from a root, until the end of the program’s life.
- So every CartPriceWatcher that is created in each request also stays alive forever.
- After millions of requests, we have millions of watchers in generation 2. Even a full GC does not clean them up.
Solution: when the work is done, unsubscribe from the event. The container disposes a Scoped service at the end of the request:
public sealed class CartPriceWatcher : IDisposable
{
private readonly PriceFeed _feed;
public CartPriceWatcher(PriceFeed feed)
{
_feed = feed;
_feed.PriceChanged += OnPriceChanged;
}
private void OnPriceChanged(object? sender, PriceChanged e) { /* ... */ }
public void Dispose() => _feed.PriceChanged -= OnPriceChanged;
}
Other common causes
- A static cache that only grows. A static Dictionary that we add to but never clean. For a cache, use IMemoryCache with an expiration time and a size limit (SizeLimit).
- A DbContext that lives forever. Every entity it reads stays in its Change Tracker.
- A timer that is not disposed. The timer points to its callback and keeps it alive.
- Unmanaged resources. Close files, connections and streams with using. The GC only knows .NET memory.
Finding a leak, step by step
Do not guess. Measure:
- See which one goes up. Look at the GC Heap size with the dotnet-counters tool. If the GC Heap is flat but the total process memory goes up, the leak is probably in native memory.
- Take two snapshots. With the dotnet-gcdump tool, for example one hour apart.
- Compare the two snapshots. The question is: the count of which object type keeps growing? For example, two million CartPriceWatcher objects.
- Find the root. With the dotnet-dump tool and the gcroot command, see the chain of references from the root to that object. Here, the answer is the event inside PriceFeed.
- Fix the code and measure again.
dotnet-counters monitor --process-id 1234
dotnet-gcdump collect --process-id 1234
Workstation GC and Server GC
| Workstation GC | Server GC | |
|---|---|---|
| For which program? | Desktop app, small service | Server with several CPU cores |
| Number of Heaps | One | Several Heaps, for parallel work |
| Memory use | Lower | Usually higher |
| Default in ASP.NET Core | No | Yes |
Check the GC settings last. First fix the code: make less garbage and find the forgotten references.
Important rules
- The GC system manages memory, not resources. Close files, connections and sockets with using.
- Every cache must have a limit. A count limit, a size limit or an expiration time.
- Every event subscription on a long-lived object must also have an unsubscribe.
- Do not call the Collect method of the GC class. It almost always makes things worse. The GC itself knows better when to run.
- In hot code, make less garbage. Every small allocation brings the generation 0 GC closer.
- Measure first, then fix. Guessing about memory is usually wrong.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| “We have a GC, so we have no leaks” | The leak is not seen until the pod dies. | Compare snapshots and find the root. |
| Raising the memory limit | It only delays the OOM. | Find the cause. |
| Event subscription on a Singleton with no unsubscribe | Every subscriber object stays alive forever. | Unsubscribe in the Dispose method. |
| A static cache with no limit | Memory goes up without end. | A cache with expiration and SizeLimit. |
| Calling the Collect method | More pauses, with no real benefit. | Leave the work to the GC itself. |
| Creating a big array in every request | More pressure on the LOH and more full GCs. | Reuse buffers with ArrayPool. |
Summary in six lines
- Local variables are on the Stack. Class objects are on the Heap.
- The GC system starts from the roots. Every object it does not reach is garbage.
- New objects are in generation 0. Most of them die soon, and their GC is cheap.
- A full GC (generation 2) is expensive. Big objects in the LOH are only cleaned by a full GC.
- A memory leak means a forgotten reference from a root, like an event on a Singleton or a static cache.
- To find a leak: dotnet-counters, two snapshots, comparison, and gcroot.