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:
- Clear phase boundary: Loading/unloading levels is a distinct operation
- Known garbage creation: Unloading creates predictable large garbage
- Idle window: Loading screen provides time for collection
- 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
- Allocation threshold (most common): Gen 0 fills β Gen 0 collection
- Low memory: OS signals β aggressive Gen 2 collection
- LOH allocation: Insufficient space β Gen 2 collection
- Runtime events: AppDomain unload, shutdown β full collection
Manual Triggers
- GC.Collect(): Use sparingly at phase boundaries
- Memory pressure: Signal unmanaged memory usage with
AddMemoryPressure()
Best Practices
- Trust the GC: Automatic triggers are optimizedβdon't fight them
- Profile first: Measure before optimizing GC behavior
- Short-lived objects: Ideal for Gen 0βcreate freely in hot paths
- Buffer pooling: Reuse large objects to avoid LOH churn
Generation-Specific Knowledge
- Gen 0: Collected frequently (~1ms), perfect for temporary objects
- Gen 1: Buffer between Gen 0 and Gen 2, collected moderately
- Gen 2: Expensive collections (10-100ms+), avoid promoting objects here
- LOH: Part of Gen 2, not compacted by default, pool buffers >85KB
Performance Implications
- Collection pause time: Gen 0 < Gen 1 << Gen 2
- Frequency: Gen 0 >>> Gen 1 > Gen 2
- 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 π
Microsoft Documentation - Fundamentals of Garbage Collection
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals.NET Blog - Maoni Stephens on GC Internals (Maoni is the GC architect)
https://devblogs.microsoft.com/dotnet/category/garbage-collection/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! π