GrabDuck

Знакомство с внутренним устройством .NET Framework. Посмотрим, как CLR создаёт объекты

:

Вниманию читателей «Хабрахабра» представляется перевод статьи Хану Коммалапати и Тома Кристиана об внутреннем устройстве .NET. Существует альтернативный вариант перевода на сайте Microsoft.

В статье рассматривается:

  • Системный домен (SystemDomain), Домен общего доступа (SharedDomain) и домен по умолчанию (DefaultDomain)
  • Представление объекта и другие особенности организации памяти
  • Представление таблицы методов
  • Распределение методов

Используемые технологии: .NET Framework, C#
  1. Домены создаваемые начальным загрузчиком
  2. Системный домен
  3. Домен общего доступа (разделяемый)
  4. Дефолтный домен
  5. Загрузчик куч
  6. Основы типов
  7. Экземпляр объекта
  8. Таблица методов
  9. Размер базового экземпляра
  10. Таблица слотов метода
  11. Описатель метода
  12. Карта таблиц виртуальных методов интерфейсов и карта интерфейса
  13. Виртуальное распределение
  14. Статические переменные
  15. EEClass
  16. Заключение


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

В этой статье мы будем исследовать внутреннее устройство CLR, включая макет экземпляра объекта, макет таблицы методов, распределение методов, интерфейсное распределение, а также различные структуры данных.

Мы будем использовать очень простые фрагменты С# кода, любое неявное использование синтаксиса языка программирования подразумевает С#. Некоторые обсуждаемые структуры данных и алгоритмы будут изменены в следующих версиях среды Microsoft® .NET Framework, но концептуальные основы останутся прежними. Будем использовать отладчик Visual Studio® .NET 2003 и расширение для отладки Son of Strike (SOS) чтобы просматривать структуры данных обсуждаемые в статье. SOS загружает внутренние данные CLR, и позволяет просматривать, сохранять интересующую информацию. Посмотрите процедуру загрузки SOS.dll в процесс отладчика в соответствующих источниках.
See the «Son of Strike» sidebar for loading SOS.dll into the Visual Studio .NET 2003 debugger process.

В статье мы будем описывать классы соответствующие реализациям в Shared Source CLI (SSCLI).

Таблица на рисунке 1 поможет в исследовании мегабайтов кода в SSCLI во время поиска необходимых структур.

Рисунок 1 SSCLI Ссылки
Компонент SSCLI Путь
AppDomain /sscli/clr/src/vm/appdomain.hpp
AppDomainStringLiteralMap /sscli/clr/src/vm/stringliteralmap.h
BaseDomain /sscli/clr/src/vm/appdomain.hpp
ClassLoader /sscli/clr/src/vm/clsload.hpp
EEClass /sscli/clr/src/vm/class.h
FieldDescs /sscli/clr/src/vm/field.h
GCHeap /sscli/clr/src/vm/gc.h
GlobalStringLiteralMap /sscli/clr/src/vm/stringliteralmap.h
HandleTable /sscli/clr/src/vm/handletable.h
InterfaceVTableMapMgr /sscli/clr/src/vm/appdomain.hpp
Large Object Heap /sscli/clr/src/vm/gc.h
LayoutKind /sscli/clr/src/bcl/system/runtime/interopservices/layoutkind.cs
LoaderHeaps /sscli/clr/src/inc/utilcode.h
MethodDescs /sscli/clr/src/vm/method.hpp
MethodTables /sscli/clr/src/vm/class.h
OBJECTREF /sscli/clr/src/vm/typehandle.h
SecurityContext /sscli/clr/src/vm/security.h
SecurityDescriptor /sscli/clr/src/vm/security.h
SharedDomain /sscli/clr/src/vm/appdomain.hpp
StructLayoutAttribute /sscli/clr/src/bcl/system/runtime/interopservices/attributes.cs
SyncTableEntry /sscli/clr/src/vm/syncblk.h
System namespace /sscli/clr/src/bcl/system
SystemDomain /sscli/clr/src/vm/appdomain.hpp
TypeHandle /sscli/clr/src/vm/typehandle.h


Момент на который стоит обратить внимание перед тем как мы пойдём дальше – информация предоставленная в этой статье действительна, только для .NET Framework 1.1 (также в основном это соответствует Shared Source CLI 1.0, с учётом ряда заметных исключений, присутствующих в различных сценариях взаимодействия) при исполнении на платформе x86. Информация изменена в следующих версиях .NET Framework, поэтому пожалуйста не занимайтесь сборкой ваших приложений с абсолютными ссылками на эти внутренние структуры.

Домены, создаваемые загрузчиком CLR


Перед тем, как запустить первую строчку управляемого кода, создаётся три домена приложения. Два из них не доступны в управляемом коде и даже не видимы для хоста CLR. Они могут быть только созданы только при загрузке CLR обеспечиваемой шиной mscoree.dll и mscorwks.dll (или mscorsvr.dll для мультипроцессорных систем). Как вы можете видеть на рисунке 2, это системный домен и разделяемый (общий) домен, они могут существовать только в одном экземпляре. Третий домен – дефолтный, только экземпляр этого домена приложения имеет наименование. Для простого хоста CLR, такого как консольное приложение, имя дефолтного домена приложений содержит имя исполняемого образа. Дополнительные домены, могут быть созданы из управляемого кода методом AppDomain.CreateDomain или из хоста неуправляемого кода используя интерфейс ICORRuntimeHost.

Сложные хосты, такие как ASP.NET создают необходимое количество доменов, в соответствии с количеством приложений, работающих в обслуживаемом Web сайте.


Рисунок 2. Домены созданные загрузчиком CLR

Системный домен


Системный домен создаёт и инициализирует домен общего доступа (SharedDomain) и домен по умолчанию (Default). Он же выполняет загрузку системной библиотеки mscorlib.dll в домен общего доступа.

Также системный домен содержит доступные в границах процесса строковые константы, интернированные явно или не явно.

