Внутреннее устройство .NET Framework — как CLR создает объекты периода выполнения

Автор:

  • Хану Коммалапати
  • Том Кристиан

Наступит время, когда общеязыковая исполняющая среда (common language runtime, CLR) станет основной инфраструктурой разработки Windows-приложений. Основательное знакомство с ней поможет вам разрабатывать эффективные приложения профессионального уровня. В этой статье мы рассмотрим внутреннее устройство CLR, в том числе структуру экземпляров объектов, структуру таблицы методов, диспетчеризацию методов, диспетчеризацию на основе интерфейсов и различные структуры данных.

Мы будем использовать очень простые примеры кода на C#, и по умолчанию всегда подразумевается именно C#. Некоторые структуры данных и алгоритмы, описанные в статье, изменятся с выходом Microsoft .NET Framework 2.0,  но базовые принципы в основном останутся прежними. Чтобы получать рассматриваемые в статье структуры данных, мы будем использовать отладчик из Visual Studio .NET 2003 и утилиту Son of Strike (SOS) — расширение этого отладчика. SOS известны внутренние структуры данных CLR, и она способна выводить полезную информацию. На врезке «Son of Strike» рассказывается, как загружать SOS.dll в процесс отладчика Visual Studio .NET 2003. На всем протяжении статьи рассматриваются классы, которым соответствуют реализации в Shared Source CLI (SSCLI), доступной по ссылке msdn.microsoft.com/net/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

Прежде чем начать, мы должны предупредить вас: при использовании платформы x86 информация, содержащаяся в статье, полностью достоверна только для .NET Framework 1.1 (и почти достоверна для Shared Source CLI 1.0,  где самыми заметными исключениями являются некоторые варианты применения interop). В .NET Framework 2.0 эта информация изменится, поэтому при разработке ПО не полагайтесь на то, что эти внутренние структуры останутся неизменными.

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

Перед тем как выполнить первую строку управляемого кода, CLR создает три домена приложения. Два из них непрозрачны для управляемого кода и даже не видны CLR-хостам. Эти домены может создать только процесс начальной загрузки CLR с помощью двух библиотек: mscoree.dll и mscorwks.dll (или mscorsvr.dll в случае многопроцессорных систем). Как видно из рис. 1, это SystemDomain (системный домен) и SharedDomain (общий домен), являющиеся Singleton-объектами. Третий домен — DefaultDomain (домен по умолчанию) — экземпляр класса AppDomain и единственный именованный домен из этих трех. В случае простых CLR-хостов, таких как консольные программы, имя домена, используемого по умолчанию, формируется по имени исполняемого файла. Дополнительные домены могут создаваться из управляемого кода вызовом метода AppDomain.CreateDomain или из неуправляемого кода хоста через интерфейс ICORRuntimeHost. Сложные хосты создают несколько доменов, например в ASP.NET количество доменов зависит от числа приложений, выполняемых данным Web-сайтом.

Домены, создаваемые при начальной загрузке CLR Рис. 1. Домены, создаваемые при начальной загрузке CLR

SystemDomain

SystemDomain создает и инициализирует SharedDomain и AppDomain, используемый по умолчанию (DefaultDomain). Он загружает в SharedDomain системную библиотеку mscorlib.dll, а также хранит явные и неявные intern-строки уровня процесса.

Применение intern-строк — один из методов оптимизации; в .NET Framework 1.1 он реализован не слишком удачно, так как CLR не позволяет сборкам отказаться от нее. Тем не менее, этот метод экономит память, поскольку для всех строк с одним и тем же текстом, определенных во всех доменах приложений, хранится только один экземпляр строки.

Кроме того, SystemDomain отвечает за генерацию идентификаторов интерфейсов уровня процесса, применяемых при создании карт InterfaceVtableMap в каждом AppDomain. SystemDomain хранит данные обо всех доменах процесса и обеспечивает загрузку и выгрузку AppDomain'ов.

SharedDomain

