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.DisposeandGC.SuppressFinalize(this). - Use
SafeHandle(or one of its derivations) when possible —SafeHandleprovides 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.
Common allocation-related defects
| Defect | Description |
|---|---|
| Allocating in a hot loop | var s = string.Format(…) or += in a loop allocates per iteration. Use StringBuilder or Span<char> formatting. |
| Boxing in collections | List<object> or ArrayList boxes value types. Use List<T> with the concrete type. |
| Closures capturing locals | A 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 allocations | Each 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 disposable | Streams, 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.