Исходный код:
https://referencesource.microsoft.com/#mscorlib/system/text/stringbuilder.cs,78bad93b62e6340d
Что это, зачем нужно
Что есть строка в C#? Напомню основы. Есть Value, то есть значимые типы данных. И есть Reference, то есть ссылочные типы данных.
Мы привыкли, что «базовые» типы, вроде int, bool, long и подобные — это значимые типы, которые хранятся в стеке (в стандартном сценарии).
Давайте проведем маленький эксперимент.
Вначале посмотрим на то — как себя ведёт int.
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23  | 
						public static void Main() {     Console.WriteLine("Int is value type? " + typeof(int).IsValueType); // true     var i = 0;     Console.WriteLine(i); // 0     Increment(i);     Console.WriteLine(i); // 0     IncrementRef(ref i);     Console.WriteLine(i); // 1 } private static void Increment(int i) {     i = i + 1; } private static void IncrementRef(ref int i) {     i = i + 1; }  | 
					
Посмотрим как себя будет вести класс
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29  | 
						public class Test {     public string StringValue {get; set;}	 } public static void Main() {     Console.WriteLine("Test is value type? " + typeof(Test).IsValueType); // false     var test = new Test();     test.StringValue = "q";     Console.WriteLine(test.StringValue); // q     AddLetter(test);     Console.WriteLine(test.StringValue); // qq     AddLetterRef(ref test);     Console.WriteLine(test.StringValue); // qqq } private static void AddLetter(Test t) {     t.StringValue = t.StringValue + "q"; } private static void AddLetterRef(ref Test t) {     t.StringValue = t.StringValue + "q"; }  | 
					
Теперь посмотрим на аналогичную картину со string
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23  | 
						public static void Main() {     Console.WriteLine("String is value type? " + typeof(string).IsValueType); // false     var s = "q";     Console.WriteLine(s); // q     AddLetter(s);     Console.WriteLine(s); // q     AddLetterRef(ref s);     Console.WriteLine(s); // qq } private static void AddLetter(string s) {     s = s + "q"; } private static void AddLetterRef(ref string s) {     s = s + "q"; }  | 
					
Это ref тип, однако у его поведения есть признаки value-type, что может запутать неопытного разработчика.
Также любые методы, вроде Insert, либо Replace не меняют состояния класса String. Всё правильно — это класс, а не структура. Такие методы, как и операция + пораждают новый экземпляр класса.
Давайте посмотрим на код string.Insert, опустив проверки и оставив только логику:
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14  | 
						public string Insert(int startIndex, string value) {   int length1 = this.Length;   int length2 = value.Length;   if (length1 == 0)     return value;   if (length2 == 0)     return this;   string str = string.FastAllocateString(length1 + length2);   Buffer.Memmove<char>(ref str._firstChar, ref this._firstChar, (UIntPtr) startIndex);   Buffer.Memmove<char>(ref Unsafe.Add<char>(ref str._firstChar, startIndex), ref value._firstChar, (UIntPtr) length2);   Buffer.Memmove<char>(ref Unsafe.Add<char>(ref str._firstChar, startIndex + length2), ref Unsafe.Add<char>(ref this._firstChar, startIndex), (UIntPtr) (length1 - startIndex));   return str; }  | 
					
Создается новая строка с нужным размером, а затем происходит немного магии по переносу данных.. Но возвращается именно новая строка! А значит, что когда мы в цикле собираем данные в одну строку — мы при каждой итерации порождаем новый экземпляр класса string, который будет висеть в куче и создавать лишнюю работу для Garbage Collector‘а.
И вот мы приходим к тому, что для решения проблемы собирания мусора в памяти нужен инструмент. И разработчики C# его дали — это StringBuilder.
Конструкторы StringBuilder
Их тут аж 6. Но они, в основном, обертки друг над другом.
Первый — без параметров
| 
					 1 2 3  | 
						public StringBuilder()     : this(DefaultCapacity) { }  | 
					
Что такое DefaultCapacity? Это числовая константа со значением 16.
| 
					 1  | 
						internal const int DefaultCapacity = 16;  | 
					
Второй — с параметром capacity на вход
| 
					 1 2 3  | 
						public StringBuilder(int capacity)     : this(String.Empty, capacity) { }  | 
					
Третий — с параметрами (String value, int capacity)
| 
					 1 2 3  | 
						public StringBuilder(String value, int capacity)     : this(value, 0, ((value != null) ? value.Length : 0), capacity) { }  | 
					
Четвертый — со строкой на вход
| 
					 1 2 3  | 
						public StringBuilder(String value)     : this(value, DefaultCapacity) { }  | 
					
Пятый — с целыми 4 параметрами на вход:
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35  | 
						public StringBuilder(String value, int startIndex, int length, int capacity) { 	if (capacity<0) { 		throw new ArgumentOutOfRangeException("capacity", 			Environment.GetResourceString("ArgumentOutOfRange_MustBePositive", "capacity")); 	} 	if (length<0) { 		throw new ArgumentOutOfRangeException("length", 			Environment.GetResourceString("ArgumentOutOfRange_MustBeNonNegNum", "length")); 	} 	if (startIndex<0) { 		throw new ArgumentOutOfRangeException("startIndex", Environment.GetResourceString("ArgumentOutOfRange_StartIndex")); 	} 	Contract.EndContractBlock(); 	if (value == null) { 		value = String.Empty; 	} 	if (startIndex > value.Length - length) { 		throw new ArgumentOutOfRangeException("length", Environment.GetResourceString("ArgumentOutOfRange_IndexLength")); 	} 	m_MaxCapacity = Int32.MaxValue; 	if (capacity == 0) { 		capacity = DefaultCapacity; 	} 	if (capacity < length) 		capacity = length; 	m_ChunkChars = new char[capacity]; 	m_ChunkLength = length; 	unsafe { 		fixed (char* sourcePtr = value) 			ThreadSafeCopy(sourcePtr + startIndex, m_ChunkChars, 0, length); 	} }  | 
					
Рассмотрим логику — вначале куча проверок и подмена value на string.Empty (если он был null).
Внутри ThreadSafeCopy происходит заполнение массива символов (m_ChunkChars) через string.wstrcpy (об этом как-нибудь в другой раз).
Шестой, последний конструктор, устанавливает capacity и maxCapacity
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20  | 
						public StringBuilder(int capacity, int maxCapacity) { 	if (capacity>maxCapacity) { 		throw new ArgumentOutOfRangeException("capacity", Environment.GetResourceString("ArgumentOutOfRange_Capacity")); 	} 	if (maxCapacity<1) { 		throw new ArgumentOutOfRangeException("maxCapacity", Environment.GetResourceString("ArgumentOutOfRange_SmallMaxCapacity")); 	} 	if (capacity<0) { 		throw new ArgumentOutOfRangeException("capacity", 											  Environment.GetResourceString("ArgumentOutOfRange_MustBePositive", "capacity")); 	} 	Contract.EndContractBlock(); 	if (capacity == 0) { 		capacity = Math.Min(DefaultCapacity, maxCapacity); 	} 	m_MaxCapacity = maxCapacity; 	m_ChunkChars = new char[capacity]; }  | 
					
Состояние класса и константы
Вначале — константы
| 
					 1 2  | 
						internal const int DefaultCapacity = 16; internal const int MaxChunkSize = 8000;  | 
					
Стандартный размер — 16 и максимальный размер чанка — 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).
Что в переводе:
Мы хотим держать массивы чанков вне LOH (Large Object Heap, об этом в другой статье). Если массив попадёт в LOH, меньше раз будет вызван код на выделение памяти, но вызовы insert’ов и replace’ов будут выполняться медленнее
| 
					 1 2 3 4 5  | 
						internal char[] m_ChunkChars;                // The characters in this block internal StringBuilder m_ChunkPrevious;      // Link to the block logically before this block internal int m_ChunkLength;                  // The index in m_ChunkChars that represent the end of the block internal int m_ChunkOffset;                  // The logial offset (sum of all characters in previous blocks) internal int m_MaxCapacity = 0;  | 
					
| m_ChunkChars | Массив с символами в текущем блоке/чанке. | 
| m_ChunkPrevious | Ссылка на предыдущий блок/чанк | 
| m_ChunkLength | Индекс окончания блока m_ChunkChars, сколько элементов хранится в этом блоке/чанке | 
| m_ChunkOffset | Логический оффсет (сколько символов во всех предыдущих блоках/чанках хранится) | 
| m_MaxCapacity | Максимальная вместимость | 
Как работает заполнение StringBuilder
По факту тут очень много магии unsafe работы с буферами и строками. Фактически, для понимания, нужно знать следующее — у главного StringBuilder (тот, который мы создаем сами в коде) при его инициализации в последней версии .NET 8 имеет размер равный 16
После того, как мы положим в него 256 символов — мы увидим такую картину:
А именно — у родительского (главного) StringBuilder паровозиком прицеплены ещё 4. И тот объект, который мы видели на первом скриншоте (с m_ChunkCapacity = 16 и m_ChunkOffset = 0) — стал последним (в самой глубине списка).
Если идти от самого последнего к первому и смотреть на их m_ChunkCapacity — мы увидим, что имеет место геометрическая прогрессия: 16, 32, 64, 128, 256. А если на размерности массивов m_ChunkChars, то увидим аналогичную прогрессию: 16, 16, 32, 64, 128. Похожую логику мы разбирали уже в C# Особенности работы List.
То есть, если мы не задаём никакой размерности, то по умолчанию — это 16. Когда добавляется 17 символ — появляется новое звено в цепи, но пока тоже на 16 элементов. А вот когда это звено заполнится — пойдет прогрессия и каждое последующее звено будет в 2 раза больше предыдущего. Но не более 8000. Почему? Потому что тут, как и в случае с C# Особенности работы List — разработчики не хотят, чтобы блок попал в LOH (Large Object Heap), работа с которым идёт медленнее.
Давайте проведем эксперимент. Судя по интернету — в романе «Война и Мир» почти 3 миллиона символов. А мы возьмем число ещё больше — половина от int.MaxValue. Попробуем поэксперементировать с этим.
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33  | 
						public static void Main() { 	var sb = new StringBuilder(); 	PrintStringBuilderInfo(sb); // 0 / 16 	AddCharsToStringBuilder(sb, 16); 	PrintStringBuilderInfo(sb); // 16 / 16 	AddCharsToStringBuilder(sb, 1); 	PrintStringBuilderInfo(sb); // 17 / 32 	AddCharsToStringBuilder(sb, 15); 	PrintStringBuilderInfo(sb); // 32 / 32 	AddCharsToStringBuilder(sb, 32); 	PrintStringBuilderInfo(sb); // 64 / 64 	AddCharsToStringBuilder(sb, int.MaxValue / 2); 	PrintStringBuilderInfo(sb); // 1073741887 / 1073744192 } private static void AddCharsToStringBuilder(StringBuilder sb, int charsCount) { 	for (var i = 0; i < charsCount; i++) 	{ 		sb.Append('-'); 	} } private static void PrintStringBuilderInfo(StringBuilder sb) { 	Console.WriteLine(sb.Length + " / " + sb.Capacity); }  | 
					
У последнего блока размерность массива равна 8000.
А если, вместо добавления символа в цикле использовать перегрузку метода Append, где есть возможность итерационного добавления?
Обновим код метода:
| 
					 1 2 3 4  | 
						private static void AddCharsToStringBuilder(StringBuilder sb, int charsCount) { 	sb.Append('-', charsCount); }  | 
					
Вывод остался таким же, но есть интересный нюанс — блоков стало много меньше.. Есть два по 16 символов, один на 32, один на 64.. И один на всё оставшееся. Вот такая подстава.
Если попытаться добавить что-нибудь в этот StringBuilder ещё, то он сразу добавить новый блок на 8000 элементов.
Как работает ToString
Вот тут, как раз, всё достаточно просто.
Генерируется строка нужного размера, а затем в неё переносятся все необходимые данные из массива из родительского (основного) StringBuilder по заданному отступу. 
Затем по цепочке достаются остальные StringBuilder‘ы и из них уже переносятся данные в строку.
Итого
Конкатенация строк приводит к забиванию мусором кучи, поэтому для решения этой проблемы стоит использовать StringBuilder. Стоит указывать ожидаемый размер строки при инициализации StringBuilder, но не стоит указывать более 8000, иначе первый же созданный блок будет именно заданного вами размера, и он попадёт в Large Object Heap (LOH). 
То есть если не указываем ничего (тогда размер будет 16), при ожидаемом размере строки в 100 символов будет создана цепь из 4 блоков. Если укажем более 8000 — попадём в Large Object Heap. Если укажем 8000, но символов будет ожидаемо меньше, то мы выделим 8кб памяти впустую. То есть лучше стараться анализировать и предугадывать потенциальный размер строки.
