Уважаемые читатели, в этой статье я хочу рассказать, что 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 получается 29 чанков, наибольший на 8000 — предел не нарушен. Одна длинная строка укладывается в 2 чанка: первый на 16 символов, второй на 199 984. С отрезками по 8000 наибольший чанк снова 8000.
Чанки можно посмотреть и без рефлексии: StringBuilder.GetChunks() отдаёт их публично, по ReadOnlyMemory<char> на каждый.
Причина — в одной строке ExpandByABlock:
// dotnet/runtime, System/Text/StringBuilder.csint 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 байт. Считается размер объекта, а не длина массива: к длине добавляется заголовок. Поэтому точное число ищется перебором.
Заголовок — это 24 байта служебных данных: указатель на таблицу методов, поле синхронизации и длина. Для Append граница на 16 символов выше: первые 16 попадают в начальный чанк, а под остаток создаётся массив длиной на 16 символов меньше самой строки.
Размер чанка зависит от длины строки:
Проверить объяснение можно настройкой порога. С DOTNET_GCLOHThreshold равным 0xC0000 граница смещается на 393 204 символа, и строка на 200 000 в эту кучу уже не попадает.
История 2. Сборки второго поколения и занятая память
Чанк в куче больших объектов сам по себе не ошибка: результат верный, память освободится. Но освобождается она при сборке второго поколения. Число сборок на 200 документах по 200 000 символов:
Один документ — 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, а полная сборка перед чтением их освобождает. Занятая память видна там, где эти экземпляры продолжают жить — в пуле или кэше.
StringBuilder: 24 МБ в куче больших объектов против нуля24 МБ — это 64 чанка по 400 КБ. Куча больших объектов по умолчанию не уплотняется, поэтому освободившиеся участки останутся разрозненными до следующей полной сборки.
А на серверном сборщике мусора?
На серверном сборщике все три способа дают одинаковый результат: 13 сборок, удвоения нет. Порог для запуска сборки там выше, поэтому разница не проявляется.
При этом занятая память та же: 64 экземпляра StringBuilder дают 25 001 КБ против нуля. На сервере вопрос не в частоте сборок, а в 25 МБ, которые остаются в куче без уплотнения. Серверный сборщик убирает разницу в числе сборок, но не занятую память.
История 3. Clear объединяет чанки в один массив
Третий случай — очистка StringBuilder. Отчёт reuse показывает, что остаётся после Clear:
Clear остаётся один чанк на 390 КБ, уже в LOHClear устанавливает Length в ноль. Сеттер находит чанк с нулевым индексом и, если тот оказался не последним, создаёт новый массив — такого размера, чтобы прежняя вместимость сохранилась. Код в StringBuilder.cs:
// dotnet/runtime, System/Text/StringBuilder.cs, сеттер LengthStringBuilder chunk = FindChunkForIndex(value); // чанк с индексом 0if (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 не нужен:
Про буфер из пула: 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 символов первый же чанк попадает в кучу больших объектов:
Комментарий в рантайме обещает медленные вставки на крупных чанках, но замер этого не показал. Вставка в начало при нескольких чанках — 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 — предел вместимости в пуле
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1066728/