In the Understanding Memory in .NET article, we explored how memory management works in .NET and learned that objects allocated on the managed heap eventually have to be processed by the garbage collector. For most applications, garbage collection is barely noticeable. In high-throughput systems, however, millions of short-lived objects can trigger more frequent collections, increase latency, and reduce throughput [1].
Performance-sensitive .NET code therefore aims to avoid unnecessary allocations and reduce the amount of work left for the garbage collector.
The techniques discussed in this article follow three general principles:
- keep small temporary buffers off the managed heap;
- avoid creating objects unnecessarily;
- reuse objects that have already been created.
The right approach depends on the problem: whether new storage is actually required, how long the data must remain valid, and whether an existing object can be reused.
Let us see how these principles are applied in .NET 10 and ASP.NET Core 10.
Keep small temporary buffers off the managed heap
A temporary buffer can be allocated as an ordinary array:
byte[] buffer = new byte[256];
An array is an object on the managed heap. Even if it is needed for only a single method call, the garbage collector will eventually have to process it. When a buffer is small and its size is known in advance, stackalloc can allocate it on the stack instead:
Span<byte> buffer = stackalloc byte[256];
This storage exists only until the method returns and does not create an object on the managed heap. Here, the result of stackalloc is assigned to a Span<byte>, so the buffer can be accessed through ordinary bounds-checked indexing without pointers or unsafe code [2].
A real-world example appears in the TryDecodeMime method of ContentDispositionHeaderValue, implemented in ContentDispositionHeaderValue.cs. A Content-Disposition HTTP header may contain a MIME-encoded file name:
attachment; filename="=?utf-8?B?0L7RgtGH0ZHRgi50eHQ=?="
To decode the file name, TryDecodeMime splits the MIME encoded-word on the ? character. The supported format produces five parts, whose boundaries are stored in a small stack-allocated buffer:
Span<Range> parts = stackalloc Range[6];
ReadOnlySpan<char> processedInputSpan = processedInput;
// "=, encodingName, encodingType, encodedData, ="
if (processedInputSpan.Split(parts, '?') != 5 ||
processedInputSpan[parts[0]] is not "\"=" ||
processedInputSpan[parts[4]] is not "=\"" ||
!processedInputSpan[parts[2]].Equals("b", StringComparison.OrdinalIgnoreCase))
{
return false;
}
ReadOnlySpan<char>.Split writes only the ranges of the resulting parts into parts, but the characters themselves remain in the original string. Unlike string.Split, this parsing step does not create an array and a separate string for every part.
There are several important restrictions to keep in mind [2]:
- use
stackalloconly for small buffers with a controlled size, because the stack is much smaller than the heap, and an allocation that is too large can cause aStackOverflowException; - avoid
stackallocinside loops because the allocated storage is released only when the method returns; - a
Span<T>that refers to a local stack buffer cannot be returned from the method because the underlying storage will no longer exist.
If the buffer size depends on input or can become large, use a bounded threshold and obtain the remaining storage another way. We will return to this topic a little later.
Avoid creating objects unnecessarily
The most effective way to reduce allocations is to avoid creating a new object when the same work can be performed through a view over existing data.
Use Span<T> to represent existing data
In the previous example, Span<T> provided safe access to memory allocated with stackalloc. The type is not limited to stack memory, however. Span<T> represents a contiguous region of memory backed by a stack buffer, an array, or unmanaged memory. The span itself neither owns nor copies that data [3].
Consider a simple example:
int[] numbers = { 10, 20, 30, 40 };
Span<int> slice = numbers.AsSpan(1, 2);
slice[0] = 99;
Console.WriteLine(numbers[1]); // 99
slice represents two elements of the original array, starting at index 1. No new array is created, so a change made through the span is visible in numbers as well.
Now consider the GetExtension method in Path.cs. It has two overloads: one accepts a string, while the other accepts a ReadOnlySpan<char>:
public static string? GetExtension(string? path);
public static ReadOnlySpan<char> GetExtension(ReadOnlySpan<char> path);
The string overload is only a wrapper around the core implementation:
public static string? GetExtension(string? path)
{
if (path == null)
return null;
return GetExtension(path.AsSpan()).ToString();
}
The actual search for the extension is implemented by the ReadOnlySpan<char> overload:
public static ReadOnlySpan<char> GetExtension(ReadOnlySpan<char> path)
{
int length = path.Length;
for (int i = length - 1; i >= 0; i--)
{
char ch = path[i];
if (ch == '.')
{
if (i != length - 1)
return path.Slice(i, length - i);
else
return ReadOnlySpan<char>.Empty;
}
if (PathInternal.IsDirectorySeparator(ch))
break;
}
return ReadOnlySpan<char>.Empty;
}
This implementation returns a range within the original path rather than a new string. Calling path.AsSpan() in the string overload does not allocate either. A new object is created only by ToString(), when the result has to become an independent string.
Use Memory<T> when a view must be retained
Span<T> is declared as a ref struct. This allows it to safely represent stack memory, but it also requires the compiler to restrict its lifetime. Those restrictions apply even when a particular span is backed by an ordinary managed array: it cannot be stored in a field of a regular object or kept across an await boundary [3].
Some operations need to retain a view of memory and access it later. Memory<T> is designed for this purpose. The distinction is easy to see in the signatures of two overloads from Stream.cs [3]:
public virtual int Read(Span<byte> buffer);
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default);
The synchronous Read completes before control returns to its caller, so it can accept a Span<byte>. ReadAsync, by contrast, may return an incomplete ValueTask<int> and continue writing later. It therefore needs a representation of the buffer that can be retained, which is why it accepts Memory<byte>.
Derived stream types can override these methods and operate directly on the supplied memory. We will examine the base implementation of ReadAsync(Memory<byte>) later.
An array can be passed to an asynchronous method as Memory<byte> without copying its contents:
byte[] buffer = new byte[4096];
Memory<byte> memory = buffer.AsMemory();
int bytesRead = await stream.ReadAsync(memory, cancellationToken);
Process(memory.Span[..bytesRead]);
AsMemory() does not create another buffer. The Memory<T> remains valid until the asynchronous operation completes, and after the await, its Span property provides access to the filled portion of the original array.
Memory<T> does not own the buffer. Until the asynchronous operation has completed, the caller must not reuse that memory or return it to a pool [4].
Both types therefore represent existing memory, but allow different lifetimes:
- use
Span<T>for immediate, synchronous access to data; - use
Memory<T>when the view must be retained, for example until an asynchronous operation completes.
Reuse objects that have already been created
A view over existing data is not always enough. An operation may require a separate buffer or object. If that resource is needed repeatedly, it can be rented from a pool and returned after use instead of being created for every call.
Use MemoryPool<T> to reuse contiguous memory regions
When an operation simply needs a contiguous region of memory, it can rent one from MemoryPool<T>:
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(minimumSize);
Memory<byte> memory = owner.Memory[..minimumSize];
await stream.ReadExactlyAsync(memory, cancellationToken);
The pool returns an IMemoryOwner<T>, which explicitly owns the rented memory. Its Memory<T> can be stored and passed to asynchronous operations while the owner remains alive. Calling Dispose() releases the rented memory back to the pool [3, 5].
This abstraction is useful when an algorithm does not care what kind of storage backs the returned memory. A custom pool implementation can abstract over different kinds of backing storage.
The System.IO.Pipelines library uses MemoryPool<T> to manage its internal buffers. A Pipe connects a producer of data to a consumer. The producer might read bytes from a network connection or stream and write them through a PipeWriter. The consumer retrieves those bytes through a PipeReader and parses them. The pipeline manages the buffers between them, so application code does not have to allocate a new array for every operation [6].
The producer asks the PipeWriter for memory and writes directly into it:
Memory<byte> memory = writer.GetMemory(minimumSize);
int bytesRead = await stream.ReadAsync(memory, cancellationToken);
writer.Advance(bytesRead);
FlushResult result = await writer.FlushAsync(cancellationToken);
The consumer receives the written data as a ReadOnlySequence<byte>, which may span multiple memory regions:
ReadResult result = await reader.ReadAsync(cancellationToken);
ReadOnlySequence<byte> buffer = result.Buffer;
// Parse the available data and determine the consumed and examined positions.
reader.AdvanceTo(consumed, examined);
The consumed position tells the pipeline which memory can be reused, while examined indicates how far the consumer inspected the data. After calling AdvanceTo, code must not retain references to the released portion of the buffer [6].
FlushAsync helps coordinate the rate of production with the rate of consumption. If too much unprocessed data accumulates, the producer pauses until the consumer releases part of the buffer. This helps contain buffer growth when the consumer processes data more slowly than the producer generates it [6].
Use ArrayPool<T> to reuse arrays
MemoryPool<T> is not suitable for every situation. Some APIs specifically require a T[], so an ordinary array is needed to call them. ArrayPool<T> can provide one without allocating a new array for every operation [7].
The base implementation of Stream.ReadAsync(Memory<byte>), which we mentioned earlier, demonstrates this approach. If the memory is not backed by an accessible array, the fallback path rents a temporary byte[] from ArrayPool<byte>:
byte[] sharedBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length);
return FinishReadAsync(ReadAsync(sharedBuffer, 0, buffer.Length, cancellationToken), sharedBuffer, buffer);
static async ValueTask<int> FinishReadAsync(Task<int> readTask, byte[] localBuffer, Memory<byte> localDestination)
{
try
{
int result = await readTask.ConfigureAwait(false);
new ReadOnlySpan<byte>(localBuffer, 0, result).CopyTo(localDestination.Span);
return result;
}
finally
{
ArrayPool<byte>.Shared.Return(localBuffer);
}
}
Here, Rent() replaces a temporary new byte[...] allocation, while the finally block ensures that Return() gives the array back to the pool even if the read fails.
Unlike MemoryPool<T>, this API does not return a separate owner object. Rent() returns the array itself, and its length may be greater than requested. The caller must track the amount of valid data and return the same array manually with Return() [7].
The array must not be accessed after it has been returned because another part of the program may rent it next. The pool also does not clear arrays automatically. For sensitive data, pass clearArray: true to Return() or clear the region that was used before returning the array [7].
Use ObjectPool<T> to reuse ready-to-use objects
MemoryPool<T> and ArrayPool<T> reuse regions of memory. Sometimes, however, the resource to reuse is an object itself, together with its configuration and internal state. ObjectPool<T> is intended for this scenario [8].
A real example appears in NewtonsoftJsonInputFormatter.cs in ASP.NET Core 10. This component deserializes HTTP request bodies using Newtonsoft.Json's JsonSerializer. Instead of creating and configuring a new serializer for every request, the formatter keeps a pool of ready-to-use instances:
private ObjectPool<JsonSerializer>? _jsonSerializerPool;
protected virtual JsonSerializer CreateJsonSerializer()
{
if (_jsonSerializerPool == null)
{
_jsonSerializerPool = _objectPoolProvider.Create(new JsonSerializerObjectPolicy(SerializerSettings));
}
return _jsonSerializerPool.Get();
}
Before deserialization, the formatter obtains a serializer from the pool and attaches an error handler for the current request. In the finally block, it removes that handler and returns the object to the pool:
var jsonSerializer = CreateJsonSerializer(context);
jsonSerializer.Error += ErrorHandler;
try
{
model = jsonSerializer.Deserialize(jsonReader, type);
}
finally
{
jsonSerializer.Error -= ErrorHandler;
ReleaseJsonSerializer(jsonSerializer);
}
ReleaseJsonSerializer returns the used serializer to the pool:
protected virtual void ReleaseJsonSerializer(JsonSerializer serializer)
=> _jsonSerializerPool!.Return(serializer);
Removing the handler before returning the serializer is essential: the next request may receive the same instance and must not inherit state from the previous operation. Unlike the memory pools, this pool reuses a configured JsonSerializer object with its own behavior and state, not merely an array of elements.
Pooling is worthwhile only for objects that are expensive to create or initialize and are used frequently enough. ObjectPool<T> limits how many objects it retains, not how many it can create. If no instance is available, Get() creates a new one. Once an object has been returned, it must no longer be used because another caller may acquire it [8].
Conclusion
Despite their differences, all the techniques discussed here follow three general principles: keep small temporary buffers off the managed heap, avoid creating objects unnecessarily, and reuse objects that have already been created.
Choosing the right tool depends on the lifetime of the data and how it will be used. A small local buffer can live on the stack, existing data can be exposed through a non-copying view, and a resource that must be created can be returned to a pool for reuse.
The goal is not to eliminate every allocation. The final string, object, or array is often genuinely necessary. The important distinction is between data the application actually needs and temporary objects introduced only by the way that data is processed.
Begin this kind of optimization with measurements. More complex memory management is justified only when reducing garbage-collection work produces a meaningful benefit and the ownership rules remain clear.
References
- Microsoft Learn - Fundamentals of garbage collection
- Microsoft Learn -
stackallocexpression - Microsoft Learn - Memory- and span-related types
- Microsoft Learn -
Memory<T>andSpan<T>usage guidelines - Microsoft Learn -
MemoryPool<T>Class - Microsoft Learn -
System.IO.Pipelines - Microsoft Learn -
ArrayPool<T>Class - Microsoft Learn - Object reuse with
ObjectPool<T>in ASP.NET Core
Top comments (0)