📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are pointers and references in .NET? How do object references, ref/in/out parameters, ref locals, Span<T> and unsafe pointers differ?

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.

Mid-Level Theory Advanced memory pointers ref-locals references span unsafe

🎯 Key points

  • A pointer is a raw memory address; an object reference is a GC-tracked pointer to the start of an object that the runtime updates when compaction moves the object
  • Passing a reference type copies the reference (reassigning the parameter does not affect the caller); ref, in and out pass a managed pointer to the caller's variable itself
  • Managed pointers (ref locals, ref returns, ref fields) can point inside objects, arrays or stack frames and are tracked by the GC; ref fields are only allowed inside ref struct types, and the compiler's escape rules (scoped) stop a ref outliving its target
  • Span<T> is a ref struct holding a managed pointer plus a length: a bounds-checked view over arrays, stackalloc or native memory, but it cannot be a class field, be boxed, or cross an await
  • Memory<T> is the heap-storable counterpart (object plus offset and length) for async code and fields; call .Span at the point of use
  • Unsafe pointers (int*) need AllowUnsafeBlocks and fixed or pinning so the GC does not move the target; they bring back buffer overruns, use-after-free and data races with no runtime checks
  • Pinning blocks compaction; keep fixed blocks short and put long-lived pinned buffers on the pinned object heap (GC.AllocateArray(..., pinned: true)) or in native memory
  • Prefer references and spans; use raw pointers only for interop or measured hot paths, isolated and tested

⏱ 30-second answer

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.

🗣 Full answer (senior)

The spectrum

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

Object references

  • A variable of a class type holds a reference (8 bytes on 64-bit). Assignment copies the reference, not the object: two variables, one object.
  • The GC knows every location that holds a reference (stack slots, registers, fields, statics) and rewrites them when compacting.
  • null is a reference to nothing; nullable reference types (string?) are a compile-time annotation on top of this.

Managed pointers: ref, in, out

  • 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 locals and ref returns (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 fields (C# 11) are allowed only in ref struct types; that's how Span<T> is implemented.
  • The compiler's escape analysis (with scoped) prevents a managed pointer from outliving what it points to.

Unsafe pointers

  • Need <AllowUnsafeBlocks>true</AllowUnsafeBlocks> and an unsafe block or method.
  • Pointing at managed memory requires pinning: 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+).
  • Native memory (NativeMemory.Alloc, interop buffers) doesn't move, so it needs no pinning, but you must free it.
  • Pointer arithmetic, no bounds checks, and use-after-free bugs are all possible again.

When to use what

  • Business code: object references, records, collections.
  • Avoiding struct copies or updating in place: in, ref locals, CollectionsMarshal.AsSpan(list).
  • Slicing and parsing buffers without allocations: Span<T>, ReadOnlySpan<T>, Memory<T> across await.
  • Interop with native APIs: LibraryImport with spans or pointers, SafeHandle for handles.
  • Last-resort hot loops: unsafe, Unsafe.Add, MemoryMarshal.GetReference, only with benchmarks and tests.

Production example

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%.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. What the GC sees

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.

2. Why managed pointers can't go on the heap

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.

3. Spans over three kinds of memory

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.

4. Pinning and the GC

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.

5. Useful safe-but-low-level APIs

  • 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.

6. Security and reliability

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.

7. Try it yourself

  1. Write 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.
  2. Return a ref to the largest element of an array and modify it through the returned ref.
  3. Write the same checksum over a byte[] using Span<byte>, unsafe pointers with fixed, and MemoryMarshal.GetReference + Unsafe.Add, and benchmark all three.
  4. Try to store a Span<byte> in a class field and use it after an await; read the compiler errors.

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains why Span<T> can't be stored on the heap. Why: Checks the stack and heap model behind ref struct restrictions.
    How to answer

    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.

  2. When: Candidate mentions MemoryMarshal.Cast or interop structs. Why: Checks layout knowledge needed when reinterpreting memory or doing interop.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Binary telemetry parser rewritten with spans to remove 1 GB/min of allocations
Situation
An IoT telemetry ingester in Azure parsed about 30,000 binary frames per second from Event Hubs. Under peak load, CPU sat at 85% and gen 0 GCs ran many times per second.
Signals
dotnet-counters showed an allocation rate around 1 GB/min and a high `% Time in GC`; a trace showed most allocations from `Skip`/`Take`/`ToArray` slicing and `BitConverter` calls in the parser.
Investigation
Captured an allocation profile with dotnet-trace and PerfView, confirmed the parser was the source, and wrote BenchmarkDotNet benchmarks against recorded production frames.
Resolution
Rewrote the parser over `ReadOnlySpan<byte>` slices with `BinaryPrimitives` and `Utf8Parser`, with no `unsafe` code. Allocations per frame dropped to near zero and CPU per frame fell about 60%.
Prevention
Kept golden-frame tests and the benchmark in CI with an allocation budget assertion, and added an allocation-rate panel and alert to the service dashboard.

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.

↩ Usually asked after

More in Memory

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me