Система Orphus
Версия для печати

Эволюция StringBuilder

Автор: Гуев Тимур Ахсарбекович
Опубликовано: 18.11.2015
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
StringBuilder в .NET 2.0
StringBuilder в .NET 4.0
Сравнение производительности

Введение

Как известно, строки в .NET являются неизменяемыми (если не использовать unsafe), а поэтому проводить с ними операцию конкатенации в больших количествах – не самая лучшая идея. Это значит, что следующий код имеет весьма серьезные проблемы с нагрузкой на память:

      string s = string.Empty;
for (int i = 0; i < 100; i++)
    s += "T";

Что же плохого в этом коде? А то, что на каждой итерации создается строка длиною на единицу больше чем на предыдущем шаге с последующим копированием символов из старой строки. Таким образом, суммарное количество задействованных символов равно:


Данная формула есть не что иное, как сумма арифметической прогрессии:


То есть такой сценарий конкатенации требует памяти пропорционально O(n2), где n — длина строки.

Для устранения проблем в подобном коде мы используем класс StringBuilder, зная, что операции с ним не приводят к таким растратам памяти, как со String. Фактически, StringBuilder представляет собой изменяемую строку.

      var strB = new StringBuilder();
for (int i = 0; i < 100; i++)
  strB.Append("T");
string str = strB.ToString();

Такой код, хоть и не лишен недостатков, и тратит впустую некоторый объем памяти, все же делает это более сдержанно, по сравнению с предыдущим.

Реализация класса StringBuilder кардинально изменилась в .NET 4.0 по сравнению с предыдущими версиями, а потому я думаю, будет интересно написать, что же с ним случилось.

StringBuilder в .NET 2.0

Класс StringBuilder в .NET 2.0 имел следующие поля:

        public
        sealed
        class StringBuilder : ISerializable
{
  internalconstint DefaultCapacity = 16;
  privateconststring CapacityField = "Capacity";
  privateconststring MaxCapacityField = "m_MaxCapacity";
  privateconststring StringValueField = "m_StringValue";
  privateconststring ThreadIDField = "m_currentThread";
  internal IntPtr m_currentThread;
  internalint m_MaxCapacity;
  internalvolatilestring m_StringValue; <-----
}

Здесь:

Фактически класс StringBuilder внутри работает с обычным строковым типом данных String. Поскольку строки со стороны библиотеки mscorlib являются изменяемыми, то StringBuilder-у ничего не стоит изменить строку, находящуюся в поле m_StringValue.

Первоначальная длина строки составляет 16 символов, а при нехватке места для добавления новых символов StringBuilder заменяет внутреннюю строку на строку длиною в два раза больше и копирует во вновь созданную все символы из предыдущей + новые. Удвоение длины строки приводит к линейной сложности (O(n)) по памяти, в отличие от квадратичной, которая присуща обычным строкам. 

Важным дополнением к увеличению производительности является задание нужной емкости при создании экземпляра класса StringBuilder. Это позволяет избежать очередного удвоения размера и копирования данных при нехватке памяти. Однако это возможно лишь в том случае, если мы заранее знаем размер получаемой строки.

Рассмотрим реализации наиболее часто используемых методов. Для читаемости кода я убрал условия проверки входных параметров.

Метод Append

          public StringBuilder Append(stringvalue)
{
  if (value == null)
    returnthis;
    string currentString = this.m_StringValue;
    IntPtr currentThread = Thread.InternalGetCurrentThread();
    if (this.m_currentThread != currentThread)
      currentString = string.GetStringForStringBuilder(
        currentString, currentString.Capacity);
      int length = currentString.Length;
      int requiredLength = length + value.Length;
      //проверка, хватает ли памяти для добавления символовif (this.NeedsAllocation(currentString, requiredLength)) 
      {
        //создаем строку длиною в 2 раза больше и копируем все символы из         // старой строкиstring newString = this.GetNewString(currentString, requiredLength);
//добавляем новые символы
        newString.AppendInPlace(value, length); 
        //заменяем старую строку новойthis.ReplaceString(currentThread, newString); 
      }
      else
      {
        currentString.AppendInPlace(value, length); 
        this.ReplaceString(currentThread, currentString);
      } 
    returnthis;
}

