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:
- Memory leaks π§ - Forgetting to free memory causes applications to consume more and more resources until they crash
- 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:
| Stack | Managed Heap |
|---|---|
| Stores value types and method calls | Stores reference-type objects |
| Automatically cleaned when methods return | Cleaned 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-specific | Shared 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:
- The runtime calculates the object's size
- If size < 85,000 bytes, allocate on SOH; otherwise, use LOH
- The allocator increments a pointer and returns the memory address
- 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:
- Smaller collection scope: Gen0 collections only examine a small portion of the heap
- Cache locality: Newer objects tend to reference each other, improving CPU cache hits
- Reduced pauses: Frequent, short Gen0 collections are less disruptive than full heap collections
- 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:
- Gen0 threshold exceeded π - Gen0 fills up (most common trigger)
- Explicit request π±οΈ - Code calls
GC.Collect()(rarely recommended) - Low memory condition πΎ - Operating system signals low memory
- 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:
- Starts from all roots
- Traverses object graphs, marking reachable objects
- 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):
- Live objects are moved to eliminate gaps
- References are updated to new addresses
- 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 Type | Generations | Frequency | Typical Pause |
|---|---|---|---|
| Gen0 | 0 | Very high | <1-5 ms |
| Gen1 | 0, 1 | Medium | 5-20 ms |
| Gen2 (full, blocking) | 0, 1, 2 | Low | 50-500+ ms |
| Gen2 (background) | 0, 1, 2 | Low | 10-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:
WeakReferencedoesn't prevent garbage collection- GC can collect the target even if
WeakReferenceexists - 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 π―
Automatic Memory Management π€
- GC automatically reclaims memory from unreachable objects
- Eliminates manual memory management bugs
- Provides safe, managed execution environment
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)
Collection Process π
- Triggered by Gen0 threshold, memory pressure, or explicit request
- Phases: Suspend β Mark β Compact β Resume
- Stop-the-world pauses (minimized by background GC)
Large Object Heap π¦
- Objects β₯85,000 bytes go directly to LOH (Gen2)
- Rarely compacted (fragmentation risk)
- Use
ArrayPool<T>to reuse large allocations
Resource Management π§Ή
- Implement
IDisposablefor unmanaged resources - Use
usingstatements for deterministic cleanup - Avoid finalizers when possible (expensive)
WeakReferencefor memory-sensitive caches
- Implement
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
- Let GC manage collection timing (avoid
Common Pitfalls β οΈ
- Don't call
GC.Collect()manually - Avoid excessive LOH allocations
- Release event handlers and clear collections
- Use
StringBuilderfor string concatenation - Always dispose unmanaged resources
- Don't call
π Quick Reference Card
| Gen0 | Newest objects, collected most frequently (~1-5ms pause) |
| Gen1 | Short-lived objects, buffer between Gen0/Gen2 |
| Gen2 | Long-lived objects, collected infrequently (10-100+ms pause) |
| LOH | Objects β₯85KB, rarely compacted, directly Gen2 |
| Roots | Local vars, static fields, CPU registers, GC handles |
| Marking | Trace from roots to find live objects |
| Compacting | Move objects together, eliminate fragmentation |
| Workstation GC | Low latency, single GC thread, for client apps |
| Server GC | High throughput, parallel GC threads, for servers |
| WeakReference | Reference that doesn't prevent collection |
| IDisposable | Pattern for deterministic resource cleanup |
| Finalizer | ~ClassName() - runs during GC (avoid if possible) |
π Further Study
Microsoft Documentation - Garbage Collection: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
- Comprehensive official documentation on .NET GC internals
Pro .NET Memory Management by Konrad Kokosa: https://prodotnetmemory.com
- Deep dive into .NET memory management with practical examples
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. π