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

Collection Triggers

When and why collections occur, allocation budgets, and signals

Collection Triggers in .NET Garbage Collection

Understanding when and why garbage collection occurs is essential for optimizing .NET application performance. Master these concepts with free flashcards and spaced repetition practice to reinforce your learning. This lesson covers automatic collection triggers, manual collection requests, and the conditions that initiate each of the three GC generationsβ€”fundamental knowledge for building memory-efficient applications.

Welcome πŸ’»

Garbage collection doesn't just happen randomly in .NETβ€”it follows specific rules and responds to particular conditions in your application. Think of it like your home's trash service: pickup happens on scheduled days (regular triggers), but you can also request special pickups (manual triggers), and sometimes emergency collection happens when bins overflow (threshold triggers).

In this lesson, you'll learn:

  • The primary conditions that trigger garbage collection
  • How memory thresholds influence collection timing
  • Generation-specific trigger mechanisms
  • When and why to manually invoke collection
  • Performance implications of different trigger scenarios

Core Concepts: What Triggers Garbage Collection? πŸ”

1. Allocation Threshold Exceeded ⚑

The most common trigger for garbage collection is when allocation attempts exceed the generation budget. Each generation has a thresholdβ€”when objects allocated in that generation exceed this limit, the GC kicks in.

GENERATION BUDGETS (Typical Values)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Generation 0:  ~256 KB - 4 MB      β”‚
β”‚ Generation 1:  ~2 MB - 16 MB       β”‚
β”‚ Generation 2:  Variable (adaptive)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

How it works:

  • Your application creates objects continuously
  • Objects start in Gen 0 (the nursery)
  • When Gen 0 fills up β†’ Gen 0 collection triggers
  • If Gen 1 is also full β†’ Gen 1 collection triggers (includes Gen 0)
  • Gen 2 collections are less frequent but more expensive

πŸ’‘ Key Insight: The GC dynamically adjusts these thresholds based on application behavior. If collections aren't freeing much memory, thresholds increase; if they're very effective, thresholds may decrease.

2. Low Memory Conditions πŸ”Ί

The operating system can notify the .NET runtime when system memory is running low. This triggers a memory pressure response.

Memory Status GC Response Priority
Normal Standard threshold-based collection Low
Medium Pressure More aggressive Gen 2 collections Medium
High Pressure Immediate full collection, compact LOH High

Two types of memory pressure:

External Memory Pressure: The OS signals low available memory

  • Windows uses memory notifications
  • Linux monitors /proc/meminfo
  • GC responds by collecting more aggressively

Internal Memory Pressure: Your application explicitly signals memory needs

  • Using GC.AddMemoryPressure() for unmanaged resources
  • Helps GC understand true memory footprint
  • Important for applications using large unmanaged allocations

3. Explicit GC.Collect() Calls 🎯

Developers can manually request garbage collection using GC.Collect(). However, this should be used sparingly and strategically.

// Basic manual collection
GC.Collect();

// Target specific generation
GC.Collect(2, GCCollectionMode.Forced);

// Wait for finalizers to complete
GC.WaitForPendingFinalizers();

When manual collection makes sense:

  • After loading large amounts of temporary data
  • Between distinct application phases (e.g., level loading in games)
  • After disposing large object graphs
  • During idle periods in interactive applications

⚠️ Warning: Calling GC.Collect() unnecessarily can hurt performance by:

  • Interrupting the GC's optimized heuristics
  • Promoting short-lived objects to higher generations prematurely
  • Causing unnecessary CPU overhead

4. Large Object Allocation πŸ“¦

Objects 85,000 bytes or larger are allocated directly to the Large Object Heap (LOH), which is part of Generation 2. Large allocations can trigger Gen 2 collections.

LARGE OBJECT ALLOCATION PATH
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Allocate object β‰₯ 85,000 bytes β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Check LOH space available      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            ↓
     β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
     ↓             ↓
  βœ… Space      ❌ No Space
  Available     Available
     β”‚             β”‚
     ↓             ↓
  Allocate    Trigger Gen 2
  Directly    Collection
     β”‚             β”‚
     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
            ↓
     Object in LOH

LOH characteristics:

  • Not compacted by default (prevents expensive memory copies)
  • Can be compacted in .NET Core+ with GCSettings.LargeObjectHeapCompactionMode
  • Fragmentation can be an issue with varying-sized allocations

5. Induced Collections (Special Scenarios) πŸ”„

Certain runtime conditions automatically trigger collection:

AppDomain Unload:

  • When an AppDomain is unloaded, a full Gen 2 collection occurs
  • Ensures all objects specific to that domain are cleaned up

