The spectrum from safe object references to managed pointers (ref, in, out, ref locals and ref returns, ref fields in ref structs) to unmanaged pointers in unsafe code, how the GC tracks and moves them, pinning, and when each is appropriate.
A pointer is a raw memory address. In .NET, an object reference is a pointer the GC tracks: when the GC compacts the heap and moves an object, it updates every reference, so you never see addresses. ref, in and out pass a managed pointer to a variable instead of copying its value, and ref locals, ref returns and ref fields let you hold one; managed pointers can point inside objects, arrays or stack frames, and the GC tracks those too. Span<T> is a ref struct holding a managed pointer plus a length, giving safe views over arrays, stackalloc memory or native memory. Unsafe pointers (int*) are untracked addresses: they need an unsafe context and fixed or pinning, because the GC might otherwise move the object underneath them.
| Kind | Example | Tracked by GC? | Can point inside an object? | Can live on the heap? | Safety |
|---|---|---|---|---|---|
| Object reference | Customer c |
Yes, updated on moves | No (points to object start) | Yes | Safe |
| Managed pointer | ref int x, in, out, ref locals, ref returns |
Yes | Yes (array element, field, stack local) | No (only on the stack or in ref struct fields) |
Safe, compiler-verified lifetimes |
Span<T> / ReadOnlySpan<T> |
Span<byte> s = buffer; |
Yes (contains a managed pointer) | Yes | No (ref struct) |
Safe, bounds-checked |
Memory<T> |
Memory<byte> m = buffer; |
Yes (holds an object plus offset) | Via the object | Yes | Safe; use across await |
| Unmanaged pointer | int* p |
No | Yes | Yes (as a number) | unsafe; you guarantee validity |
nint / IntPtr holding an address |
nint handle |
No | n/a | Yes | Opaque handle or address |
null is a reference to nothing; nullable reference types (string?) are a compile-time annotation on top of this.ref passes the variable itself: the callee can read and write the caller's storage.in (or ref readonly) passes by reference without allowing writes, which avoids copying large structs.out requires the callee to assign it.ref var item = ref array[i];, ref T Find(...)) let you work on storage in place, for example updating a struct inside an array without copying it.ref struct types; that's how Span<T> is implemented.scoped) prevents a managed pointer from outliving what it points to.<AllowUnsafeBlocks>true</AllowUnsafeBlocks> and an unsafe block or method.fixed (byte* p = array) { ... } stops the GC moving the array for the duration of the block. GCHandle.Alloc(obj, GCHandleType.Pinned) pins longer, and GC.AllocateArray<T>(n, pinned: true) allocates on the pinned object heap (.NET 5+).NativeMemory.Alloc, interop buffers) doesn't move, so it needs no pinning, but you must free it.in, ref locals, CollectionsMarshal.AsSpan(list).Span<T>, ReadOnlySpan<T>, Memory<T> across await.LibraryImport with spans or pointers, SafeHandle for handles.unsafe, Unsafe.Add, MemoryMarshal.GetReference, only with benchmarks and tests.A telemetry ingester parsed binary frames by copying slices into new arrays (buffer.Skip(n).Take(m).ToArray()), allocating about 1 GB per minute. Rewriting the parser over ReadOnlySpan<byte> slices, with BinaryPrimitives for numbers, removed nearly all allocations without any unsafe code, and CPU per frame dropped by about 60%.
Checks depth on memory semantics and modern C# performance features.
Green flags: explains GC-tracked references versus raw pointers, ref/in/out, ref locals and returns, Span<T> as a ref struct, pinning and its costs, and prefers safe constructs.
Red flags: "C# has no pointers"; confuses reference types with ref parameters; uses unsafe casually; unaware the GC moves objects.
stack frame heap
+----------------------+
| c (object ref) ------+-------> [header][MethodTable*][Name ref][Age int]
| r (ref int) --------+----------------------------------------^ (interior pointer
| p (int*) 0x7FF8... | untracked: if the object moves, p now into the same object)
+----------------------+ points at garbage
The GC reports and updates c and r. p is just a number; that's why it needs fixed.
A ref might point to a stack local. If it were stored in a heap object, it could outlive the stack frame and point at reused memory. So the runtime restricts managed pointers to the stack and to ref struct fields, and the compiler checks that a ref never escapes the scope of what it refers to. That's also why Span<T> can't be a class field or cross an await.
Span<byte> a = new byte[64]; // heap array
Span<byte> b = stackalloc byte[64]; // stack
unsafe { Span<byte> c = new((void*)NativeMemory.Alloc(64), 64); } // native (free it later)
All three expose the same API with bounds checks. Code written against Span<byte> doesn't care where the bytes live.
| Technique | Duration | GC impact |
|---|---|---|
fixed statement |
Block scope | Minimal if short; the object is pinned only while a GC happens inside the block |
GCHandle pinned |
Until freed | Can fragment gen 0/1 if long-lived |
Pinned object heap (GC.AllocateArray(..., pinned: true)) |
Object lifetime | No fragmentation of other generations; good for long-lived I/O buffers |
| Native memory | Until freed | None, but you manage lifetime |
Long-lived pinned objects in gen 0 stop the GC compacting around them, which can grow the heap. Sockets and file I/O in .NET use the pinned object heap or native memory internally for this reason.
CollectionsMarshal.AsSpan(list): a span over a List<T>'s backing array (don't add or remove items while using it).CollectionsMarshal.GetValueRefOrAddDefault(dictionary, key, out exists): update a dictionary value in place with one lookup.MemoryMarshal.Cast<TFrom, TTo>: reinterpret a span of bytes as a span of structs (watch alignment and endianness).Unsafe.As, Unsafe.Add: no bounds checks; reserve for benchmarked inner loops.unsafe code reintroduces buffer overruns and use-after-free bugs, the classic sources of memory-corruption vulnerabilities. Keep it isolated, reviewed, fuzzed or property-tested, and prefer spans, which are bounds-checked and usually just as fast because the JIT removes provably redundant checks.
void Increment(ref int value) and call it on an array element and a local. Then do the same with a struct inside a List<T> using CollectionsMarshal.AsSpan.ref to the largest element of an array and modify it through the returned ref.byte[] using Span<byte>, unsafe pointers with fixed, and MemoryMarshal.GetReference + Unsafe.Add, and benchmark all three.Span<byte> in a class field and use it after an await; read the compiler errors.namespace PointersAndRefs;
using System.Runtime.InteropServices;
public record struct Counter(string Name, int Count);
public static class Program
{
public static void Main()
{
// Object reference: two variables, one object.
var a = new List<int> { 1 };
var b = a;
b.Add(2);
Console.WriteLine(a.Count); // 2
// ref to an array element: update in place.
int[] scores = [10, 50, 30];
ref int max = ref LargestElement(scores);
max = 99;
Console.WriteLine(string.Join(",", scores)); // 10,99,30
// Update structs inside a List<T> without copying them out and back.
var counters = new List<Counter> { new("a", 0), new("b", 0) };
foreach (ref var counter in CollectionsMarshal.AsSpan(counters))
{
counter.Count++;
}
Console.WriteLine(counters[0].Count); // 1
// One dictionary lookup to add-or-update.
var totals = new Dictionary<string, int>();
ref int total = ref CollectionsMarshal.GetValueRefOrAddDefault(totals, "au", out _);
total += 5;
Console.WriteLine(totals["au"]); // 5
Console.WriteLine(Checksum(scores.AsSpan()));
Console.WriteLine(ChecksumUnsafe(scores));
}
private static ref int LargestElement(int[] values)
{
int index = 0;
for (var i = 1; i < values.Length; i++)
{
if (values[i] > values[index]) index = i;
}
return ref values[index];
}
private static long Checksum(ReadOnlySpan<int> values)
{
long sum = 0;
foreach (var v in values) sum += v;
return sum;
}
// Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
private static unsafe long ChecksumUnsafe(int[] values)
{
long sum = 0;
fixed (int* start = values) // pin while we hold a raw pointer
{
for (int* p = start, end = start + values.Length; p < end; p++)
{
sum += *p;
}
}
return sum;
}
}
Mistake: saying C# has no pointers.
Every object reference is a GC-tracked pointer, ref is a managed pointer, and unsafe code has raw pointers.
Instead: describe the spectrum and explain that the GC tracking is what makes references safe.
Mistake: confusing passing a reference with passing by reference.
Passing a class instance copies the reference, so reassigning the parameter doesn't affect the caller; only ref does that.
Instead: distinguish "reference type passed by value" from "any type passed by ref".
Mistake: holding a raw pointer to managed memory without pinning.
The GC can move the object at any allocation, leaving the pointer aimed at other data and corrupting memory.
Instead: use fixed, a pinned GCHandle or the pinned object heap, and keep the pinned window short.
Mistake: pinning long-lived buffers in gen 0.
Pinned objects stop compaction around them, fragmenting the heap and increasing memory use.
Instead: allocate long-lived pinned buffers with GC.AllocateArray(..., pinned: true) or use native memory.
Mistake: reaching for unsafe for performance by default.
It removes safety checks and often gains nothing, because spans with simple loops already let the JIT drop bounds checks.
Instead: start with Span<T>, benchmark, and use unsafe only for proven hot spots with thorough tests.
Mistake: modifying a List<T> while holding CollectionsMarshal.AsSpan.
Adding items can reallocate the backing array, so the span silently points at the old array.
Instead: don't change the list's size while the span is in use.
The senior view is that .NET gives you pointer-level performance without pointer-level risk, if you use the right tool.
| Tool | Use it for | Cost / risk |
|---|---|---|
| Object reference | Normal business code | None beyond GC |
ref / in / out, ref locals |
In-place updates, avoiding large struct copies | Aliasing surprises if overused |
Span<T> / ReadOnlySpan<T> |
Allocation-free parsing and slicing in synchronous code | Stack-only; cannot cross await |
Memory<T> / ReadOnlyMemory<T> |
Buffers in async pipelines, fields, PipeReader/Stream APIs |
Slightly slower to access; owner must manage lifetime (IMemoryOwner<T>) |
CollectionsMarshal, MemoryMarshal |
One-lookup dictionary updates, spans over List<T> |
Invalid if the collection is resized while in use |
unsafe pointers, Unsafe.* |
Interop and proven hot loops | No bounds checks; memory corruption and security bugs possible |
Concurrency: none of these make shared data thread-safe. Two threads writing through spans or raw pointers to the same buffer race exactly as with arrays, and a raw pointer into memory another thread frees is a use-after-free. Use ownership rules (one writer), Interlocked/locks, or immutable buffers.
Seniors also understand the GC's side of the deal: it moves objects, which is why pinning exists and costs compaction, and why the ref struct rules cannot be bypassed. Being able to explain those rules is what makes a team trust span-heavy or interop code in review.
The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
CPUs read memory most efficiently when a value sits at an address that is a multiple of its size, which is called natural alignment: a 4-byte int at a multiple of 4, an 8-byte long at a multiple of 8. Compilers and the CLR add padding between fields to keep that alignment, so struct { byte; long; byte; } takes 24 bytes, not 10. Misaligned access can be slower, can span two cache lines, and isn't guaranteed atomic. In .NET, classes use auto layout (the runtime reorders fields to minimise padding), structs default to sequential layout (but get auto layout if they contain references), and every object carries a 16-byte overhead on 64-bit. I use StructLayout, FieldOffset and Pack when matching a native or binary layout, and otherwise let the runtime decide.
How to tell it (STAR)
**Situation:** At [company], our telemetry ingester was running at [85%] CPU at peak and spending a lot of time in GC. **Task:** I had to increase headroom without scaling out. **Action:** Allocation profiling showed the binary parser creating new arrays for every slice. I rewrote it over `ReadOnlySpan<byte>` with `BinaryPrimitives`, kept it fully safe code, and verified it with golden frames recorded from production and a BenchmarkDotNet suite. **Result:** Allocations dropped by about [1 GB/min], CPU per frame fell [60%], and we avoided adding [N] instances.
Last updated 23 Oct 2026.