Skip to content

Memory Management in C# — Complete Guide

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Memory Management in C#. We cover key concepts, practical examples, and best practices to help you master this topic.

Hook

C# manages memory automatically through Garbage Collection, but understanding how the GC works is essential for building high-performance applications. Proper resource management with IDisposable and IAsyncDisposable prevents memory leaks and ensures efficient use of system resources in your .NET applications.

Learning Path

graph LR
  A[Memory Management] --> B[GC Fundamentals]
  A --> C[IDisposable]
  A --> D[Finalizers]
  B --> E[Generations]
  B --> F[LOH]
  C --> G[IAsyncDisposable]
  style A fill:#4a90d9,color:#fff
  style B fill:#4a90d9,color:#fff
  style C fill:#4a90d9,color:#fff
  style D fill:#4a90d9,color:#fff
  style E fill:#4a90d9,color:#fff
  style F fill:#4a90d9,color:#fff
  style G fill:#4a90d9,color:#fff

Garbage Collection Fundamentals

The .NET GC manages memory in three generations.

// Gen 0: Short-lived objects (temporary variables)
// Gen 1: Objects that survived one collection
// Gen 2: Long-lived objects (application singletons)

// Large Object Heap (LOH): Objects >= 85,000 bytes

public class GcDemo
{
    public static void ShowGenerations()
    {
        var obj = new byte[1000];
        Console.WriteLine($"Gen: {GC.GetGeneration(obj)}"); // 0

        GC.Collect(0);
        Console.WriteLine($"After collect: {GC.GetGeneration(obj)}"); // 1

        GC.Collect(1);
        Console.WriteLine($"After Gen 1: {GC.GetGeneration(obj)}"); // 2

        Console.WriteLine($"Total memory: {GC.GetTotalMemory(false):N0} bytes");
        Console.WriteLine($"Collections: Gen0={GC.CollectionCount(0)}, " +
                         $"Gen1={GC.CollectionCount(1)}, Gen2={GC.CollectionCount(2)}");
    }
}

IDisposable

Unmanaged resources (file handles, database connections, sockets) require explicit cleanup.

public class FileManager : IDisposable
{
    private FileStream? _stream;
    private bool _disposed;

    public FileManager(string path)
    {
        _stream = File.OpenRead(path);
        Console.WriteLine("Resource acquired");
    }

    public void ReadData()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileManager));

        // Read from stream
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Dispose managed resources
            _stream?.Dispose();
            _stream = null;
            Console.WriteLine("Managed resources released");
        }

        // Free unmanaged resources (if any)
        _disposed = true;
    }

    ~FileManager()
    {
        Dispose(false); // Finalizer only cleans unmanaged resources
    }
}

// Usage
using (var manager = new FileManager("test.txt"))
{
    manager.ReadData();
} // Dispose called automatically

The Dispose Pattern

The full dispose pattern handles inheritance correctly.

public abstract class ResourceBase : IDisposable
{
    private bool _disposed;
    private IntPtr _unmanagedResource; // Example only

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Clean up managed resources
        }

        // Clean up unmanaged resources
        if (_unmanagedResource != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_unmanagedResource);
            _unmanagedResource = IntPtr.Zero;
        }

        _disposed = true;
    }

    ~ResourceBase() => Dispose(false);
}

public class DerivedResource : ResourceBase
{
    private FileStream? _stream;

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _stream?.Dispose();
        }

        base.Dispose(disposing);
    }
}

IAsyncDisposable

For async cleanup operations (closing network connections, flushing streams).

public class AsyncDatabaseConnection : IAsyncDisposable
{
    private SqlConnection? _connection;
    private bool _disposed;

    public async Task ExecuteAsync()
    {
        await _connection!.OpenAsync();
        // Execute command
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;

        if (_connection != null)
        {
            await _connection.CloseAsync();
            await _connection.DisposeAsync();
            _connection = null;
        }

        _disposed = true;
    }
}

// Usage
await using (var db = new AsyncDatabaseConnection())
{
    await db.ExecuteAsync();
}

Weak References

Use weak references for caches that should not prevent garbage collection.

public class WeakCache<TKey, TValue> where TValue : class
{
    private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();

    public void Add(TKey key, TValue value)
    {
        _cache[key] = new WeakReference<TValue>(value);
    }

