StringBuilder делит текст на чанки по 8000 символов — вчетверо ниже порога кучи больших объектов. Запас такой, что попасть туда невозможно.Но чанк на 400 КБ в этой куче возможен и получить его можно разными способами. Читать далее
Уважаемые читатели, в этой статье я хочу рассказать, что StringBuilder делает с длинными строками, — и представить свои выводы.
StringBuilder не хранит текст одним массивом. Он делит его на чанки, а предел чанка — 8000 символов. Сделано это ради одного: чтобы чанки не попадали в кучу больших объектов. Порог 85 000 байт, символ занимает два, значит около 40 000 символов — предел взят в четыре раза ниже, с запасом.
Предел срабатывает не всегда. Append строки, которая не влезает в текущий чанк, создаёт под её остаток отдельный, и если остаток длиннее 8000 символов, предел на него не действует — на 200 000 символов это массив под 400 КБ в той самой куче, ради которой предел и задавали. Ни компилятор, ни анализаторы про это не сообщают.
Будет 3 истории:
длинная строка в одном Append обходит предел чанка — и почему граница проходит по 42 488 символам, а не по 85 000;
второе поколение собирается вдвое чаще, а в куче держится 24 МБ, пока живы 64 экземпляра StringBuilder;
Clear не выбрасывает чанки, а объединяет их в один массив — и текст, собранный отрезками по 8000, тоже оказывается в LOH.
Весь код проверки — в репо BuilderLohProof. Машины те же, что в статье про ArrayPool:
Ryzen 9 5950X;
Core i9-10900KF;
2 × Xeon Silver 4314;
Xeon W-2255.
Все на .NET 8, 9 и 10, BenchmarkDotNet 0.15.8.
История 1. Чанк на 200 000 символовТекст на 200 000 символов, собранный семью способами. Чанки читаются рефлексией по полям m_ChunkChars и m_ChunkPrevious.

Одна длинная строка вместо частей по 1000: 2 чанка вместо 29, и второй занимает 199 984 символа — это 400 КБ и куча больших объектов
Частями по 1000 получается 29 чанков, наибольший на 8000 — предел не нарушен. Одна длинная строка укладывается в 2 чанка: первый на 16 символов, второй на 199 984. С отрезками по 8000 наибольший чанк снова 8000.
Чанки можно посмотреть и без рефлексии: StringBuilder.GetChunks() отдаёт их публично, по ReadOnlyMemory<char> на каждый.
Причина — в одной строке ExpandByABlock:
// dotnet/runtime, System/Text/StringBuilder.cs
int newBlockLength = Math.Max(minBlockCharCount, // сколько нужно под остаток строки
Math.Min(Length, MaxChunkSize)); // и сколько разрешает предел
m_ChunkChars = GC.AllocateUninitializedArray<char>(newBlockLength);Math.Max выбирает большее. Если под остаток нужно 199 984 символа, предел в 8000 в решении не участвует. Комментарий рядом с MaxChunkSize этот случай не упоминает:
internal const int MaxChunkSize = 8000; // Completely arbitrary, but a nice number of chars.
// We want to keep chunk arrays out of large object heap (< 85K bytes ~ 40K chars) to be sure.
// Making the maximum chunk size big means less allocation code called, but also more waste
// in unused characters and slower inserts / replaces.Где граница?Порог кучи больших объектов — 85 000 байт. Считается размер объекта, а не длина массива: к длине добавляется заголовок. Поэтому точное число ищется перебором.

42 488 × 2 + 24 = 85 000: два байта на символ и 24 байта заголовка массива на x64
Заголовок — это 24 байта служебных данных: указатель на таблицу методов, поле синхронизации и длина. Для Append граница на 16 символов выше: первые 16 попадают в начальный чанк, а под остаток создаётся массив длиной на 16 символов меньше самой строки.
Размер чанка зависит от длины строки:

На 20 000 чанк уже втрое больше предела, но в LOH ещё не попадает
Проверить объяснение можно настройкой порога. С DOTNET_GCLOHThreshold равным 0xC0000 граница смещается на 393 204 символа, и строка на 200 000 в эту кучу уже не попадает.
Чанк в куче больших объектов сам по себе не ошибка: результат верный, память освободится. Но освобождается она при сборке второго поколения. Число сборок на 200 документах по 200 000 символов:

