Polyglot
Languages C# memory
C# § memory

Memory and the CLR

C# memory management is governed by the garbage collector of the CLR. Most of the time the GC is invisible; programs allocate freely and the runtime reclaims unreachable memory without explicit programmer intervention. The cases where the model becomes visible — the IDisposable pattern for deterministic cleanup of unmanaged resources, the generational structure of the GC, the trade-offs of allocation-heavy code, and the modern Span<T> / Memory<T> types for low-allocation processing — are the substance of this page. The discipline of writing memory-correct C# is therefore mostly the discipline of disposing properly and avoiding unnecessary allocation; the allocation-free pattern of C and C++ is occasionally needed but rarely the default.

The CLR memory model

The CLR maintains:

  • The managed heap — a single region (per process; per thread for the small-object heap on workstation GC) where reference-type instances are allocated.
  • The stack — per-thread, conventional; holds value-type locals, parameters, and references to heap objects.
  • The Large Object Heap (LOH) — a separate heap for objects larger than ≈85,000 bytes; collected less frequently to avoid moving large objects.

Allocation is fast: the GC maintains a bump pointer into the next-generation region, and allocation is typically a single increment. Deallocation is amortised across collection cycles; the program pays for it only when collection runs.

The garbage collector

The .NET GC is a generational tracing collector:

  • Generation 0 holds newly-allocated objects.
  • Generation 1 holds objects that survived a Gen 0 collection.
  • Generation 2 holds objects that survived a Gen 1 collection.

Most allocations are short-lived; most of the work is in Gen 0, which is collected most frequently and most cheaply. The generations are inspected progressively: a Gen 0 collection examines only Gen 0; a Gen 1 collection examines Gen 0 and Gen 1; a Gen 2 (or full) collection examines everything.

GC modes:

  • Workstation GC — the default for client applications; lower latency, higher CPU usage during collection.
  • Server GC — the default for server applications (<ServerGarbageCollection>true</ServerGarbageCollection>); multiple heaps (one per logical processor); higher throughput.
  • Background GC — a concurrent variant; the application continues to run while Gen 2 is being collected.

The conventional advice:

  • Most programs need not configure the GC explicitly.
  • Server applications benefit from server GC.
  • Allocation-heavy code reduces GC pressure by avoiding unnecessary allocations (tuples, anonymous types, strings) in hot paths.

IDisposable and the dispose pattern

The GC reclaims managed memory automatically; it does not automatically release unmanaged resources — file handles, socket descriptors, GDI handles, locks, native memory. Types that hold unmanaged resources implement IDisposable:

public class ResourceHolder : IDisposable {
    private bool   disposed;
    private IntPtr nativeHandle;

    public ResourceHolder() {
        nativeHandle = AllocateNativeResource();
    }

    public void Dispose() {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing) {
        if (disposed) return;

        if (disposing) {
            // dispose managed resources here
        }

        // release unmanaged resources here
        FreeNativeResource(nativeHandle);
        nativeHandle = IntPtr.Zero;

        disposed = true;
    }

    ~ResourceHolder() {
        Dispose(false);
    }
}

The pattern — dispose-with-bool, finalizer-as-fallback, GC.SuppressFinalize — is the full dispose pattern; types that hold only managed resources need the simpler:

public class SimpleHolder : IDisposable {
    private OtherDisposable inner = new OtherDisposable();

    public void Dispose() {
        inner.Dispose();
    }
}

The rule: a class with no unmanaged resources need not have a finalizer; a class with unmanaged resources needs the full pattern.

using statements

The using statement guarantees Dispose() is called at scope exit:

using (var stream = new FileStream(path, FileMode.Open)) {
    // use stream
}    // stream.Dispose() called here, including on exception

Equivalent to the explicit:

FileStream stream = new FileStream(path, FileMode.Open);
try {
    // use stream
} finally {
    stream.Dispose();
}

C# 8 introduced the using declaration, which calls Dispose at the end of the enclosing scope:

void Process(string path) {
    using var stream = new FileStream(path, FileMode.Open);
    // use stream
    // ...
}    // stream.Dispose() called here

The form reduces the indentation of the equivalent using statement and is the conventional choice in modern code.

Multiple resources may be acquired together:

using (var input  = new FileStream(input_path,  FileMode.Open))
using (var output = new FileStream(output_path, FileMode.Create)) {
    // input and output are disposed in reverse order
}

C# 7 admitted multiple variables of the same type in a single using:

using (FileStream input = new FileStream(in_path, FileMode.Open),
                  output = new FileStream(out_path, FileMode.Create)) {
    // ...
}

Finalizers and the trade-offs

A finalizer — declared with ~ClassName() — runs when the GC determines the object is unreachable, before its memory is reclaimed:

public class FileHolder {
    private IntPtr handle;

    ~FileHolder() {
        if (handle != IntPtr.Zero) NativeFree(handle);
    }
}

The trade-offs of finalizers:

  • Non-deterministic: the timing of finalizer execution is unspecified; it may run long after the object becomes unreachable, or never (for short-lived programs).
  • Single-threaded: all finalizers run on a single dedicated thread, so a slow finalizer blocks all others.
  • Two collection cycles: an object with a finalizer survives the first collection (so the finalizer can run); only on the next collection is the memory reclaimed.
  • Resource pressure: a class with a finalizer holds resources longer than necessary.

The conventional discipline:

  • Implement finalizers only for classes that own unmanaged resources.
  • Always pair a finalizer with IDisposable.Dispose and GC.SuppressFinalize(this).
  • Use SafeHandle (or one of its derivations) when possible — SafeHandle provides a tested finalizer and integrates with the runtime’s resource-tracking.
