Skip to main content

.NET Garbage Collection

Introduction

Memory management is a critical aspect of any application's performance and reliability. In .NET, this task is primarily handled by the Garbage Collector (GC), a key component of the Common Language Runtime (CLR). Unlike languages such as C and C++ where developers must manually allocate and deallocate memory, .NET provides automatic memory management through garbage collection.

In this article, we'll explore how the .NET Garbage Collector works, its benefits, and how you can write more efficient code to work with it rather than against it.

What is Garbage Collection?

Garbage Collection is an automatic memory management feature that:

  1. Allocates objects on the managed heap
  2. Tracks when objects are no longer being used
  3. Reclaims the memory occupied by these unused objects

This automation eliminates common memory-related issues such as:

  • Memory leaks
  • Dangling pointer references
  • Double freeing of memory

How .NET Garbage Collection Works

The Managed Heap

When your .NET application runs, the runtime allocates a region of memory called the managed heap. All reference types (such as classes, strings, and arrays) are allocated on this heap.

csharp
// Object is created on the managed heap
Person person = new Person("John Doe");

Object Lifetime and Generations

The .NET GC organizes objects into three generations:

  • Generation 0: New objects
  • Generation 1: Objects that have survived one garbage collection
  • Generation 2: Long-lived objects that have survived multiple collections

This generational approach is based on the empirical observation that:

  • Most objects are short-lived
  • The longer an object has survived, the longer it will likely continue to exist

The Collection Process

The garbage collection process involves these primary steps:

  1. Marking: The GC identifies which objects are still in use (reachable from root references)
  2. Compacting: It compacts memory by moving reachable objects together
  3. Promotion: Surviving objects are moved to the next generation

Here's a simple visualization of what happens during collection:

Before GC:
[Obj1][Unused][Obj2][Unused][Obj3][Unused][Obj4]

After GC:
[Obj1][Obj2][Obj3][Obj4][ ]
↑ Free space available for new allocations

Understanding Object References

The GC determines if an object is still needed by checking if it's reachable from any root. Roots include:

  • Static variables
  • Local variables on a thread's stack
  • CPU registers
  • Finalization queue references
  • Interop references (COM objects)

Let's look at a basic example:

csharp
void ExampleMethod()
{
// Object is created and referenced
var myObject = new SampleClass();

// Object is still referenced here
DoSomething(myObject);

// After this point, myObject may be collected
// when the method exits since the reference goes out of scope
}

Practical Example: Memory Management in Action

Let's see garbage collection in action with a more concrete example:

csharp
using System;

class Program
{
static void Main()
{
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");

// Create a large number of objects
for (int i = 0; i < 1000000; i++)
{
var obj = new object();
}

// Force garbage collection
GC.Collect();

Console.WriteLine($"Gen 0 collections after loop: {GC.CollectionCount(0)}");

// Output example:
// Gen 0 collections: 0
// Gen 0 collections after loop: 1
}
}