Весь код, не зависящий от домена, загружается в SharedDomain. Системная библиотека Mscorlib необходима пользовательскому коду во всех AppDomain. Она автоматически загружается в SharedDomain. Основные типы данных из пространства имен System, такие как Object, ValueType, Array, Enum, String и Delegate, заранее загружаются в этот домен при первоначальной загрузке CLR. Пользовательский код также можно загружать в этот домен — для этого из приложения, служащего CLR-хостом, вызывается метод CorBindToRuntimeEx, которому передаются атрибуты LoaderOptimization. Чтобы консольная программа загружала код в SharedDomain, пометьте метод Main приложения атрибутом System.LoaderOptimizationAttribute. Кроме того, SharedDomain управляет картой сборок, индексированной по базовому адресу. Эта карта применяется в качестве поисковой таблицы при управлении совместно используемыми зависимостями между сборками, загружаемыми в DefaultDomain и другие AppDomain, созданные в управляемом коде. DefaultDomain — это домен, в который загружается пользовательский код, не используемый совместно.

DefaultDomain

DefaultDomain — это экземпляр AppDomain, в котором обычно выполняется код приложения. Хотя некоторым приложениям требуется, чтобы во время выполнения создавались дополнительные AppDomain (например приложениям, использующим подключаемые модули, или приложениям, генерирующим большие объемы кода в период выполнения), большинство программ в течение всей своей жизни создает один домен. Весь код, выполняемый в этом домене, связывается с контекстом на уровне домена. Если приложение использует несколько AppDomain, взаимодействие между доменами осуществляется через прокси .NET Remoting. Дополнительные границы контекста внутри домена можно создать с помощью типов, производных от System.ContextBoundObject. У каждого AppDomain имеются свои SecurityDescriptor, SecurityContext и DefaultContext, а также свои кучи загрузчика (High-Frequency Heap, Low-Frequency Heap и Stub Heap), таблицы описателей (Handle Table, Large Object Heap Handle Table), диспетчер карты таблиц виртуальных методов интерфейсов (Interface Vtable Map Manager) и кэш сборок (Assembly Cache).

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

Кучи загрузчика (LoaderHeaps) предназначены для загрузки различных специальных объектов (artifacts), существующих в течение всего срока жизни домена, — объектов, используемых CLR, и объектов, обеспечивающих оптимизацию. Размер этих куч увеличивается предсказуемыми порциями, чтобы уменьшить фрагментацию. Кучи загрузчика отличаются от кучи сборщика мусора (GC Heap) (или нескольких таких куч в случае симметричной многопроцессорной обработки, SMP) тем, что GC Heap содержит экземпляры объектов, а кучи загрузчика хранят данные системы типов. В HighFrequencyHeap выделяется память для часто используемых объектов, таких как MethodTable, MethodDesc, FieldDesc и Interface Map, а в LowFrequencyHeap — для структур данных, к которым обращаются реже, таких как EEClass, ClassLoader и их поисковых таблиц. В StubHeap содержатся приемники (stubs), используемые при защите по правам доступа кода (code access security, CAS), обертывании COM-вызовов и при вызовах P/Invoke.

Мы рассмотрели домены и кучи загрузчика с высокоуровневой точки зрения, теперь изучим их физическое устройство на примере простого приложения (листинг 1). Мы остановили выполнение программы на операторе «mc.Method1();» и вывели дамп информации о домене командой расширения отладчика SOS — DumpDomain (см. сведения о загрузке SOS на врезке «Son of Strike»). Вот как выглядит отредактированный вывод:


!DumpDomain
System Domain: 793e9d58, LowFrequencyHeap: 793e9dbc,
HighFrequencyHeap: 793e9e14, StubHeap: 793e9e6c,
Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40

Shared Domain: 793eb278, LowFrequencyHeap: 793eb2dc,
	HighFrequencyHeap: 793eb334, StubHeap: 793eb38c,
	Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40

Domain 1: 149100, LowFrequencyHeap: 00149164,
HighFrequencyHeap: 001491bc, StubHeap: 00149214,
Name: Sample1.exe, Assembly: 00164938 [Sample1],
ClassLoader: 00164a78

Наша консольная программа Sample1.exe загружена в AppDomain «Sample1.exe». Mscorlib.dll загружена в SharedDomain, но показана и в домене SystemDomain, поскольку является базовой системной библиотекой. В каждом домене созданы HighFrequencyHeap, LowFrequencyHeap и StubHeap. SystemDomain и SharedDomain используют один и тот же ClassLoader, а Default AppDomain использует свой загрузчик.