Process Shutdown:

  • Finalizers run during graceful shutdown
  • Gen 2 collection ensures cleanup code executes

CPU Idle Detection (Server GC):

  • In server applications, GC may trigger during CPU idle periods
  • Spreads collection cost across available processing time

Generational Budget Adjustment:

  • GC continuously monitors survival rates
  • Adjusts Gen 0/1 budgets based on observed patterns
  • Can trigger collections to rebalance generations

Detailed Example 1: Generation 0 Threshold Trigger πŸŽ“

Let's trace what happens when Gen 0 fills up:

public class DataProcessor
{
    public void ProcessBatch()
    {
        // Assume Gen 0 budget is 1 MB for this example
        // Current Gen 0 usage: 950 KB
        
        for (int i = 0; i < 1000; i++)
        {
            // Each TempData object is ~200 bytes
            var temp = new TempData();
            temp.Process();
            // temp goes out of scope - becomes garbage
        }
        
        // After ~250 iterations:
        // Gen 0 usage: 950 KB + (250 Γ— 200 bytes) = ~1000 KB
        // ⚑ GEN 0 COLLECTION TRIGGERED ⚑
        
        // GC runs, reclaims ~950 KB of garbage
        // Survivors promoted to Gen 1 (if any)
        // Gen 0 reset, allocations continue
    }
}

What happens during the trigger:

Step Action Memory Impact
1 Detect threshold exceeded Gen 0 @ 1.05 MB (over 1 MB limit)
2 Suspend execution threads Application paused (~1-5ms)
3 Mark live objects Identify reachable references
4 Reclaim dead objects Free ~950 KB of unreferenced objects
5 Promote survivors to Gen 1 ~50 KB promoted
6 Resume execution Gen 0 now empty, ready for allocations

πŸ’‘ Performance Tip: Short-lived objects in tight loops are ideal for Gen 0 collectionβ€”they're created and collected quickly without ever being promoted.

Detailed Example 2: Low Memory Trigger πŸŽ“

public class ImageProcessor
{
    public void ProcessLargeDataset()
    {
        List<byte[]> images = new List<byte[]>();
        
        // Load 1000 high-resolution images
        for (int i = 0; i < 1000; i++)
        {
            // Each image is 5 MB
            byte[] imageData = LoadImage(i);
            images.Add(imageData);
            
            // System memory drops below 20% available
            // OS sends low memory notification to .NET
            // ⚑ MEMORY PRESSURE TRIGGER ⚑
            
            // GC performs aggressive Gen 2 collection
            // Attempts to free memory across all generations
        }
        
        // Better approach: process and release incrementally
        for (int i = 0; i < 1000; i++)
        {
            byte[] imageData = LoadImage(i);
            ProcessImage(imageData);
            // imageData becomes garbage immediately
            // No long-term retention
        }
    }
}

Memory pressure response sequence:

LOW MEMORY DETECTION
     β”‚
     ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ OS sends notification       β”‚
β”‚ Available RAM < 20%         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ .NET CLR receives signal    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Trigger Gen 2 collection    β”‚
β”‚ GCCollectionMode.Aggressive β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Compact LOH (if enabled)    β”‚
β”‚ Reduce fragmentation        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Return freed memory to OS   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”§ Try This: Monitor memory pressure in your application:

GC.RegisterForFullGCNotification(10, 10);
// Notification when Gen 2 collection is approaching
// Allows preemptive action before crisis

Detailed Example 3: Manual Collection Strategic Use πŸŽ“

Here's a legitimate scenario where manual collection helps:

public class GameLevelManager
{
    public void LoadLevel(int levelId)
    {
        // Unload previous level assets
        UnloadCurrentLevel();
        
        // At this point, many large objects are unreferenced:
        // - Textures, models, audio clips from old level
        // - These are in Gen 2 and LOH
        // - Natural Gen 2 collection might not happen soon
        
        // Strategic manual collection
        GC.Collect(2, GCCollectionMode.Forced);
        GC.WaitForPendingFinalizers();
        GC.Collect(2, GCCollectionMode.Forced);
        
        // Two collections ensure:
        // 1st: Marks objects for finalization
        // 2nd: Actually reclaims finalized objects
        
        // Now load new level with clean slate
        LoadNewLevel(levelId);
        
        // Memory is optimally reclaimed before new allocations
    }
    
    private void UnloadCurrentLevel()
    {
        // Release references to level assets
        currentTextures = null;
        currentModels = null;
        currentAudio = null;
        // Objects still in memory, awaiting collection
    }
}

Why this works:

  1. Clear phase boundary: Loading/unloading levels is a distinct operation
  2. Known garbage creation: Unloading creates predictable large garbage
  3. Idle window: Loading screen provides time for collection
  4. Prevents mid-gameplay pauses: Better to collect during loading than gameplay

Comparison of approaches:

Approach Memory Reclaimed Pause Timing User Impact
Natural GC Delayed, unpredictable During gameplay 😟 Stuttering, frame drops
Manual during load Immediate, complete During loading screen 😊 Smooth gameplay
No collection Grows until crisis Forced emergency GC 😠 Long pause mid-game

πŸ’‘ Best Practice: If you use manual collection, measure its impact with performance profiling tools. Don't assume it helpsβ€”verify!

Detailed Example 4: Large Object Trigger Scenario πŸŽ“

public class VideoProcessor
{
    public void ProcessVideoFrame()
    {
        // 4K video frame: 3840 Γ— 2160 Γ— 4 bytes (RGBA)
        // = 33,177,600 bytes (~31.6 MB)
        // This exceeds 85,000 byte LOH threshold
        
        byte[] frameBuffer = new byte[33_177_600];
        // ⚑ Allocated directly to LOH (Gen 2)
        
        // If LOH is near capacity, triggers Gen 2 collection
        // before completing allocation
        
        LoadFrameData(frameBuffer);
        ProcessFrame(frameBuffer);
        
        // frameBuffer goes out of scope
        // Remains in LOH until next Gen 2 collection
    }
    
    // Better approach: reuse buffers
    private byte[] _reusableBuffer;
    
    public void ProcessVideoFrameOptimized()
    {
        // Allocate once, reuse many times
        if (_reusableBuffer == null)
        {
            _reusableBuffer = new byte[33_177_600];
            // Single LOH allocation for object lifetime
        }
        
        LoadFrameData(_reusableBuffer);
        ProcessFrame(_reusableBuffer);
        
        // Buffer stays alive, no repeated LOH allocations
        // No LOH fragmentation from varying sizes
    }
}

LOH fragmentation problem:

LOH BEFORE ALLOCATIONS (simplified)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ [  Free: 100 MB  ]                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

AFTER MIXED ALLOCATIONS
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ [10MB Used] [5MB Free] [20MB Used] [8MB Free] β”‚
β”‚ [15MB Used] [3MB Free] [35MB Used] [4MB Free] β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Total Free: 20 MB, but largest contiguous: 8 MB!

CANNOT allocate 10 MB object despite 20 MB free
⚑ TRIGGERS GEN 2 COLLECTION & COMPACTION

πŸ”§ Try This: Enable LOH compaction strategically:

GCSettings.LargeObjectHeapCompactionMode = 
    GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced);
// Next Gen 2 collection will compact LOH

Common Mistakes ⚠️

Mistake 1: Calling GC.Collect() Unnecessarily

❌ Wrong:

public void ProcessRecords()
{
    foreach (var record in records)
    {
        ProcessSingleRecord(record);
        GC.Collect(); // DON'T DO THIS!
    }
}

βœ… Right:

public void ProcessRecords()
{
    foreach (var record in records)
    {
        ProcessSingleRecord(record);
        // Let GC handle timing automatically
    }
}

Why it's wrong: The GC has sophisticated heuristics. Manual collection on every iteration:

  • Wastes CPU cycles on unnecessary collections
  • Disrupts generational age tracking
  • May promote young objects prematurely
  • Creates more pauses than natural GC would

Mistake 2: Ignoring Generation-Specific Behavior

❌ Wrong:

// Assuming Gen 0 collection is expensive
public void CreateTemporaryObjects()
{
    // Trying to "avoid" GC by reusing inappropriately
    for (int i = 0; i < 1000; i++)
    {
        sharedTempObject.Reset(); // Awkward reuse
        ProcessWithSharedObject();
    }
}

βœ… Right:

public void CreateTemporaryObjects()
{
    // Gen 0 collection is FAST - embrace short-lived objects
    for (int i = 0; i < 1000; i++)
    {
        var temp = new TempData(); // Natural allocation
        temp.Process();
        // Quickly garbage-collected in Gen 0
    }
}

Why it's wrong: Gen 0 collections are extremely fast (typically <1ms). Short-lived objects are exactly what Gen 0 is optimized for. Fighting this natural pattern creates worse code.

Mistake 3: Not Understanding Memory Pressure

❌ Wrong:

public class NativeResourceManager
{
    private IntPtr _largeUnmanagedBuffer;
    
    public void AllocateNativeMemory()
    {
        // Allocate 500 MB unmanaged memory
        _largeUnmanagedBuffer = Marshal.AllocHGlobal(500_000_000);
        
        // GC doesn't know about this memory!
        // May not trigger when actually under pressure
    }
}

