Наступит время, когда общеязыковая исполняющая среда (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 структуры данных, упоминаемые в статье.
| Структура данных | Путь в 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 создает три домена приложения. Два из них непрозрачны для управляемого кода и даже не видны
Рис. 1. Домены, создаваемые при начальной загрузке CLR
SystemDomain создает и инициализирует SharedDomain и AppDomain, используемый по умолчанию (DefaultDomain). Он загружает в SharedDomain системную библиотеку mscorlib.dll, а также хранит явные и неявные intern-строки уровня процесса.
Применение intern-строк — один из методов оптимизации; в .NET Framework 1.1 он реализован не слишком удачно, так как CLR не позволяет сборкам отказаться от нее. Тем не менее, этот метод экономит память, поскольку для всех строк с одним и тем же текстом, определенных во всех доменах приложений, хранится только один экземпляр строки.
Кроме того, SystemDomain отвечает за генерацию идентификаторов интерфейсов уровня процесса, применяемых при создании карт InterfaceVtableMap в каждом AppDomain. SystemDomain хранит данные обо всех доменах процесса и обеспечивает загрузку и выгрузку AppDomain'ов.
Весь код, не зависящий от домена, загружается в SharedDomain. Системная библиотека Mscorlib необходима пользовательскому коду во всех AppDomain. Она автоматически загружается в SharedDomain. Основные типы данных из пространства имен System, такие как Object, ValueType, Array, Enum, String и Delegate, заранее загружаются в этот домен при первоначальной загрузке CLR. Пользовательский код также можно загружать в этот домен — для этого из приложения, служащего
DefaultDomain — это экземпляр AppDomain, в котором обычно выполняется код приложения. Хотя некоторым приложениям требуется, чтобы во время выполнения создавались дополнительные AppDomain (например приложениям, использующим подключаемые модули, или приложениям, генерирующим большие объемы кода в период выполнения), большинство программ в течение всей своей жизни создает один домен. Весь код, выполняемый в этом домене, связывается с контекстом на уровне домена. Если приложение использует несколько AppDomain, взаимодействие между доменами осуществляется через прокси .NET Remoting. Дополнительные границы контекста внутри домена можно создать с помощью типов, производных от System.ContextBoundObject. У каждого AppDomain имеются свои SecurityDescriptor, SecurityContext и DefaultContext, а также свои кучи загрузчика (High-Frequency Heap,
Кучи загрузчика (LoaderHeaps) предназначены для загрузки различных специальных объектов (artifacts), существующих в течение всего срока жизни домена, — объектов, используемых CLR, и объектов, обеспечивающих оптимизацию. Размер этих куч увеличивается предсказуемыми порциями, чтобы уменьшить фрагментацию. Кучи загрузчика отличаются от кучи сборщика мусора (GC Heap) (или нескольких таких куч в случае симметричной многопроцессорной обработки, SMP) тем, что GC Heap содержит экземпляры объектов, а кучи загрузчика хранят данные системы типов. В HighFrequencyHeap выделяется память для часто используемых объектов, таких как MethodTable, MethodDesc, FieldDesc и Interface Map, а в LowFrequencyHeap — для структур данных, к которым обращаются реже, таких как EEClass, ClassLoader и их поисковых таблиц. В StubHeap содержатся приемники (stubs), используемые при защите по правам доступа кода (code access security, CAS), обертывании
Мы рассмотрели домены и кучи загрузчика с высокоуровневой точки зрения, теперь изучим их физическое устройство на примере простого приложения (листинг 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 использует свой загрузчик.
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, генерируемые
Тип — фундаментальная единица программирования в .NET. В C# тип можно объявить с помощью ключевых слов class, struct и interface. Большинство типов явно создается программистом, однако .NET CLR может неявно генерировать типы в определенных случаях взаимодействия или вызова удаленных объектов (.NET Remoting). К этим генерируемым типам относятся оболочки, вызываемые COM или исполняющей средой, и траспарентные прокси.
Мы начнем знакомство с устройством
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 (экземпляр объекта) для обозначения структуры данных, находящейся по адресу, на который указывает ссылка на объект.
Рис. 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. Эта таблица содержит информацию о разновидности типа (интерфейс, абстрактный класс, конкретный класс,
Одной из важных структур данных, на которые ссылается MethodTable, является EEClass. Загрузчик классов CLR создает EEClass по метаданным перед созданием MethodTable. MethodTable класса SmallClass, объявленного в листинге 2, ссылается на его EEClass. Эти структуры ссылаются на свои модули и сборки. MethodTable и EEClass обычно создаются в кучах загрузчика, специфичных для доменов. Тип Byte[] — особый случай: его MethodTable и EEClass создаются в кучах загрузчика SharedDomain. Кучи загрузчика специфичны для AppDomain, и упомянутые выше данные после загрузки не уничтожаются, пока AppDomain не будет выгружен. Но AppDomain, используемый по умолчанию, нельзя выгружать, поэтому содержащийся в нем код существует, пока CLR не завершит работу.
Как мы уже говорили, все экземпляры значимых типов хранятся или в стеке потока, или в 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, содержащий полезную, но редко нужную экземплярам объектов информацию. К этой информации относятся данные о блокировке объекта, его
SmallClass obj = new SmallClass()
// Выполняем некие операции
lock(obj) { /* операции, требующие синхронизации */ }
obj.GetHashCode();В этом коде номер syncblk объекта smallObj сначала равен нулю (блок синхронизации не используется). При выполнении оператора lock CLR создает запись syncblk и помещает в заголовок объекта ее номер. Поскольку в C# ключевое слово преобразовывается в блок
Рис. 3. Структура экземпляра объекта
В SyncBlock имеются и другие поля, применяемые при COM interop и маршалинге делегатов в неуправляемый код, но обычно эти поля не задействуются при использовании объекта.
За номером syncblk в ObjectInstance идет TypeHandle. Но пока что не будем на него отвлекаться — рассмотрим его после переменных экземпляра. За TypeHandle идет список полей экземпляра, длина которого бывает разной. По умолчанию поля экземпляра размещаются так, чтобы память использовалась эффективно, а промежутки между полями были минимальны. В листинге 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. Экземпляр объекта, показанный в окне просмотра содержимого памяти
Как видите, по умолчанию члены объекта не обязательно размещаются в памяти в том порядке, в каком они объявлены в исходном коде (в лексической последовательности). В случаях взаимодействия через 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 не известно, сколько памяти занимают неуправляемые ресурсы —
TypeHandle — указатель на MethodTable — размещается непосредственно за номером syncblk. Перед созданием экземпляра объекта CLR просматривает данные о загруженных типах, загружает тип, если не обнаруживает данных о нем, получает адрес MethodTable, создает экземпляр объекта и заносит в экземпляр объекта значение TypeHandle. Код, генерируемый
В этой разделе мы показываем содержимое структур данных 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).
Для каждого класса или интерфейса, загружаемого в 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).
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). Ниже показан вывод
!DumpHeap -type MyClass
Address MT Size
00a819ac 009552a0 12
total 1 objects
Statistics:
MT Count TotalSize Class Name
9552a0 1 12 MyClassТаблица слотов методов, встроенная в MethodTable, содержит ссылки на дескрипторы соответствующих методов (MethodDesc), определяющие поведение типа. Method Slot Table создается по линеаризованному списку методов реализации, упорядоченному следующим образом: унаследованные виртуальные методы, добавленные виртуальные методы, методы экземпляра и статические методы.
ClassLoader анализирует метаданные текущего класса, родительских классов и интерфейсов и создает таблицу методов. В процессе формирования таблицы методов он замещает переопределенные виртуальные методы, замещает скрытые методы дочернего класса, создает новые слоты и при необходимости дублирует слоты. Дублирование слотов требуется для создания иллюзии того, будто у каждого интерфейса есть своя небольшая vtable. Однако продублированные слоты указывают на одну и ту же физическую реализацию. У MyClass имеется три метода экземпляра, конструктор класса (.cctor) и конструктор объекта (.ctor). Конструктор объекта автоматически генерируется компилятором C# для всех объектов, не имеющих явно определенного конструктора. Конструктор класса генерируется компилятором, поскольку мы определили и инициализировали статическую переменную. На рис. 6 показана структура таблицы методов класса MyClass. Здесь 10 методов, поскольку слот Method2 дублируется, чтобы можно было связать IVMap со слотами (мы расскажем об этом ниже). В листинге 4 приведен отредактированный
Рис. 6. Структура MethodTable класса 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, код которых задан явно, будут группироваться со статическими методами и методами экземпляра соответственно.
Method Descriptor (MethodDesc) — инкапсуляция реализации метода, создаваемая CLR. Имеется несколько типов Method Descriptor, позволяющих вызывать не только управляемые реализации, но и неуправляемые (через interop). В этой статье мы рассмотрим только MethodDesc для управляемых методов на примере кода (листинг 2). MethodDesc генерируется при загрузке класса и сначала указывает на
Дизассемблировав код, на который ссылается запись слота таблицы методов, показанная на рис. 7, можно увидеть вызов PreJitStub. Вот как выглядит немного сокращенный дизассемблированный код, полученный для Method2 перед
!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 перед
!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 в дескрипторе метода содержит информацию о разновидности метода — является ли он статическим методом, методом экземпляра, методом интерфейса или
Рассмотрим еще один сложный аспект MethodTable: реализацию интерфейсов. Она выполнена так, чтобы использование интерфейсов в управляемой среде было простым, а все сложные задачи решались при размещении объектов в памяти. Затем мы расскажем о том, как размещаются данные, описывающие интерфейсы, и о том, как работает диспетчеризация методов интерфейсов.
По смещению 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 смещение не используется.
Теперь рассмотрим диспетчеризацию виртуальных методов и сравним ее с прямой диспетчеризацией и диспетчеризацией на основе интерфейсов. Вот как выглядит дизассемблированный код вызова виртуального метода 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 появляется на свет перед созданием MethodTable и в сочетании с MethodTable является
Для каждого типа, загруженного в AppDomain, существует по одному EEClass. К этим типам относятся интерфейсы, классы, абстрактные классы, массивы и структуры. Каждый EEClass — узел дерева, отслеживаемый исполняющей средой. CLR использует для навигации по структурам EEClass при загрузке классов, формировании MethodTable, верификации и приведении типов сетевую модель. Отношения потомок-родитель между EEClass основаны на иерархии наследования, тогда как отношения родитель-потомок создаются на основе иерархии наследования и последовательности загрузки классов. По мере дальнейшего выполнения управляемого кода добавляются новые узлы EEClass, и сеть отношений усложняется — между узлами устанавливаются новые отношения. Кроме того, существуют горизонтальные отношения между EEClass, находящимися в сети на одном и том же уровне. У EEClass три поля, управляющих отношениями между загруженными типами: ParentClass, SiblingChain и ChildrenChain. На рис. 8 дана структура EEClass для класса MyClass (листинг 2).
На рис. 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 в таблицах метаданных
MyClass имеет восемь слотов vtable (т. е. восемь методов, поддерживающих диспетчеризацию виртуальных методов). Методы Method1 и Method2 не виртуальные, но они поддерживают диспетчеризацию через интерфейсы, поэтому считаются виртуальными и добавлены в список. Добавив в список .cctor и .ctor, получаем всего 10 (0xA) методов. У класса MyClass два статических поля, показанных в конце вывода, и нет полей экземпляра. Остальные данные говорят сами за себя.
Наш обзор некоторых наиболее важных деталей внутреннего устройства CLR подошел к концу. Очевидно, что можно было бы рассказать гораздо больше и рассмотреть некоторые вопросы подробнее. Тем не менее, надеемся, что дали вам представление о том, как все это работает. Многое из того, о чем говорилось в статье, скорее всего изменится в будущих версиях CLR и .NET Framework. Но, хотя структуры данных, рассмотренные в статье, могут измениться, базовые принципы останутся теми же.