Вдвое больше сборок второго поколения: 49 против 24 на трёх машинах, 40 против 21 на четвёртой
Один документ — 400 КБ в этой куче. На 1000 документов в секунду это 390 МБ/с туда, где сборка идёт только вместе со вторым поколением.
При этом объём выделенной памяти почти одинаков: 160,06 МБ у длинной строки и 160,44 МБ у частей по 1000. Работа совпадает, различается только куча, в которую попадает память.
По времени результат менее устойчив. На трёх машинах длинная строка обрабатывается в 1,62–1,67 раза медленнее на длинах 50 000 и 200 000. На Xeon W-2255 отличий не видно: 163,8 мкс против 186,5 при разбросе 12,3 мкс — погрешность замера перекрывает разницу. На длине 20 000 чанк ещё вне LOH, и отличий нет ни на одной машине — причина именно в этой куче.
У всех способов прирост кучи в этом замере близок к нулю, и это не ошибка замера. Экземпляры StringBuilder теряют последнюю ссылку сразу после ToString, а полная сборка перед чтением их освобождает. Занятая память видна там, где эти экземпляры продолжают жить — в пуле или кэше.

64 живых экземпляра StringBuilder: 24 МБ в куче больших объектов против нуля
24 МБ — это 64 чанка по 400 КБ. Куча больших объектов по умолчанию не уплотняется, поэтому освободившиеся участки останутся разрозненными до следующей полной сборки.
А на серверном сборщике мусора?На серверном сборщике все три способа дают одинаковый результат: 13 сборок, удвоения нет. Порог для запуска сборки там выше, поэтому разница не проявляется.
При этом занятая память та же: 64 экземпляра StringBuilder дают 25 001 КБ против нуля. На сервере вопрос не в частоте сборок, а в 25 МБ, которые остаются в куче без уплотнения. Серверный сборщик убирает разницу в числе сборок, но не занятую память.
Третий случай — очистка StringBuilder. Отчёт reuse показывает, что остаётся после Clear:

Текст собран частями по 8000 и вне LOH — после Clear остаётся один чанк на 390 КБ, уже в LOH
Clear устанавливает Length в ноль. Сеттер находит чанк с нулевым индексом и, если тот оказался не последним, создаёт новый массив — такого размера, чтобы прежняя вместимость сохранилась. Код в StringBuilder.cs:
// dotnet/runtime, System/Text/StringBuilder.cs, сеттер Length
StringBuilder chunk = FindChunkForIndex(value); // чанк с индексом 0
if (chunk != this) // а это не последний чанк
{
int capacityToPreserve = Math.Min(Capacity,
Math.Max(Length * 6 / 5, m_ChunkChars.Length)); // сколько вместимости сохранить
int newLen = capacityToPreserve - chunk.m_ChunkOffset;
if (newLen > chunk.m_ChunkChars.Length)
{
char[] newArray = GC.AllocateUninitializedArray<char>(newLen); // один массив на всё
Array.Copy(chunk.m_ChunkChars, newArray, chunk.m_ChunkLength);
m_ChunkChars = newArray;
}
}Вместимость сохраняется намеренно: StringBuilder рассчитывает на такой же объём текста при следующем заполнении. Взамен мелкие чанки заменяются одним массивом, и на длинном тексте он выходит за порог.

Clear удваивает объём выделенной памяти: к чанкам добавляется ещё один массив на 390 КБ
Пулы StringBuilder попадают под это чаще всего. У StringBuilderPooledObjectPolicy из ObjectPool задан предел вместимости в 4096 символов, и разросшийся экземпляр в пул не возвращается — отчёт reuse это подтверждает. Пул, написанный вручную без такой проверки, будет держать в куче по массиву на каждый экземпляр.
Когда все части текста известны заранее, StringBuilder не нужен:

string.Concat и string.Create выделяют вдвое меньше памяти: у них нет чанков, только итоговая строка
Про буфер из пула: ArrayPool<char>.Shared.Rent(200 000) выдаёт массив на 262 144 символа, и он тоже оказывается в куче больших объектов — один на весь процесс, а не по одному на вызов. Подробный разбор — в статье про ArrayPool.
Отдельно про ValueStringBuilder. В рантайме он internal, из своего кода доступен только пакетом LinkDotNet.StringBuilder, а растёт через ArrayPool<char>.Shared.Rent. На 200 000 символов пул выдаст массив на 262 144 — те же 512 КБ в куче больших объектов.
Разница в том, что массив берётся из пула и переиспользуется, а не создаётся заново. При параллельной работе пул выдаст несколько таких массивов, а без Dispose арендованный вообще не вернётся.
Вместимость часто задают заранее, но выше 42 488 символов первый же чанк попадает в кучу больших объектов:

