You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Memory Management and Garbage Collection in .NET

Garbage Collection Fundamentals

Generational GC model, collection triggers, and survival mechanics

Garbage Collection Fundamentals

Master garbage collection in .NET with free flashcards and spaced repetition practice. This lesson covers heap memory organization, garbage collector generations, and automatic memory reclamationβ€”essential concepts for building high-performance .NET applications.

Welcome to Garbage Collection in .NET πŸ’»

Garbage collection (GC) is one of the most powerful features of the .NET runtime, handling memory management automatically so developers can focus on building features rather than tracking object lifetimes. Understanding how the garbage collector works is crucial for writing efficient applications and diagnosing performance issues.

In this lesson, you'll learn:

  • How the managed heap organizes memory
  • The three-generation system that optimizes collection
  • When and why garbage collection occurs
  • How objects move through their lifecycle
  • Performance implications of GC behavior

What is Garbage Collection? πŸ—‘οΈ

Garbage collection is the automatic process of reclaiming memory occupied by objects that are no longer accessible or needed by your application. Unlike languages like C or C++ where developers must manually allocate and free memory, .NET's garbage collector handles this automatically.

The Core Problem GC Solves

Without garbage collection, developers face two major challenges:

  1. Memory leaks πŸ’§ - Forgetting to free memory causes applications to consume more and more resources until they crash
  2. Dangling pointers ⚠️ - Freeing memory too early causes crashes when the program tries to access freed memory

The garbage collector eliminates both problems by:

  • Tracking which objects are still in use
  • Automatically freeing memory when objects become unreachable
  • Moving objects in memory to reduce fragmentation

How the GC Determines "Garbage"

An object is considered garbage (eligible for collection) when there are no roots pointing to it. A root is a reference that the GC uses as a starting point, including:

  • Local variables in currently executing methods
  • Static fields in classes
  • CPU registers holding object references
  • GC handles created for interop scenarios
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     REACHABILITY ANALYSIS               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  ROOT β†’ Object A β†’ Object B             β”‚
β”‚    ↓                                    β”‚
β”‚  Object C    Object D (unreachable) ❌  β”‚
β”‚    ↓                                    β”‚
β”‚  Object E                               β”‚
β”‚                                         β”‚
β”‚  βœ… Reachable: A, B, C, E              β”‚
β”‚  ❌ Garbage: D                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The GC starts from all roots and traces through object references. Any object not reached during this trace is considered garbage and can be collected.

The Managed Heap πŸ—οΈ

The managed heap is where the .NET runtime allocates all reference-type objects. Understanding its structure is fundamental to understanding garbage collection.

Heap vs Stack

Before diving into the heap, let's clarify the difference:

StackManaged Heap
Stores value types and method callsStores reference-type objects
Automatically cleaned when methods returnCleaned by garbage collector
Very fast allocation (just increment pointer)Fast allocation with GC overhead
Limited size (typically 1-2 MB)Large size (can grow to available memory)
Thread-specificShared across threads

Heap Organization

The managed heap is divided into several regions:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚        MANAGED HEAP STRUCTURE           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Small Object Heap (SOH)         β”‚ β”‚
β”‚  β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”          β”‚ β”‚
β”‚  β”‚   β”‚ Gen0 β”‚ Gen1 β”‚ Gen2 β”‚          β”‚ β”‚
β”‚  β”‚   β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜          β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Large Object Heap (LOH)         β”‚ β”‚
β”‚  β”‚   (Objects β‰₯ 85,000 bytes)        β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Pinned Object Heap (POH)        β”‚ β”‚
β”‚  β”‚   (.NET 5+, pinned objects)       β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Small Object Heap (SOH): Contains objects smaller than 85,000 bytes. This is where most objects live and where the generational system applies.

Large Object Heap (LOH): Holds objects 85,000 bytes or larger. These objects are expensive to move, so the LOH is only compacted when explicitly requested (prior to .NET 4.5.1) or when memory pressure is high.

Pinned Object Heap (POH): Introduced in .NET 5, stores objects that are pinned (fixed in memory) to avoid fragmenting other heaps.

Allocation Process 🎯

When you create a new object with new, here's what happens:

  1. The runtime calculates the object's size
  2. If size < 85,000 bytes, allocate on SOH; otherwise, use LOH
  3. The allocator increments a pointer and returns the memory address
  4. The object's constructor runs
Before allocation:          After allocation:
                           
  Heap Pointer                Heap Pointer
       ↓                           ↓
  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”¬β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
  β”‚ [free space]     β”‚        β”‚Objβ”‚[free space]β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       
Simple pointer bump (extremely fast!)

This allocation strategy is remarkably fastβ€”often faster than stack allocation in other languagesβ€”because it's just incrementing a pointer. The cost comes later during garbage collection.

The Generational Model πŸ”„

The .NET garbage collector uses a generational model based on empirical observations about object lifetimes:

πŸ’‘ Generational Hypothesis: Most objects die young. A small percentage of objects live for the entire application lifetime.

This insight led to dividing the heap into three generations:

Generation 0 (Gen0) πŸ‘Ά

  • Newest objects live here
  • Collected most frequently
  • Typical size: 256 KB to a few MB
  • Objects surviving a Gen0 collection are promoted to Gen1

Use case: Temporary objects like loop variables, string builders in methods, LINQ query intermediates

Generation 1 (Gen1) πŸ§’

  • Short-lived but not immediate objects
  • Acts as a buffer between Gen0 and Gen2
  • Collected less frequently than Gen0
  • Objects surviving a Gen1 collection are promoted to Gen2

Use case: Objects that survive one collection but aren't long-term (request-scoped objects in web apps)

Generation 2 (Gen2) πŸ‘΄

  • Long-lived objects
  • Collected infrequently
  • Largest generation (can be many GB)
  • Objects rarely move out of Gen2

Use case: Application configuration, caches, static objects, singleton instances

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     OBJECT LIFETIME PROGRESSION         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  NEW β†’ Gen0 ──survives──→ Gen1          β”‚
β”‚         β”‚                  β”‚            β”‚
β”‚      dies βœ—            survives         β”‚
β”‚         β”‚                  β”‚            β”‚
β”‚         ↓                  ↓            β”‚
β”‚    [collected]          Gen2            β”‚
β”‚                            β”‚            β”‚
β”‚                        stays here       β”‚
β”‚                     (rarely collected)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why Generations Improve Performance ⚑

The generational approach provides several benefits:

  1. Smaller collection scope: Gen0 collections only examine a small portion of the heap
  2. Cache locality: Newer objects tend to reference each other, improving CPU cache hits
  3. Reduced pauses: Frequent, short Gen0 collections are less disruptive than full heap collections
  4. Compaction benefits: Moving objects in Gen0 improves memory layout without touching long-lived objects

🧠 Memory Device: Think of generations like school grades: most students (objects) drop out early (Gen0), some make it to middle school (Gen1), and only a few graduate to high school (Gen2).

Collection Triggers and Process 🎬

Garbage collection doesn't happen continuouslyβ€”it's triggered by specific conditions.

When Collection Occurs

The GC runs when:

  1. Gen0 threshold exceeded πŸ“Š - Gen0 fills up (most common trigger)
  2. Explicit request πŸ–±οΈ - Code calls GC.Collect() (rarely recommended)
  3. Low memory condition πŸ’Ύ - Operating system signals low memory
  4. Memory pressure πŸ“ˆ - High allocation rate detected

Collection Phases

A garbage collection goes through several phases:

Phase 1: Suspension (Stop-the-World) ⏸️

All managed threads are suspended to ensure the heap doesn't change during collection. This is the primary source of GC pauses.

Phase 2: Marking 🏷️

The GC:

  1. Starts from all roots
  2. Traverses object graphs, marking reachable objects
  3. Builds a list of live objects
MARKING PROCESS:

ROOT β†’ Obj1 β†’ Obj2
        β”‚
        └───→ Obj3

Obj4 (no path from root) ❌

Result: Obj1, Obj2, Obj3 marked as live
        Obj4 identified as garbage
Phase 3: Compacting πŸ“¦

For Gen0 and Gen1 (always compacted):

  1. Live objects are moved to eliminate gaps
  2. References are updated to new addresses
  3. Heap pointer is adjusted
Before Compaction:        After Compaction:
β”Œβ”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”      β”Œβ”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
β”‚A β”‚XXβ”‚B β”‚XXβ”‚C β”‚  β”‚  β†’   β”‚A β”‚B β”‚C β”‚ free β”‚
β””β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”˜      β””β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜
XX = garbage                 Contiguous!

For Gen2/LOH (conditionally compacted):

  • May use sweep instead: mark free spaces but don't move objects
  • Maintains a free list for future allocations
  • Compaction only when fragmentation is severe