Данный метод просто проверяет, хватает ли места в текущем экземпляре для добавления новой строки, если да, то происходит копирование на месте в незанятую часть строки, иначе удвоение размера и копирование старой и новой строки.

Метод Insert

          public StringBuilder Insert(int index, stringvalue)
 {
  if (value == null)
    returnthis.Insert(index, value, 0);
  elsereturnthis.Insert(index, value, 1);
 }

public StringBuilder Insert(int index, stringvalue, int count)
 {
  IntPtr tid;
  string threadSafeString = this.GetThreadSafeString(out tid);
  int length = threadSafeString.Length;
   if (value != null && value.Length != 0)
    {
     if (count != 0)
      {
       int requiredLength;
       try
        {
          requiredLength = checked (length + value.Length * count); //вычисляем длину новой строки
        }
       catch (OverflowException ex)
        {
          thrownew OutOfMemoryException();
        }    
       if (this.NeedsAllocation(threadSafeString, requiredLength)) //проверка хватает ли места для добавления символов
        {
             //создаем строку длиною в 2 раза больше и копируем все символы из старой строкиstring newString = this.GetNewString(threadSafeString, requiredLength);
        //вставляем новые символы
           newString.InsertInPlace(index, value, count, length, requiredLength);
        //заменяем старую строку новойthis.ReplaceString(tid, newString);
        }
       else
        {
           threadSafeString.InsertInPlace(index, value, count, length, requiredLength);
           this.ReplaceString(tid, threadSafeString);
        }
       returnthis;
      }
    }
  returnthis;
 }

Данный метод (аналогично предыдущему) проверяет, хватает ли места в текущем экземпляре для вставки новой строки и, в зависимости от этого, удваивает размер строки или же вставляет на месте в исходную строку.

Метод Remove

          public StringBuilder Remove(int startIndex, int length)
 {
   IntPtr tid;
   string threadSafeString = this.GetThreadSafeString(out tid);
   int length1 = threadSafeString.Length; 
    //удаляем ненужные символы, сдвигая оставшуюся часть строки влево
   threadSafeString.RemoveInPlace(startIndex, length, length1);   
   this.ReplaceString(tid, threadSafeString); //заменяем старую строку новойreturnthis;
 }

Данный метод удаляет ненужные символы, сдвигая оставшуюся часть строки влево. При удалении последних символов фактически ничего сдвигать не требуется.

Метод ToString

          public
          override
          string ToString()
 {
   string str = this.m_StringValue;
    if (this.m_currentThread != Thread.InternalGetCurrentThread() || 2 * str.Length < str.ArrayLength)
        returnstring.InternalCopy(str); //возвращаем копию строки
   str.ClearPostNullChar();
   this.m_currentThread = IntPtr.Zero; 
     return str; //возвращаем ссылку на текущую строку
 }

Как видно из реализации, данный метод возвращает либо копию строки, либо строку, которой оперирует. Как правило, первый вызов данного метода возвращает ссылку на исходную строку, поэтому выполняется очень быстро, однако каждый последующий вызов, независимо от того, менялась ли строка методами StringBuilder-a, приводит к копированию строки. Класс StringBuilder в .NET 2.0 делает упор именно на быстроту работы этого метода.

В общем, класс StringBuilder в .NET 2.0 реализован достаточно просто. Он использует изменяемую строку, а при нехватке места создает новую строку, длина которой в два раза больше предыдущей. Такой сценарий удвоения длины приводит к линейной сложности по памяти, что на порядок лучше квадратичной. Однако при больших длинах строк и это неэффективно. Кстати, из-за своего большего размера, строка часто может располагаться в куче для больших объектов (LOH), что также не есть хорошо.

StringBuilder в .NET 4.0

Как я уже сказал, в .NET 4.0 реализация класса StringBuilder поменялась. Теперь для хранения символов вместо String используется Char[], а сам класс представляет собой связный список StringBuilder-ов подобно RopeString.