public class BetterHandle : IDisposable {
    private readonly SafeFileHandle handle;

    public BetterHandle(string path) {
        handle = NativeOpen(path);
    }

    public void Dispose() {
        handle.Dispose();
    }
}

SafeHandle derivatives are the conventional choice for new code; the manual finalizer pattern is needed only for legacy code and for direct interop with native APIs.

Span<T> and Memory<T>

C# 7.2 introduced Span<T> — a stack-only view over contiguous memory:

int[]    arr  = { 1, 2, 3, 4, 5 };
Span<int> span = arr;             // implicit conversion from array
Span<int> mid  = span.Slice(1, 3);    // {2, 3, 4}; no allocation

Span<byte> stack = stackalloc byte[256];

Span<T> is a ref struct: it lives only on the stack, cannot be a field of a non-ref type, cannot be captured by lambdas. The trade-off is the lifetime safety: a Span<T> over stack memory cannot escape the enclosing scope, and the compiler enforces this.

The principal uses:

  • View into part of an array without allocating a sub-array.
  • View into part of a string without allocating a substring.
  • View into stack-allocated memory.
  • Generic numeric or text processing without allocations.
ReadOnlySpan<char> input = "hello, world".AsSpan();
ReadOnlySpan<char> first = input.Slice(0, 5);     // "hello"; no allocation

int total = 0;
foreach (char c in input) {
    if (char.IsLetter(c)) total++;
}

Memory<T> is the heap-friendly counterpart to Span<T> — it can be a field of any type and can be passed to async methods (which Span<T> cannot, because of the ref-struct restriction). The trade-off: Memory<T> is one indirection slower than Span<T> (it stores a reference to the underlying memory plus an offset and length).

The conventional choice:

  • Span<T> for synchronous, performance-critical paths where the lifetime is known to fit on the stack.
  • Memory<T> for asynchronous code or when the view needs to be stored.

stackalloc

The stackalloc operator allocates on the stack:

Span<byte> buffer = stackalloc byte[256];

The allocation is fast (a stack-pointer adjustment), free at scope exit, and bounded by the stack size (typically 1 MiB per thread). The conventional use is for short-lived buffers in performance-critical paths:

public bool TryParse(ReadOnlySpan<char> input, out int result) {
    Span<char> normalised = stackalloc char[input.Length];
    int len = 0;
    foreach (char c in input) {
        if (c != ' ') normalised[len++] = c;
    }
    return int.TryParse(normalised.Slice(0, len), out result);
}

The construction was once unsafe (allocation produced an int*); since C# 7.2, stackalloc produces a Span<T> and is safe.

ref returns and ref locals

C# 7 admitted ref returns and ref locals — references that designate the same storage as the source:

public ref int Find(int[] arr, int target) {
    for (int i = 0; i < arr.Length; i++) {
        if (arr[i] == target) return ref arr[i];
    }
    throw new ArgumentException("not found");
}

int[] data = { 1, 2, 3, 4, 5 };
ref int found = ref Find(data, 3);
found = 99;
Console.WriteLine(data[2]);    // 99: modification through the ref

The ref int return type yields a reference; the ref int found = ref ... declares a ref local. The construction admits direct modification of array elements, dictionary values, and other indexed storage without copying, and is the foundation of methods like Dictionary<,>.GetValueRefOrAddDefault.

The construction is rare in user code; it is principally useful in performance-critical libraries.

Pinning, GCHandle, unsafe code

The GC may move objects on the heap during collection, which would invalidate any raw pointers into them. Code that needs a stable address — typically for native interop — uses pinning:

fixed statement

unsafe {
    byte[] buffer = new byte[1024];
    fixed (byte* p = buffer) {
        // p points at buffer[0]; the GC will not move buffer during this block
        NativeFunction(p, buffer.Length);
    }
}

The fixed statement requires unsafe context. It is the conventional way to pass a managed array to a P/Invoke call.

GCHandle.Alloc(obj, GCHandleType.Pinned)

For longer-lived pinning (across method boundaries):

GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try {
    IntPtr ptr = handle.AddrOfPinnedObject();
    NativeRegister(ptr);
    // the buffer is pinned until handle.Free()
} finally {
    handle.Free();
}

Pinned objects fragment the heap (the GC cannot compact around them); pinning should be brief and rare.

Marshal for native interop

The System.Runtime.InteropServices.Marshal class provides primitives for native interop: Marshal.AllocHGlobal for native heap allocation, Marshal.PtrToStructure for native-to-managed conversion, Marshal.Copy for byte-level transfer.

The unsafe and Marshal-based interfaces are rare in modern application code; they appear in libraries that wrap native APIs and in performance-critical paths.

DefectDescription
Allocating in a hot loopvar s = string.Format(…) or += in a loop allocates per iteration. Use StringBuilder or Span<char> formatting.
Boxing in collectionsList<object> or ArrayList boxes value types. Use List<T> with the concrete type.
Closures capturing localsA lambda that captures a local creates a closure object per declaration site; the local is moved to the heap. Hot paths should avoid closures or use static lambdas (C# 9).
Async allocationsEach await typically allocates a state machine. Use ValueTask<T> for hot async paths that often complete synchronously.
Implicit IEnumerable<T> materialisation.ToList() or .ToArray() allocates; foreach over an IEnumerable<T> does not.
Forgetting using on disposableStreams, sockets, file handles, locks — all leak resources without using.

The combination of using, IDisposable, generic collections, and the Span<T>/Memory<T> types covers most of the practical memory and resource concerns in C#. The cases that remain — fine-grained allocation control, native interop, lock-free data structures — are reduced by careful API design and by libraries that handle the low-level details.