Phase 4: Resumption ▢️

Managed threads resume execution.

Collection Types

Ephemeral Collection (Gen0 or Gen0+Gen1):

  • Fastest and most common
  • Typical pause: 1-10 milliseconds
  • Happens frequently

Full Collection (Gen0+Gen1+Gen2):

  • Examines entire heap
  • Typical pause: 10-100+ milliseconds
  • Happens infrequently
  • Often includes LOH collection

Background Collection (.NET 4+):

  • Gen2 collected on dedicated thread while Gen0/Gen1 collections continue
  • Reduces pause times for large heaps
  • Only applies to Gen2
Collection TypeGenerationsFrequencyTypical Pause
Gen00Very high<1-5 ms
Gen10, 1Medium5-20 ms
Gen2 (full, blocking)0, 1, 2Low50-500+ ms
Gen2 (background)0, 1, 2Low10-50 ms

Workstation vs Server GC πŸ–₯️

The .NET runtime provides two GC modes optimized for different scenarios:

Workstation GC

Characteristics:

  • Optimized for low latency (responsive UI)
  • Runs on the thread that triggered collection
  • Single dedicated GC thread for background collection
  • Smaller Gen0/Gen1 sizes

Best for: Desktop applications, client applications, single-processor machines

Server GC

Characteristics:

  • Optimized for high throughput
  • One dedicated GC thread per logical processor
  • Larger Gen0/Gen1 sizes (more memory for allocation)
  • Parallel collection across multiple threads

Best for: Server applications, web servers, high-performance computing

WORKSTATION GC:              SERVER GC:
                            
  App Thread                 App Thread 1
      β”‚                          β”‚
   allocates                  allocates
      β”‚                          β”‚
   GC runs ───→ pause          β•±β”‚β•²
      β”‚                       β•± β”‚ β•²
   resumes               GC  GC GC GC
                        (parallel collection)
                             β”‚
                          shorter pause

πŸ’‘ Configuration: Set in your project file:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Example 1: Object Lifecycle Demonstration πŸ”¬

Let's trace how objects move through generations:

using System;

public class GCDemo
{
    public static void Main()
    {
        // Create an object
        var obj = new MyClass { Data = "Important" };
        
        Console.WriteLine($"Initial generation: {GC.GetGeneration(obj)}");
        // Output: Initial generation: 0
        
        // Force Gen0 collection
        GC.Collect(0);
        Console.WriteLine($"After Gen0 collection: {GC.GetGeneration(obj)}");
        // Output: After Gen0 collection: 1 (promoted!)
        
        // Force Gen1 collection
        GC.Collect(1);
        Console.WriteLine($"After Gen1 collection: {GC.GetGeneration(obj)}");
        // Output: After Gen1 collection: 2 (promoted again!)
        
        // Object stays in Gen2
        GC.Collect(2);
        Console.WriteLine($"After Gen2 collection: {GC.GetGeneration(obj)}");
        // Output: After Gen2 collection: 2 (stays in Gen2)
    }
}

public class MyClass
{
    public string Data { get; set; }
}

Key observations:

  • Objects start in Gen0
  • Each survival promotes to next generation
  • Once in Gen2, objects rarely leave
  • The object reference (obj) keeps it alive through all collections

Example 2: Large Object Heap Behavior πŸ“¦

public class LOHDemo
{
    public static void Main()
    {
        // Small object - goes to SOH
        byte[] smallArray = new byte[1000];
        Console.WriteLine($"Small array generation: {GC.GetGeneration(smallArray)}");
        // Output: Small array generation: 0
        
        // Large object - goes directly to LOH (Gen2)
        byte[] largeArray = new byte[85_000];
        Console.WriteLine($"Large array generation: {GC.GetGeneration(largeArray)}");
        // Output: Large array generation: 2 (directly to Gen2!)
        
        // LOH objects skip generational progression
        GC.Collect();
        Console.WriteLine($"After collection: {GC.GetGeneration(largeArray)}");
        // Output: After collection: 2 (remains in Gen2)
    }
}

Why this matters:

  • Large objects bypass Gen0/Gen1 entirely
  • LOH is rarely compacted (causes fragmentation)
  • Frequent LOH allocations can hurt performance

πŸ’‘ Best Practice: Reuse large arrays/buffers using ArrayPool<T> instead of allocating new ones.

Example 3: Weak References and Cache Management πŸ”—

using System;

public class CacheDemo
{
    private static WeakReference weakCache;
    
    public static void Main()
    {
        // Create a cached object
        var data = new byte[1000];
        Array.Fill(data, (byte)42);
        
        // Store as weak reference
        weakCache = new WeakReference(data);
        
        Console.WriteLine($"Cache alive: {weakCache.IsAlive}");
        // Output: Cache alive: True
        
        // Remove strong reference
        data = null;
        
        // Force collection
        GC.Collect();
        GC.WaitForPendingFinalizers();
        
        Console.WriteLine($"Cache alive after GC: {weakCache.IsAlive}");
        // Output: Cache alive after GC: False
        
        // Try to retrieve
        var retrieved = weakCache.Target as byte[];
        Console.WriteLine($"Retrieved data: {retrieved == null}");
        // Output: Retrieved data: True (null - collected!)
    }
}

Use case: Implementing memory-sensitive caches that release memory under pressure.

How it works:

  • WeakReference doesn't prevent garbage collection
  • GC can collect the target even if WeakReference exists
  • Perfect for caches that should yield memory when needed

Example 4: Finalization and Resource Cleanup 🧹

using System;

public class ResourceHolder : IDisposable
{
    private bool disposed = false;
    private IntPtr unmanagedResource;
    
    public ResourceHolder()
    {
        unmanagedResource = /* allocate unmanaged memory */;
        Console.WriteLine("Resource allocated");
    }
    
    // Finalizer (destructor)
    ~ResourceHolder()
    {
        Console.WriteLine("Finalizer called");
        Dispose(false);
    }
    
    // IDisposable implementation
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // Don't call finalizer
    }
    
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // Clean up managed resources
                Console.WriteLine("Disposing managed resources");
            }
            
            // Clean up unmanaged resources
            if (unmanagedResource != IntPtr.Zero)
            {
                // Free unmanaged memory
                Console.WriteLine("Freeing unmanaged resource");
                unmanagedResource = IntPtr.Zero;
            }
            
            disposed = true;
        }
    }
}

public class FinalizationDemo
{
    public static void Main()
    {
        // Best practice: use 'using' statement
        using (var resource = new ResourceHolder())
        {
            // Use resource
        } // Dispose called automatically
        
        // Without 'using' - finalizer runs eventually
        var leaked = new ResourceHolder();
        leaked = null;
        
        GC.Collect();
        GC.WaitForPendingFinalizers();
        // Output: Finalizer called (but delayed!)
    }
}

Key points:

  • Finalizers run on a dedicated thread after GC identifies the object as garbage
  • Finalization delays collection - object survives at least one GC cycle
  • Dispose pattern allows deterministic cleanup
  • GC.SuppressFinalize() prevents finalizer from running if already disposed

⚠️ Warning: Avoid finalizers when possibleβ€”they're expensive and non-deterministic!

Common Mistakes ⚠️

Mistake 1: Calling GC.Collect() Unnecessarily

❌ Wrong:

void ProcessData()
{
    var data = LoadData();
    ProcessItems(data);
    data = null;
    GC.Collect(); // DON'T DO THIS!
}

βœ… Right:

void ProcessData()
{
    var data = LoadData();
    ProcessItems(data);
    // Let GC decide when to collect
}

Why: The GC is highly optimized. Manual collection usually hurts performance by:

  • Triggering expensive full collections
  • Disrupting the generational strategy
  • Promoting objects prematurely

Exception: Only call GC.Collect() after major phase transitions (like loading a level in a game).

Mistake 2: Ignoring Large Object Allocations

❌ Wrong:

for (int i = 0; i < 1000; i++)
{
    byte[] buffer = new byte[100_000]; // LOH allocation!
    ProcessData(buffer);
    // Buffer becomes garbage
}

βœ… Right:

using System.Buffers;

for (int i = 0; i < 1000; i++)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(100_000);
    try
    {
        ProcessData(buffer);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Why: Repeated LOH allocations cause:

  • Fragmentation (LOH rarely compacts)
  • Frequent Gen2 collections
  • Increased memory usage

Mistake 3: Misunderstanding Object Lifetimes

❌ Wrong:

public class DataCache
{
    private List<byte[]> cache = new List<byte[]>();
    
    public void AddData(byte[] data)
    {
        cache.Add(data); // Keeps objects alive forever!
    }
}

βœ… Right:

public class DataCache
{
    private List<WeakReference<byte[]>> cache = 
        new List<WeakReference<byte[]>>();
    
    public void AddData(byte[] data)
    {
        cache.Add(new WeakReference<byte[]>(data));
        // Can be collected under memory pressure
    }
}

Why: Strong references in collections prevent GC, causing memory leaks.

Mistake 4: Forgetting to Dispose Resources

❌ Wrong:

void ReadFile()
{
    var stream = new FileStream("data.txt", FileMode.Open);
    // Use stream...
    // File handle leaked until finalizer runs!
}

βœ… Right:

void ReadFile()
{
    using (var stream = new FileStream("data.txt", FileMode.Open))
    {
        // Use stream...
    } // Disposed immediately
}

// Or with C# 8+:
void ReadFile()
{
    using var stream = new FileStream("data.txt", FileMode.Open);
    // Use stream...
    // Disposed at end of method
}

Why: Unmanaged resources (file handles, database connections) aren't tracked by GC and must be released explicitly.

Mistake 5: Creating Excessive String Allocations

❌ Wrong:

string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i.ToString(); // Creates 1000 string objects!
}

βœ… Right:

var builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    builder.Append(i);
}
string result = builder.ToString();

Why: Strings are immutable. Each concatenation creates a new string object, filling Gen0 and triggering frequent collections.

Key Takeaways 🎯

  1. Automatic Memory Management πŸ€–

    • GC automatically reclaims memory from unreachable objects
    • Eliminates manual memory management bugs
    • Provides safe, managed execution environment
  2. Generational Strategy πŸ“Š

    • Gen0: Newest objects, collected frequently, small size
    • Gen1: Buffer generation, medium collection frequency
    • Gen2: Long-lived objects, collected infrequently, large size
    • Most objects die in Gen0 (generational hypothesis)
  3. Collection Process πŸ”„

    • Triggered by Gen0 threshold, memory pressure, or explicit request
    • Phases: Suspend β†’ Mark β†’ Compact β†’ Resume
    • Stop-the-world pauses (minimized by background GC)
  4. Large Object Heap πŸ“¦

    • Objects β‰₯85,000 bytes go directly to LOH (Gen2)
    • Rarely compacted (fragmentation risk)
    • Use ArrayPool<T> to reuse large allocations
  5. Resource Management 🧹

    • Implement IDisposable for unmanaged resources
    • Use using statements for deterministic cleanup
    • Avoid finalizers when possible (expensive)
    • WeakReference for memory-sensitive caches
  6. Performance Best Practices ⚑

    • Let GC manage collection timing (avoid GC.Collect())
    • Minimize allocations in hot paths
    • Reuse objects/buffers when possible
    • Choose appropriate GC mode (Workstation vs Server)
    • Monitor GC metrics in production
  7. Common Pitfalls ⚠️

    • Don't call GC.Collect() manually
    • Avoid excessive LOH allocations
    • Release event handlers and clear collections
    • Use StringBuilder for string concatenation
    • Always dispose unmanaged resources

πŸ“‹ Quick Reference Card

Gen0Newest objects, collected most frequently (~1-5ms pause)
Gen1Short-lived objects, buffer between Gen0/Gen2
Gen2Long-lived objects, collected infrequently (10-100+ms pause)
LOHObjects β‰₯85KB, rarely compacted, directly Gen2
RootsLocal vars, static fields, CPU registers, GC handles
MarkingTrace from roots to find live objects
CompactingMove objects together, eliminate fragmentation
Workstation GCLow latency, single GC thread, for client apps
Server GCHigh throughput, parallel GC threads, for servers
WeakReferenceReference that doesn't prevent collection
IDisposablePattern for deterministic resource cleanup
Finalizer~ClassName() - runs during GC (avoid if possible)

πŸ“š Further Study

  1. Microsoft Documentation - Garbage Collection: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals

    • Comprehensive official documentation on .NET GC internals
  2. Pro .NET Memory Management by Konrad Kokosa: https://prodotnetmemory.com

    • Deep dive into .NET memory management with practical examples
  3. Performance Improvements in .NET (Microsoft DevBlogs): https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/

    • Latest GC improvements and benchmarks for modern .NET

Congratulations! You now understand the fundamental concepts of garbage collection in .NET. In the next lesson, we'll explore how to monitor and tune GC performance for production applications. πŸš€