Причина такого изменения достаточно очевидна: при такой реализации не требуется перевыделять память при ее нехватке, что присуще предыдущей реализации. Это также означает, что метод ToString() работает немного медленнее, поскольку окончательную строку необходимо сначала сформировать, а метод Append() работает быстрее, поскольку не требует копирования. Однако это вписывается в типичный сценарий использования для StringBuilder: много вызовов Append(), а затем один вызов ToString().

Класс StringBuilder в .NET 4.0 имеет следующие поля

        public
        sealed
        class StringBuilder : ISerializable
 {
   internalconstint DefaultCapacity = 16;
   internalconstint MaxChunkSize = 8000;
   internalchar[] m_ChunkChars; <-----
   internal StringBuilder m_ChunkPrevious; <-----
   internalint m_ChunkLength;
   internalint m_ChunkOffset;
   internalint m_MaxCapacity;
   privateconststring CapacityField = "Capacity";
   privateconststring MaxCapacityField = "m_MaxCapacity";
   privateconststring StringValueField = "m_StringValue";
   privateconststring ThreadIDField = "m_currentThread";
 }

В .NET Framework 4 и .NET Framework 4.5 при создании экземпляра объекта StringBuilder путем вызова конструктора StringBuilder(Int32, Int32) и длина, и емкость экземпляра StringBuilder может увеличиваться за значением его свойства MaxCapacity. Это может произойти, в частности, при вызове методов Append и AppendFormat для добавления маленьких строк.

Максимальная длина элемента списка MaxChunkSize равна 8000 символов. Как вы понимаете, это сделано не просто так. Вот комментарий разработчиков класса:

ПРИМЕЧАНИЕ

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 (since you do need to slide characters over within a buffer).

Мы хотим, чтобы массив символов не попадал в кучу для больших объектов. Если сделать максимальный размер элемента списка (кусочка) большим, нужно было бы меньше аллокаций памяти, но больше символов остались бы не используемыми, и операции insert/replace выполнялись бы медленнее.

Рассмотрим реализации наиболее часто используемых методов:

Метод Append

        public
        unsafe StringBuilder Append(stringvalue)
 {
   if (value != null)
    {
      char[] chArray = this.m_ChunkChars;
      int index = this.m_ChunkLength;
      int length = value.Length;
      int num = index + length;
  
      if (num < chArray.Length) // Если хватает места в текущем экземпляре для вставки новой строки
       {
        if (length <= 2)
         {
          if (length > 0)
            chArray[index] = value[0];
          if (length > 1)
            chArray[index + 1] = value[1];
         }
        else
         {
          fixed (char* smem = value)
           fixed (char* dmem = &chArray[index])
            string.wstrcpy(dmem, smem, length);
         }
        this.m_ChunkLength = num;
      }
       elsethis.AppendHelper(value);
      }
      returnthis;
    }

privateunsafevoid AppendHelper(stringvalue)
 {
    fixed (char* chPtr = value)
     this.Append(chPtr, value.Length);
 }


internalunsafe StringBuilder Append(char* value, int valueCount)
 {
   int num1 = valueCount + this.m_ChunkLength; // Оптимизацияif (num1 <= this.m_ChunkChars.Length)
      {
        StringBuilder.ThreadSafeCopy(value, this.m_ChunkChars, this.m_ChunkLength, valueCount);
        this.m_ChunkLength = num1;
      }
    else
      {
   // Копируем первую часть(кусочек)int count = this.m_ChunkChars.Length - this.m_ChunkLength;
        if (count > 0)
        {
          StringBuilder.ThreadSafeCopy(value, this.m_ChunkChars, this.m_ChunkLength, count);
          this.m_ChunkLength = this.m_ChunkChars.Length;
        }
  // Увеличиваем билдер, добавляя еще один кусочекint num2 = valueCount - count;
        this.ExpandByABlock(num2);
  // Копируем вторую часть (кусочек)
        StringBuilder.ThreadSafeCopy(value + count, this.m_ChunkChars, 0, num2);
        this.m_ChunkLength = num2;
      }
      returnthis;
    }

Метод Append() работает следующим образом: если в текущем элементе списка хватает символов для вставки новой строки, то происходит копирование в нее, если же нет, то копируется та часть которая помещается, а для того, что не поместилось, создается новый элемент списка (экземпляр StringBuilder-a), у которого длина массива равна длине всей исходной строки либо длине оставшейся строки, в зависимости от того, что больше. Однако, как было сказано выше, максимальная длина массива составляет 8000.

В общем, формула для вычисления длины нового элемента списка выглядит так:

        int length = Math.Max(minBlockCharCount, Math.Min(this.Length, 8000)), 

где minBlockCharCount - оставшаяся длина строки после копирования ее части, которая помещается в текущий экземпляр.

Таким образом, в результате работы следующего кода длины массивов у элементов списка будут равны: 8000, 4092, 2048, 1024, 512, 256, 128, 64, 32, 16, 16.

        for (int i = 0; i < 10000; i++)
  s.Append ("T");

При таких длинах массивов операция обращения к определенному символу в исходной строке выполняется достаточно быстро, практически за O(1), так как элементов списка не слишком много.

Метод Insert

        public
        unsafe StringBuilder Insert(int index, stringvalue)
 {
   if (value != null)
    {
     fixed (char* chPtr = value)
      this.Insert(index, chPtr, value.Length);
    }
   returnthis;
 }

privateunsafevoid Insert(int index, char* value, int valueCount)
 {
   if (valueCount <= 0)
     return;
   StringBuilder chunk;
   int indexInChunk;
    //Вставляет символы в строку при необходимости создавая новый элемент списка (StringBuilder)this.MakeRoom(index, valueCount, out chunk, out indexInChunk, false);
   this.ReplaceInPlaceAtChunk(ref chunk, ref indexInChunk, value, valueCount);
 }

Что будет результатом выполнения такого кода?

StringBuilder s = new StringBuilder();
for (int i = 0; i < 10000; i++)
  s.Insert (0, "T");

Результат будет отличаться от кода, использующего Append(), причем весьма серьезно!

Мы получим очень большой список StringBuilder-ов, каждый элемент которого будет иметь длину 16 символов. В результате этого операция обращения к определенному символу по индексу будет выполняться медленней, чем ожидалось, а именно пропорционально длине списка, то есть O(n).

Метод Remove

        public StringBuilder Remove(int startIndex, int length)
    {
      if (this.Length == length && startIndex == 0)
      {
        // Оптимизация. Если мы удаляем всю строку.this.Length = 0;
        returnthis;
      }
      else
      {
        if (length > 0)
        {
          StringBuilder chunk;
          int indexInChunk;
          this.Remove(startIndex, length, out chunk, out indexInChunk);
        }
        returnthis;
      }
    }

privatevoid Remove(int startIndex, int count, out StringBuilder chunk, outint indexInChunk)
    {
      int num = startIndex + count;
      //Находим элементы списка(кусочки) в которых находятся начальный       // и конечный символ для удаления.
      chunk = this;
      StringBuilder stringBuilder = (StringBuilder) null;
      int sourceIndex = 0;
      while (true)
      {
        if (num - chunk.m_ChunkOffset >= 0)
        {
          if (stringBuilder == null)
          {
            stringBuilder = chunk;
            sourceIndex = num - stringBuilder.m_ChunkOffset;
          }
          if (startIndex - chunk.m_ChunkOffset >= 0)
            break;
        }
        else
          chunk.m_ChunkOffset -= count;
        chunk = chunk.m_ChunkPrevious;
      }
      indexInChunk = startIndex - chunk.m_ChunkOffset;
      int destinationIndex = indexInChunk;
      int count1 = stringBuilder.m_ChunkLength - sourceIndex;
     //Если начальный и конечный кусочки не равны if (stringBuilder != chunk)
      {
        destinationIndex = 0;
        // Удаляем символы после startIndex до конца начального элемента         // списка (кусочка)  
        chunk.m_ChunkLength = indexInChunk;
        // Удаляем символы между начальным и конечным кусочком.
        stringBuilder.m_ChunkPrevious = chunk;
        stringBuilder.m_ChunkOffset = chunk.m_ChunkOffset 
          + chunk.m_ChunkLength;
        // Если стартовый индекс для удаления ноль в начальном кусочке,         // то мы можем выкинуть весь элемент списка(кусочек)      if (indexInChunk == 0)
        {
          stringBuilder.m_ChunkPrevious = chunk.m_ChunkPrevious;
          chunk = stringBuilder;
        }
      }
      stringBuilder.m_ChunkLength -= sourceIndex - destinationIndex;
      if (destinationIndex == sourceIndex)  // Иногда не требуется перемещенияreturn;
        // Удаляем символы в конечном кусочке,         // перемещая оставшиеся символы влево
        StringBuilder.ThreadSafeCopy(
          stringBuilder.m_ChunkChars, sourceIndex, 
          stringBuilder.m_ChunkChars, destinationIndex, count1);
    }

