Garbage Collection in .NET: A Complete Guide to Memory Management in C#

Garbage Collection in .NET: A Complete Guide to Memory Management in C#

In .NET, memory management is primarily handled by the Garbage Collector (GC). As developers, we usually don’t need to manually allocate and free memory as in C or C++. However, it is crucial to understand how garbage collection works in C#, because it impacts performance, resource management, and application stability.

What is Garbage Collection?

Garbage Collection is the process of automatically freeing memory occupied by objects that are no longer accessible by the application. The CLR (Common Language Runtime) manages this process and ensures efficient use of system memory.

How Does It Work?

  • Objects are allocated on the managed heap.
  • The GC tracks references to objects.
  • When no references exist, the object is marked as eligible for collection.
  • GC reclaims memory and compacts the heap.

Garbage Collection Generations in .NET

The .NET Garbage Collector (GC) is designed to manage memory efficiently by automatically reclaiming unused objects. To optimize performance, .NET organizes objects into generations, which represent the "age" of objects in memory. The assumption is that most objects die young (e.g., temporary variables, short-lived results), while a few live longer (e.g., application-level caches, singleton services).

  • Generation 0 (Gen 0): This is the youngest generation and contains newly allocated objects.
    • Objects like local variables, method-scoped data, and temporary objects are created here.
    • Garbage collection in Gen 0 is very fast and occurs frequently because these objects are expected to be short-lived.
    • If an object survives a collection in Gen 0, it is promoted to Gen 1.
  • Generation 1 (Gen 1): This is a buffer generation, holding objects that survived one garbage collection.
    • Objects that are not too short-lived but also not permanent typically live here.
    • Acts as a “middle ground” before moving to long-term storage (Gen 2).
    • If an object in Gen 1 survives the next collection, it is promoted to Gen 2.
  • Generation 2 (Gen 2): This is the oldest generation and holds long-lived objects.
    • Examples include static objects, large data structures, singletons, caches, and application-wide state.
    • Garbage collections in Gen 2 are less frequent but more expensive because this memory region is larger.
    • If an object survives here, it stays until explicitly collected by a full GC (which can be expensive).

Memory Dynamics of Generations

  • Promotion: Objects surviving in Gen 0 move to Gen 1; those surviving in Gen 1 move to Gen 2.
  • Ephemeral Segment: The memory area used for Gen 0 and Gen 1 is called the ephemeral segment. Since these are collected more often, this memory segment is optimized for quick allocations and collections.
  • Large Object Heap (LOH): Objects greater than 85 KB are directly allocated on the LOH, which is collected only during full Gen 2 collections. Examples include large arrays and big data buffers.
  • Compaction: After collection, the GC compacts memory by moving live objects together, thus eliminating memory fragmentation and speeding up allocations.
  • Workload Adaptation: The GC dynamically adjusts its behavior based on application workload. For instance, if many objects survive collections, the GC may run less frequently to reduce overhead.

How Generational GC Improves Performance

  • Most short-lived objects (like strings, loops, temporary collections) are reclaimed quickly in Gen 0 without impacting long-lived data.
  • Since full Gen 2 collections are expensive, they are performed less often, reducing performance overhead.
  • Memory is efficiently reused, keeping heap fragmentation low due to compaction.

Finalizers in C#

A finalizer is a method that is called by the GC before reclaiming an object’s memory. It is defined using the ~ClassName() syntax.


using System;

class MyClass
{
    ~MyClass() // Finalizer
    {
        Console.WriteLine("Finalizer called, cleaning up unmanaged resources...");
    }
}

class Program
{
    static void Main()
    {
        MyClass obj = new MyClass();
        obj = null;
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }
}
  

Do We Always Need Finalizers or IDisposable?

If your class only uses managed resources (like string, int, List etc.), you don’t need to implement IDisposable or write a finalizer. The GC will take care of everything.


class Student
{
    public string Name { get; set; }
    public int Age { get; set; }
}