Листинг 1. 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();
    }
}

	

В выводе не показаны размеры переданной (commited) и зарезервированной памяти (reserved) в кучах загрузчика. Для HighFrequencyHeap эти размеры составляют 4 и 32 Кб соответственно, а для LowFrequencyHeap и StubHeap — 4 и 8 Кб. Кроме того, в выводе SOS нет данных о куче InterfaceVtableMap. У каждого домена есть InterfaceVtableMap (карта таблиц виртуальных методов интерфейсов, будем для краткости называть ее IVMap), которая создается в собственной куче загрузчика при инициализации домена. Для кучи IVMap первоначальные размеры переданной и зарезервированной памяти равны 4 Кб. О важной роли IVMap мы поговорим в следующих разделах, когда речь пойдет о структурах типов.

На рис. 1 показаны используемые по умолчанию Process Heap, JIT Code Heap, GC Heap (для небольших объектов) и Large Object Heap (для объектов размером 85000 или более байтов), чтобы продемонстрировать семантическое различие между этими кучами и кучами загрузчика. В JIT Code Heap хранятся инструкции x86, генерируемые JIT-компилятором. GC Heap и Large Object — кучи, в которых выполняется сбор мусора. В них создаются экземпляры управляемых объектов.

Основы устройства типов

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

Мы начнем знакомство с устройством .NET-типов с того, что рассмотрим фрейм стека, содержащий ссылку на объект (обычно именно в стеке начинается жизненный цикл объекта). Код в листенге 2 — простая консольная программа, в точке входа в которую вызывается статический метод. Метод Create создает экземпляр типа SmallClass, содержащий массив байтов. Мы используем этот массив для демонстрации создания экземпляра объекта в Large Object Heap. Код тривиален, но вполне подойдет в качестве примера.

Листинг 2. Большие и небольшие объекты

		
		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;
    }
}

	

На рис. 2 показан типичный снимок фрейма стека при использовании соглашения fastcall. Мы установили точку прерывания на строку «return smallObj;» метода Create. (Fastcall — соглашение о вызове, используемое в .NET, при котором аргументы функций по возможности передаются в регистрах, остальные аргументы помещаются в стек справа налево, а затем выталкиваются из стека вызываемой функцией.) Локальная переменная objSize значимого типа размещается в стеке. Для переменных ссылочных типов, таких как smallObj, в стек помещается значение фиксированного размера (4 байта, тип DWORD), содержащее адрес экземпляра объекта, созданного в обычной GC Heap. В традиционном C++ это значение называют указателем на объект, а в мире управляемых приложений — ссылкой на объект. Как бы то ни было, оно содержит адрес экземпляра объекта. Мы будем использовать термин ObjectInstance (экземпляр объекта) для обозначения структуры данных, находящейся по адресу, на который указывает ссылка на объект.

Фрейм стека и кучи приложения SimpleProgram Рис. 2. Фрейм стека и кучи приложения SimpleProgram

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

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

Одной из важных структур данных, на которые ссылается MethodTable, является EEClass. Загрузчик классов CLR создает EEClass по метаданным перед созданием MethodTable. MethodTable класса SmallClass, объявленного в листинге 2, ссылается на его EEClass. Эти структуры ссылаются на свои модули и сборки. MethodTable и EEClass обычно создаются в кучах загрузчика, специфичных для доменов. Тип Byte[] — особый случай: его MethodTable и EEClass создаются в кучах загрузчика SharedDomain. Кучи загрузчика специфичны для AppDomain, и упомянутые выше данные после загрузки не уничтожаются, пока AppDomain не будет выгружен. Но AppDomain, используемый по умолчанию, нельзя выгружать, поэтому содержащийся в нем код существует, пока CLR не завершит работу.

ObjectInstance