Реализация данного метода существенно усложнилась. Однако надо учесть, что предыдущая реализация копировала большое количество символов, смещая их влево. Здесь же необходимо производить смещение, только если удаляемая строка попадает на стык двух элементов (StringBuilder-ов) в списке.

Метод ToString

        public
        override
        unsafe
        string ToString()
    {
      if (this.Length == 0)
        returnstring.Empty;
      string str = string.FastAllocateString(this.Length);
      StringBuilder stringBuilder = this;
      fixed (char* chPtr = str)
      {
        do
        {
          if (stringBuilder.m_ChunkLength > 0)
          {
            char[] chArray = stringBuilder.m_ChunkChars;
            int num = stringBuilder.m_ChunkOffset;
            int charCount = stringBuilder.m_ChunkLength;
            fixed (char* smem = chArray)
              string.wstrcpy(chPtr + num, smem, charCount);
          }
          stringBuilder = stringBuilder.m_ChunkPrevious;
        }
        while (stringBuilder != null);
      }
      return str;
    }

Данный метод проходит по всему связному списку StringBuilder-ов и последовательно копирует символы каждого из элементов списка в результирующую строку.

Сравнение производительности

Пожалуй, самая интересная часть – это сравнение производительности двух версий класса. В качестве временной шкалы используются миллисекунды.

Тест 1. Сколько требуется памяти для хранения строки заданной длины.

Длина строки

.NET 2.0

.NET 4.0

0

72

72

20

104

144

100

296

416

260

1064

1264

515

2100

2328

1025

4136

4416

2050

8232

8552

4100

16224

16784

4490

16224

16784

9000

32840

33176

15000

32840

33176

16384

32840

49248

16385

65608

49248

17500

65608

49248

20000

65608

49248

Как видите, при небольшой длине строки новая реализация проигрывает старой. Оно и понятно, ведь для каждого элемента списка (StringBuilder) требуется информация о длине, емкости, смещении от начала строки + для массива символов overhead. Но как только длина строки становится больше 16384 символов, старая реализация начинает проигрывать (из-за удвоения размера строки, она содержит много неиспользуемых символов).

Тест 2. Метод Append()

Количество вызовов

.NET 2.0

.NET 4.0

1

0,0004

0,0012

10

0,0012

0,0041

100

0,0069

0,002

1000

0,039

0,0197

10000

0,2725

0,1642

100000

2,8108

1,5686

Пожалуй, это тот самый метод, в котором новая реализация побеждает. Поскольку при нехватке памяти теперь удваивать длину строки и копировать в нее символы не требуется, то данный метод выполняется значительно быстрее, почти в два раза (точнее в 1,8 раз).

Тест 3. Метод Insert()

Будем производить вставку в строку, уже заполненную символами, длиною 1000 символов.

1. Вставка в начало строки

Количество вызовов

.NET 2.0

.NET 4.0

1

0,002

0,0032

10

0,0045

0,0086

100

0,0316

0,0357

1000

0,789

1,8909

10000

46,7942

230,428

100000

4226,4089

33994,6906

2. Вставка в середину строки

Количество вызовов

.NET 2.0

.NET 4.0

1

0,0024

0,0065

10

0,009

0,0036

100

0,0262

0,0279

1000

0,461

0,8432

10000

31,2598

154,631

100000

2036,1254

33446,0967

3. Вставка в конец строки

