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

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

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

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

© Habrahabr.ru