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

📚 Interview question

What is the Pinned Object Heap (POH), and when should you allocate pinned arrays instead of using fixed or GCHandle?

Pinning, why it fragments the heap, the POH (.NET 5), GC.AllocateArray with pinned: true, its restrictions and typical uses such as I/O buffer pools.

Senior Theory Expert gc interop memory pinning poh

🎯 Key points

  • Pinning prevents the GC from moving an object so native code can use its address
  • Long-lived pins in young generations cause fragmentation
  • The POH (.NET 5+) holds objects that never move and is collected with Gen 2
  • Allocate with GC.AllocateArray or AllocateUninitializedArray and pinned: true
  • Element types must not contain references
  • Use the POH for long-lived, pooled I/O buffers; use fixed for short pins

⏱ 30-second answer

Pinning stops the GC from moving an object so native code can hold its address. Pinning objects in Gen 0 or Gen 1 blocks compaction around them and causes fragmentation, especially when pins are long-lived (such as buffers handed to the OS for async I/O). The Pinned Object Heap (since .NET 5) is a separate area, collected with Gen 2, whose objects never move. You allocate there with GC.AllocateArray<T>(length, pinned: true) or GC.AllocateUninitializedArray<T>(length, pinned: true), for element types without references. Use it for long-lived buffers used with native code or I/O, typically inside a pool; keep fixed for short pins.

🗣 Full answer (senior)

How I would answer this in an interview

"Pinning tells the GC it must not move an object, because native code is holding its address. Short pins are almost free, but long-lived pins in Gen 0 and Gen 1 fragment the young heap: the GC has to compact around immovable objects. Since .NET 5 there's the Pinned Object Heap, a separate heap collected with Gen 2 whose objects never move. You allocate there with GC.AllocateArray<T>(length, pinned: true) (or the uninitialised variant), only for element types with no references. My rule: fixed for pins within one call, POH for long-lived, reused buffers that native code or the OS holds onto, usually hidden inside a pool."


1. Why pinning exists

The GC is a moving collector: compaction changes object addresses and updates every managed reference. Native code (the OS during async I/O, a C library via P/Invoke) holds raw addresses the GC can't update. Pinning guarantees the address stays valid.

2. Ways to pin

Mechanism Lifetime of the pin Cost Best for
fixed statement Inside the block Near zero: recorded as a pinned local in GC info, no handle One P/Invoke call, short unsafe loops
Implicit marshalling pins Duration of a P/Invoke call Near zero Passing byte[] / Span<T> to blittable native APIs
Memory<T>.Pin() → MemoryHandle Until disposed Pinned GC handle Async APIs that need a pointer for a while
GCHandle.Alloc(obj, GCHandleType.Pinned) Until Free() Handle + fragmentation if long-lived Legacy interop, callbacks
POH allocation Object lifetime No handle; never moves; collected with Gen 2 Long-lived buffers used by native code/I/O

3. Why long pins hurt

Gen 0 region during a GC (P = pinned, a/b = live, x = garbage)
[a][x][P][x][x][b][x][P][x][x][x]

After compaction around pins:
[a][b][P][ free gap ][P][ free  ]
         ^ cannot slide past P; gaps remain, and P may be kept in Gen 0 (demotion)
  • The GC can't slide survivors past a pinned object, so free space becomes fragmented.
  • Thousands of long-lived pins (one per socket, for example) can leave the ephemeral area full of holes, shrinking usable allocation contexts and raising memory use.
  • Pins in Gen 0 can lead to demotion (keeping survivors in younger generations) and more frequent GCs.

4. The Pinned Object Heap (.NET 5+)

Property Detail
What can live there Arrays only, via GC.AllocateArray<T>(n, pinned: true) or GC.AllocateUninitializedArray<T>(n, pinned: true)
Element type rule Must not contain references (byte, int, unmanaged structs); otherwise ArgumentException
Movement Never moved, never compacted
Collection Only during Gen 2 GCs
Size threshold None: small arrays can go there too
Observability GC.GetGCMemoryInfo().GenerationInfo[4], POH size counter in dotnet-counters

5. When to use which

Situation Choice
One native call with a buffer fixed or pass a Span<T>
Short-lived buffer for one request ArrayPool<T>; pin briefly if needed
Thousands of long-lived socket / file buffers Pooled POH slabs, sliced into Memory<byte>
Buffer shared with native code for the app's lifetime POH array or NativeMemory.Alloc
Type containing references Not POH; redesign or use GCHandle sparingly
Short-lived allocations Not POH: they'd wait for a Gen 2 GC to be reclaimed

6. Real-world use

ASP.NET Core's Kestrel uses a pinned block memory pool: it allocates blocks on the POH once and reuses them for socket I/O, so request processing never pins fresh arrays. Most application code never needs to pin anything directly.

Production example

Our [custom TCP gateway] handled [about 20,000] concurrent connections, and each connection pinned its receive buffer with GCHandle.Alloc(..., Pinned) for the connection's lifetime. Memory kept growing and GC.GetGCMemoryInfo() showed high fragmentation in Gen 0/1; SOS gchandles listed [about 20,000] pinned handles. I replaced the per-connection buffers with a pool that allocates [a few] large slabs on the POH with GC.AllocateUninitializedArray<byte>(..., pinned: true) and hands out Memory<byte> slices. Pinned handles went to near zero, ephemeral fragmentation disappeared, and working set dropped by [X%].

🔍 What the interviewer is checking

The interviewer checks deep GC knowledge relevant to high-performance I/O.

Green flags: why pinning fragments, POH since .NET 5, API and restrictions, long-lived pooled slabs, fixed for short pins, diagnostics.

Red flags: pins everything; allocates short-lived buffers on the POH; does not know the reference restriction.

📚 Learn in depth

1. How fixed is implemented

unsafe static int Sum(byte[] data)
{
    fixed (byte* p = data)
    {
        int sum = 0;
        for (int i = 0; i < data.Length; i++) sum += p[i];
        return sum;
    }
}

The compiler emits a pinned local (.locals init (uint8& pinned V_0) in IL). The JIT reports it in GC info as pinned. If a GC happens while the method is inside the block, the GC sees the pinned local and doesn't move the array. No handle table entry, no allocation: that's why fixed is cheap. The danger is only duration: a fixed block that loops for seconds becomes a long pin.

2. GC handles

GCHandle.Alloc(obj, GCHandleType.Pinned) creates an entry in the runtime's handle table. The GC scans handle tables as roots each collection. Pinned handles are roots and immovable. Leaking them (never calling Free) is both a memory leak and a permanent fragmentation source.

Handle types for comparison:

Type Root? Prevents move?
Normal Yes No
Pinned Yes Yes
Weak / WeakTrackResurrection No No

3. Async I/O and pinning

For overlapped I/O on Windows, the OS writes into your buffer after the call returns, so the buffer must stay pinned until completion. With per-operation pins on fresh arrays, every in-flight receive is a pin in Gen 0. A server with many idle connections waiting on reads holds many long pins. Pre-pinned POH buffers make this a non-issue. (On Linux, the socket implementation reads when data is ready, which pins for shorter periods, but pooled pinned buffers still avoid repeated pin/unpin work.)

4. A minimal slab pool on the POH

public sealed class PinnedSlabPool
{
    private readonly byte[] _slab;
    private readonly int _blockSize;
    private readonly ConcurrentBag<int> _free = new();

    public PinnedSlabPool(int blockSize, int blockCount)
    {
        _blockSize = blockSize;
        _slab = GC.AllocateUninitializedArray<byte>(blockSize * blockCount, pinned: true);
        for (int i = 0; i < blockCount; i++) _free.Add(i);
    }

    public bool TryRent(out int block, out Memory<byte> memory)
    {
        if (_free.TryTake(out block))
        {
            memory = _slab.AsMemory(block * _blockSize, _blockSize);
            return true;
        }
        memory = default;
        return false;
    }

    public void Return(int block) => _free.Add(block);
}

In real code you would wrap this as a MemoryPool<byte> returning IMemoryOwner<byte>, so ownership and disposal are explicit (see the MemoryPool<T> question).

5. The "no references" rule

GC.AllocateArray<T>(n, pinned: true) throws ArgumentException if T contains references (RuntimeHelpers.IsReferenceOrContainsReferences<T>() is true). The practical reasons it is a good constraint:

  • POH buffers exist to be handed to native code or the OS, which treats them as raw bytes. A GC-tracked reference inside them could be overwritten by native code.
  • A reference-free object never needs its contents scanned or updated by the GC, which keeps a never-compacted heap cheap to manage.

For exact runtime design notes, check the .NET runtime GC documentation; for interviews, knowing the rule and its practical consequence is what matters.

6. POH vs LOH vs native memory

POH LOH NativeMemory.Alloc
Managed by GC Yes (Gen 2) Yes (Gen 2) No: you must free it
Moves Never Rarely (compaction on request) Never
Size threshold None >= 85,000 bytes None
References allowed No Yes N/A
Typical use Pinned I/O buffers Big arrays/strings Interop, very large or custom-managed buffers

7. Diagnosing pinning

  • dotnet-counters: POH size, fragmentation.
  • dotnet-dump analyze → gchandles -stat (pinned handle counts), gcwhere <addr>.
  • GC trace events include pinned object information per collection with verbose GC events.
  • GC.GetGCMemoryInfo().PinnedObjectsCount gives the number of pinned objects seen in the last GC.

8. Version notes

Version Change
.NET 5 POH, GC.AllocateArray/AllocateUninitializedArray with pinned
.NET 5 Kestrel moves to a pinned block memory pool
.NET 6 NativeMemory APIs
.NET 7 Regions; pinned objects confined to regions reduce cross-heap impact
.NET 8–10 POH model unchanged; still the recommended home for long-lived pinned buffers

9. Try it yourself

  1. Run the code example: confirm the slab's address is unchanged after GC.Collect() and GetGeneration reports 2.
  2. Try GC.AllocateArray<string>(10, pinned: true) and observe the ArgumentException.
  3. Allocate 10,000 small arrays and pin each with a GCHandle for 30 seconds while allocating garbage; watch PinnedObjectsCount and FragmentedBytes from GC.GetGCMemoryInfo().
  4. Repeat with one POH slab sliced 10,000 ways and compare.
  5. Take a dump of the first version and run gchandles -stat in dotnet-dump analyze.
  6. Write a P/Invoke to a C function taking byte* and call it with fixed vs a pinned POH buffer; benchmark both.

💻 Code

using System.Runtime.CompilerServices;

// One long-lived pinned slab, sliced into fixed-size I/O buffers
const int BufferSize = 16 * 1024;
const int Count = 64;
byte[] slab = GC.AllocateUninitializedArray<byte>(BufferSize * Count, pinned: true);

Memory<byte> Buffer(int index) => slab.AsMemory(index * BufferSize, BufferSize);

nint before = AddressOf(slab);
GC.Collect(); // demo only
nint after = AddressOf(slab);

Console.WriteLine($"Generation: {GC.GetGeneration(slab)}");               // 2 (POH is collected with Gen 2)
Console.WriteLine($"Address stable across GC: {before == after}");      // True: POH objects never move

// Safe to hand the address to native code for as long as the slab lives
Memory<byte> first = Buffer(0);
first.Span.Fill(0x2A);
Console.WriteLine($"First buffer starts with {first.Span[0]}");

GCGenerationInfo poh = GC.GetGCMemoryInfo().GenerationInfo[4];
Console.WriteLine($"POH size after last GC: {poh.SizeAfterBytes:N0} bytes");

static unsafe nint AddressOf(byte[] array) => (nint)Unsafe.AsPointer(ref array[0]);

Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> for the address check.

⚠ Common mistakes

1. Long-lived GCHandle pins in young generations

What happens: each connection or job pins a fresh buffer for minutes. Why it is wrong: immovable objects fragment Gen 0/1 and raise memory. Do instead: allocate long-lived pinned buffers on the POH, ideally as pooled slabs.

2. Allocating short-lived buffers on the POH

What happens: GC.AllocateArray<byte>(size, pinned: true) per request. Why it is wrong: POH objects are only reclaimed by Gen 2 GCs, so garbage piles up and full GCs increase. Do instead: POH only for long-lived, reused buffers; ArrayPool for per-request buffers.

3. Trying to pin reference-containing types

What happens: GC.AllocateArray<MyClass>(n, pinned: true). Why it is wrong: pinned allocations require reference-free element types; it throws. Do instead: store primitives or unmanaged structs; keep references elsewhere.

4. Forgetting GCHandle.Free()

What happens: a handle is allocated in a callback path and never freed. Why it is wrong: the object can never be collected and stays pinned forever. Do instead: try/finally around handle use, or wrap it in a disposable owner.

5. Awaiting inside a pin

What happens: a MemoryHandle from Pin() is held across long awaits. Why it is wrong: the pin lives as long as the slowest I/O. Do instead: use pre-pinned pooled buffers for long async operations.

6. Pinning when no pin is needed

What happens: code pins arrays before passing Span<T> to managed APIs or blittable P/Invoke. Why it is wrong: spans need no pinning for managed code, and marshalling already pins blittable arguments for the call. Do instead: pin only when native code keeps the pointer after the call returns.

7. Using the POH to "avoid the LOH"

What happens: large arrays allocated pinned to dodge LOH issues. Why it is wrong: the POH is also Gen 2 and is never compacted; churn there is worse. Do instead: fix churn with pooling or streaming.

8. Many tiny pinned allocations

What happens: thousands of 4 KB POH arrays. Why it is wrong: per-object overhead and scattered free space in the POH. Do instead: allocate a few large slabs and slice them.

9. Using a pooled pinned buffer after returning it

What happens: a slice is still referenced (or still used by native code) after Return. Why it is wrong: another operation now writes into the same memory, corrupting data. Do instead: explicit ownership (IMemoryOwner<T>), return only after native completion.

10. Not measuring before adding pinning code

What happens: a custom pinned pool is added "for performance". Why it is wrong: most apps have no pinning problem; complexity and bugs increase. Do instead: check PinnedObjectsCount, gchandles and fragmentation first.

🏛 Senior insight: trade-offs

The POH matters mostly to authors of networking, I/O and interop libraries.

Workload Pinning strategy
Occasional P/Invoke with a span fixed, short scope
Server with thousands of sockets Pooled POH slabs
Long-lived native buffer sharing POH array or native memory (NativeMemory.Alloc)
Application code Rarely needs pinning at all

Seniors check gchandles and fragmentation before adding pinned allocations, and prefer pools that hide pinning from application code.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate pools pinned slabs as Memory Why: Checks MemoryPool and ownership
    How to answer

    MemoryPool<T> rents buffers as IMemoryOwner<T>: an IDisposable object whose .Memory property is the buffer and whose Dispose() returns it. That makes ownership explicit: whoever holds the owner must dispose it, and you can transfer it (through a channel, a queue or a method) so the receiver disposes. MemoryPool<T>.Shared is backed by ArrayPool<T>.Shared. The memory may be larger than requested, so slice to the length you use. Use it over ArrayPool<T> when buffers cross components or async boundaries or when you want to swap the memory source. System.IO.Pipelines gets segments from a MemoryPool<byte>; Kestrel uses its own pinned block pool for sockets. A custom MemoryManager<T> can expose native or pinned memory as Memory<T>.

  2. When: Candidate compares POH with LOH Why: Checks LOH behaviour
    How to answer

    Objects of 85,000 bytes or more (in practice large arrays and strings) are allocated on the LOH. It is collected only during Gen 2 GCs and, by default, is swept, not compacted, so freed space becomes free-list gaps. Two problems follow: churn (frequent large temporary allocations exceed the LOH budget and trigger Gen 2 GCs) and fragmentation (free gaps too small for new large objects, so the heap grows). Fix with `ArrayPool<T>`, RecyclableMemoryStream, streaming instead of buffering, chunked processing, and sizing buffers to stay under the threshold. One-off compaction is available through GCSettings.LargeObjectHeapCompactionMode.

  3. When: Candidate mentions pointers and fixed Why: Probes pointers, references and Span
    How to 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.

🛠 Real-world problems: how you handle them

Socket gateway fragmentation fixed with POH slabs
Situation
A custom TCP gateway handled [about 20,000] concurrent connections.
Signals
High fragmentation in Gen 0 and Gen 1, growing memory, many pinned handles in `gchandles`.
Investigation
Found each connection's receive buffer was pinned with `GCHandle` for the connection's lifetime.
Resolution
Allocated [a few] large slabs on the POH and sliced them per connection as `` `Memory<byte>` ``.
Prevention
Added POH size and pinned handle count to diagnostics dashboards.

How to tell it (STAR)

Our [TCP gateway] pinned a buffer per connection, which fragmented the young heap. I moved buffers to pooled POH slabs, which cut memory by [X%] and removed thousands of pinned handles.

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me