class Program
{
    static void Main()
    {
        Student s = new Student { Name = "John", Age = 20 };
        // No need for cleanup, GC handles it automatically.
    }
}
  

Forcing Garbage Collection

Although not recommended in most scenarios, you can force GC manually:


using System;

class Program
{
    static void Main()
    {
        Console.WriteLine("Memory before collection: " + GC.GetTotalMemory(false));

        // Create large objects
        byte[] data = new byte[1024 * 1024 * 10]; // 10 MB

        Console.WriteLine("Memory after allocation: " + GC.GetTotalMemory(false));

        data = null; // Release reference

        GC.Collect(); // Force garbage collection
        GC.WaitForPendingFinalizers();

        Console.WriteLine("Memory after GC: " + GC.GetTotalMemory(true));
    }
}
  

Dispose Pattern in C#: A Complete Guide with Examples

Why Do We Need Dispose?

In C#, the garbage collector (GC) automatically manages memory for managed resources. However, unmanaged resources like file handles, database connections, sockets, or OS-level handles must be released manually. For this, we use the IDisposable interface and the Dispose() method. So, instead of relying only on GC, .NET provides the IDisposable interface for deterministic cleanup of unmanaged resources.


using System;
using System.IO;

class Program
{
    static void Main()
    {
        // Using ensures Dispose() is called automatically
        using (StreamWriter writer = new StreamWriter("test.txt"))
        {
            writer.WriteLine("Hello Garbage Collection!");
        } // Dispose() called here
    }
}
  

Explanation

  • StreamWriter works with a file handle, which is an unmanaged resource.
  • If we relied only on the GC, the file might remain open until the GC eventually cleaned it up — which is non-deterministic.
  • By wrapping it inside a using block, the Dispose() method is called immediately after the block ends, ensuring the file is properly closed at the right time.
  • This demonstrates how IDisposable complements Garbage Collection by giving developers precise control over the release of unmanaged resources.

The IDisposable Interface

The IDisposable interface defines one method:

public interface IDisposable
{
    void Dispose();
}

When a class implements IDisposable, it signals that the class holds unmanaged resources and must provide a way to release them.

The Dispose Pattern

The recommended dispose pattern includes:

  • A public Dispose() method to free both managed and unmanaged resources.
  • A protected virtual Dispose(bool disposing) method where the real cleanup happens.
  • A finalizer (~ClassName()) as a safety net in case Dispose() is not called.

Code Example

Example of Unmanaged Resource Cleanup

Unmanaged resources are things like file handles, database connections, sockets, or OS handles. They must be released explicitly. Here’s how to do it properly:


using System;
using System.Runtime.InteropServices;

public class UnmanagedWrapper : IDisposable
{
    private IntPtr unmanagedResource; // Example unmanaged resource
    private bool disposed = false;    // To avoid double dispose

    // Public Dispose method
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // Prevent finalizer from running
    }

    // Protected virtual dispose method
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // Free managed resources here
                // Example: managedObject.Dispose();
            }

            // Free unmanaged resources here
            if (unmanagedResource != IntPtr.Zero)
            {
                // Release unmanaged handle
                unmanagedResource = IntPtr.Zero;
            }

            disposed = true;
        }
    }

    // Finalizer (destructor)
    ~UnmanagedWrapper()
    {
        Dispose(false);
    }
}

class Program
{
    static void Main()
    {
        using (var resource = new UnmanagedWrapper(1024))
        {
            Console.WriteLine("Using unmanaged resource...");
        }
    }
}
  

Suppressing Finalization

You can suppress the finalizer if resources are already cleaned up manually:


using System;

class Resource : IDisposable
{
    ~Resource()
    {
        Console.WriteLine("Finalizer called.");
    }

    public void Dispose()
    {
        Console.WriteLine("Dispose called.");
        GC.SuppressFinalize(this); // Prevents finalizer from running
    }
}

class Program
{
    static void Main()
    {
        using (Resource r = new Resource())
        {
            Console.WriteLine("Using resource...");
        }
    }
}
  