Как мы уже говорили, все экземпляры значимых типов хранятся или в стеке потока, или в GC Heap. Экземпляры всех ссылочных типов хранятся в GC Heap или LOH. На рис. 3 показан типичный пример структуры экземпляра объекта. На объект могут ссылаться локальные переменные, содержащиеся в стеке, таблицы описателей, используемые при interop или P/Invoke, регистры (указатель this и аргументы методов при выполнении методов) и очередь подготовки к уничтожению (в случае объектов, у которых есть методы подготовки к уничтожению). OBJECTREF указывает не на начало ObjectInstance, а на адрес, смещенный на DWORD (4 байта). Это DWORD-поле называется Object Header и содержит индекс записи таблицы SyncTableEntry (номер syncblk, отсчитываемый от единицы). Поскольку связывание осуществляется по индексу, CLR при необходимости может перемещать таблицу в памяти, увеличивая размер. SyncTableEntry содержит слабую обратную ссылку на объект, с помощью которой CLR может определить владельца SyncBlock. Слабые ссылки (weak references) позволяют GC уничтожить объект при сборе мусора, когда сильных ссылок (strong references) на него нет. Кроме того, в SyncTableEntry хранится указатель на SyncBlock, содержащий полезную, но редко нужную экземплярам объектов информацию. К этой информации относятся данные о блокировке объекта, его хэш-код, всевозможные данные шлюзования (thunking) и индекс его AppDomain. В большинстве случаев под SyncBlock экземпляра объекта не выделяется память, и номер syncblk (номер блока синхронизации) равен нулю. Ситуация меняется, когда поток выполняет операторы наподобие lock(obj) или obj.GetHashCode:

SmallClass obj = new SmallClass()
// Выполняем некие операции
lock(obj) { /* операции, требующие синхронизации */ }
obj.GetHashCode();

В этом коде номер syncblk объекта smallObj сначала равен нулю (блок синхронизации не используется). При выполнении оператора lock CLR создает запись syncblk и помещает в заголовок объекта ее номер. Поскольку в C# ключевое слово преобразовывается в блок try-finally, в котором используется класс Monitor, в syncblk создается синхронизирующий объект Monitor. При вызове метода GetHashCode в syncblk помещается хэш-код объекта.

Структура экземпляра объекта Рис. 3. Структура экземпляра объекта

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

За номером syncblk в ObjectInstance идет TypeHandle. Но пока что не будем на него отвлекаться — рассмотрим его после переменных экземпляра. За TypeHandle идет список полей экземпляра, длина которого бывает разной. По умолчанию поля экземпляра размещаются так, чтобы память использовалась эффективно, а промежутки между полями были минимальны. В листинге 3 показан класс SimpleClass, где объявлено несколько переменных экземпляра разного размера.

Листинг 3. Класс SimpleClass, содержащий переменные экземпляра

	
		class SimpleClass
{
    private byte b1 = 1;                // 1 байт
    private byte b2 = 2;                // 1 байт
    private byte b3 = 3;                // 1 байт
    private byte b4 = 4;                // 1 байт
    private char c1 = 'A';              // 2 байта
    private char c2 = 'B';              // 2 байта
    private short s1 = 11;              // 2 байта
    private short s2 = 12;              // 2 байта
    private int i1 = 21;                // 4 байта
    private long l1 = 31;               // 8 байтов
    private string str = "MyString";    // 4 байта (это лишь
                                        // OBJECTREF)

    // Общий размер полей экземпляра - 28 байтов
    static void Main()
    {
        SimpleClass simpleObj = new SimpleClass();
        return;
    }
}

	

На рис. 4 показано, как выглядит экземпляр объекта SimpleClass в окне просмотра содержимого памяти, открытом в отладчике Visual Studio. Мы установили точку прерывания на операторе return кода (листинг 3) и по адресу simpleObj, содержащемуся в регистре ECX, определили адрес экземпляра объекта, дамп памяти для которого мы хотим посмотреть. Первый 4-байтовый блок — номер syncblk. Поскольку мы не писали код синхронизации для этого объекта (и не обращались к его хэш-коду), этот номер равен 0. Ссылка на объект, хранящаяся в переменной, размещенной в стеке, указывает на 4 байта, начало которых находится по смещению 4. Переменные b1, b2, b3 и b4 типа Byte упакованы в DWORD и располагаются «бок о бок». Две переменных типа short — s1 и s2 — также располагаются «бок о бок». Переменная str типа String представляется 4-байтовой ссылкой OBJECTREF, указывающей на собственно экземпляр строки, размещенный в GC Heap. Особенность типа String в том, что при загрузке сборки обеспечивается, чтобы все строки, содержащие один и тот же литерал, представлялись одним и тем же экземпляром, хранящимся в глобальной таблице строк. Такие строки называются intern-строками, их применение позволяет оптимизировать использование памяти. Как упоминалось выше, в .NET Framework 1.1 сборка не может отказаться от применения intern-строк, хотя не исключено, что в будущих версиях CLR предусмотрят такую возможность.

