System.String не то, чем кажется. Представление строк в памяти .NET
Тип System.String — один из самых используемых при разработке. В этой статье я хотел бы поговорить о нюансах его реализации, начнем с базовой информации:
System.String — это ссылочный тип;
System.String — это неизменяемый тип. На самом деле, строку нельзя изменить (по крайней мере с помощью безопасного кода). Все методы вроде .Trim, .Insert и пр. не изменяют содержимое строки, на которую первоначально ссылались, а просто устанавливают ссылку на новую.
Теперь заглянем немного глубже. Опираясь на наши вводные, посмотрим, как это устроено в памяти. Начнем с того, что строки (как и массивы) не фиксированы в размерах. Однако, стоит помнить, что экземпляр любого типа не может занимать в памяти больше 2Gb в памяти, это ограничение распространяется как на x86, так и x64 системы. Обычно GC знает о том, сколько места в памяти занимает объект при его создании. Потому что он основан на определенных типах и свойствах, которые не меняются. Но это не наш случай. Давайте разберемся. Под катом строка не ссылается на массив char«ом, а содержит их внутри. Если обратиться к исходникам, то мы обнаружим эти два замечательных поля.
// The String class represents a static string of characters. Many of
// the string methods perform some type of transformation on the current
// instance and return the result as a new string. As with arrays, character
// positions (indices) are zero-based.
[Serializable]
[NonVersionable] // This only applies to field layout
[System.Runtime.CompilerServices.TypeForwardedFrom("mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089")]
public sealed partial class String{
// Остальной код
//
// These fields map directly onto the fields in an EE StringObject. See object.h for the layout.
//
[NonSerialize]
private readonly int _stringLength;
// For empty strings, _firstChar will be '\0', since strings are both null-terminated and length-prefixed.
// The field is also read-only, however String uses .ctors that C# doesn't recognise as .ctors,
// so trying to mark the field as 'readonly' causes the compiler to complain.
[NonSerialized]
private char _firstChar;
//Остальной код
}
Ссылка на код.
Класс String в C# — это управляемый исходный файл, но большая часть его кода реализована на C или ассемблере. В файле String.cs содержится 9 методов, которые помечены как extern и аннотированы атрибутом MethodImplAttribute с параметром InternalCall. Это говорит о том, что их реализации предоставляются исполняющей средой в другом месте.
Чтобы разобраться лучше перейдем в layout object.h и увидим:
/*
* StringObject
*
* Special String implementation for performance.
*
* m_StringLength - Length of string in number of WCHARs
* m_FirstChar - The string buffer
*
*/
class StringObject : public Object{
// Остальной код
private:
DWORD m_StringLength;
WCHAR m_FirstChar;
// Остальной код
}
Ссылка на код.
Получатся, вот так GC будет видеть строку в памяти:
Именно так, потому что следующие m_StringLength байт будут заняты массивом данных этой строки. В таком случае фактические строковые данные не будут храниться в массиве байтов, расположенном в другом месте памяти, и поэтому для их обнаружения не требуется ссылка на указатель и их поиск.
Давайте рассмотрим всю картину, сколько в итоге памяти занимает строка?
8 (sync) + 8 (type) + 4 (length) + 4(extra field) + 2 (null terminator) + 2 * length
Получается: 26 + 2 * length
8 байт — SyncBlock, 8 байт — Method table pointer, тут все понятно.
4 байт — m_stringLength member, фактическое количество символов в строке
4 байт — extra field, до .NET 4.0 это место было отведено для m_arrayLength. В предыдущих реализациях длинна строки могла отличаться от длинны массива символов, входящего в нее. В последующих версиях эта память была сохранена и оставлена пустой. Вероятнее для исключение проблем с кодом pinvoke.
2 * length — по два байта на каждый символ, начиная с m_FirstChar. Строки всегда имеют кодировку Unicode. Это очень важно знать и понимать. Работа с строкой так, будто она представлена в другой кодировке, практически всегда является ошибкой. Набор кодированных символов Unicode содержит более 65536 символов. Это означает, что один символ (System.Char) не может охватывать все символы. Это приводит к использованию суррогатов, где символы выше U+FFFF представлены в строках как два символа. По сути, строка использует форму кодировки символов UTF-16. Большинству разработчиков, возможно, не нужно много знать об этом, но, по крайней мере, знать об этом стоит.
2 байта — Null terminator. Хотя строки не заканчиваются Null (не путать с ключевым словом null) с точки зрения API, массив символов завершается Null, так как это означает, что он может быть передан непосредственно в неуправляемые функции без какого-либо копирования. При условии, что взаимодействие указывает, что символы в строке должны быть маршалированны как Unicode.
Основное различие между x86 и x64 системами заключается в размере DWORD — указателя памяти. В 32-битных системах он составляет 4 байта, в 64-битных уже 8 байт.
Но как GC выделяет память для объекта, размер которого он не знает? Ответ прост: никак. Обычно сначала GC выделяет память, а потом вызывается конструктор класса. С String все иначе, при инициализации типа сам конструктор выделяет память для объекта. Рассмотрим на примере.
Для работы с строками чаще всего используется String.Builder или String.Format (который в конечном итоге использует String.Builder). В конечном итоге, вызывается метод StringBuilder.ToString (), он же внутри вызывает FastAllocateString для класса String:
public override string ToString()
{
//Остальной код
string result = string.FastAllocateString(Length);
//Остальной код
}
Ссылка на код.
Рассмотрим его подробнее.
// This class is marked EagerStaticClassConstruction because it's nice to have this
// eagerly constructed to avoid the cost of defered ctors. I can't imagine any app that doesn't use string
[EagerStaticClassConstruction]
public partial class String
{
[Intrinsic]
public static readonly string Empty = "";
internal static string FastAllocateString(int length)
{
// We allocate one extra char as an interop convenience so that our strings are null-
// terminated, however, we don't pass the extra +1 to the string allocation because the base
// size of this object includes the _firstChar field.
string newStr = RuntimeImports.RhNewString(EETypePtr.EETypePtrOf(), length);
Debug.Assert(newStr._stringLength == length);
return newStr;
}
}
Ссылка на код.
Оказывается, это просто обертка, найдем RhNewString:
[MethodImpl(MethodImplOptions.InternalCall)]
[RuntimeImport(RuntimeLibrary, "RhNewString")]
internal static extern unsafe string RhNewString(MethodTable* pEEType, int length);
internal static unsafe string RhNewString(EETypePtr pEEType, int length)
=> RhNewString(pEEType.ToPointer(), length);
Ссылка на код.
Этот метод помечен как внешний и к нему применен атрибут [MethodImpl (MethodImplOptions.InternalCall)], это означает, что он будет реализован CLR в неуправляемом коде. В конечном итоге стек вызовов оказывается в написанной от руки ассемблерной функции:
;; Allocate a new string.
;; ECX == MethodTable
;; EDX == element count
FASTCALL_FUNC RhNewString, 8
push ecx
push edx
;; Make sure computing the aligned overall allocation size won't overflow
cmp edx, MAX_STRING_LENGTH
ja StringSizeOverflow
; Compute overall allocation size (align(base size + (element size * elements), 4)).
lea eax, [(edx * STRING_COMPONENT_SIZE) + (STRING_BASE_SIZE + 3)]
and eax, -4
; ECX == MethodTable
; EAX == allocation size
; EDX == scratch
INLINE_GETTHREAD edx, ecx ; edx = GetThread(), TRASHES ecx
; ECX == scratch
; EAX == allocation size
; EDX == thread
mov ecx, eax
add eax, [edx + OFFSETOF__Thread__m_alloc_context__alloc_ptr]
jc StringAllocContextOverflow
cmp eax, [edx + OFFSETOF__Thread__m_alloc_context__alloc_limit]
ja StringAllocContextOverflow
; ECX == allocation size
; EAX == new alloc ptr
; EDX == thread
; set the new alloc pointer
mov [edx + OFFSETOF__Thread__m_alloc_context__alloc_ptr], eax
; calc the new object pointer
sub eax, ecx
pop edx
pop ecx
; set the new object's MethodTable pointer and element count
mov [eax + OFFSETOF__Object__m_pEEType], ecx
mov [eax + OFFSETOF__String__m_Length], edx
ret
Ссылка на код.
Это также показывает кое-что еще, о чем мы говорили ранее. Ассемблерный код фактически выделяет память, необходимую для строки, на основе требуемой длины, переданной вызывающим кодом.
С одной стороны, строки являются фундаментальным типом, именно поэтому должны быть максимально оптимизированы. С другой стороны, для такого базового типа строки (и текстовые данные в целом) имеют большую сложность, чем изначально можно предполагать. Информация, предложенная в статье, не является исчерпывающей, но поможет вам лучше понимать процессы, происходящие под катом ваших проектов.