Интернирование строк это функционал оптимизации, немного тоталитарный в среде .NET Framework 1.1, так как CLR не даёт возможности сборкам оптимизировать этот фунционал. При этом, память используется для хранения только одного экземпляра строки для всех строковых литералов во всех доменах приложения.

Системный домен также служит для генерации идентификаторов интерфейсов в границах процесса, которые используются при создании карты интерфейсов (InterfaceVtableMaps) в каждом домене приложений (AppDomain).

Системный домен прослеживает все домены в процессе и предоставляет функциональность загрузки и выгрузки доменов приложений.

Домен общего доступа (SharedDomain)


Весь доменно-нейтральный код загружается в домен общего доступа. Mscorlib, системная библиотека, необходима для кода пользователя во всех доменах приложений(AppDomains). Эта бибилиотека автоматически загружается в домен общего доступа. Базовые типы из пространства имён System, такие как Object, ValueType, Array, Enum, String и Delegate загружаются предварительно в этот домен в процессе загрузки CLR загрузчиком. Код пользователя может также быть загружен в этот домен, с помощью установки атрибутов LoaderOptimization приложением хоcтом CLR во время вызова CorBindToRuntimeEx. Консольное приложение может загружать код в домен общего доступа при добавлении атрибута System.LoaderOptimizationAttribute к методу Main приложения. Домен общего доступа также управляет картой сборок, индексированной относительно базового адреса, карта действует как таблица справочник для управления общими зависимостями сборок, загруженных в дефолтный домен и другие домены приложений, созданные в управляемом коде. Дефолтный домен служит только для загрузки частного кода пользователя, который не должен быть доступен другим приложениям.

Дефолтный домен


Дефолтный домен это экземпляр домена приложения, где как правило выполняется код приложения. В то время как некоторые приложения требуют, чтобы дополнительные домены приложения были созданы во время выполнения (такие, что имеют архитектуру плагинов или приложения выполняющие генерацию значительного количества кода во время выполнения), большинство приложений создают один домен во время их выполнения. Весь код, выполняемый в этом домене, контекстно ограничен на уровне домена. Если в приложении создано несколько доменов приложения, любой кросс-доменный доступ будет происходить через прокси .NET Remoting. Дополнительные внутри-доменные границы могут быть созданы используя типы наследованные от System.ContextBoundObject.

Каждый домен приложений имеет свои собственные SecurityDescriptor, SecurityContext и DefaultContext, также как собственный загрузчик куч (High-Frequency Heap, Low-Frequency Heap, and Stub Heap),
Таблицы описателей (Handle Table, Large Object Heap Handle Table), Менеджер карты интерейсов Vtable и кэш сборок.

Кучи загрузчика


Кучи загрузчика(LoaderHeaps) предназначены для загрузки различных артефактов времени выполнения CLR и артефактов оптимизации, существующих в течение всего времени существования домена. Эти кучи увеличиваются на предсказуемые фрагменты для минимизации фрагментации. Кучи загрузчика отличаются от кучи сборщика мусора (GC) (или набора куч в случае симметричных мультипроцессоров SMP) в том, что куча сборщика мусора содержит экземпляры объектов, а кучи загрузчика содержат системные типы. Часто запрашиваемые структуры, такие как таблицы методов, описатели методов (MethodDescs), описатели полей (FieldDescs) и карта интерфейсов располагаются в куче частого доступа (HighFrequencyHeap). Структуры, к которым обращения более редки, такие как EEClass и загрузчик классов(ClassLoader), а также их служебные таблицы, располагаются в куче с низкой частотой обращений (LowFrequencyHeap). Служебная куча (StubHeap) содержит блоки, обеспечивающие поддержку безопасности доступа в коде code access security (CAS), оболочку COM вызовов и вызовов P/Invoke. Рассмотрев домены и кучи загрузчики на высоком уровне, теперь посмотрим на их физическую организацию более пристально в контексте простого приложения на рисунке 3. Остановим выполнение программы на «mc.Method1();» и создадим дамп домена с помощью расширенной команды DumpDomain отладчика SOS. Ниже представлен результат:
!DumpDomain
System Domain: 793e9d58, LowFrequencyHeap: 793e9dbc, 
HighFrequencyHeap: 793e9e14, StubHeap: 793e9e6c,
Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40
</br>
Shared Domain: 793eb278, LowFrequencyHeap: 793eb2dc,
HighFrequencyHeap: 793eb334, StubHeap: 793eb38c,
Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40
</br>
Domain 1: 149100, LowFrequencyHeap: 00149164,
HighFrequencyHeap: 001491bc, StubHeap: 00149214,
Name: Sample1.exe, Assembly: 00164938 [Sample1],
ClassLoader: 00164a78

Рисунок 3 Sample1.exe
using System;

public interface MyInterface1
{
    void Method1();
    void Method2();
}
public interface MyInterface2
{
    void Method2();
    void Method3();
}

class MyClass : MyInterface1, MyInterface2
{
    public static string str = "MyString";
    public static uint   ui = 0xAAAAAAAA;
    public void Method1() { Console.WriteLine("Method1"); }
    public void Method2() { Console.WriteLine("Method2"); }
    public virtual void Method3() { Console.WriteLine("Method3"); }
}

class Program
{
    static void Main()
    {
        MyClass mc = new MyClass();
        MyInterface1 mi1 = mc;
        MyInterface2 mi2 = mc;

        int i = MyClass.str.Length;
        uint j = MyClass.ui;

        mc.Method1();
        mi1.Method1();
        mi1.Method2();
        mi2.Method2();
        mi2.Method3();
        mc.Method3();
    }
}



Наше консольное приложение, Sample1.exe, загружено в домен приложения (AppDomain), который имеет имя «Sample1.exe”. Mscorlib.dll загружен в домен общего доступа (SharedDomain), но также фигурирует в системном домене (SystemDomain), как системная библиотека ядра. Куча высокочастотного доступа (HighFrequencyHeap), низкочастотного доступа (LowFrequencyHeap) и stub-куча(StubHeap) располагаются в каждом домене. Системный домен и домен общего доступа используют один и тот же загрузчик классов (ClassLoader), в то время как дефолтный домен (Default AppDomain) использует свой собственный.

Результат команды не отображает зарезервированный и используемый размер куч загрузчика. Куча высокочастотного доступа первоначально резервирует 32Кб и использует 4Кб.

Куча низкочастотного доступа stub кучи первоначально резервируют 8Кб и занимают 4Кб.

Также не показана куча карты интерфейсов (InterfaceVtableMap, далее IVMap) Каждый домен обладает картой интерфейсов, которая создаётся на своей собственной куче загрузчика во время фазы инициализации домена. Куча карты интерфейсов (IVMap) резервирует 4Кб и занимает 4Кб первоначально. Мы обсудим значимость карты интерфейсов, когда будем исследовать макет типа (type layout) в последующих секциях.

На Рисуноке 2 показаны куча дефолтного процесса (default Process Heap), куча компилятора времени выполнения (JIT Code), куча сборщика мусора (GC) для маленьких объектов (SOH) и куча больших объектов (LOH) (для объектов с размером 85000 байт или более) чтобы проиллюстрировать семантическое различие между ними и кучами загрузчика. JIT-компилятор или компилятор времени выполнения генерирует инструкции для архитектуры x86 и сохраняет их в куче для JIT кода. Куча сборщика мусора и куча больших объектов являются кучами, которые обрабатываются сборщиком мусора, на этих кучах создаются управляемые объекты.

Основы типов


Тип является фундаментальным элементом программирования в .NET. В C# тип может быть объявлен с помощью следующих ключевых слов: class, struct и interface. Большинство типов создаются самим программистом явно, однако, в особых случаях взаимодействия и в сценариях вызовов удалённых объектов (.NET Remoting), .NET CLR генерирует типы неявно. Эти генерируемые типы включают COM и вызываемые обертки времени выполнения (Runtime Callable Wrappers) и сквозные прокси (Transparent Proxies).

Мы исследуем .NET фундаментальные типы, начиная со структуры стека что содержит ссылки на объект (как правило, стек – одно из мест, с которых экземпляр объекта начинает своё сущесвование).
Код приведённый на Рисунке 4 содержит простую программу с консольной точкой входа, где вызывается статический метод.

Метод Method1создаёт экземпляр типа SmallClass, который содержит массив байт используемый для демонстрации создания экземпляра объекта в куче больших объектов LOH. Код тривиален, но будет задействован в нашем обсуждении.

Рисунок 4 Большие и маленькие объекты
using System;

class SmallClass
{
    private byte[] _largeObj;
    public SmallClass(int size)
    {
        _largeObj = new byte[size];
        _largeObj[0] = 0xAA;
        _largeObj[1] = 0xBB;
        _largeObj[2] = 0xCC;
    }

    public byte[] LargeObj
    {
        get { return this._largeObj; }
    }
}

class SimpleProgram
{
    static void Main(string[] args)
    {
        SmallClass smallObj = SimpleProgram.Create(84930,10,15,20,25);
        return;
    }

    static SmallClass Create(int size1, int size2, int size3, 
        int size4, int size5)
    {
        int objSize = size1 + size2 + size3 + size4 + size5;
        SmallClass smallObj = new SmallClass(objSize);
        return smallObj;
    }
}

Рисунок 5 показывает снимок типичного стека вызовов fastcall остановленного в точке останова на строке „return smallObj;“ в методе Create. (Fastcall — .NET конвенция вызовов которая определяет, что аргументы передаются в функции в регистрах, когда это возможно, с остальными аргументами передаваемыми через стек справа на лево и затем извлекаемыми из стека вызываемой функцией
Локальная переменная значимого типа или типа-значения objSize размещена прямо в стеке. Переменные ссылочного типа, такие как smallObj хранятся с фиксированным занимаемым размером (4 битовое двойное слово DWORD) в стеке и содержат адрес экземпляров объектов размещённых в обычной куче сборщика мусора.

В традиционном C++, this – это указатель на объект; в управляемом мире программирования this — это референс или ссылка на объект (object reference). Тем не менее, она содержит адрес экземпляра объекта. Мы будем использовать термин экземпляр объекта (ObjectInstance) для структуры данных расположенной на адресе указанном в ссылке на объект.


Рисунок 5. SimpleProgram стек и кучи

Экземпляр объекта smallObj на обычной куче сборщика мусора содержит Byte[] указывающий на _largeObj, чей размер 85000 байт ( заметьте, что рисунок показывает 85016 байт, что является действительным размером занимаемой области). CLR обращается с объектами размером более чем или равному 85000 байт по другому, в отличие от объектов меньшего. Большие объекты располагаются в куче больших объектов (LOH), в то время как маленькте объекты создаются а обычной куче сборщика мусора, которая оптимизирует размещение объектов и сбор мусора. LOH не сжимается, при этом обычная куча сжимается при каждом сборе мусора. Более того LOH очищается только при полном сборе мусора.

Экземпляр smallObj содержит описатель типа указывающий на таблицу методов (MethodTable) соответствующего типа. Будет присутствовать по одной таблице методов для каждого объявленного и все экземпляры объектов одного и того же типа будут указывать на одну и ту же таблицу методов. Также описатель будет содержать информацию о разновидности типа (интерфейс, абстрактный класс, конкретный класс, обёртка COM, прокси), число реализованных интерфейсов, карту интерфейсов для распределения методов, число слотов в таблице методов и таблицу слотов указывающих на реализацию.

Одна важная структура данных указывает на EEClass. Загрузчик классов CLR создаёт EEClass из метаданных до того как формируется таблица методов. На Рисунке 4, таблица методов SmallClass указывает на его EEClass. Эти структуры указывают на их модули и сборки. Таблица методов и EEClass как правило располагаются в домен-спeцифичных кучах загрузчика. Byte[] — это особый случай; Таблица методов и EEClass располагаются в кучах загрузчика домена общего доступа. Кучи загрузчика относятся к определённому домену (домен-специфичны) и любые структуры данных, упомянутые ранее, однажды загруженные, никуда не денутся пока домен не будет выгружен. Также, дефолтный домен не может быть выгружен и следовательно код существует пока не будет остановлен CLR.

Экземпляр объекта


Как мы заметили, все экземпляры типов-значений либо встраиваются в стек потока или встраиваются в кучу сборщика мусора. Все ссылочные типы создаются на куче сборщика мусора или куче больших объектов(LOH). Рисунок 6 показывает типичный макет экземпляра объекта. На объект может ссылаться локальная переменная, созданная на стеке, таблиц описателей в ситуациях внешнего взаимодействия и P/Invoke сценариях, из регистров (это может быть this-указать и аргументы метода в течении выполнения метода) или из очереди завершителя(finalizer) для объектов имеющих завершающие методы(finalizer methods). OBJECTREF не указывает на начало экземпляра объекта, а указывает со смещением в 4 байта(DWORD) от начала. DWORD называется заголовком объекта и содержит индекс (номер синхронизирующего блока synblk, начинающийся с единицы) в таблице SyncTableEntry. Так как распределение происходит через индекс, CLR может переместить таблицу в памяти когда необходимо увеличение размера. The SyncTableEntry обслуживает мягкие ссылки обратно к объекту, так что владение блоком синхронизации может быть прослежено CLR. Мягкие ссылки позволяют сборщику мусора выполнять очистку, когда уже не существуют другие жёсткие ссылки. SyncTableEntry также хранит указатель на SyncBlock содержащий полезную информацию, но менее необходимую для всех экземпляров объекта. Эта информация включает блокировки объекта, его хеш-код, любые данные преобразования и индекс домена (AppDomainIndex). Для большинства экземпляров объектов, не будет существовать пространства выделенного для блока синхронизации (SyncBlock) и номер syncblock будет равен нулю. Это изменится когда выполняемый поток наткнется на выражение lock(obj) или obj.GetHashCode, как показано ниже:
SmallClass obj = new SmallClass() // Do some work here 
lock(obj) { /* Do some synchronized work here */ } 
obj.GetHashCode();


Рисунок 6. Представление экземпляра объекта

В этом коде, smallObj будет использовать ноль (нет syncblk) в качестве его номера в таблице блоков синхронизации (Syncblk Entry Table). Инструкция lock заставляет CLR создать syncblock запись и записать в заголовок соответствующий номер. Поскольку ключевое слово lock в С# развертывается в блок try-catch с использованием класса Monitor, объект Monitor создаётся в SyncBlock для синхронизации. Вызов метода GetHashCode() заполняет поле Hashcode хэш-кодом объекта в SyncBlock.

SyncBlock содержит другие поля используемые в взаимодействии с COM и маршалинге делегатов в неуправляемый код, но не относящиеся к типичному использованию объектов.

Хендлер типа (TypeHandle) следуют за номером syncblk в экземпляре объекта. В порядке поддержания непрерывности рассуждения, я буду обсуждать хендлер типа после разъяснения экземпляров переменных. Переменный список полей экземпляра следует за хендлером типа. По умолчанию, поля экземпляра размещаются таким образом, что бы использование памяти было эффективным и пропуски при выравнивании были минимальны. Код на Рисунке 7 содержит простой класс SimpleClass имеющий набор переменных экземпляра содержащихся в нём, с различными размерами.

Рисунок 7 SimpleClass с экземплярными переменными
class SimpleClass
{
    private byte b1 = 1;                // 1 byte
    private byte b2 = 2;                // 1 byte
    private byte b3 = 3;                // 1 byte
    private byte b4 = 4;                // 1 byte
    private char c1 = 'A';              // 2 bytes
    private char c2 = 'B';              // 2 bytes
    private short s1 = 11;              // 2 bytes
    private short s2 = 12;              // 2 bytes
    private int i1 = 21;                // 4 bytes
    private long l1 = 31;               // 8 bytes
    private string str = "MyString"; // 4 bytes (only OBJECTREF)

    //Total instance variable size = 28 bytes 

    static void Main()
    {
        SimpleClass simpleObj = new SimpleClass();
        return;
    }
}


Рисунок 8 содержит пример экземпляра объекта SimpleClass отображаемый в окне памяти отладчика Visual Studio. Мы установили точку останова на операторе return рисунок 7 и использовали адрес simpleObj содержащийся в регистре ECX чтобы отобразить экземпляр объекта в окне просмотра памяти. Первый 4-х байтовый блок это номер syncblk. Мы не используем экземпляр в любом коде требующем синхронизации (и не обращаемся к методу HashCode), поэтому это поле установлено в 0. Ссылка на объект сохранена в переменной стека, указывает на 4 байта, расположенные со смещением 4. Байтовые переменные b1, b2, b3 и b4 располагаются бок о бок друг с другом. Байтовые переменные b1, b2, b3, и b4 все размещены подряд, рядом друг к другом. Обе переменные типа short s1 и s2 также размещены рядом. Строковая переменная str это 4-х байтовый ODJECTREF указывающий на актуальный экземпляр строки расположенный в куче сборщика мусора. Строка (String) специальный тип, все экземпляры содержащие одинаковый текст будут указывать на один и тот же экземпляр в глобальной таблице строк – это выполняется в процессе загрузки сборки. Этот процесс называется интернированием строк и спроектирован для оптимизации использования памяти. Как мы заметили ранее в .NET Framework 1.1 сборка не может отключить процесс интернирования, возможно в будущих версиях среды исполнения CLR будет предоставлена такая возможность.


Рисунок 8. Отладочное окно отображающее экземпляр объекта в памяти

Таким образом лексическая последовательность членов переменных в исходном коде не поддерживается в памяти по умолчанию. В сценариях внешнего взаимодействия, где лексическая последовательность должна быть перенесена в память, атрибут StructLayoutAttribute может быть использован, который принимает значение перечисления LayoutKind в качестве аргумента. LayoutKind.Sequential будет обеспечивать лексическую последовательность для маршализированных данных. В .NET Framework это не повлияет на управляемый макет (в версии .NET Framework 2.0 применение атрибута будет иметь эффект). В сценариях внешних взаимодействий где вам на самом деле необходимо иметь дополнительное смещение и явный контроль над последовательностью полей, LayoutKind.Explicit может быть использован совместно с атрибутом FieldOffset на уровне поля. Взглянув на непосредственное содержимое памяти, давайте воспользуемся отладчиком SOS чтобы посмотреть нва содержимое экземпляра объекта. Одна полезная команда это DumpHeap, которая позволяет выводить всё содержимое кучи и все экземпляры определённого типа. Вместо использования регистров, DumpHeap может показать адрес только что созданного нами объекта:

!DumpHeap -type SimpleClass
Loaded Son of Strike data table version 5 from 
"C:/WINDOWS/Microsoft.NET/Framework/v1.1.4322/mscorwks.dll"
 Address       MT     Size
00a8197c 00955124       36
Last good object: 00a819a0
total 1 objects
Statistics:
      MT    Count TotalSize Class Name
  955124        1        36 SimpleClass


Общий размер объекта 36 байт. Не имеет значения, на сколько велика строка, экземпляры SimpleClass содержат только DWORD OBJECTREF. Переменные экземпляра SimpleClass занимают только 28 байт. Оставшиеся 8 байтов включают хендлер типа TypeHandle (4 байта) и номер блока синхронизации syncblk (4 байта). Получив адрес экземпляра simpleObj, давайте снимем дамп содержимого этого экземпляра использую команду DumpObj, как показано здесь:
!DumpObj 0x00a8197c
Name: SimpleClass
MethodTable 0x00955124
EEClass 0x02ca33b0
Size 36(0x24) bytes
FieldDesc*: 00955064
      MT    Field   Offset                 Type       Attr    Value Name
00955124  400000a        4         System.Int64   instance      31 l1
00955124  400000b        c                CLASS   instance 00a819a0 str
    << some fields omitted from the display for brevity >>
00955124  4000003       1e          System.Byte   instance        3 b3
00955124  4000004       1f          System.Byte   instance        4 b4

Как отмечено, макетом размещения по умолчанию, сгенерированным для классов компилятором C# является LayoutType.Auto (для структур используется LayoutType.Sequential ); таким образом загрузчик классов переупорядочивает поля экземпляра для минимизации смещений. Мы можем использовать ObjSize для получения графа включающего пространство, занятое экземпляром, str. Здесь полученный вывод:

!ObjSize 0x00a8197c
sizeof(00a8197c) = 72 ( 0x48) bytes (SimpleClass)

Son of Strike
SOS отладочное расширение используемое для отображения содержимого структур данных CLR в этой статье. Это часть пакета установки .NET Framework и расположено по пути %windir%\Microsoft.NET\Framework\v1.1.4322. До загрузки SOS в процесс, включите управляемую отладку в свойствах проекта в Visual Studio .NET. Добавьте директорию в которой расположен SOS.dll в переменную окружения PATH. Для загрузки SOS при остановке в точке останова, откройте Debug | Windows | Immediate. В окне immediate выполните .load sos.dll. Используйте !help для получения списка команд отладчика. Для более подробной информации о SOS смотрите документацию msdn Bugslayer column.

Если вы отнимите размер экземпляра SimpleClass (36 байтов) от всего размера графа объектов (72 байта), вы получите размер str, который составляет 36 байт. Давайте проверим это сняв дамп экземпляра str. Ниже результат вывода команды:

!DumpObj 0x00a819a0
Name: System.String
MethodTable 0x009742d8
EEClass 0x02c4c6c4
Size 36(0x24) bytes

Если вы добавите размер экземпляра строки str (36 байт) к размеру экземпляра SimpleClass (36 байт), вы получите общий размер 72 байта, что соответствует выводу команды ObjSize. Заметьте, что ObjSize не будет включать память занятую инфраструктурой syncblk. Также, в .NET Framework 1.1, CLR не известно о памяти занятой любыми неуправляемыми ресурсами, такими как GDI объекты, COM объекты, файловые хендлеры, и так далее; поэтому они не будут отражены этой командой.
Хендлер типа (TypeHandle), указатель на таблицу методов (MethodTable), расположен прямо после номера syncblk. До создания экземпляра объекта, CLR просматривает загруженные типы и загружает информацию о типе если тип не обнаружен, получает адрес таблицы методов, создаёт экземпляр объекта и заносит значение в TypeHandle экземпляра объекта. Код скомпилированный JIT компилятором использует хендлер типа TypeHandle для нахождения таблицы методов MethodTable для распределения методов. Скомпилированный JIT-компилятором код использует хендлер типа (TypeHandle) для позиционирования таблицы методов (MethodTable) для распределения вызовов методов. CLR использует хендлер типа (TypeHandle), когда необходимо найти загруженный тип через таблицу методов MethodTable.

Таблица методов MethodTable


Каждый класс и интерфейс, когда загружен в домен приложения, будет представлен в памяти структурой данных MethodTable. Это является результатом действий по загрузке классов до создания самого первого экземпляра объекта. В то время как экземпляр объекта ObjectInstance хранит состояние, MethodTable хранит информацию о поведении. MethodTable связывает экземпляр объекта с отображёнными в памяти структурами метаданных сгенерированными компилятором языка с помощью EEClass. Информация в таблице методов MethodTable и структуры данных, прикреплённые к ней могут быть доступны из управляемого кода через System.Type Указатель на таблицу методов может быть также получен даже в управляемом коде через свойство Type.RuntimeTypeHandle. Хендлер типа TypeHandle содержащийся в ObjectInstance, указывает на смещение от начала таблицы методов. Это смещение составляет 12 байт по умолчанию и содержит информацию для сборщика мусора, которая здесь обсуждаться не будет.

Рисунок 9 показывает типичное представление таблицы методов. Мы покажем некоторые важные поля хенлера типа, но для более полного списка используйте рисунок. Давайте начнём с Base Instance Size, так как он имеет прямую корреляцию с профилем памяти времени выполнения.


Рисунок 9 Представление таблицы методов

Базовый размер экземпляраBase Instance Size


Базовый размер экземпляра это размер объекта, вычисляемый загрузчиком класса, основанный на декларациях полей в коде. Как рассмотрено ранее, текущая реализация сборщика мусора требует размер экземпляра объекта как минимум 12 байт. Если класс не имеет ни одного объявленного экземплярного поля, это приведёт к избыточности в 4 байта.

Остальные 8 байт будут заняты заголовком (Object Header) (который может содержать номер блока синхронизации syncblk) и хендлером типа (TypeHandle). Снова размер объекта может быть подвержен влиянию StructLayoutAttribute.

Посмотрим на снимок памяти (окно памяти в Visual Studio .NET 2003 ) таблицы методов для MyClass из рисунка 3 (MyClass с двумя интерфейсами) и сравним это с генерированным с помощью SOS выводом. На рисунке 9, размер объекта расположен по 4-х байтовому смещению и имеет значение 12 (0x0000000C) байт. Следующее является выводом DumpHeap из SOS:

!DumpHeap -type MyClass
 Address       MT     Size
00a819ac 009552a0       12
total 1 objects
Statistics:
    MT  Count TotalSize Class Name
9552a0      1        12    MyClass

Таблица слотов методов


Встроенная в таблице методов таблица слотов указывает на соответствующие описатели методов (MethodDesc), предоставляющие поведение типа. Таблица слотов методов создаётся на базе линейного списка объявлений методов располагающихся в следующем порядке: наследованные виртуальные методы, объявленные виртуальные методы, экземплярные методы, статические методы. Загрузчик классов идёт через метаданные текущего класса, родительского класса и интерфейсы и создаёт таблицу методов. В процессе формирования заменяются переопределённые виртуальные методы, заменяются скрываемые методы родительского класса, создаются новые слоты и дублируются слоты по необходимости. Дублирование слотов необходимо для создания иллюзии что каждый интерфейс имеет свою собственную мини vtable. Однако, слоты дубликаты указывают на ту же физическую реализацию.MyClass имеет три экземплярных метода, конструктор класса (.cctor) и конструктор объекта (.ctor). Конструктор объекта автоматически генерируется компилятором C# для всех объектов не имеющих конструкторов определённых явно. Конструктор класса генерируется компилятором когда мы имеем статические переменные определённые и инициализированные. Рисунок 10 показывает представление таблицы методов для MyClass. Представление показывает 10 методов потому что имеет место дублирование слота Method2 для IVMap, который будет рассмотрен следующим. Рисунок 11 показывает редактируемый SOS дамп таблицы методов класса MyClass.


Рисунок 10 Представление таблицы методов MyClass

Рисунок 11 SOS дамп таблицы методов для MyClass
!DumpMT -MD 0x9552a0
  Entry  MethodDesc  Return Type       Name
0097203b 00972040    String            System.Object.ToString()
009720fb 00972100    Boolean           System.Object.Equals(Object)
00972113 00972118    I4                System.Object.GetHashCode()
0097207b 00972080    Void              System.Object.Finalize()
00955253 00955258    Void              MyClass.Method1()
00955263 00955268    Void              MyClass.Method2()
00955263 00955268    Void              MyClass.Method2()
00955273 00955278    Void              MyClass.Method3()
00955283 00955288    Void              MyClass..cctor()
00955293 00955298    Void              MyClass..ctor()


Первые 4 метода любого типа будут всегда ToString, Equals, GetHashCode и Finalize. Эти методы виртуальные наследованные от System.Object. Слот Method2 имеет дубликат, но оба указывают на один и тот же дескриптор метода. Явно кодированный .cctor и .ctor будут сгруппированы со статическими и экземплярными методами соответственно.

Описатель метода


Описатель метода (MethodDesc) это инкапсуляция реализации метода как его понимает CLR. Существует много типов описателей методов, что поддерживают вызовы к различным реализациям внешних взаимодействий, в добавок к управляемым реализациям. В этой статье мы будем рассматривать, только управляемый MethodDesc в контексте код а показанного на рисунке 3. MethodDesc сгенерирован как часть процесса загрузки класса и первоначально указывает на промежуточный язык (IL). Каждый описатель метода MethodDesc заполнен содержимым PreJitStub, который отвечает за включение JIT компиляции. Рисунок 12 показывает типичное представление. Запись слота таблицы методов на самом деле указывает на заглушку вместо настоящей структуры данных MethodDesc. Эта запись располагается по отрицательному смещению из 5-и байт от настоящего MethodDesc и является частью 8-и байтового заполнения, наследуемого каждым методом. Эти 5 байт содержат инструкции для вызова подпрограммы PreJitStub. Это 5-и байтовое смещение может быть видно из вывода DumpMT (для MyClass на рисунке 11) of SOS, поскольку MethodDesc всегда 5 байт после расположения указанного в записи таблицы слотов методов. До первого вызова выполняется вызов подпрограммы JIT компиляции. После выполнения компиляции 5 байт содержащие инструкцию вызова будут перезаписаны командой безусловного перехода на JIT скомпилированный код в архитектуре x86.


Рисунок 12 Описатель метода

Дизассемблирование кода на который указывает запись в таблице слотов методов на рисунке 12 будет показывать вызов на PreJitStub. Здесь сокращённый вывод дизассемблирования до JIT компиляции для метода Method2:

!u 0x00955263
Unmanaged code
00955263 call        003C3538        ;call to the jitted Method2()
00955268 add         eax,68040000h   ;ignore this and the rest 
                                     ;as !u thinks it as code

Теперь давайте запустим метод и дизассемблируем тот же адрес:
!u 0x00955263
Unmanaged code
00955263 jmp     02C633E8        ;call to the jitted Method2()
00955268 add     eax,0E8040000h  ;ignore this and the rest 
                                 ;as !u thinks it as code

Только первые 5 байт по этому адресу являются кодом; остальные содержат данные метода Method2 описателя методов. Команда »!u" не в курсе этого и генерирует бессмысленный код, то есть вы можете игнорировать всё после 5-и первых байт.

CodeOrIL до JIT компиляции содержит относительный виртуальный адрес(RVA) реализации метода в промежуточном языке (IL). Это поле устанавливается для индикации что это есть промежуточный код. CLR обновляет это это поле адресом JIT –компилированного кода после компиляции по требованию. Давайте выберем метод из тех что выведены и снимем дамп MethodDesc используя команду DumpMT до и после JIT компиляции:

!DumpMD 0x00955268
Method Name : [DEFAULT] [hasThis] Void MyClass.Method2()
MethodTable 9552a0
Module: 164008
mdToken: 06000006 
Flags : 400
IL RVA : 00002068


После компиляции, MethodDesc выглядит следующим образом:
!DumpMD 0x00955268
Method Name : [DEFAULT] [hasThis] Void MyClass.Method2()
MethodTable 9552a0
Module: 164008
mdToken: 06000006
Flags : 400
Method VA : 02c633e8

Поле флагов в описателе метода закодировано для хранения информации о типе метода, таким как статичный, экземплярный, интерфейсный метод или COM реализация.

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

IVMap и Карта интерфейсов


По смещению 12 в таблице методов находится важный указатель, IVMap. Как показано на рисунке 9, IVMap указывает на таблицу сопоставлений уровня домена приложения, которая индексируется с помощью идентификатора интерфейса уровня процесса. Каждая реализация интерфейса будет иметь запись в IVMap. Если MyInterface1 реализован двумя классами, то будет две записи в таблице IVMap. Запись будет указывать назад на начало подчинённой таблицы встроенной в таблицу методов (MethodTable) MyClass, как показано на рисунке 9. Это референс с помощью которого происходит распределение интерфейсного метода. IVMap создаётся на основе информации карты интерфейса встроенной в таблице методов. Карта интерфейса создана на основе метаданных класса в процессе построения таблицы методов. Как только загрузка типа выполнена, только IVMap используется в методе распределения. Поле по смешению 28 в таблицы методов (Interface Map ) указывает на запись InterfaceInfo встроенную внутри таблицы методов. В нашем случае, присутствует две записи для каждого из двух интерфейсов реализованных классом MyClass. Первые 4 байта первой записи InterfaceInfo указывают на хендлер типа (TypeHandle) интерфейса MyInterface1 (смотри рисунок 9 и рисунок 10). Следующее слово (2 байта) занято флагами (где 0 наследован от родительского класса и 1 реализована в текущем классе). Следующим словом сразу после флагов идёт начальный слот, который используется загрузчиком классов для размещения подчинённой таблицы реализации интерфейса. Для MyInterface1 значение равно 4, которое значит что слоты 5 и 6 указывают на реализацию. Для интерфейса MyInterface2, значение равно 6, то есть слоты 7 и 8 указывают на реализацию. Загрузчик классов дуюлирует слоты если это необходимо для создания иллюзии, что каждый интерфейс получает свою собственную реализацию, хотя физически сопоставляется с тем же описателем метода. В классе MyClass метод MyInterface1.Method2 и метод MyInterface2.Method2 будут указывать на одну и ту же реализацию.

Распределение интерфейсного метода выполняется через IVMap, в то время как распределение прямых методов происходит через адрес MethodDesc сохранённый в соответствующем слоте. Как отмечено ранее, .NET Framework использует конвенцию вызовов fastcall. Первые два аргумента обычно передаются через регистры ECX и EDX, если возможно. Первый аргумент экземплярного метода всегда «this» указатель, который передаётся через регистр ECX, как показано инструкцией «mov ecx, esi»:

mi1.Method1();
mov    ecx,edi                 ;move "this" pointer into ecx        
mov    eax,dword ptr [ecx]     ;move "TypeHandle" into eax 
mov    eax,dword ptr [eax+0Ch] ;move IVMap address into eax at offset 12
mov    eax,dword ptr [eax+30h] ;move the ifc impl start slot into eax
call   dword ptr [eax]         ;call Method1

mc.Method1();
mov    ecx,esi                 ;move "this" pointer into ecx
cmp    dword ptr [ecx],ecx     ;compare and set flags
call   dword ptr ds:[009552D8h];directly call Method1

Этот дизассемблированный код демонстрирует что прямые вызовы экземплярных методов MyClass не используют смещение. JIT компилятор записывает адрес описателя метода непосредственно в коде. Распределение основанное на интерфейсе происходит через IVMap и требует несколько больше инструкций, чем прямое распределение. Одна из инструкций используется для получения адреса IVMap, а другая для получения начального слота реализации интерфейса в таблице слотов методов. Также, приведение экземпляра объекта к интерфейсу это просто копирование этого указателя в целевую переменную. На рисунке 2, «mi1 = mc;» использует одиночную инструкцию для копирования OBJECTREF из mc в mi1.

Виртуальное распределение


Давайте сейчас посмотрим на виртуальное распределение и сравним это с прямым и основанным на интерфейсах распределении. Здесь приведено дизассемблирование для вызова виртуального метода MyClass.Method3 из рисунка 3:
mc.Method3();
Mov    ecx,esi               ;move "this" pointer into ecx
Mov    eax,dword ptr [ecx]   ;acquire the MethodTable address
Call   dword ptr [eax+44h]   ;dispatch to the method at offset 0x44

Виртуальное распределение всегда происходит через фиксированный номер слота, независимо от от указателя в таблице методов в полученной иерархии реализации класса (типа). В процессе построения таблицы методов, загрузчик классов заменяет родительскую реализацию с переопределённой дочерней реализацией. Как результат, вызовы методов кодируются против родительского объекта распределённого к реализации дочернего объекта. Дизассемблирование демонстрирует что распределение происходит через слот номер 8 в окне памяти отладчика (как видно на рисунке 10) так же как и в выводе DumpMT.

Статические переменные


Статические переменные важная составляющая часть структуры данных таблицы методов. Они располагаются как часть таблицы методов сразу после массива слотов таблицы методов. Все примитивные статические типы встраиваемые, в то время как статические объекты значения, такие как структуры и ссылочные типы адресуются через OBJECTREF созданных в таблицах хендлеров. OBJECTREF в таблице методов указывает на экземпляр объекта созданный в куче. Однажды созданный, OBJECTREF в таблице хендлеров будет держать экземпляр объекта в куче невредимым, пока домен приложения не будет выгружен. На рисунке 9, статическая строковая переменная str, указывает на OBJECTREF в таблице хендлеров, который указывает на MyString в куче сборщика мусора.

EEClass


EEClass появляется до создания таблицы методов и в комбинации с таблицей методов является CLR версией объявления типа. На самом деле, EEClass и таблица методов логически явлются одной структурой данных (вместе они представляют один тип)и были разделены на основе частотыв использования. Поля используемые достаточно часто располагаются в таблице методов, а поля используемые не часто находятся в EEClass. Так что информация (такая как имена, поля и смещения)необходимая для JIT компиляции функций оказывается в EEClass, однако данные необходимые во время исполнения (такие как слоты vtable и информация сборщика мусора) располагается в таблице методов.

Для каждого типа загруженного в домен приложения будет создан один EEClass. Это включает интерфейсы, классы, абстрактные классы, массивы и структуры. Каждый EEClass это узел дерева отслеживаемый механизмом исполнения. CLR использует эту сеть для навигации через структуры EEClass для таких целей как загрузка класса, построение таблицы методов, проверка типа и приведения типов. Взаимосвязь дочернего элемента с родитель между EEClass устанавливается на основе иерархии наследования, в свою очередь взаимосвязи родителя с дочерним элементом устанавливаются на основе комбинации иерархии наследования и последовательности загрузки классов. Новые узлы EEClass добавляются, накладываются взаимосвязи между узлами и новые взаимосвязи устанавливаются в процессе выполнения управляемого кода. Существуют также горизонтальные связи с близнецами EEClass в сети. EEClass обладает тремя полями для управления взаимосвязями узлов между загруженными типами: Родительский класс ParentClass, Цепочка близнец SiblingChain и дочерняя цепочка ChildrenChain. Смотрите рисунок 13 для схематичного представления EEClass в контексте класса MyClass с рисунка 4.

Рисунок 13 показывает только несколько полей относящихся к этому обсуждению. Потому что мы пропустили некоторые поля в представлении, мы не показали смещения на этом рисунке. EEClass имеет циклические ссылки на таблицу методов. EEClass также указывает на блоки данных описателя метода, расположенных в куче частого доступа домена приложения по умолчанию. Референс на список объектов описателей полей расположенный на куче процесса обеспечивает информацию о размещении полей во время построения таблицы методов. EEClass располагается на куче с низкой частотой доступа домена приложения, так что операционная система может эффективнее управлять страницами памяти, и как следствие уменьшается рабочее пространство.


Рисунок 13 Представление EEClass

Другие поля, показанные на рисунке 13 являются второстепенными и не требуют объяснения в контексте MyClass (рисунок 3). Давайте посмотрим на фактическую физическую память создав дамп EEClass используя SOS. Запустим программу с рисунка 3 после установки точки останова на строке mc.Method1. Для начала получим адрес EEClass для MyClass используя команду Name2EE:

!Name2EE C:/Working/test/ClrInternals/Sample1.exe MyClass

MethodTable: 009552a0
EEClass: 02ca3508
Name: MyClass

Первый аргумент команды Name2EE это имя модуля, которое может быть получено из команды DumpDomain. Теперь мы знаем адрес EEClass, и получим дамп самого класса EEClass:
!DumpClass 02ca3508
Class Name : MyClass, mdToken : 02000004, Parent Class : 02c4c3e4 
ClassLoader : 00163ad8, Method Table : 009552a0, Vtable Slots : 8
Total Method Slots : a, NumInstanceFields: 0,
NumStaticFields: 2,FieldDesc*: 00955224

      MT    Field   Offset  Type           Attr    Value    Name
009552a0  4000001   2c      CLASS          static 00a8198c  str
009552a0  4000002   30      System.UInt32  static aaaaaaaa  ui 

Рисунок 13 и вывод DumpClass выглядят в основном одинаково. Токен метаданных (mdToken) представляет индекс MyClass в таблицах метаданных сопоставления памяти модуля PE файла, родительский класс указывает на System.Object. Цепочка близнец (Рисунок 13) демонстрирует, что это загружено как результат загрузки класса Program.

MyClass имеет восемь слотов vtable (методы что могут быть распределены виртуально). При том, что методы Method1 Method2 не являются виртуальными, они будут рассматриваться как виртуальные методы при выполнении распределения через интерфейсы и поэтому добавлены в список. Добавим .cctor и .ctor в список, и вы получите всего 10(0xA) методов. Класс имеет два статических поля приведенные в конце. MyClass не имеет экземплярных полей. Остальные поля не требуют объяснения.

Заключение


Закончился наш тур по некоторым самым важным внутренним компонентам CLR. Очевидно, осталось гораздо больше не рассмотренного, и того что следовало бы рассмотреть более глубоко, но мы надеемся это даст вам некоторое впечатление как это работает. Много информации представленной здесь возможно будет изменена в последующих выпусках CLR и .NET Framework. Но всё же, если структуры данных рассмотренные в этой статье могут быть изменены, концепции будут оставаться без изменений.