What Does the disposing Variable Do?

The disposing parameter indicates whether the method was called explicitly via Dispose() or by the finalizer:

  • disposing = true: Called from Dispose(). Both managed and unmanaged resources can be safely released, since other managed objects are still accessible.
  • disposing = false: Called from the finalizer (~UnmanagedWrapper()). At this point, you should only release unmanaged resources. Managed objects may already have been finalized, so accessing them can cause exceptions.

Can We Access Managed Data Members Inside a Finalizer?

No, you cannot reliably access managed data members inside a finalizer. The garbage collector does not guarantee the order in which managed objects are finalized. By the time your finalizer runs, other managed objects may already be cleaned up, so dereferencing them could throw exceptions or behave unpredictably.

Best Practices for Garbage Collection

  • Always use using for disposable objects.
  • Avoid forcing garbage collection unless absolutely necessary.
  • Be mindful of memory leaks caused by event handlers or static references.
  • Release unmanaged resources explicitly using IDisposable.
  • Do not implement IDisposable if your class only contains managed resources.
  • Always implement IDisposable when working with unmanaged resources.
  • Call GC.SuppressFinalize(this) inside Dispose() to avoid unnecessary finalization overhead.
  • Never reference managed objects inside a finalizer.
  • Ensure Dispose(bool disposing) is protected virtual so derived classes can override it.
  • Use the using statement (using var obj = new UnmanagedWrapper();) to ensure automatic cleanup.

Conclusion

Garbage Collection in .NET is a powerful feature that simplifies memory management. However, understanding its inner workings, along with finalizers and the IDisposable pattern, allows developers to write efficient and safe C# applications. Always remember: if your class uses only managed resources, let the GC handle it; if it uses unmanaged resources, implement proper cleanup. The Dispose pattern ensures safe and efficient resource management in .NET. By separating managed and unmanaged cleanup with the disposing flag, you protect your code from errors during garbage collection and provide developers with explicit control over resource lifetime.

👨‍🏫 1-on-1 .NET Training with Sudipto

Learn C#, ASP.NET Core, MVC, Blazor, SQL Server and more with personalized guidance from a Microsoft Certified Professional with 20+ years of experience. All classes are 1-on-1, ensuring you get the attention you deserve. Whether you're a beginner or preparing for interviews, I help you build solid fundamentals and real-world confidence.

🚀 Book a Free Consultation

📞 Contact Info

Email: supernova.software.solutions@gmail.com

Website: supernovaservices.com

Location: Dum Dum, Kolkata, India

Class Guidelines for Effective 1-on-1 Learning

To keep every session productive and distraction-free, please follow these simple guidelines:

  • Quiet Environment: Join from a calm, private room with minimal background noise. Avoid public or noisy places.
  • No Interruptions: Inform family/roommates in advance. Keep doors closed during class.
  • Mobile on Silent / DND: Set your phone to Silent or Do Not Disturb to prevent calls and notifications.
  • Be Fully Present: Do not multitask. Avoid attending to other calls, visitors, or errands during the session.
  • Stable Setup: Use a laptop/desktop with a stable internet connection and required software installed (Visual Studio/.NET, SQL Server, etc.).
  • Punctuality: Join on time so we can utilize the full session effectively.
  • Prepared Materials (If any): Keep project files, notes, and questions ready for quicker progress.

Following these guidelines helps you focus better and ensures I can deliver the best learning experience in every class.

Schedule a Quick 10-Minute Call

I prefer to start with a short 10-minute free call so I can understand:

  • Your learning objectives and career goals
  • Your current skill level
  • The exact topics you want to learn

Why? Because course content, teaching pace, and fees all depend on your needs — there’s no “one-size-fits-all” pricing. Please leave your details below, and I’ll get back to you to arrange a convenient time for the call.



Google Review Testimonials

.NET Online Training
Average Rating: 4.9
Votes: 50
Reviews: 50