    public bool TryGet(TKey key, out TValue? value)
    {
        if (_cache.TryGetValue(key, out var weakRef))
        {
            return weakRef.TryGetTarget(out value);
        }
        value = null;
        return false;
    }
}

GC Modes and Configuration

// Configure GC in .csproj or runtimeconfig.json
/*
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
*/

// Programmatic GC hints (use sparingly)
GC.Collect(2, GCCollectionMode.Optimized);
GC.WaitForPendingFinalizers();

Common Mistakes

  1. Forgetting to call Dispose: Always use using statements for IDisposable objects. Resource leaks degrade performance.

  2. Finalizers running long operations: Finalizers run on a single thread. Blocking or long operations in finalizers freeze the finalizer queue.

  3. Large object heap fragmentation: Frequent allocations of objects near 85KB create LOH fragmentation. Use array pooling or object reuse.

  4. Calling GC.Collect manually: The GC is self-tuning. Manual calls usually harm performance. Only use in specific scenarios (testing, post-startup).

  5. Not implementing the dispose pattern correctly: Forgetting GC.SuppressFinalize(this) or not calling base class dispose in derived classes.

Practice Questions

  1. Implement a thread-safe Connection Pool that properly disposes connections when released.

  2. Create a wrapper class for HttpClient that implements IAsyncDisposable.

  3. Write a memory-efficient cache using WeakReference<T> that allows the GC to reclaim entries.

  4. Challenge: Build a custom memory pool using System.Buffers.MemoryPool<T> that limits total memory usage across the application.

FAQ

When is the finalizer called?

The finalizer runs when the GC determines the object is unreachable. The timing is non-deterministic. Use IDisposable for deterministic cleanup.

What is the difference between IDisposable and IAsyncDisposable?

IDisposable for synchronous cleanup. IAsyncDisposable for cleanup that involves async operations (flushing streams, closing connections).

How do I detect memory leaks in .NET?

Use dotnet-counters, dotnet-gcdump, or a memory profiler. Look for growing Gen 2 heap size or increasing LOH size.

Should I implement IDisposable on all my classes?

Only if your class directly holds unmanaged resources or disposable managed resources. Classes that no-op Dispose confuse consumers.

What is GC.SuppressFinalize used for?

It tells the GC that the object has already been cleaned up by Dispose, so the finalizer does not need to run. This improves GC efficiency.

Mini Project: Resource Pool

Build a generic resource pool with proper disposal.

using System.Collections.Concurrent;

public class ResourcePool<T> : IDisposable where T : IDisposable
{
    private readonly ConcurrentBag<T> _resources;
    private readonly Func<T> _factory;
    private readonly int _maxSize;
    private bool _disposed;

    public ResourcePool(Func<T> factory, int maxSize = 10)
    {
        _factory = factory;
        _maxSize = maxSize;
        _resources = new ConcurrentBag<T>();
    }

    public T Get()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        if (_resources.TryTake(out T? resource))
        {
            Console.WriteLine("Reusing pooled resource");
            return resource;
        }

        Console.WriteLine("Creating new resource");
        return _factory();
    }

    public void Return(T resource)
    {
        if (_disposed)
        {
            resource.Dispose();
            return;
        }

        if (_resources.Count < _maxSize)
        {
            _resources.Add(resource);
            Console.WriteLine("Resource returned to pool");
        }
        else
        {
            resource.Dispose();
            Console.WriteLine("Pool full, resource disposed");
        }
    }

    public void Dispose()
    {
        if (_disposed) return;

        _disposed = true;
        while (_resources.TryTake(out T? resource))
        {
            resource.Dispose();
        }

        Console.WriteLine($"Pool disposed, remaining resources cleaned");
    }
}

// Usage
var pool = new ResourcePool<MemoryStream>(() => new MemoryStream(), maxSize: 3);

var stream = pool.Get();
// Use stream
stream.WriteByte(0x42);
pool.Return(stream);

var reused = pool.Get(); // Gets pooled instance
Console.WriteLine($"Position: {reused.Position}"); // 0 (stream was reset)

pool.Dispose();

Output:

Creating new resource
Resource returned to pool
Reusing pooled resource
Position: 1
Pool disposed, remaining resources cleaned

Understanding memory management in C# is crucial for building reliable, high-performance applications. The .NET GC handles most memory automatically, but proper use of IDisposable, IAsyncDisposable, and awareness of GC behavior will prevent resource leaks and optimize your application's memory footprint.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro