← Back to DevBytes

Memory Management in C#: A Deep Dive

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:

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:

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:

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:

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:

// 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;
        }
    }
}

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles