Preface
Today I’m going to share a tip for string concatenation scenarios. We often encounter situations where many short strings need to be concatenated, and in such cases it is highly unrecommended to use String.Concat — that is, using the += operator.
Currently the most recommended approach is to use StringBuilder to build these strings. But is there a faster, lower-memory way? That would be ValueStringBuilder, which I’d like to introduce today.
ValueStringBuilder
ValueStringBuilder is not a public API, but it is heavily used in .NET’s base class libraries. Because it is a value type, it is not allocated on the heap, so it avoids GC pressure.
Microsoft’s ValueStringBuilder can be used in two ways. One way is to provide your own block of memory for string construction. This means you can use stack space, heap space, or even unmanaged heap space. This is very GC-friendly and can significantly reduce GC pressure under high concurrency.
// Constructor: pass a Span<char> as the buffer
public ValueStringBuilder(Span<char> initialBuffer);
// Usage:
// Stack space
var vsb = new ValueStringBuilder(stackalloc char[512]);
// Regular array
var vsb = new ValueStringBuilder(new char[512]);
// Unmanaged heap
var length = 512;
var ptr = NativeMemory.Alloc((nuint)(512 * Unsafe.SizeOf<char>()));
var span = new Span<char>(ptr, length);
var vsb = new ValueStringBuilder(span);
.....
NativeMemory.Free(ptr); // Unmanaged memory must be freed after use
The other way is to specify a capacity, and it will get a buffer from the default ArrayPool<char> object pool. Because it uses an object pool, it is also relatively GC-friendly. It is very important to remember to return the pooled object.
// Pass the estimated capacity
public ValueStringBuilder(int initialCapacity)
{
// Obtain the buffer from the object pool
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
......
}
Now let’s compare the performance of +=, StringBuilder, and ValueStringBuilder.
// A simple class
public class SomeClass
{
public int Value1; public int Value2; public float Value3;
public double Value4; public string? Value5; public decimal Value6;
public DateTime Value7; public TimeOnly Value8; public DateOnly Value9;
public int[]? Value10;
}
// Benchmark class
[MemoryDiagnoser]
[HtmlExporter]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class StringBuilderBenchmark
{
private static readonly SomeClass Data;
static StringBuilderBenchmark()
{
var baseTime = DateTime.Now;
Data = new SomeClass
{
Value1 = 100, Value2 = 200, Value3 = 333,
Value4 = 400, Value5 = string.Join('-', Enumerable.Range(0, 10000).Select(i => i.ToString())),
Value6 = 655, Value7 = baseTime.AddHours(12),
Value8 = TimeOnly.MinValue, Value9 = DateOnly.MaxValue,
Value10 = Enumerable.Range(0, 5).ToArray()
};
}
// Using the familiar StringBuilder
[Benchmark(Baseline = true)]
public string StringBuilder()
{
var data = Data;
var sb = new StringBuilder();
sb.Append("Value1:"); sb.Append(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.Append(data.Value2);
}
sb.Append(" ,Value3:"); sb.Append(data.Value3);
sb.Append(" ,Value4:"); sb.Append(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
}
sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
sb.Append(" ,Value10:");
if (data.Value10 is null or {Length: 0}) return sb.ToString();
for (int i = 0; i < data.Value10.Length; i++)
{
sb.Append(data.Value10[i]);
}
return sb.ToString();
}
// StringBuilder with Capacity
[Benchmark]
public string StringBuilderCapacity()
{
var data = Data;
var sb = new StringBuilder(20480);
sb.Append("Value1:"); sb.Append(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.Append(data.Value2);
}
sb.Append(" ,Value3:"); sb.Append(data.Value3);
sb.Append(" ,Value4:"); sb.Append(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
}
sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
sb.Append(" ,Value10:");
if (data.Value10 is null or {Length: 0}) return sb.ToString();
for (int i = 0; i < data.Value10.Length; i++)
{
sb.Append(data.Value10[i]);
}
return sb.ToString();
}
// Directly using += for string concatenation
[Benchmark]
public string StringConcat()
{
var str = "";
var data = Data;
str += ("Value1:"); str += (data.Value1);
if (data.Value2 > 10)
{
str += " ,Value2:"; str += data.Value2;
}
str += " ,Value3:"; str += (data.Value3);
str += " ,Value4:"; str += (data.Value4);
str += " ,Value5:"; str += (data.Value5);
if (data.Value6 > 20)
{
str += " ,Value6:"; str += data.Value6.ToString("F2");
}
str += " ,Value7:"; str += data.Value7.ToString("yyyy-MM-dd HH:mm:ss");
str += " ,Value8:"; str += data.Value8.ToString("HH:mm:ss");
str += " ,Value9:"; str += data.Value9.ToString("yyyy-MM-dd");
str += " ,Value10:";
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
str += (data.Value10[i]);
}
}
return str;
}
// ValueStringBuilder allocated on the stack
[Benchmark]
public string ValueStringBuilderOnStack()
{
var data = Data;
Span<char> buffer = stackalloc char[20480];
var sb = new ValueStringBuilder(buffer);
sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
}
sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
}
sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
sb.Append(" ,Value10:");
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
sb.AppendSpanFormattable(data.Value10[i]);
}
}
return sb.ToString();
}
// ValueStringBuilder allocated on the heap using ArrayPool
[Benchmark]
public string ValueStringBuilderOnHeap()
{
var data = Data;
var sb = new ValueStringBuilder(20480);
sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
}
sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
}
sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
sb.Append(" ,Value10:");
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
sb.AppendSpanFormattable(data.Value10[i]);
}
}
return sb.ToString();
}
}
The results are as follows.

From the results above, we can draw the following conclusions.
StringConcatis the slowest; this approach is never recommended.- Using
StringBuilderis about 6.5 times faster thanStringConcat; this is the recommended method. StringBuilderwith an initial capacity is about 25% faster than plainStringBuilder. As I mentioned in You Should Set Initial Capacity for Collection Types, setting an initial capacity is definitely a highly recommended practice.- Stack-allocated
ValueStringBuilderis about 50% faster thanStringBuilder, and even about 25% faster thanStringBuilderwith an initial capacity. Additionally, it has the lowest GC count. - Heap-allocated
ValueStringBuilderis about 55% faster thanStringBuilder, with slightly higher GC counts than the stack-allocated one.
From the above, we can see that ValueStringBuilder has excellent performance. Even when the buffer is allocated on the stack, it is 25% faster than StringBuilder.
Source Code Analysis
The source code of ValueStringBuilder is not long. Let’s share a few important methods. Partial source is as follows.
// Uses ref struct, so it can only be allocated on the stack
public ref struct ValueStringBuilder
{
// If the buffer is allocated from ArrayPool, store it here
// so it can be returned on Dispose
private char[]? _arrayToReturnToPool;
// Temporarily stores the externally provided buffer
private Span<char> _chars;
// Current string length
private int _pos;
// Externally provided buffer
public ValueStringBuilder(Span<char> initialBuffer)
{
// Use the external buffer, so don’t take from the pool
_arrayToReturnToPool = null;
_chars = initialBuffer;
_pos = 0;
}
public ValueStringBuilder(int initialCapacity)
{
// If capacity is provided externally, get from ArrayPool
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
_chars = _arrayToReturnToPool;
_pos = 0;
}
// Returns the string’s Length. Since Length is readable and writable,
// to reuse ValueStringBuilder just set Length to 0.
public int Length
{
get => _pos;
set
{
Debug.Assert(value >= 0);
Debug.Assert(value <= _chars.Length);
_pos = value;
}
}
......
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(char c)
{
// Adding a character is very efficient: just set it at the corresponding Span position
int pos = _pos;
if ((uint) pos < (uint) _chars.Length)
{
_chars[pos] = c;
_pos = pos + 1;
}
else
{
// If buffer is insufficient, go to
GrowAndAppend(c);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(string? s)
{
if (s == null)
{
return;
}
// Appending a string is also very efficient
int pos = _pos;
// If the string length is 1, it can be appended like a single character
if (s.Length == 1 && (uint) pos < (uint) _chars .Length)
{
_chars[pos] = s[0];
_pos = pos + 1;
}
else
{
// For multiple characters, use the slower method
AppendSlow(s);
}
}
private void AppendSlow(string s)
{
// Appending a string: grow first if space is insufficient
// then copy using Span, which is quite efficient
int pos = _pos;
if (pos > _chars.Length - s.Length)
{
Grow(s.Length);
}
s
#if !NETCOREAPP
.AsSpan()
#endif
.CopyTo(_chars.Slice(pos));
_pos += s.Length;
}
// Special handling for objects that need formatting
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AppendSpanFormattable<T>(T value, string? format = null, IFormatProvider? provider = null)
where T : ISpanFormattable
{
// ISpanFormattable is very efficient
if (value.TryFormat(_chars.Slice(_pos), out int charsWritten, format, provider))
{
_pos += charsWritten;
}
else
{
Append(value.ToString(format, provider));
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void GrowAndAppend(char c)
{
// Grow for a single character, then append
Grow(1);
Append(c);
}
// Grow method
[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow(int additionalCapacityBeyondPos)
{
Debug.Assert(additionalCapacityBeyondPos > 0);
Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos,
"Grow called incorrectly, no resize is needed.");
// Also double the capacity, and get buffer from the object pool by default
char[] poolArray = ArrayPool<char>.Shared.Rent((int) Math.Max((uint) (_pos + additionalCapacityBeyondPos),
(uint) _chars.Length * 2));
_chars.Slice(0, _pos).CopyTo(poolArray);
char[]? toReturn = _arrayToReturnToPool;
_chars = _arrayToReturnToPool = poolArray;
if (toReturn != null)
{
// If the original buffer was from the pool, it must be returned
ArrayPool<char>.Shared.Return(toReturn);
}
}
//
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Dispose()
{
char[]? toReturn = _arrayToReturnToPool;
this = default; // For safety, clear the current object when disposing
if (toReturn != null)
{
// Must remember to return to the object pool
ArrayPool<char>.Shared.Return(toReturn);
}
}
}
From the source code above, we can summarize several characteristics of ValueStringBuilder:
- Compared to
StringBuilder, the implementation is very simple. - Everything is aimed at high performance, such as various uses of
Span, various inlining parameters, and the use of object pools, etc. - Memory usage is very low: it is a struct type, and it is also a
ref struct, meaning it will not be boxed and will not be allocated on the heap.
Applicable Scenarios
ValueStringBuilder is a high-performance string creation method. For different scenarios, there are different usage modes.
- Very frequent string concatenation with short string length: Use a stack-allocated
ValueStringBuilder.
As we all know, ASP.NET Core has very high performance. In its internal library UrlBuilder, stack allocation is used because memory is reclaimed after the current method finishes, causing no GC pressure.

- Very frequent string concatenation with unpredictable string length: Use a
ValueStringBuilderwith a capacity specified via ArrayPool. For example, many places in the .NET BCL use this, such as the ToString implementation of dynamic methods. Allocating from the pool is not as efficient as stack allocation, but it still reduces memory usage and GC pressure.

- Very frequent string concatenation with controllable string length: Combine stack allocation and ArrayPool allocation. For example, in the regular expression parser class, if the string length is small, use stack space; if larger, use ArrayPool.

Scenarios to Watch Out For
- Cannot use
ValueStringBuilderinsideasync/await. As we all know,ValueStringBuilderis aref struct; it can only be allocated on the stack.async/awaitcompiles into a state machine that splits the method before and afterawait, soValueStringBuildercannot be easily passed within the method. The compiler will give a warning.

- Cannot return
ValueStringBuilderas a return value. Since it is allocated on the current stack, it will be released after the method ends; returning it would point to an unknown address. The compiler will also warn about this.

- If you need to pass
ValueStringBuilderto another method, you must pass it byref. Otherwise, value copying will create multiple instances. The compiler will not warn about this, but you must be very careful.

- If using stack allocation, it is safer to keep the buffer size under 5KB. The reason for this will be explained another time if the opportunity arises.
Summary
Today we shared a high-performance, nearly memory-free string concatenation struct called ValueStringBuilder. In most scenarios, it is recommended. However, be very careful about the scenarios mentioned above. If they don’t apply, you can still use the efficient StringBuilder for string concatenation.
Original source code link: https://github.com/InCerryGit/BlogCode-Use-ValueStringBuilder