Разница вместимости — 1000 символов, разница по времени — 27 мкс
Комментарий в рантайме обещает медленные вставки на крупных чанках, но замер этого не показал. Вставка в начало при нескольких чанках — 85,4 нс против 26,7 на одном большом: StringBuilder перебирает чанки с конца, пока не найдёт нужный. Replace по всему тексту даёт 6086 нс против 6076, разницы нет.
Вставка в середину не поддаётся замеру: она делит чанк на два, и после 100 вставок 26 чанков превращаются в 126 — каждый следующий вызов работает с другим набором данных.
ВыводыПо цифрам:
предел чанка в 8000 символов не срабатывает при добавлении длинной строки одним вызовом: чанк создаётся по размеру её остатка;
граница кучи больших объектов для char[] — 42 488 символов: 42 488 × 2 + 24 = 85 000; для Append она начинается с 42 504;
длинная строка одним вызовом даёт вдвое больше сборок второго поколения: 49 против 24 на трёх машинах и 40 против 21 на четвёртой;
64 экземпляра StringBuilder, остающиеся в памяти, занимают 24 220 КБ вместо нуля;
Clear при нескольких чанках создаёт один массив под прежнюю вместимость: выделено 783 КБ против 392, в куче 24 222 КБ против нуля;
на серверном сборщике число сборок одинаковое — 13 у всех способов, а занятая память та же;
все перегрузки Append дают один результат: 272–283 мкс и 781,5 КБ, другая перегрузка от попадания в LOH не спасёт;
на .NET 8, 9 и 10 результат один и тот же — в рантайме это не меняли.
Что стоит помнить:
длинный текст, приходящий одной строкой, лучше добавлять частями по 8000 символов через Append(ReadOnlySpan<char>);
capacity выше 42 488 символов приводит к тому, что первый чанк попадает в кучу больших объектов;
string.Concat и string.Create выделяют вдвое меньше памяти, когда все части текста известны заранее;
в пуле StringBuilder, написанном вручную, Capacity проверяется перед возвратом — как в StringBuilderPooledObjectPolicy из ObjectPool;
Clear не освобождает чанки, а создаёт один массив под прежнюю вместимость;
на серверном сборщике смотреть надо не на число сборок, а на занятую память.
BuilderLohProof — 8 классов замеров, 5 отчётов, прогоны на четырёх машинах и трёх рантаймах
StringBuilder.cs — MaxChunkSize, ExpandByABlock и сеттер Length
StringBuilderPooledObjectPolicy.cs — предел вместимости в пуле
Всем удачи и до новых встреч!
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
62.5%Только когда знаю итоговый размер10
25%Никогда, хватает дефолта4
6.25%StringBuilder не использую1
Проголосовали 16 пользователей. Воздержался 1 пользователь.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Бенчмаркая Enumerable.Chunk: почему батчей меньше, а проход до ×2,7 дольше | 0 | 8.57 | 05-08-2026 |
| 2 | Бенчмаркая Span.Sort: выбрал компаратор-структуру — и получил 88 байт на вызов | 0 | 20.52 | 21-08-2026 |
| 3 | Бенчмаркая поиск по строке: самописные циклы проигрывают от ×14 до ×154 | 0 | 8.91 | 15-07-2026 |
| 4 | Бенчмаркая LINQ: подстава с OrderBy — одно условие и полная сортировка | 0 | 8.68 | 22-08-2026 |
| 5 | Многоэтапные сборки в Docker: как уменьшить образ с 1,2 ГБ до 50 МБ | 5 | 8 | 28-06-2026 |
| 6 | Бенчмаркая ключи Dictionary: забыл IEquatable — получил ×5 и 96 байт на каждый поиск | 1 | 9.13 | 25-07-2026 |
| 7 | Бенчмаркая System.Text.Json: те же данные, те же настройки, до ×4,3 разницы | 0 | 11.26 | 30-07-2026 |
| 8 | Разбор утечки памяти в StackExchange.Utils: как 4 КБ конфигурации съели 2 ГБ RAM | 1 | 12.1 | 27-07-2026 |
| 9 | Строки кода должны помещаться на экране | 0 | 13.58 | 01-12-2025 |
| 10 | SSB — «мы стояли на „плоскости“» | 0 | 5 | 18-07-2026 |