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<string>(), 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

Ссылка на код.

Это также показывает кое-что еще, о чем мы говорили ранее. Ассемблерный код фактически выделяет память, необходимую для строки, на основе требуемой длины, переданной вызывающим кодом.

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


ссылка на оригинал статьи https://habr.com/ru/articles/727300/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *