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.
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.
"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:fixedfor pins within one call, POH for long-lived, reused buffers that native code or the OS holds onto, usually hidden inside a pool."
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.
| 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 |
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)
| 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 |
| 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 |
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.
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%].
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.
fixed is implementedunsafe 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.
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 |
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.)
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).
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:
For exact runtime design notes, check the .NET runtime GC documentation; for interviews, knowing the rule and its practical consequence is what matters.
| 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 |
dotnet-counters: POH size, fragmentation.dotnet-dump analyze → gchandles -stat (pinned handle counts), gcwhere <addr>.GC.GetGCMemoryInfo().PinnedObjectsCount gives the number of pinned objects seen in the last GC.| 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 |
GC.Collect() and GetGeneration reports 2.GC.AllocateArray<string>(10, pinned: true) and observe the ArgumentException.GCHandle for 30 seconds while allocating garbage; watch PinnedObjectsCount and FragmentedBytes from GC.GetGCMemoryInfo().gchandles -stat in dotnet-dump analyze.byte* and call it with fixed vs a pinned POH buffer; benchmark both.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.
GCHandle pins in young generationsWhat 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>.
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.
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.
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.
Last updated 31 Oct 2026.