In this example, we:

  1. Check the initial collection count for Generation 0
  2. Create many temporary objects that immediately become eligible for collection
  3. Force a garbage collection (normally you shouldn't do this!)
  4. See that the garbage collector has run

Controlling the Garbage Collector

While the GC is automatic, .NET provides ways to influence its behavior:

1. Using IDisposable and the using Statement

For resources that need deterministic cleanup (like file handles or database connections), .NET provides the IDisposable pattern:

csharp
using System;
using System.IO;

class Program
{
static void Main()
{
// The StreamReader will be properly disposed when the using block exits
using (StreamReader reader = new StreamReader("file.txt"))
{
string content = reader.ReadToEnd();
Console.WriteLine(content);
}
// The reader is automatically disposed here, even if an exception occurs
}
}

2. Understanding Large Object Heap

Objects larger than 85,000 bytes are allocated on the Large Object Heap (LOH), which is collected less frequently and isn't compacted by default:

csharp
// This large array goes to the LOH
byte[] largeArray = new byte[100000];

3. Weak References

When you want to keep a reference to an object but allow it to be collected if memory is needed:

csharp
using System;

class Program
{
static void Main()
{
// Create an object and a weak reference to it
var myObject = new object();
WeakReference weakRef = new WeakReference(myObject);

// Check if the object is still available
Console.WriteLine($"Is alive: {weakRef.IsAlive}"); // True

// Remove strong reference
myObject = null;

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

// Check again
Console.WriteLine($"Is alive after collection: {weakRef.IsAlive}"); // False
}
}

Best Practices for Working with the Garbage Collector

Do:

  1. Dispose of unmanaged resources properly

    csharp
    public class ResourceHolder : IDisposable
    {
    private IntPtr nativeResource;
    private bool disposed = false;

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

    protected virtual void Dispose(bool disposing)
    {
    if (!disposed)
    {
    if (disposing)
    {
    // Dispose managed resources
    }

    // Free unmanaged resources
    if (nativeResource != IntPtr.Zero)
    {
    // Release the native resource
    nativeResource = IntPtr.Zero;
    }

    disposed = true;
    }
    }

    ~ResourceHolder()
    {
    Dispose(false);
    }
    }
  2. Null references you no longer need

    csharp
    void ProcessLargeData()
    {
    var largeData = LoadLargeDataSet();

    // Process the data
    var result = ProcessData(largeData);

    // Allow largeData to be collected if memory pressure occurs
    largeData = null;

    // Continue with other operations using just result
    SaveResults(result);
    }
  3. Consider object pooling for frequently created/destroyed objects

    csharp
    using System.Collections.Concurrent;

    public class StringBuilderPool
    {
    private readonly ConcurrentBag<StringBuilder> _pool = new ConcurrentBag<StringBuilder>();

    public StringBuilder Get()
    {
    if (_pool.TryTake(out StringBuilder sb))
    {
    sb.Clear();
    return sb;
    }

    return new StringBuilder();
    }

    public void Return(StringBuilder sb)
    {
    _pool.Add(sb);
    }
    }

Don't:

  1. Avoid unnecessary allocations in performance-critical paths

    csharp
    // Bad: Creates a new string on each iteration
    for (int i = 0; i < 1000; i++)
    {
    string message = $"Processing item {i}";
    Log(message);
    }

    // Better: Reuse the StringBuilder
    var sb = new StringBuilder();
    for (int i = 0; i < 1000; i++)
    {
    sb.Clear();
    sb.Append("Processing item ").Append(i);
    Log(sb.ToString());
    }
  2. Don't call GC.Collect() without good reason The garbage collector is optimized to run when needed. Forcing collection can hurt performance more than it helps.

Real-World Application: Memory Profiling

Let's explore a real-world scenario where understanding the garbage collector helps diagnose a memory issue:

csharp
using System;
using System.Collections.Generic;

class CacheService
{
// This dictionary will keep growing if we're not careful
private static Dictionary<string, object> _cache = new Dictionary<string, object>();

public static void AddToCache(string key, object value)
{
_cache[key] = value;
}

public static object GetFromCache(string key)
{
if (_cache.TryGetValue(key, out object value))
return value;
return null;
}

// Missing: We need a way to remove items from the cache!
public static void RemoveFromCache(string key)
{
if (_cache.ContainsKey(key))
_cache.Remove(key);
}
}

class Program
{
static void Main()
{
// This could lead to a memory leak if keys are dynamically generated
for (int i = 0; i < 10000; i++)
{
string key = $"temp_item_{i}";
CacheService.AddToCache(key, new byte[10000]);

// Solution: Remove items we don't need anymore
if (i > 100)
{
string oldKey = $"temp_item_{i-100}";
CacheService.RemoveFromCache(oldKey);
}
}
}
}

In this example:

  1. Without the RemoveFromCache method, the application would continue to consume memory
  2. By implementing proper cache management, we ensure objects can be garbage collected

Summary

The .NET Garbage Collector is a sophisticated system that automates memory management, making development easier and reducing common memory-related bugs. Key takeaways include:

  • Objects are allocated on the managed heap and organized by generations
  • The GC automatically reclaims memory from unreachable objects
  • Proper resource handling with IDisposable ensures deterministic cleanup
  • Understanding GC behavior helps write more efficient code

While the garbage collector handles most memory management tasks automatically, understanding how it works allows you to write more efficient and responsive applications.

Additional Resources

Exercises

  1. Create a class that implements IDisposable properly, including a finalizer and the standard dispose pattern.

  2. Write a program that creates and releases objects of different sizes, and observe the garbage collection generations using GC.CollectionCount().

  3. Implement a simple object pool for a custom class and benchmark its performance against creating new instances each time.

  4. Profile a simple application with a memory profiler to identify objects that aren't being collected as expected.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)