Сообщений 2 Оценка 911 [+1/-0] Оценить |
Введение Метод Append Сравнение производительности Заключение |
Как известно, строки в .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 имел следующие поля:
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. Это позволяет избежать очередного удвоения размера и копирования данных при нехватке памяти. Однако это возможно лишь в том случае, если мы заранее знаем размер получаемой строки.
Рассмотрим реализации наиболее часто используемых методов. Для читаемости кода я убрал условия проверки входных параметров.
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; } |
Данный метод просто проверяет, хватает ли места в текущем экземпляре для добавления новой строки, если да, то происходит копирование на месте в незанятую часть строки, иначе удвоение размера и копирование старой и новой строки.
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; } |
Данный метод (аналогично предыдущему) проверяет, хватает ли места в текущем экземпляре для вставки новой строки и, в зависимости от этого, удваивает размер строки или же вставляет на месте в исходную строку.
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; } |
Данный метод удаляет ненужные символы, сдвигая оставшуюся часть строки влево. При удалении последних символов фактически ничего сдвигать не требуется.
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), что также не есть хорошо.
Как я уже сказал, в .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 выполнялись бы медленнее. |
Рассмотрим реализации наиболее часто используемых методов:
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), так как элементов списка не слишком много.
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).
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-ов) в списке.
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-ов и последовательно копирует символы каждого из элементов списка в результирующую строку.
Пожалуй, самая интересная часть – это сравнение производительности двух версий класса. В качестве временной шкалы используются миллисекунды.
Длина строки |
.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 символов, старая реализация начинает проигрывать (из-за удвоения размера строки, она содержит много неиспользуемых символов).
Количество вызовов |
.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 раз).
Будем производить вставку в строку, уже заполненную символами, длиною 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 |
Комментарии излишни – новая реализация проигрывает при вставке в любое место.
Будем производить удаление 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 |
Новая реализация выигрывает при удалении почти из любого места, так как теперь не требуется смещать символы оставшейся строки влево (точнее, требуется, но не так много как раньше).
Как было сказано выше, данный метод проигрывает предыдущей реализации. Оно и понятно, ведь предыдущая реализация возвращала просто ссылку на строку, которой она оперировала (при первом вызове), а новая вынуждена собирать результирующую строку по кускам, обходя каждый элемент связного списка.
Строка формируется методом 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 символов.
Учитывая, что теперь 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 |
Как правило, мы работаем с данным классом по определенному сценарию: множественный вызов метода 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.
Сообщений 2 Оценка 911 [+1/-0] Оценить |