Geronom 4 часа назад Бенчмаркая StringBuilder: подстава на длинном текстеУровень сложностиСреднийВремя на прочтение7 минОхват и читатели2.6K C# * .NET * Программирование * Аналитика Уважаемые читатели, в этой статье я хочу рассказать, что 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 на каждый. Причина — в одной строке ExpandByABlock:// dotnet/runtime, System/Text/StringBuilder.cs int newBlockLength = Math.Max(minBlockCharCount, // сколько нужно под остаток строки Math.Min(Length, MaxChunkSize)); // и сколько разрешает предел m_ChunkChars = GC.AllocateUninitializedArray(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 в эту кучу уже не попадает.История 2. Сборки второго поколения и занятая памятьЧанк в куче больших объектов сам по себе не ошибка: результат верный, память освободится. Но освобождается она при сборке второго поколения. Число сборок на 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 МБ, которые остаются в куче без уплотнения. Серверный сборщик убирает разницу в числе сборок, но не занятую память.История 3. Clear объединяет чанки в один массивТретий случай — очистка StringBuilder. Отчёт reuse показывает, что остаётся после Clear: Текст собран частями по 8000 и вне LOH — после Clear остаётся один чанк на 390 КБ, уже в LOHClear устанавливает 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(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.Shared.Rent(200 000) выдаёт массив на 262 144 символа, и он тоже оказывается в куче больших объектов — один на весь процесс, а не по одному на вызов. Подробный разбор — в статье про ArrayPool.Отдельно про ValueStringBuilder. В рантайме он internal, из своего кода доступен только пакетом LinkDotNet.StringBuilder, а растёт через ArrayPool.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);capacity выше 42 488 символов приводит к тому, что первый чанк попадает в кучу больших объектов;string.Concat и string.Create выделяют вдвое меньше памяти, когда все части текста известны заранее;в пуле StringBuilder, написанном вручную, Capacity проверяется перед возвратом — как в StringBuilderPooledObjectPolicy из ObjectPool;Clear не освобождает чанки, а создаёт один массив под прежнюю вместимость;на серверном сборщике смотреть надо не на число сборок, а на занятую память.Код из статьиBuilderLohProof — 8 классов замеров, 5 отчётов, прогоны на четырёх машинах и трёх рантаймахСсылкиОбращение dotnet/runtime #107482 — с него начался замерStringBuilder.cs — MaxChunkSize, ExpandByABlock и сеттер LengthStringBuilderPooledObjectPolicy.cs — предел вместимости в пулеКуча больших объектовНастройки сборщика мусора — DOTNET_GCLOHThresholdGC.GetGCMemoryInfo — чтение размера кучиValueStringBuilder.cs — рост через ArrayPoolLinkDotNet.StringBuilder — ValueStringBuilder пакетомВсем удачи и до новых встреч! Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста. Задаёте capacity при создании StringBuilder? 0%Всегда075%Только когда знаю итоговый размер325%Никогда, хватает дефолта10%StringBuilder не использую0 Проголосовали 4 пользователя. Воздержался 1 пользователь. Теги: StringBuilder LOH куча больших объектов чанки сборщик мусора оптимизация памяти аллокации BenchmarkDotNet .NET 10 Clear Хабы: C# .NET Программирование
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Бенчмаркая Sum: ускорил циклом — замедлил в ×4,7 | 2 | 7 | 18-07-2026 |
| 2 | Когда контекстное окно кончается, а проект — нет | 5 | 7 | 23-06-2026 |
| 3 | Крипта с нуля. Выясняем, как устроен блокчейн | 0 | 8.23 | 24-04-2026 |
| 4 | Эпический билд. Собираем компьютеры для сложных математических расчетов | 0 | 8.36 | 06-05-2026 |
| 5 | What is the AI Context Window? | 0 | 9.59 | 21-07-2026 |
| 6 | Прорыв или нет? Разбираем, кто есть кто в генеративных моделях | -1 | 9.2 | 24-07-2026 |
| 7 | 🤒 (((((Кому уважайте её🌎 или её🌞 или её🌙 или её💦 ... | 0 | 3.48 | 27-07-2026 |
| 8 | 🤒 Например 7,5 миллиард рождались и сразу гцин пищера и ... | -10 | 1 | 15-07-2026 |
| 9 | 🤒 Например 7,5 миллиард рождались и сразу гцин пищера и ... | -9 | 1 | 21-07-2026 |