Экземпляр объекта, показанный в окне просмотра содержимого памяти Рис. 4. Экземпляр объекта, показанный в окне просмотра содержимого памяти

Как видите, по умолчанию члены объекта не обязательно размещаются в памяти в том порядке, в каком они объявлены в исходном коде (в лексической последовательности). В случаях взаимодействия через interop, где нужно, чтобы при размещении полей в памяти соблюдалась их лексическая последовательность, можно применить атрибут StructLayoutAttribute, принимающий аргумент — перечислимое LayoutKind. Значение LayoutKind.Sequential означает, что при маршалинге данных поля передаются в лексической последовательности, но в .NET Framework 1.1 этот атрибут не влияет на размещение полей в управляемом коде (хотя в .NET Framework 2.0 будет влиять). В случаях взаимодействия через interop, где действительно нужно добавлять дополнительные промежутки между полями и явно задавать последовательность полей, можно применить LayoutKind.Explicit и задавать атрибуты FieldOffset на уровне полей.

Рассмотрев неструктурированное содержимое памяти, исследуем экземпляр объекта с помощью SOS. У 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 содержат только OBJECTREF размера DWORD. Члены экземпляра 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
    << некоторые поля опущены для краткости >>
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)
	

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

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

Если вы сложите размер экземпляра строки для члена str (36 байтов) c размером экземпляра SimpleClass (36 байтов), то получите общий размер 72 байта, который и выводит команда ObjSize.

Заметьте: ObjSize не учитывает память, занимаемую инфраструктурой syncblk. Кроме того, в .NET Framework 1.1 CLR не известно, сколько памяти занимают неуправляемые ресурсы — GDI-объекты, COM-объекты, описатели файлов, поэтому команда ObjSize не учитывает объем занимаемой ими памяти.

TypeHandle — указатель на MethodTable — размещается непосредственно за номером syncblk. Перед созданием экземпляра объекта CLR просматривает данные о загруженных типах, загружает тип, если не обнаруживает данных о нем, получает адрес MethodTable, создает экземпляр объекта и заносит в экземпляр объекта значение TypeHandle. Код, генерируемый JIT-компилятором, использует TypeHandle, чтобы получать MethodTable при диспетчеризации методов. CLR использует TypeHandle всякий раз, когда требуется получить из MethodTable данные о загруженном типе.

Son of Strike

В этой разделе мы показываем содержимое структур данных CLR с помощью расширения отладчика SOS. SOS устанавливается с .NET Framework и находится в каталоге %windir%\Microsoft.NET\Framework\v1.1.4322. Перед загрузкой SOS в процесс активизируйте отладку управляемого кода, настроив свойства проекта Visual Studio .NET. Добавьте каталог, где находится SOS.dll, в переменную окружения PATH. Чтобы загружать SOS.dll в момент, когда выполнение программы остановлено на точке прерывания, откройте окно Debug | Windows | Immediate. В окне непосредственного выполнения введите команду .load sos.dll. Для получения списка команд отладчика введите команду !help. Дополнительную информацию о SOS см. в колонке «Отладка и оптимизация» за июнь 2004 г. (msdn.microsoft.com/msdnmag/issues/03/06/Bugslayer).

MethodTable

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

На рис. 5 показана типичная структура MethodTable. Мы расскажем о наиболее важных полях TypeHandle, а более полный их список см. на самой иллюстрации. Начнем с поля Base Instance Size, напрямую связанного с профилем памяти периода выполнения (runtime memory profile).

Структура MethodTable Рис. 5. Структура MethodTable

Base Instance Size