Количество вызовов

.NET 2.0

.NET 4.0

1

0,0016

0,0069

10

0,0073

0,009

100

0,0114

0,0275

1000

0,078

0,4967

10000

0,6387

1,6942

100000

7,3624

18,6122

Комментарии излишни – новая реализация проигрывает при вставке в любое место.

Тест 4. Метод Remove()

Будем производить удаление 10 символов из строки, уже заполненной символами, до тех пор, пока не исчерпаем ее.

1. Удаляем из начала строки

Длина строки

.NET 2.0

.NET 4.0

100

0,0036

0,0123

1000

0,0353

0,0201

10000

2,7025

0,7114

100000

257,3006

22,1768

2. Удаляем из середины строки

Длина строки

.NET 2.0

.NET 4.0

100

0,0078

0,0217

1000

0,0242

0,0291

10000

1,5189

0,8571

100000

135,7896

22,1296

3. Удаляем из конца строки

Длина строки

.NET 2.0

.NET 4.0

100

0,0069

0,0192

1000

0,0078

0,0143

10000

0,0632

0,1128

100000

0,5562

0,6285

Новая реализация выигрывает при удалении почти из любого места, так как теперь не требуется смещать символы оставшейся строки влево (точнее, требуется, но не так много как раньше).

Тест 5. Метод ToString()

Как было сказано выше, данный метод проигрывает предыдущей реализации. Оно и понятно, ведь предыдущая реализация возвращала просто ссылку на строку, которой она оперировала (при первом вызове), а новая вынуждена собирать результирующую строку по кускам, обходя каждый элемент связного списка.

Строка формируется методом Append()

Длина строки

.NET 2.0

.NET 4.0

10

0,004

0,0036

100

0,004

0,0004

1000

0,004

0,009

10000

0,004

0,0102

100000

0,004

0,0599

Строка формируется методом Insert()

Длина строки

.NET 2.0

.NET 4.0

10

0,004

0,0028

100

0,004

0,0016

1000

0,004

0,0176

10000

0,004

0,0438

100000

0,004

0,1104

Новая реализация работает заметно медленнее, если строка формировалась с помощью метода Insert(), поскольку список в этом случае состоит из множества элементов (StringBuilder-ов) длиною в 16 символов.

Тест 6. Обращение по определенному индексу

Учитывая, что теперь StringBuilder представляет собой связный список, операция обращения к строке по определенному индексу становится дорогостоящей. Особенно если он формировался с помощью метода Insert.

Строка формируется методом Append()

Длина строки

.NET 2.0

.NET 4.0

10

0,0008

0,0012

100

0,0053

0,0032

1000

0,0151

0,0069

10000

0,0533

0,0697

100000

0,4971

1,6659

Строка формируется методом Insert()

Длина строки

.NET 2.0

.NET 4.0

10

0,0012

0,0036

100

0,0024

0,0069

1000

0,0094

0,1326

10000

0,0504

19,3951

100000

0,4647

1364,6003

Тест 7. Обычный сценарий: множество вызовов Append(), а затем вызов ToString()

Как правило, мы работаем с данным классом по определенному сценарию: множественный вызов метода Append(), за которым следует один вызов ToString(). Реализация данного класса поменялась именно в расчете на данный сценарий.

Длина строки

.NET 2.0

.NET 4.0

10

0,0024

0,0028

100

0,0041

0,0053

1000

0,0402

0,0131

10000

0,3727

0,1508

100000

3,5313

1,6816

Заключение

Как мы увидели, класс StringBuilder в .NET 2.0 был оптимизирован для быстроты работы метода ToString(), в то время как в .NET 4.0 - для быстроты метода Append(). Новая реализация метода Append() работает почти в 2 раза быстрее, в то время как методы Insert() и ToString() работают медленнее. Но поскольку мы работаем с данным классом по определенному сценарию: вызываем множество раз метод Append(), за которым следует единственный вызов метода ToString(), то увеличение производительности имеет место.

Учитывая новую реализацию класса, при которой лишь множественный вызов метода Append() приводит к увеличению производительности, класс теперь можно было бы назвать StringAppender.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.