Understanding Memory Management in C#
Memory management in C# is one of those topics that separates good developers from great ones. At its core, it's about how the .NET runtime handles the allocation and deallocation of memory for your objects, arrays, and variables. Unlike C or C++, where you manually call malloc and free, C# provides an automatic memory management system centered around the Garbage Collector (GC). This automation doesn't mean you can ignore memory entirely — quite the opposite. Understanding what happens under the hood allows you to write more performant, predictable, and resource-friendly applications.
The Stack and the Heap: Two Fundamental Arenas
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before diving into garbage collection, you need to grasp the two primary regions where your data lives: the stack and the managed heap. They serve different purposes and operate under different rules.
The Stack
The stack is a contiguous block of memory that operates on a Last-In-First-Out (LIFO) principle. It's blazingly fast because allocation and deallocation are simply a matter of moving a pointer. The stack stores:
- Local variables of value types (
int,double,bool, structs) - References to objects (the reference itself, not the object data)
- Parameters passed to methods
- Return addresses for function calls
When a method is called, its local variables are pushed onto the stack. When the method returns, that stack frame is popped off, instantly freeing all those local variables. No GC involvement, no fragmentation, no overhead — pure efficiency.
// All of these live on the stack when declared inside a method
int count = 42;
double price = 19.99;
bool isActive = true;
DateTime today = DateTime.Now; // DateTime is a struct, so it lives on the stack
// This reference lives on the stack, but the actual string object lives on the heap
string name = "Alice";
// This struct lives entirely on the stack
Point point = new Point { X = 10, Y = 20 };
public struct Point
{
public int X;
public int Y;
}
The Managed Heap
The managed heap is where all reference type objects live. When you write new StringBuilder() or new List<int>(), the CLR allocates memory on the heap. Unlike the stack, the heap doesn't have that tidy LIFO discipline — objects get allocated and freed at different times, potentially leading to fragmentation. This is exactly why we need a garbage collector.
// These objects all live on the managed heap
StringBuilder builder = new StringBuilder(); // reference on stack, object on heap
List<int> numbers = new List<int>(); // same pattern
MyClass instance = new MyClass(); // object on heap
// Arrays are reference types too — they live on the heap
int[] scores = new int[100]; // entire array on heap
public class MyClass
{
public string Data; // string (reference type) on heap
public int Id; // value type embedded inside the object on heap
}
Garbage Collection: The Heart of C# Memory Management
The Garbage Collector is the automatic memory manager that tracks object lifetimes and reclaims memory that's no longer in use. It operates on a fundamental principle: if an object cannot be reached by any live reference (a "root"), it's considered garbage and its memory can be collected.
GC Roots
The GC starts from a set of roots — references that are guaranteed to be alive — and walks the object graph from there. Roots include:
- Static fields and properties
- Local variables on the stack for currently executing methods
- CPU registers that hold references
- GC handles (special references created by the runtime)
- Finalization queue references
Any object that can be reached by following references from these roots is considered alive. Everything else is dead and eligible for collection.
Generational Garbage Collection
The .NET GC uses a generational model based on the empirical observation that most objects are short-lived. It divides the heap into three generations:
- Generation 0: The nursery. All new objects start here. Collections here are frequent but extremely fast.
- Generation 1: A buffer between short-lived and long-lived objects. Objects that survive a Gen 0 collection get promoted here.
- Generation 2: The old-age home. Long-lived objects like static collections and singletons live here. Collections are rare and expensive.
The Large Object Heap (LOH) is a special area for objects 85,000 bytes or larger. These objects are always considered Gen 2 and are never compacted by default (though you can opt into compaction in recent .NET versions).
// Demonstrating generation concepts
public class GarbageCollectionDemo
{
public static void Run()
{
// This object starts in Gen 0
var temp = new StringBuilder();
temp.Append("I'm a fresh object in Gen 0");
// Force a Gen 0 collection
GC.Collect(0);
// temp is still referenced, so it survives and gets promoted to Gen 1
// Create lots of garbage to trigger collections
for (int i = 0; i < 1000; i++)
{
var garbage = new byte[1000]; // Gen 0 objects, quickly collected
}
// temp is now in Gen 1 or possibly Gen 2 depending on collections
Console.WriteLine($"temp is in generation {GC.GetGeneration(temp)}");
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2)}");
}
}
Collection Triggers
A GC collection doesn't happen randomly. It's triggered by:
- Exceeding a generation's memory threshold (the most common trigger)
- Explicit calls to
GC.Collect()(generally discouraged in production) - Low system memory conditions detected by the OS
- Application shutdown (final cleanup)
Value Types vs. Reference Types: Memory Implications
The distinction between value types and reference types isn't just a semantic one — it has profound implications for memory layout and performance.
Value Types (structs)
Value types contain their data directly. When you assign one value type to another, you get a copy of all the data. They can live on the stack (when local variables) or be embedded directly inside reference type objects on the heap.
public struct Vector3
{
public float X;
public float Y;
public float Z;
}
public class Transform
{
// These Vector3 structs are embedded directly inside the Transform object on the heap
// They don't create separate heap allocations
public Vector3 Position;
public Vector3 Rotation;
public Vector3 Scale;
}
// Usage
void ProcessVectors()
{
// These live on the stack — zero heap allocations
Vector3 a = new Vector3 { X = 1, Y = 2, Z = 3 };
Vector3 b = a; // Complete copy — a and b are independent
// 3 floats copied, no GC pressure, no indirection
b.X = 10; // a.X is still 1 — they're separate copies
}
Reference Types (classes)
Reference types store a reference to their data, which lives on the heap. Assignment copies the reference, not the data, so multiple variables can point to the same object.
public class Player
{
public string Name;
public int Score;
}
void ProcessPlayers()
{
// Reference on stack, object allocated on the heap
Player p1 = new Player { Name = "Alice", Score = 100 };
// p2 gets a copy of the reference — both point to the same heap object
Player p2 = p1;
p2.Score = 200; // Now p1.Score is ALSO 200 — same object!
// This creates a second heap allocation
Player p3 = new Player { Name = "Bob", Score = 300 };
}
Boxing and Unboxing: The Hidden Performance Killer
Boxing occurs when a value type is converted to a reference type (typically object or an interface). The CLR allocates a wrapper object on the heap and copies the value type's data into it. Unboxing extracts that value back. Both operations carry performance costs that are easy to overlook.
public class BoxingDemo
{
public static void Demonstrate()
{
int value = 42; // Lives on the stack
// Boxing: heap allocation + copy
object boxed = value;
// Now there's a heap object containing a copy of 42
// Unboxing: type check + copy from heap to stack
int unboxed = (int)boxed;
// The real danger: boxing in loops and collections
// Pre-generics era code (avoid this!)
ArrayList list = new ArrayList();
for (int i = 0; i < 1000; i++)
{
list.Add(i); // BOXING on every iteration! 1000 heap allocations
}
// Modern approach: generic collections avoid boxing entirely
List<int> typedList = new List<int>();
for (int i = 0; i < 1000; i++)
{
typedList.Add(i); // NO boxing — int stored directly in array
}
// String concatenation can cause boxing too
int score = 1500;
// Boxing occurs: score is boxed to call ToString() on it via object
string message = "Score: " + score;
// Better: use explicit conversion or interpolation (which avoids boxing)
string better = $"Score: {score}"; // No boxing in modern C#
}
}
Boxing is particularly insidious because it's often invisible. Any time you pass a struct to a method that takes object, store it in a non-generic collection, or cast it to an interface, boxing may occur. In performance-critical code paths, this can cause significant GC pressure.
IDisposable and Deterministic Cleanup
Garbage collection handles memory, but many resources aren't just memory: file handles, database connections, network sockets, native handles. These need deterministic cleanup — you can't wait for the GC to get around to it. The IDisposable interface solves this.
public class FileProcessor : IDisposable
{
private FileStream _fileStream;
private bool _disposed = false;
public FileProcessor(string path)
{
_fileStream = new FileStream(path, FileMode.OpenOrCreate);
}
public void ProcessData()
{
if (_disposed)
throw new ObjectDisposedException(nameof(FileProcessor));
// Use the file stream...
byte[] data = new byte[1024];
_fileStream.Read(data, 0, data.Length);
}
// The standard dispose pattern
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Skip finalizer since we already cleaned up
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed resources
_fileStream?.Dispose();
}
// Free native resources if any (set their handles to invalid)
_disposed = true;
}
// Finalizer as a safety net (only if we had native resources)
~FileProcessor()
{
Dispose(false);
}
}
The using Statement
The using statement guarantees that Dispose is called, even if an exception occurs. It's compiled into a try/finally block by the compiler.
public void ReadFileSafely(string path)
{
// Classic using block — ensures disposal at the end of the scope
using (var processor = new FileProcessor(path))
{
processor.ProcessData();
} // Dispose called here automatically, even if exception thrown
// Modern C# 8+ using declaration — disposes at end of enclosing scope
using var processor2 = new FileProcessor(path);
processor2.ProcessData();
// Dispose called when processor2 goes out of scope
// Async disposal (C# 8+ with IAsyncDisposable)
await using var asyncProcessor = new AsyncFileProcessor(path);
await asyncProcessor.ProcessAsync();
// IAsyncDisposable.DisposeAsync() called on exit
}
// IAsyncDisposable for async resources
public class AsyncFileProcessor : IAsyncDisposable
{
private FileStream _stream;
public AsyncFileProcessor(string path)
{
_stream = new FileStream(path, FileMode.Open);
}
public async Task ProcessAsync()
{
byte[] buffer = new byte[4096];
await _stream.ReadAsync(buffer, 0, buffer.Length);
}
public async ValueTask DisposeAsync()
{
if (_stream != null)
{
await _stream.DisposeAsync();
_stream = null;
}
}
}
Finalizers: The Safety Net (and Why to Avoid Them)
A finalizer (destructor in C# syntax) runs when the GC collects an object. It's a last-resort cleanup mechanism. However, finalizers come with a heavy cost: objects with finalizers take longer to collect (they survive at least one collection cycle to have their finalizer run) and the finalizer thread itself is a bottleneck.
public class NativeResourceWrapper
{
private IntPtr _nativeHandle;
private bool _disposed;
public NativeResourceWrapper()
{
_nativeHandle = NativeMethods.AllocateResource();
}
// Use IDisposable for deterministic cleanup
public void Dispose()
{
if (!_disposed)
{
NativeMethods.FreeResource(_nativeHandle);
_nativeHandle = IntPtr.Zero;
_disposed = true;
GC.SuppressFinalize(this); // Prevents finalizer from running
}
}
// Finalizer only runs if Dispose was never called — a safety net
~NativeResourceWrapper()
{
// Do NOT reference other managed objects here — they may have been collected
NativeMethods.FreeResource(_nativeHandle);
_nativeHandle = IntPtr.Zero;
}
}
// Best practice: always use SafeHandle or similar for native resources
// SafeHandle automatically handles finalization correctly
public class BetterNativeWrapper : IDisposable
{
private SafeFileHandle _handle;
public void Dispose()
{
_handle?.Dispose();
}
}
Modern Memory Features: Span<T> and Memory<T>
Introduced in C# 7.2 and expanded since, Span<T> and Memory<T> represent contiguous regions of memory without requiring heap allocations. They're stack-only types (ref structs) that can point to stack memory, heap memory, or unmanaged memory — giving you zero-allocation slicing and processing.
public class SpanDemonstration
{
// Span is a ref struct — it can ONLY live on the stack
// This means you can't put it in a class field, box it, or store it in a List
public static void DemonstrateSpans()
{
// Span over an array on the heap — no allocation for the span itself
int[] data = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
Span<int> span = data.AsSpan();
// Slice without allocating a new array
Span<int> firstThree = span.Slice(0, 3); // {1, 2, 3}
Span<int> lastFive = span.Slice(5, 5); // {6, 7, 8, 9, 10}
// Modify through the span — modifies the original array
firstThree[0] = 100; // data[0] is now 100
// Stack-allocated memory with Span (requires unsafe context or stackalloc)
Span<byte> stackSpan = stackalloc byte[64];
for (int i = 0; i < stackSpan.Length; i++)
{
stackSpan[i] = (byte)(i % 256);
}
// String processing without allocations
string text = "Hello, World!";
ReadOnlySpan<char> textSpan = text.AsSpan();
ReadOnlySpan<char> world = textSpan.Slice(7, 5); // "World" — no string allocated!
// Parse from a span without substring allocations
if (int.TryParse(world, out int result))
{
// Would fail on "World" but demonstrates the pattern
}
}
// Memory is the heap-friendly version — can be stored in fields
private Memory<byte> _buffer;
public void SetBuffer(Memory<byte> buffer)
{
_buffer = buffer; // Stored in a field, unlike Span
}
public void ProcessBuffer()
{
// Get a Span from Memory to do the actual work
Span<byte> span = _buffer.Span;
for (int i = 0; i < span.Length; i++)
{
span[i] ^= 0xFF; // XOR operation
}
}
}
The power here is profound: you can process strings, arrays, and buffers with zero additional heap allocations. This is especially valuable in high-performance scenarios like parsing, networking, and serialization.
Unsafe Code and Manual Memory Management
Sometimes you need to step outside the managed safety net — for interop with native code, extreme performance optimization, or working with raw memory layouts. C# allows this via unsafe contexts.
public unsafe class UnsafeMemoryDemo
{
// Pointers can only exist in unsafe contexts
public static unsafe void ProcessRawMemory()
{
// Allocate memory from the unmanaged heap (not GC-managed)
int* pointer = (int*)Marshal.AllocHGlobal(sizeof(int) * 100);
// Write values through the pointer
for (int i = 0; i < 100; i++)
{
*(pointer + i) = i * i; // Pointer arithmetic
}
// Read values back
int sum = 0;
for (int i = 0; i < 100; i++)
{
sum += pointer[i]; // Array-like syntax with pointers
}
Console.WriteLine($"Sum of squares 0-99: {sum}");
// MUST free manually — GC doesn't track this memory
Marshal.FreeHGlobal((IntPtr)pointer);
pointer = null; // Good practice to avoid dangling pointer
}
// Fixed statement: pin a managed object so GC doesn't move it
public static unsafe void PinManagedObject()
{
byte[] managedArray = new byte[1024];
// Pin the array and get a pointer to its data
fixed (byte* ptr = managedArray)
{
// managedArray is pinned — GC won't move it during this block
for (int i = 0; i < 1024; i++)
{
ptr[i] = (byte)(i % 256);
}
}
// Array is unpinned here, GC can move it again
// Alternative: fixed on a string
string s = "Hello";
fixed (char* cPtr = s)
{
// cPtr now points to the actual string data
char first = *cPtr; // 'H'
}
}
// Stackalloc in unsafe context
public static unsafe void StackAllocate()
{
// Allocate 256 bytes on the stack — extremely fast
byte* buffer = stackalloc byte[256];
// Initialize
for (int i = 0; i < 256; i++)
{
buffer[i] = 0;
}
// Automatically freed when method returns — no GC, no free() needed
}
}
Unsafe code requires the unsafe keyword and the project must be configured with <AllowUnsafeBlocks>true</AllowUnsafeBlocks>. Use it sparingly — the performance gains must justify the safety risks.
Memory-Mapped Files and Large Data
For very large datasets, loading everything into managed memory can cause GC pauses and high memory usage. Memory-mapped files allow you to work with data that's larger than available RAM by mapping file contents directly into the process address space.
public class MemoryMappedFileDemo
{
public static void ProcessLargeFile(string path)
{
// Open a memory-mapped file for a 10GB file without loading it all into RAM
using var mmf = MemoryMappedFile.CreateFromFile(
path,
FileMode.Open,
null, // no name
0, // default capacity
MemoryMappedFileAccess.Read);
// Create a view accessor for a portion of the file
long fileSize = new FileInfo(path).Length;
long offset = 0;
long chunkSize = 1024 * 1024 * 100; // 100MB chunks
while (offset < fileSize)
{
long remaining = fileSize - offset;
long viewSize = Math.Min(chunkSize, remaining);
using var accessor = mmf.CreateViewAccessor(
offset,
viewSize,
MemoryMappedFileAccess.Read);
// Process this chunk
byte[] buffer = new byte[viewSize];
accessor.ReadArray(0, buffer, 0, buffer.Length);
// Do work with buffer...
offset += viewSize;
}
}
}
Best Practices for Memory Management in C#
1. Prefer Generics Over Non-Generic Collections
Non-generic collections like ArrayList and Hashtable box value types. Always use List<T>, Dictionary<TKey, TValue>, and friends.
// Bad — boxing on every insertion
ArrayList numbers = new ArrayList();
numbers.Add(1); // boxes int
// Good — zero boxing
List<int> numbers = new List<int>();
numbers.Add(1); // direct storage
2. Implement IDisposable Correctly
If your class owns disposable resources, implement IDisposable. Use the standard dispose pattern. Call GC.SuppressFinalize(this) to avoid the finalizer overhead when you've already cleaned up.
3. Avoid Finalizers Unless Absolutely Necessary
Finalizers slow down collection and can cause resurrection (objects being brought back to life). Use SafeHandle derivatives for native resources instead of writing finalizers yourself.
4. Use Structs Judiciously
Structs can reduce GC pressure but come with their own costs. Avoid structs that are too large (generally, keep them under 16-24 bytes). Don't make mutable structs — they lead to subtle bugs because of copy semantics. Use readonly structs when possible.
// Good struct design
public readonly struct Point3D
{
public readonly double X;
public readonly double Y;
public readonly double Z;
public Point3D(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public double Magnitude => Math.Sqrt(X * X + Y * Y + Z * Z);
}
// Bad — too large, should be a class
public struct BigStruct // 64 bytes is pushing it
{
public long A;
public long B;
public long C;
public long D;
public long E;
public long F;
public long G;
public long H;
}
5. Pool Frequently Allocated Objects
For objects that are allocated and discarded frequently, consider object pooling. ArrayPool<T> is built into .NET for buffer pooling.
public class BufferProcessor
{
// ArrayPool is built into .NET — use it for temporary arrays
public void ProcessLargeData(byte[] source)
{
// Rent a buffer instead of allocating a new one each time
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
for (int i = 0; i < source.Length; i += buffer.Length)
{
int bytesToCopy = Math.Min(buffer.Length, source.Length - i);
Array.Copy(source, i, buffer, 0, bytesToCopy);
// Process buffer...
}
}
finally
{
// Return the buffer — must always return, hence finally block
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
}
}
6. Avoid Defensive String.Allocations
Strings are immutable reference types that dominate heap allocations in many applications. Use StringBuilder for building strings in loops, leverage Span<char> for parsing, and consider string interning for repeated constant strings.
// Bad — creates a new string on every iteration
string result = "";
for (int i = 0; i < 1000; i++)
{
result += i.ToString(); // Allocates new string each time
}
// Good — uses StringBuilder, far fewer allocations
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append(i);
}
string result = sb.ToString();
// Even better for parsing — zero allocation with Span
ReadOnlySpan<char> input = "123,456,789".AsSpan();
int commaIndex = input.IndexOf(',');
ReadOnlySpan<char> first = input.Slice(0, commaIndex); // "123" — no allocation
7. Be Mindful of LOH Allocations
Objects 85,000 bytes or larger go on the Large Object Heap. LOH collections are expensive (Gen 2). Avoid frequent large allocations. If you must allocate large buffers frequently, consider pooling or using ArrayPool<T>.
8. Use Weak References for Caching
When implementing caches, WeakReference<T> allows the GC to collect cached objects under memory pressure while you can still access them if they survive.
public class ImageCache
{
private Dictionary<string, WeakReference<Bitmap>> _cache = new();
public Bitmap GetOrLoad(string path)
{
if (_cache.TryGetValue(path, out var weakRef))
{
if (weakRef.TryGetTarget(out var cached))
{
return cached; // Cache hit — still alive
}
// Object was collected, remove stale entry
_cache.Remove(path);
}
var image = new Bitmap(path);
_cache[path] = new WeakReference<Bitmap>(image);
return image;
}
}
Common Memory Pitfalls and How to Avoid Them
Pitfall 1: Event Handler Leaks
The most notorious memory leak in .NET: an event subscriber keeps the publisher alive because the publisher holds a reference to the subscriber via the delegate.
public class EventLeakDemo
{
public class Publisher
{
public event EventHandler<DataEventArgs> DataReceived;
public void Raise(DataEventArgs args)
{
DataReceived?.Invoke(this, args);
}
}
public class Subscriber
{
private Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
// The publisher now holds a reference to this subscriber via the delegate
_publisher.DataReceived += OnDataReceived;
}
private void OnDataReceived(object sender, DataEventArgs e)
{
Console.WriteLine($"Received: {e.Data}");
}
// If you never unsubscribe, the publisher keeps the subscriber alive forever
public void Cleanup()
{
_publisher.DataReceived -= OnDataReceived; // CRITICAL: unsubscribe
}
}
// Better pattern: use weak event handlers
public class SafeSubscriber
{
private WeakReference<Action<DataEventArgs>> _handler;
// ...implementation using WeakReference for the delegate
}
}
Pitfall 2: Static Collection Growth
Static collections live forever (they're GC roots). If you add objects to a static list and never remove them, you have a permanent memory leak.
public class StaticLeakExample
{
// This collection is a GC root — anything in it lives forever
private static List<object> _cache = new List<object>();
public static void BadCache(object data)
{
_cache.Add(data); // Never removed — memory usage grows indefinitely
}
// Better: use a bounded cache with eviction
private static ConcurrentQueue<object> _boundedCache = new();
private const int MaxCacheSize = 1000;
public static void GoodCache(object data)
{
_boundedCache.Enqueue(data);
while (_boundedCache.Count > MaxCacheSize)
{
_boundedCache.TryDequeue(out _); // Evict oldest entries
}
}
}
Pitfall 3: Lambdas Capturing Variables
Lambdas that capture local variables can extend lifetimes unexpectedly. The captured variable lives as long as the delegate lives.
public class LambdaCaptureDemo
{
// Bad: the lambda captures 'this' implicitly via the field reference
public Action CreateLeakingAction()
{
string localData = new string('x', 10000); // Large allocation
// This lambda captures localData — keeping it alive
return () => Console.WriteLine(localData.Length);
// localData lives as long as the returned Action is referenced
}
// Better: be explicit about what you capture
public Action CreateBetterAction()
{
string localData = new string('x', 10000);
int length = localData.Length; // Capture just the int, not the string
return () => Console.WriteLine(length);
// The large string can be collected, only the int is captured
}
}
Pitfall 4: Forgetting to Dispose in Async Code
Async methods that create disposable resources need special care. The await using pattern (C# 8+) handles this properly.
public async Task ProcessAsync(string connectionString)
{
// The connection will be disposed when the method completes
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM Users";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
// Process rows...
}
// All three resources are disposed automatically here, in reverse order
}
Diagnosing Memory Issues
Tools are essential for understanding what's actually happening in your application's memory. Here are the key ones:
- dotMemory (JetBrains): Commercial profiler with excellent visualization of heap snapshots, retention paths, and generation analysis.
- PerfView (Microsoft): Free, extremely powerful ETW-based profiler. Steep learning curve but invaluable for production diagnostics.
- dotnet-dump: Command-line tool for analyzing dumps. Use
dotnet-dump analyzeto inspect the managed heap. - GC Telemetry: The .NET runtime exposes GC events via EventSource. You can monitor GC frequency, pause times, and generation sizes.
// Simple in-code GC monitoring
public class GCMonitor
{
public static void PrintGCStats()
{
Console.WriteLine($"Total memory: {GC.GetTotalMemory(false):N0} bytes");
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2)}");
// Get GC mode (workstation vs server)
Console.WriteLine($"GC mode: {(GCSettings.IsServerGC ? "Server" : "Workstation")}");
Console.WriteLine($"Latency mode: {GCSettings.LatencyMode}");
}
// Temporarily switch to low-latency GC for time-critical operations
public static void PerformLatencySensitiveWork()
{
var oldMode = GCSettings.LatencyMode;
try
{
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// Do time-critical work here...
}
finally
{
GCSettings.LatencyMode = oldMode;
}
}
}