Base Instance Size — это размер объекта, вычисленный загрузчиком классов по объявлениям полей в коде. Как говорилось выше, текущая реализация GC требует, чтобы для ее нужд в объекте выделялось минимум 12 байтов. Если в классе не определены никакие поля экземпляра, в класс добавляется 4 байта. Остальные 8 байтов занимают Object Header (который может содержать номер syncblk) и TypeHandle. Кроме того, на размер объекта влияет атрибут StructLayoutAttribute.

Рассмотрим снимок памяти (окно просмотра содержимого памяти в Visual Studio .NET 2003) для MethodTable класса MyClass, показанного в листинге 1 (у MyClass два интерфейса), и сравним его с выводом, сгенерированным SOS. На рис. 5 размер объекта находится по смещению 4 байта и содержит значение 12 (0×0000000C). Ниже показан вывод SOS-команды DumpHeap:

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

Method Slot Table

Таблица слотов методов, встроенная в MethodTable, содержит ссылки на дескрипторы соответствующих методов (MethodDesc), определяющие поведение типа. Method Slot Table создается по линеаризованному списку методов реализации, упорядоченному следующим образом: унаследованные виртуальные методы, добавленные виртуальные методы, методы экземпляра и статические методы.

ClassLoader анализирует метаданные текущего класса, родительских классов и интерфейсов и создает таблицу методов. В процессе формирования таблицы методов он замещает переопределенные виртуальные методы, замещает скрытые методы дочернего класса, создает новые слоты и при необходимости дублирует слоты. Дублирование слотов требуется для создания иллюзии того, будто у каждого интерфейса есть своя небольшая vtable. Однако продублированные слоты указывают на одну и ту же физическую реализацию. У MyClass имеется три метода экземпляра, конструктор класса (.cctor) и конструктор объекта (.ctor). Конструктор объекта автоматически генерируется компилятором C# для всех объектов, не имеющих явно определенного конструктора. Конструктор класса генерируется компилятором, поскольку мы определили и инициализировали статическую переменную. На рис. 6 показана структура таблицы методов класса MyClass. Здесь 10 методов, поскольку слот Method2 дублируется, чтобы можно было связать IVMap со слотами (мы расскажем об этом ниже). В листинге 4 приведен отредактированный SOS-дамп таблицы методов класса MyClass.

Структура MethodTable класса MyClass Рис. 6. Структура MethodTable класса MyClass

Листинг 4. 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()

	

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

MethodDesc

Method Descriptor (MethodDesc) — инкапсуляция реализации метода, создаваемая CLR. Имеется несколько типов Method Descriptor, позволяющих вызывать не только управляемые реализации, но и неуправляемые (через interop). В этой статье мы рассмотрим только MethodDesc для управляемых методов на примере кода (листинг 2). MethodDesc генерируется при загрузке класса и сначала указывает на IL-код (Intermediate Language). Каждый MethodDesc заполняется вызовом PreJitStub — заглушкой, инициирующей JIT-компиляцию. На рис. 7 приведена типичная структура дескриптора метода. Запись слота таблицы методов на самом деле указывает на заглушку, а не на саму структуру MethodDesc. Эта заглушка смещена на 5 байтов назад относительно самого MethodDesc и является частью области длиной 8 байтов, наследуемой каждым методом. Пять байтов содержат инструкции вызова подпрограммы PreJitStub. Это 5-байтовое смещение можно увидеть в выводе SOS-команды DumpMT (например для класса MyClass, листнинг 4). MethodDesc всегда смещен на 5 байтов вперед относительно адреса, на который указывает запись таблицы Method Slot Table. При первом вызове выполняется вызов подпрограммы JIT-компиляции. После компиляции 5 байтов, содержащие инструкции вызова, будут замещены безусловным переходом на x86-код, полученный в результате JIT-компиляции.

Дескриптор метода Рис. 7. Дескриптор метода

Дизассемблировав код, на который ссылается запись слота таблицы методов, показанная на рис. 7, можно увидеть вызов PreJitStub. Вот как выглядит немного сокращенный дизассемблированный код, полученный для Method2 перед JIT-компиляцией:

