Garbage Collection (GC) is .NET's automatic memory management system that reclaims memory occupied by unused objects.
How Garbage Collection Works:
- Mark Phase: GC identifies which objects are still in use by traversing object references from roots (static fields, local variables, CPU registers)
- Sweep Phase: GC removes unreachable objects
- Compact Phase: GC moves surviving objects together to reduce fragmentation
GC Generations:
.NET uses a generational garbage collection model with three generations:
Generation 0 (Gen 0) - Young objects
├── Short-lived objects
├── Temporary variables
├── Fast collection (< 1ms typically)
└── Most frequent collections
Generation 1 (Gen 1) - Middle-aged objects
├── Survived one Gen 0 collection
├── Buffer between Gen 0 and Gen 2
├── Fast collection
└── Intermediate frequency
Generation 2 (Gen 2) - Old objects
├── Long-lived objects
├── Static data
├── Slower collection (can be 100ms+)
└── Least frequent collections
Large Object Heap (LOH)
├── Objects > 85,000 bytes
├── Not compacted by default
├── Collected with Gen 2
└── Can cause fragmentation
Generation Promotion:
// Example of object lifetime
public void DemonstrateGenerations()
{
// Gen 0 - temporary object
var temp = new byte[100];
// Gen 0 collection happens
GC.Collect(0);
// 'temp' survives, promoted to Gen 1
// More Gen 0 collections...
GC.Collect(0);
// 'temp' still alive, promoted to Gen 2
Console.WriteLine($"Generation: {GC.GetGeneration(temp)}");
}
GC Modes:
1. Workstation GC
- For client applications
- Optimized for responsiveness
- Runs on same thread that triggered collection
// In .csproj
false
2. Server GC
- For server applications
- Optimized for throughput
- Multiple GC threads (one per CPU)
- Larger heap segments
// In .csproj
true
GC Collection Types:
// Check GC mode
bool isServerGC = GCSettings.IsServerGC;
Console.WriteLine($"Server GC: {isServerGC}");
// Check latency mode
Console.WriteLine($"Latency Mode: {GCSettings.LatencyMode}");
// Set latency mode
GCSettings.LatencyMode = GCLatencyMode.LowLatency; // For time-critical operations
// Force garbage collection (avoid in production!)
GC.Collect(); // Full collection
GC.Collect(0); // Gen 0 only
GC.Collect(2, GCCollectionMode.Optimized);
// Wait for finalization
GC.WaitForPendingFinalizers();
// Get GC stats
for (int i = 0; i <= GC.MaxGeneration; i++)
{
Console.WriteLine($"Gen {i} collections: {GC.CollectionCount(i)}");
}
GC Notifications:
// Register for GC notifications
GC.RegisterForFullGCNotification(10, 10);
// In a monitoring thread
while (true)
{
GCNotificationStatus status = GC.WaitForFullGCApproach();
if (status == GCNotificationStatus.Succeeded)
{
Console.WriteLine("Full GC is approaching...");
// Prepare: redirect traffic, pause operations, etc.
}
status = GC.WaitForFullGCComplete();
if (status == GCNotificationStatus.Succeeded)
{
Console.WriteLine("Full GC completed.");
// Resume normal operations
}
}
GC Performance Tips:
// 1. Reuse objects when possible
private static readonly StringBuilder _builder = new StringBuilder();
public string BuildString(params string[] parts)
{
_builder.Clear();
foreach (var part in parts)
_builder.Append(part);
return _builder.ToString();
}
// 2. Use object pooling
private static readonly ObjectPool _pool =
new DefaultObjectPool(new StringBuilderPooledObjectPolicy());
public string BuildStringPooled(params string[] parts)
{
var builder = _pool.Get();
try
{
foreach (var part in parts)
builder.Append(part);
return builder.ToString();
}
finally
{
_pool.Return(builder);
}
}
// 3. Use structs for small, short-lived data
public struct Point // Value type, stack allocated
{
public int X { get; set; }
public int Y { get; set; }
}
// 4. Avoid finalizers unless necessary
public class ResourceHolder : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (!_disposed)
{
// Clean up managed resources
_disposed = true;
GC.SuppressFinalize(this); // Prevent finalizer call
}
}
// Only add finalizer if holding unmanaged resources
~ResourceHolder()
{
Dispose();
}
}
GC Pressure:
// Add memory pressure for unmanaged resources
public class UnmanagedResourceHolder : IDisposable
{
private IntPtr _unmanagedMemory;
private const long ResourceSize = 1024 * 1024; // 1MB
public UnmanagedResourceHolder()
{
_unmanagedMemory = Marshal.AllocHGlobal((int)ResourceSize);
GC.AddMemoryPressure(ResourceSize); // Tell GC about unmanaged memory
}
public void Dispose()
{
if (_unmanagedMemory != IntPtr.Zero)
{
Marshal.FreeHGlobal(_unmanagedMemory);
GC.RemoveMemoryPressure(ResourceSize);
_unmanagedMemory = IntPtr.Zero;
}
}
}
Weak References:
// Hold reference without preventing collection
public class CacheManager
{
private readonly Dictionary> _cache = new();
public void AddToCache(string key, byte[] data)
{
_cache[key] = new WeakReference(data);
}
public byte[] GetFromCache(string key)
{
if (_cache.TryGetValue(key, out var weakRef) &&
weakRef.TryGetTarget(out var data))
{
return data; // Object still alive
}
return null; // Object was collected
}
}
Best Practices:
- Don't call
GC.Collect()manually in production - Use
IDisposablefor deterministic cleanup - Minimize allocations in hot paths
- Use structs for small, short-lived data
- Pool objects for frequently allocated types
- Monitor GC metrics in production
Related Resources
A memory leak in .NET occurs when objects that are no longer needed remain referenced, preventing garbage collection.
Common Causes of Memory Leaks:
1. Event Handler Leaks
// BAD: Memory leak
public class Publisher
{
public event EventHandler OnDataChanged;
}
public class Subscriber
{
public Subscriber(Publisher publisher)
{
publisher.OnDataChanged += HandleDataChanged; // Leak!
}
private void HandleDataChanged(object sender, EventArgs e)
{
// Handle event
}
}
// GOOD: Proper cleanup
public class Subscriber : IDisposable
{
private readonly Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.OnDataChanged += HandleDataChanged;
}
public void Dispose()
{
_publisher.OnDataChanged -= HandleDataChanged; // Unsubscribe!
}
private void HandleDataChanged(object sender, EventArgs e)
{
// Handle event
}
}
2. Static References
// BAD: Keeps all users in memory forever
public static class UserCache
{
private static readonly List _users = new List();
public static void AddUser(User user)
{
_users.Add(user); // Never released!
}
}
// GOOD: Use weak references or implement eviction
public static class UserCache
{
private static readonly Dictionary> _users = new();
public static void AddUser(User user)
{
_users[user.Id] = new WeakReference(user);
}
public static User GetUser(int id)
{
if (_users.TryGetValue(id, out var weakRef) &&
weakRef.TryGetTarget(out var user))
{
return user;
}
return null;
}
}
3. Timer Leaks
// BAD: Timer keeps object alive
public class DataRefresher
{
private Timer _timer;
public DataRefresher()
{
_timer = new Timer(RefreshData, null, 0, 1000);
}
private void RefreshData(object state)
{
// Refresh logic
}
// No disposal - leak!
}
// GOOD: Dispose timer
public class DataRefresher : IDisposable
{
private Timer _timer;
public DataRefresher()
{
_timer = new Timer(RefreshData, null, 0, 1000);
}
private void RefreshData(object state)
{
// Refresh logic
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
}
}
4. Captured Variables in Closures
// BAD: Captures large object
public void ProcessData()
{
var largeData = LoadLargeDataSet(); // 100MB
Task.Run(() =>
{
// Only need one field, but captures entire object
Console.WriteLine(largeData.Count);
});
}
// GOOD: Capture only what you need
public void ProcessData()
{
var largeData = LoadLargeDataSet();
int count = largeData.Count; // Copy value
largeData = null; // Allow GC
Task.Run(() =>
{
Console.WriteLine(count);
});
}
5. Unmanaged Resources
// BAD: Native memory leak
public class ImageProcessor
{
private IntPtr _nativeBuffer;
public ImageProcessor()
{
_nativeBuffer = Marshal.AllocHGlobal(1024 * 1024);
}
// No cleanup!
}
// GOOD: Implement IDisposable
public class ImageProcessor : IDisposable
{
private IntPtr _nativeBuffer;
private bool _disposed;
public ImageProcessor()
{
_nativeBuffer = Marshal.AllocHGlobal(1024 * 1024);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Dispose managed resources
}
// Free unmanaged resources
if (_nativeBuffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativeBuffer);
_nativeBuffer = IntPtr.Zero;
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~ImageProcessor()
{
Dispose(false);
}
}
Identifying Memory Leaks:
1. Using Visual Studio Diagnostic Tools
// Take memory snapshots during execution
// Debug -> Windows -> Show Diagnostic Tools
// Take Snapshot -> Compare snapshots to find growing objects
2. Using dotMemory or ANTS Memory Profiler
# Professional memory profilers
# - Show object retention paths
# - Identify event handler leaks
# - Compare snapshots
# - Find large object heap fragmentation
3. Using PerfView
# Free Microsoft tool
dotnet tool install -g Microsoft.Diagnostics.Tools.dotnet-trace
dotnet-trace collect --process-id --providers Microsoft-Windows-DotNETRuntime
4. Custom Memory Monitoring
public class MemoryMonitor
{
private readonly Timer _timer;
public MemoryMonitor()
{
_timer = new Timer(CheckMemory, null, 0, 5000);
}
private void CheckMemory(object state)
{
var process = Process.GetCurrentProcess();
var memoryMB = process.WorkingSet64 / 1024 / 1024;
Console.WriteLine($"Memory Usage: {memoryMB} MB");
Console.WriteLine($"Gen 0: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2: {GC.CollectionCount(2)}");
// Alert if memory grows continuously
if (memoryMB > 1000)
{
Console.WriteLine("WARNING: High memory usage!");
// Force GC to see if memory is really leaked
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var afterGC = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
Console.WriteLine($"After GC: {afterGC} MB");
}
}
}
5. Using Memory Dumps
# Create dump file
dotnet-dump collect --process-id
# Analyze dump
dotnet-dump analyze dump_file.dmp
# Commands in dump analysis
> dumpheap -stat # Show object statistics
> dumpheap -mt # Show instances
> gcroot # Show what keeps object alive
> eeheap -gc # Show heap stats
6. Application Insights / Logging
public class MemoryMetrics
{
private readonly ILogger _logger;
private readonly IMetricsCollector _metrics;
public void LogMemoryUsage()
{
var info = GC.GetGCMemoryInfo();
_logger.LogInformation(
"Memory: Heap={HeapSize}MB, FragmentedBytes={Fragmented}MB",
info.HeapSizeBytes / 1024 / 1024,
info.FragmentedBytes / 1024 / 1024);
_metrics.TrackMetric("MemoryUsage", info.HeapSizeBytes);
_metrics.TrackMetric("Gen2Collections", GC.CollectionCount(2));
}
}
Detection Patterns:
public class LeakDetector
{
private long _baselineMemory;
private int _measurementCount;
public void EstablishBaseline()
{
// Force GC to get accurate baseline
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
_baselineMemory = GC.GetTotalMemory(false);
_measurementCount = 0;
}
public bool CheckForLeak()
{
_measurementCount++;
if (_measurementCount % 10 == 0) // Check every 10 operations
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var currentMemory = GC.GetTotalMemory(false);
var growth = currentMemory - _baselineMemory;
var growthPercentage = (growth * 100.0) / _baselineMemory;
if (growthPercentage > 20) // 20% growth
{
Console.WriteLine($"Potential leak detected! Growth: {growthPercentage:F2}%");
return true;
}
}
return false;
}
}
Best Practices:
- Always unsubscribe from events
- Implement IDisposable properly
- Use weak references for caches
- Avoid static collections of objects
- Use
usingstatements for disposable resources - Monitor memory in production
- Profile regularly during development
- Use memory profilers to find retention paths
Related Resources
Stack Memory:
- Used for static memory allocation
- Stores value types, method parameters, and local variables
- LIFO (Last In, First Out) structure
- Very fast allocation and deallocation
- Memory is automatically managed when methods enter/exit scope
- Limited in size (typically 1MB per thread)
- Thread-specific - each thread has its own stack
- No garbage collection needed
Heap Memory:
- Used for dynamic memory allocation
- Stores reference types (objects, arrays, strings)
- Managed by the Garbage Collector
- Slower allocation and deallocation compared to stack
- Much larger size (limited by available system memory)
- Shared across all threads in the application
- Requires garbage collection to reclaim unused memory
- Memory fragmentation can occur
Example:
public void Example()
{
// Stack: value type stored on stack
int x = 5;
// Stack: variable 'person' (reference) on stack
// Heap: actual Person object on heap
Person person = new Person();
// Stack: struct stored on stack
Point point = new Point(10, 20);
}
Key Differences:
- Stack is faster but limited in size
- Heap is slower but can hold larger amounts of data
- Stack variables are automatically cleaned up
- Heap requires garbage collection
- Value types typically go on stack (unless part of reference type)
- Reference types always go on heap
Related Resources
Profiling Tools:
-
Visual Studio Profiler
- CPU Usage
- Memory Usage
- Database performance
- .NET Object Allocation Tracking
-
dotTrace (JetBrains)
- Timeline profiling
- Sampling and tracing
- Call tree analysis
-
dotMemory (JetBrains)
- Memory snapshots
- Memory leak detection
- Retention analysis
-
PerfView
- ETW-based performance analysis
- CPU and memory profiling
- Free Microsoft tool
-
BenchmarkDotNet
- Micro-benchmarking library
- Precise performance measurements
Optimization Strategy:
1. Measure First
// Use BenchmarkDotNet
[Benchmark]
public void MethodToProfile()
{
// Code to measure
}
2. Identify Bottlenecks
- CPU hotspots
- Memory allocations
- I/O operations
- Database queries
- Lock contention
3. Common Optimization Techniques
Reduce Allocations:
// Bad: Creates new string on each call
public string GetFullName(string first, string last)
{
return first + " " + last;
}
// Better: Use StringBuilder for multiple concatenations
public string GetFullName(string first, string last)
{
return string.Concat(first, " ", last);
}
// Best for frequent calls: Use Span
public void GetFullName(ReadOnlySpan first, ReadOnlySpan last, Span destination)
{
first.CopyTo(destination);
destination[first.Length] = ' ';
last.CopyTo(destination.Slice(first.Length + 1));
}
Use Value Types When Appropriate:
// Instead of class for small data
public readonly struct Point
{
public int X { get; }
public int Y { get; }
}
Async/Await for I/O:
public async Task GetDataAsync()
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
4. Memory Optimization
- Use object pooling for frequently created objects
- Dispose IDisposable objects properly
- Avoid boxing/unboxing
- Use
Span<T>andMemory<T>for data manipulation - Consider using
stackallocfor small arrays
5. Monitoring in Production
- Application Insights
- Performance counters
- Custom logging and metrics
- Health checks
Related Resources
Span<T>:
A ref struct that provides a type-safe and memory-safe representation of a contiguous region of arbitrary memory.
Key Characteristics:
- Stack-only type (ref struct)
- Zero allocation
- Can point to stack memory, managed heap, or native memory
- Cannot be used in async methods
- Cannot be stored as a field in regular classes
Example:
// Working with arrays without allocation
public int Sum(Span numbers)
{
int sum = 0;
foreach (int num in numbers)
{
sum += num;
}
return sum;
}
// Can be called with different memory sources
int[] array = {1, 2, 3, 4, 5};
Sum(array); // Works with array
Sum(array.AsSpan(1, 3)); // Works with slice
Span stackArray = stackalloc int[5];
Sum(stackArray); // Works with stack memory
String Manipulation:
public ReadOnlySpan ExtractUsername(string email)
{
int atIndex = email.IndexOf('@');
return email.AsSpan(0, atIndex); // No allocation!
}
Memory<T>:
Similar to Span<T> but can be stored on the heap and used in async methods.
Key Characteristics:
- Can be stored as a field in classes
- Can be used in async methods
- Slightly more overhead than Span<T>
- Provides
.Spanproperty to get a Span<T>
Example:
public class BufferProcessor
{
private Memory _buffer;
public async Task ProcessAsync()
{
// Memory can be used across await
await Task.Delay(100);
// Get Span when doing actual work
Span span = _buffer.Span;
ProcessData(span);
}
private void ProcessData(Span data)
{
// Fast processing without allocations
}
}
When to Use:
Use Span<T> when:
- Working with buffers in synchronous code
- Need maximum performance and zero allocations
- Manipulating strings or arrays without creating copies
- Working with stack-allocated memory
Use Memory<T> when:
- Need to store reference to memory as a field
- Working in async methods
- Need to pass memory across async boundaries
- Building APIs that might be used in async contexts
Use ReadOnlySpan<T>/ReadOnlyMemory<T> when:
- You don't need to modify the data
- Provides additional safety guarantees
Performance Benefits:
// Traditional approach - allocations!
public string[] SplitData(string data)
{
return data.Split(','); // Allocates array + strings
}
// Span approach - zero allocations!
public void SplitData(ReadOnlySpan data, Span ranges)
{
int rangeIndex = 0;
int start = 0;
for (int i = 0; i < data.Length; i++)
{
if (data[i] == ',')
{
ranges[rangeIndex++] = start..i;
start = i + 1;
}
}
ranges[rangeIndex] = start..data.Length;
}