In the common language runtime (CLR), the garbage collector (GC) serves as an automatic memory manager to manages the allocation and release of memory for an application. [1]

  • Each process has its own, separate virtual address space.

    All processes on the same computer share the same physical memory and the page file, if there’s one.

  • By default, on 32-bit computers, each process has a 2-GB user-mode virtual address space.

  • The garbage collector allocates and frees virtual memory for an appliction on the managed heap.

    • Free: The block of memory has no references to it and is available for allocation.

    • Reserved: The block of memory is available for using and can’t be used for any other allocation request. However, data can’t be stored to this memory block until it’s committed.

    • Committed: The block of memory is assigned to physical storage.

  • Virtual address space can get fragmented, which means that there are free blocks known as holes in the address space.

    When a virtual memory allocation is requested, the virtual memory manager has to find a single free block that is large enough to satisfy the allocation request.

  • An application can run out of memory if there isn’t enough virtual address space to reserve or physical space to commit.

1. Memory allocation, release, and compaction

When initializing a new process, the runtime reserves a contiguous region of address space for the process which is called the managed heap.

  • The managed heap maintains a pointer to the address where the next object in the heap will be allocated. Initially, this pointer is set to the managed heap’s base address.

  • All reference types are allocated on the managed heap.

    • When an application creates the first reference type, memory is allocated for the type at the base address of the managed heap.

    • When the application creates the next object, the runtime allocates memory for it in the address space immediately following the first object.

    • Allocating memory from the managed heap is faster than unmanaged memory allocation.

      Because the runtime allocates memory for an object by adding a value to a pointer, it’s almost as fast as allocating memory from the stack.

      In addition, because new objects that are allocated consecutively are stored contiguously in the managed heap, an application can access the objects quickly.

  • When the garbage collector performs a collection, it releases the memory for objects that are no longer being used by the application by examining the application’s roots.

    • An application’s roots include static fields, local variables on a thread’s stack, CPU registers, GC handles, and the finalize queue.

    • Each root either refers to an object on the managed heap or is set to null.

    • The garbage collector uses this list to create a graph that contains all the objects that are reachable from the roots.

    • Objects that aren’t in the graph are unreachable from the application’s roots.

    • The garbage collector considers unreachable objects garbage and releases the memory allocated for them.

  • The garbage collector examines the managed heap, and uses a memory-copying function to compact the reachable objects in memory, freeing up the blocks of address spaces allocated to unreachable objects.

  • Memory is compacted only if a collection discovers a significant number of unreachable objects. If all the objects in the managed heap survive a collection, then there’s no need for memory compaction.

  • To improve performance, the runtime allocates memory for large objects in a separate heap, the large object heap (LOH).

    • The garbage collector automatically releases the memory for large objects.

    • However, to avoid moving large objects in memory, this memory is usually not compacted.

Before a garbage collection starts, all managed threads are suspended except for the thread that triggered the garbage collection.

Screenshot of how a thread triggers a Garbage Collection.

2. Generations

The GC algorithm is based on several considerations:

  • It’s faster to compact the memory for a portion of the managed heap than for the entire managed heap.

  • Newer objects have shorter lifetimes, and older objects have longer lifetimes.

  • Newer objects tend to be related to each other and accessed by the application around the same time.

Garbage collection primarily occurs with the reclamation of short-lived objects.

  • To optimize the performance of the garbage collector, the managed heap is divided into three generations, 0, 1, and 2, so it can handle long-lived and short-lived objects separately.

  • The garbage collector stores new objects in generation 0. Objects created early in the application’s lifetime that survive collections are promoted and stored in generations 1 and 2.

    However, if they’re large objects, they go on the large object heap (LOH), which is sometimes referred to as generation 3.

  • Most objects are reclaimed for garbage collection in generation 0 and don’t survive to the next generation.

  • If an application attempts to create a new object when generation 0 is full, the garbage collector performs a collection to free address space for the object.

  • After the garbage collector performs a collection of generation 0, it compacts the memory for the reachable objects and promotes them to generation 1.

  • If a collection of generation 0 doesn’t reclaim enough memory for the application to create a new object, the garbage collector can perform a collection of generation 1 and then generation 2.

  • Objects in generation 2 that survive a collection remain in generation 2 until they’re determined to be unreachable in a future collection.

    • Objects on the large object heap (which is sometimes referred to as generation 3) are also collected in generation 2.

    • A generation 2 garbage collection is also known as a full garbage collection because it reclaims objects in all generations (that is, all objects in the managed heap).

Objects that aren’t reclaimed in a garbage collection are known as survivors and are promoted to the next generation:

  • Objects that survive a generation 0 garbage collection are promoted to generation 1.

  • Objects that survive a generation 1 garbage collection are promoted to generation 2.

  • Objects that survive a generation 2 garbage collection remain in generation 2.

3. Unmanaged resources and dispose pattern

The .NET garbage collector doesn’t allocate or release unmanaged memory.

  • For unmanaged resources, they requires to be explicitly cleanup.

  • The most common type of unmanaged resource is an object that wraps an operating system resource, such as a file handle, window handle, network connection, or database connections.

Although the garbage collector is able to track the lifetime of an object that encapsulates an unmanaged resource, it doesn’t know how to release and clean up the unmanaged resource.

  • Implement the dispose pattern to provide an IDisposable.Dispose implementation to enable the deterministic release of unmanaged resources.

    public void Dispose()
    {
        // Dispose of unmanaged resources.
        Dispose(true);
        // Suppress finalization.
        GC.SuppressFinalize(this);
    }
    
    // Any non-sealed class should have an Dispose(bool) overload method.
    // If the method call comes from a finalizer, only the code that frees
    // unmanaged resources should execute.
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }
    
        if (disposing)
        {
            // TODO: dispose managed state (managed objects).
        }
    
        // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below.
        // TODO: set large fields to null.
    
        _disposed = true;
    }
    The disposing parameter should be false when called from a finalizer, and true when called from the IDisposable.Dispose method. In other words, it is true when deterministically called and false when non-deterministically called.
    string filePath = "example.txt";
    string textToWrite = "Hello, this is a test message!";
    
    // Use the using statement to ensure the StreamWriter is properly disposed of
    using (StreamWriter writer = new StreamWriter(filePath))
    {
        writer.WriteLine(textToWrite);
    }
  • Enable the non-deterministic release of unmanaged resources when the consumer of a type fails to call IDisposable.Dispose to dispose of them deterministically.

    • Use a safe handle to wrap the unmanaged resource.

      using Microsoft.Win32.SafeHandles;
      using System;
      using System.Runtime.InteropServices;
      
      public class BaseClassWithSafeHandle : IDisposable
      {
          // To detect redundant calls
          private bool _disposedValue;
      
          // Instantiate a SafeHandle instance.
          private SafeHandle? _safeHandle = new SafeFileHandle(IntPtr.Zero, true);
      
          // Public implementation of Dispose pattern callable by consumers.
          public void Dispose()
          {
              Dispose(true);
              GC.SuppressFinalize(this);
          }
      
          // Protected implementation of Dispose pattern.
          protected virtual void Dispose(bool disposing)
          {
              if (!_disposedValue)
              {
                  if (disposing)
                  {
                      _safeHandle?.Dispose();
                      _safeHandle = null;
                  }
      
                  _disposedValue = true;
              }
          }
      }
    • Or, define a finalizer.

      using System;
      
      public class BaseClassWithFinalizer : IDisposable
      {
          // To detect redundant calls
          private bool _disposedValue;
      
          ~BaseClassWithFinalizer() => Dispose(false);
      
          // Public implementation of Dispose pattern callable by consumers.
          public void Dispose()
          {
              Dispose(true);
              GC.SuppressFinalize(this);
          }
      
          // Protected implementation of Dispose pattern.
          protected virtual void Dispose(bool disposing)
          {
              if (!_disposedValue)
              {
                  if (disposing)
                  {
                      // TODO: dispose managed state (managed objects)
                  }
      
                  // TODO: free unmanaged resources (unmanaged objects) and override finalizer
                  // TODO: set large fields to null
                  _disposedValue = true;
              }
          }
      }
      A finalizer is only required if you directly reference unmanaged resources.
      Object finalization can be a complex and error-prone operation, it’s recommend to use a safe handle instead of providing the finalizer.

3.1. System.IAsyncDisposable

The method IAsyncDisposable.DisposeAsync is used to asynchronously close or release unmanaged resources such as files, streams, and handles held by an instance of the class that implements the IAsyncDisposable interface, instead of IDisposable.Dispose to perform a resource-intensive dispose operation without blocking the main thread of a GUI application for a long time.

It’s typical when implementing the IAsyncDisposable interface that classes also implement the IDisposable interface.

  • A good implementation pattern of the IAsyncDisposable interface is to be prepared for either synchronous or asynchronous disposal, however, it’s not a requirement.

  • If no synchronous disposable of a class is possible, having only IAsyncDisposable is acceptable.

    If implementing the IAsyncDisposable interface but not the IDisposable interface, an app can potentially leak resources.

    If a class implements IAsyncDisposable, but not IDisposable, and a consumer only calls Dispose, the implementation would never call DisposeAsync. This would result in a resource leak.

  • Any nonsealed class should define a DisposeAsyncCore() method that also returns a ValueTask.

    public async ValueTask DisposeAsync()
    {
        // Perform async cleanup.
        await DisposeAsyncCore();
    
        // Dispose of unmanaged resources.
        Dispose(false);
    
        // Suppress finalization.
        GC.SuppressFinalize(this);
    }
    
    protected virtual ValueTask DisposeAsyncCore()
    {
    }
  • If an implementation of IAsyncDisposable is sealed, the DisposeAsyncCore() method is not needed, and the asynchronous cleanup can be performed directly in the IAsyncDisposable.DisposeAsync() method.

    public sealed class SealedExampleAsyncDisposable : IAsyncDisposable
    {
        private readonly IAsyncDisposable _example;
    
        public SealedExampleAsyncDisposable() =>
            _example = new NoopAsyncDisposable();
    
        public ValueTask DisposeAsync() => _example.DisposeAsync();
    }
  • Implement both dispose and async dispose patterns

    class ExampleConjunctiveDisposableusing : IDisposable, IAsyncDisposable
    {
        IDisposable? _disposableResource = new MemoryStream();
        IAsyncDisposable? _asyncDisposableResource = new MemoryStream();
    
        public void Dispose()
        {
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    
        public async ValueTask DisposeAsync()
        {
            await DisposeAsyncCore().ConfigureAwait(false);
    
            Dispose(disposing: false);
            GC.SuppressFinalize(this);
        }
    
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                _disposableResource?.Dispose();
                _disposableResource = null;
    
                if (_asyncDisposableResource is IDisposable disposable)
                {
                    disposable.Dispose();
                    _asyncDisposableResource = null;
                }
            }
        }
    
        protected virtual async ValueTask DisposeAsyncCore()
        {
            if (_asyncDisposableResource is not null)
            {
                await _asyncDisposableResource.DisposeAsync().ConfigureAwait(false);
            }
    
            if (_disposableResource is IAsyncDisposable disposable)
            {
                await disposable.DisposeAsync().ConfigureAwait(false);
            }
            else
            {
                _disposableResource?.Dispose();
            }
    
            _asyncDisposableResource = null;
            _disposableResource = null;
        }
    }
  • To properly consume an object that implements the IAsyncDisposable interface, using the await and using keywords together.

    await using (var writer = new StreamWriter("./hello"))
    {
        await writer.WriteAsync("Hello, World!");
    }
    
    using var reader = new StreamReader("./hello");
    var text = await reader.ReadToEndAsync();
    Console.Write(text); // Hello, World!

3.2. System.Object.Finalize method

The Finalize method is used to allow an object to try to free resources and perform other cleanup operations before it is reclaimed by garbage collection.

~Object ();

If a type does override the Finalize method, the garbage collector adds an entry for each instance of the type to an internal structure called the finalization queue. The finalization queue contains entries for all the objects in the managed heap whose finalization code must run before the garbage collector can reclaim their memory.

The garbage collector then calls the Finalize method automatically under the following conditions:

  • After the garbage collector has discovered that an object is inaccessible, unless the object has been exempted from finalization by a call to the GC.SuppressFinalize method.

  • On .NET Framework only, during shutdown of an application domain, unless the object is exempt from finalization. During shutdown, even objects that are still accessible are finalized.

  • Finalize is automatically called only once on a given instance, unless the object is re-registered by using a mechanism such as GC.ReRegisterForFinalize and the GC.SuppressFinalize method has not been subsequently called.

  • Finalize should be overriden for a class that uses unmanaged resources, such as file handles or database connections that must be released when the managed object that uses them is discarded during garbage collection.

    It shouldn’t be implemented for managed objects because the garbage collector releases managed resources automatically.

    public void Dispose()
    {
        // Dispose of unmanaged resources.
        Dispose(true);
        // Suppress finalization.
        GC.SuppressFinalize(this);
    }