!u 0x00955263
Неуправляемый код
; вызов метода Method2(), JIT-компиляцию
; которого нужно выполнить
00955263 call        003C3538
; то, что идет дальше, игнорируется, но
; команда !u "считает", что это код
00955268 add         eax,68040000h
Теперь выполним метод и дизассемблируем  код, содержащийся по тому же адресу:
!u 0x00955263
Неуправляемый код
; вызов метода Method2() после JIT-компиляции
00955263 jmp     02C633E8
; то, что идет дальше, игнорируется, но
; команда !u "считает", что это код
00955268 add     eax,0E8040000h

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

Запись CodeOrIL перед JIT-компиляцией содержит относительный виртуальный адрес (Relative Virtual Address, RVA) реализации метода на IL. Для этого поля задан флаг, указывающий, что оно содержит адрес IL-кода. После компиляции по требованию CLR помещает в это поле адрес кода, полученного в результате компиляции. Давайте выведем MethodDesc рассматриваемого нами метода командой DumpMT. Перед JIT-компиляцией MethodDesc имеет вид:

!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

Поле Flags в дескрипторе метода содержит информацию о разновидности метода — является ли он статическим методом, методом экземпляра, методом интерфейса или COM-реализацией.

Рассмотрим еще один сложный аспект MethodTable: реализацию интерфейсов. Она выполнена так, чтобы использование интерфейсов в управляемой среде было простым, а все сложные задачи решались при размещении объектов в памяти. Затем мы расскажем о том, как размещаются данные, описывающие интерфейсы, и о том, как работает диспетчеризация методов интерфейсов.

Interface Vtable Map и Interface Map

По смещению 12 в MethodTable находится важный указатель — IVMap. Как видно на рис. 5, IVMap указывает на таблицу сопоставления уровня AppDomain, в которой индексами являются идентификаторы интерфейсов уровня процесса. Идентификатор интерфейса генерируется, когда впервые загружается тип, где объявлен этот интерфейс. Для каждой реализации интерфейса имеется запись в IVMap. Если MyInterface1 реализован двумя классами, в таблице IVMap будет две записи. Как показано на рис. 5, каждая запись указывает на начало подтаблицы, содержащейся в таблице методов MyClass. Эта ссылка используется при диспетчеризации методов интерфейса. IVMap создается на основе информации Interface Map, содержащейся в таблице методов. Interface Map создается по метаданным класса при формировании MethodTable. После того как тип загружен, при диспетчеризации методов используется только IVMap.

Interface Map находится по смещению 28 и указывает на записи InterfaceInfo, содержащиеся в MethodTable. В данном случае есть две записи — по одной для каждого из двух интерфейсов, реализованных MyClass. Первые 4 байта первой записи InterfaceInfo указывают на TypeHandle MyInterface1. Затем идет поле Flags типа WORD (2 байта), содержащее флаги (0 — наследование от родителя, 1 — реализация в базовом классе). Поле типа WORD, идущее за Flags, — Start Slot, используемое загрузчиком классов при формировании подтаблицы, которая описывает реализации интерфейса. В случае MyInterface1 это поле содержит 4, т. е. на реализацию указывают слоты 5 и 6. В случае MyInterface2 поле содержит значение 6, т. е. на реализацию указывают слоты 7 и 8. ClassLoader дублирует слоты, если необходимо создать иллюзию, будто у каждого интерфейса своя реализация, тогда как на самом деле реализации физически сопоставлены одному и тому же дескриптору метода. В MyClass1 MyInterface1.Method2 и MyInterface2.Method2 будут указывать на одну и ту же реализацию.

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

mi1.Method1();
mov    ecx,edi                 ; поместить указатель this в ECX
mov    eax,dword ptr [ecx]     ; поместить TypeHandle в EAX
mov    eax,dword ptr [eax+0Ch] ; поместить адрес IVMap
                               ; (смещение 12) в EAX
mov    eax,dword ptr [eax+30h] ; поместить начальный слот 
                               ; реализации интерфейса в EAX
call   dword ptr [eax]         ; вызов метода

mc.Method1();
mov    ecx,esi                 ; поместить указатель this в ECX
cmp    dword ptr [ecx],ecx     ; сравнение и установка флагов
call   dword ptr ds:[009552D8h]; прямой вызов Method1

Как видно из этого дизассемблированного кода, при прямом вызове методов экземпляра MyClass смещение не используется. JIT-компилятор записывает адрес MethodDesc прямо в код. При диспетчеризации на основе интерфейсов используется IVMap и выполняется несколько больше инструкций, чем при прямой диспетчеризации. Одна инструкция получает адрес IVMap, другая — начальный слот реализации интерфейса, содержащийся в Method SlotTable. При приведении экземпляра объекта к интерфейсу этот указатель просто копируется в нужную переменную. Оператору «mi1 = mc;» на рис. 1 соответствует единственная инструкция, которая копирует OBJECTREF из mc в mi1.

Диспетчеризация виртуальных методов

Теперь рассмотрим диспетчеризацию виртуальных методов и сравним ее с прямой диспетчеризацией и диспетчеризацией на основе интерфейсов. Вот как выглядит дизассемблированный код вызова виртуального метода MyClass.Method3 (листинг 1).

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

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

Статические переменные — важная составляющая структуры данных MethodTable. Они размещаются в MethodTable сразу после таблицы слотов методов. Все переменные элементарных статических типов подставляются прямо в память объекта, а статические объекты значимых типов (вроде структур) и ссылочных типов доступны через ссылки OBJECTREF, создаваемые в таблицах описателей. OBJECTREF в MethodTable ссылается на OBJECTREF в таблице описателей AppDomain, в свою очередь ссылающуюся на экземпляр объекта, созданный в куче. После создания OBJECTREF в таблице описателей экземпляр объекта будет храниться в куче до тех пор, пока не произойдет выгрузка AppDomain. На рис. 5 статическая переменная str строкового типа ссылается на OBJECTREF в таблице дескрипторов, указывающей на MyString в GC Heap.

EEClass

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

Для каждого типа, загруженного в AppDomain, существует по одному EEClass. К этим типам относятся интерфейсы, классы, абстрактные классы, массивы и структуры. Каждый EEClass — узел дерева, отслеживаемый исполняющей средой. CLR использует для навигации по структурам EEClass при загрузке классов, формировании MethodTable, верификации и приведении типов сетевую модель. Отношения потомок-родитель между EEClass основаны на иерархии наследования, тогда как отношения родитель-потомок создаются на основе иерархии наследования и последовательности загрузки классов. По мере дальнейшего выполнения управляемого кода добавляются новые узлы EEClass, и сеть отношений усложняется — между узлами устанавливаются новые отношения. Кроме того, существуют горизонтальные отношения между EEClass, находящимися в сети на одном и том же уровне. У EEClass три поля, управляющих отношениями между загруженными типами: ParentClass, SiblingChain и ChildrenChain. На рис. 8 дана структура EEClass для класса MyClass (листинг 2).

Структура EEClass Рис. 8. Структура EEClass

На рис. 8 показаны только некоторые поля, имеющие отношение к рассматриваемому вопросу. Поскольку мы опустили часть полей структуры, мы не стали показывать на рисунке смещения полей. EEClass содержит циклическую ссылку на MethodTable. Кроме того, EEClass ссылается на фрагменты MethodDesc, созданные в HighFrequencyHeap в AppDomain, используемом по умолчанию. Ссылка на список объектов FieldDesc, создаваемый в куче процесса, служит для получения информации о структуре полей при конструировании MethodTable. Память под EEClass выделяется в LowFrequencyHeap AppDomain, поэтому операционная система может эффективнее управлять размещением страниц в памяти, сокращая рабочий набор.

Названия других полей на рис. 8 в контексте класса MyClass (листинг 1) говорят сами за себя. Давайте посмотрим, что хранится в физической памяти, и для этого получим дамп EEClass с помощью SOS. Запустите программу (листинг 1) установив точку прерывания на строке 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

На рис. 8 и в выводе команды DumpClass, в сущности, содержится одна и та же информация. Маркер метаданных (mdToken) представляет индекс класса MyClass в таблицах метаданных PE-файла модуля, проецируемых в память, а Parent Class — это System.Object. Содержимое Sibling Chain (рис. 8) показывает, что класс загружен при загрузке класса Program.

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

Заключение

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


К началу страницы К началу страницы

Показ: