Levelwise
English
C# and .NET basics

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.

Not reviewedWritten with AI helpReading time: 15 minExample of an online shop shopping cartC# and .NET 10 code

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:

StackFreed when the method endsAddToCart()int quantity = 3Cart cartHandleRequest()Guid userIdHeapShared by all, collected automaticallyCartdecimal TotalList LinesList<CartLine>Another objectGarbage: no referencesA number inside an object is stored together with that object
The cart variable on the Stack is only a reference. The Cart object itself is on the Heap.

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

  1. 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.
  2. Value types (int, decimal, Guid, struct). The variable is the value itself. If you give it to another variable, a copy is made.
A half-true belief: “Value types are always on the Stack” is not true. If a decimal is a field of a class, it is on the Heap together with that object. The place of the data is decided by where it is defined, not only by its type.
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.

RootsLocal variableMethod that is runningstaticField, lives foreverSingletonService, lives foreverCartCartLineDictionaryCart (old)PriceChangedCartWatcherOrderstringGarbageNo path from any rootAlive, but not needed anymoreThis is the memory leak
The GC system only asks "is there a path from a root to this object?". It does not ask "does the program still need this object?".

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:

  1. Generation 0 (Gen0). Every new object is created here. It is small and fills up quickly. Its GC is fast.
  2. Generation 1 (Gen1). Objects that survived one GC. A layer between young and old.
  3. 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.

Live example: GC generations

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.

Gen0New objects, fast and frequent GC
Gen1Survived once
Gen2Long-lived, full and costly GC
LOHBig objects, only with full GC
    The lesson of this example: The more garbage you make, the more often the generation 0 GC runs. The more objects stay alive for no reason, the more often the full GC runs. The full GC is the long pause that the user feels.

    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:

    1. Each event keeps a reference to all its subscribers. So PriceFeed points to every CartPriceWatcher.
    2. 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.
    3. So every CartPriceWatcher that is created in each request also stays alive forever.
    4. 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:

    1. 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.
    2. Take two snapshots. With the dotnet-gcdump tool, for example one hour apart.
    3. Compare the two snapshots. The question is: the count of which object type keeps growing? For example, two million CartPriceWatcher objects.
    4. 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.
    5. Fix the code and measure again.
    dotnet-counters monitor --process-id 1234
    dotnet-gcdump collect --process-id 1234
    High memory is not always a leak. The GC system does not always give freed memory back to the operating system right away. It keeps it for later. The right measure is this: look at the memory after each full GC. If it goes back to the same flat level every time, it is not a leak. If this level is higher every time, we have a leak.

    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

    1. The GC system manages memory, not resources. Close files, connections and sockets with using.
    2. Every cache must have a limit. A count limit, a size limit or an expiration time.
    3. Every event subscription on a long-lived object must also have an unsubscribe.
    4. Do not call the Collect method of the GC class. It almost always makes things worse. The GC itself knows better when to run.
    5. In hot code, make less garbage. Every small allocation brings the generation 0 GC closer.
    6. 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

    1. Local variables are on the Stack. Class objects are on the Heap.
    2. The GC system starts from the roots. Every object it does not reach is garbage.
    3. New objects are in generation 0. Most of them die soon, and their GC is cheap.
    4. A full GC (generation 2) is expensive. Big objects in the LOH are only cleaned by a full GC.
    5. A memory leak means a forgotten reference from a root, like an event on a Singleton or a static cache.
    6. To find a leak: dotnet-counters, two snapshots, comparison, and gcroot.