βœ… Right:

public class NativeResourceManager
{
    private IntPtr _largeUnmanagedBuffer;
    private const long BufferSize = 500_000_000;
    
    public void AllocateNativeMemory()
    {
        _largeUnmanagedBuffer = Marshal.AllocHGlobal(BufferSize);
        
        // Inform GC about memory pressure
        GC.AddMemoryPressure(BufferSize);
        
        // GC now knows total memory footprint
        // Will trigger collection appropriately
    }
    
    public void FreeNativeMemory()
    {
        if (_largeUnmanagedBuffer != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_largeUnmanagedBuffer);
            GC.RemoveMemoryPressure(BufferSize);
            _largeUnmanagedBuffer = IntPtr.Zero;
        }
    }
}

Mistake 4: Misunderstanding LOH Behavior

❌ Wrong:

public byte[] GetBuffer(int size)
{
    // Allocating different-sized large buffers repeatedly
    return new byte[size]; // size > 85,000
    // Creates LOH fragmentation over time
}

βœ… Right:

public class BufferPool
{
    private static readonly ArrayPool<byte> _pool = 
        ArrayPool<byte>.Shared;
    
    public byte[] GetBuffer(int size)
    {
        // Rent from pool - reuses existing buffers
        return _pool.Rent(size);
    }
    
    public void ReturnBuffer(byte[] buffer)
    {
        _pool.Return(buffer);
    }
}

Key Takeaways 🎯

Automatic Triggers

  1. Allocation threshold (most common): Gen 0 fills β†’ Gen 0 collection
  2. Low memory: OS signals β†’ aggressive Gen 2 collection
  3. LOH allocation: Insufficient space β†’ Gen 2 collection
  4. Runtime events: AppDomain unload, shutdown β†’ full collection

Manual Triggers

  1. GC.Collect(): Use sparingly at phase boundaries
  2. Memory pressure: Signal unmanaged memory usage with AddMemoryPressure()

Best Practices

  1. Trust the GC: Automatic triggers are optimizedβ€”don't fight them
  2. Profile first: Measure before optimizing GC behavior
  3. Short-lived objects: Ideal for Gen 0β€”create freely in hot paths
  4. Buffer pooling: Reuse large objects to avoid LOH churn

Generation-Specific Knowledge

  1. Gen 0: Collected frequently (~1ms), perfect for temporary objects
  2. Gen 1: Buffer between Gen 0 and Gen 2, collected moderately
  3. Gen 2: Expensive collections (10-100ms+), avoid promoting objects here
  4. LOH: Part of Gen 2, not compacted by default, pool buffers >85KB

Performance Implications

  1. Collection pause time: Gen 0 < Gen 1 << Gen 2
  2. Frequency: Gen 0 >>> Gen 1 > Gen 2
  3. Survival rate matters: High survival = premature promotion = problems

πŸ“‹ Quick Reference Card: GC Triggers

Trigger Type Condition Generation When to Use Manual
Allocation Threshold Gen budget exceeded 0, 1, or 2 Neverβ€”automatic is best
Memory Pressure OS low memory signal 2 (aggressive) Signal with AddMemoryPressure()
LOH Allocation Object β‰₯85KB, no space 2 (with LOH) Pool large buffers instead
Manual Request GC.Collect() called Specified Phase boundaries, loading screens
Runtime Events Shutdown, unload 2 (full) Automaticβ€”no intervention needed

🧠 Memory Device: "Always Monitor Large Managed Resources"
Allocation thresholds, Memory pressure, LOH triggers, Manual collection, Runtime events

Did You Know? πŸ€”

Server GC vs. Workstation GC have different trigger strategies!

  • Workstation GC: Optimized for UI responsiveness, shorter pauses, triggered more conservatively
  • Server GC: Optimized for throughput, uses multiple GC threads (one per CPU core), triggered more aggressively to maximize memory availability

You can switch modes in your application config:

<configuration>
  <runtime>
    <gcServer enabled="true"/>
  </runtime>
</configuration>

Server GC uses more memory but provides better throughput for server applications handling concurrent requests!

Further Study πŸ“š

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

  2. .NET Blog - Maoni Stephens on GC Internals (Maoni is the GC architect)
    https://devblogs.microsoft.com/dotnet/category/garbage-collection/

  3. PerfView Tutorial - Analyzing GC Performance
    https://github.com/microsoft/perfview/blob/main/documentation/Tutorial.md

Master these collection triggers, and you'll be able to write memory-efficient .NET applications that work with the garbage collector rather than against it! πŸš€