Common Intermediate Language и системное программирование в Microsoft .NET

Виртуальная система выполнения. Автоматическое управление памятью

Разбить на страницы
Показывать лекцию целиком

Виртуальная система выполнения (Virtual Execution System - VES) представляет собой абстрактную виртуальную машину, способную выполнять управляемый код. Можно сказать, что виртуальная система выполнения существует только "на бумаге", потому что ни одна из реализаций CLI не содержит интерпретатора CIL-кода (вместо этого используется JIT-компилятор, транслирующий инструкции CIL в команды процессора). Другими словами, виртуальная система выполнения не зря называется виртуальной (то есть мнимой), ее предназначение - служить образцом, которому должна соответствовать любая реализация CLI. Какую бы технологию ни использовала эта реализация для выполнения программ, эта технология должна работать так же, как работала бы виртуальная система выполнения.

Если сравнить CLI с ее ближайшим конкурентом - платформой Java, можно прийти к выводу, что VES является значительно более абстрактной моделью, чем виртуальная машина Java (Java Virtual Machine - JVM). Причина такого отличия кроется в том, что изначально Java была ориентирована на реализацию в бытовых приборах. При этом, естественно, подразумевалось, что байт-код Java будет непосредственно выполняться специальными процессорами, и поэтому JVM является фактически спецификацией такого процессора. Аппаратная реализация VES никогда даже не предполагалась, и это позволило избежать при составлении ее спецификации ненужных деталей, дав тем самым каждой реализации CLI большую свободу выбора наиболее оптимальной стратегии выполнения CIL-кода.

Состояние виртуальной машины

Изучение работы виртуальной машины CLI заключается в том, чтобы понять, что представляет собой состояние виртуальной машины и как это состояние меняется во времени. В этом разделе мы не будем затрагивать вопрос изменения состояния, так как оно связано с выполнением инструкций языка CIL, разговор о котором мы отложим до третьей главы нашего учебника.

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

(рис 1.9) Состояние виртуальной машины

Состояние виртуальной машины является совокупностью состояний нитей и состояния кучи.

Состояние нити представляет собой односвязный список состояний методов. Метод, состояние которого находится в самом конце этого списка, является активным, то есть выполняемым в данный момент времени. Если активный метод вызовет другой метод, то в конец списка будет добавлено новое состояние для вызываемого метода. Если же активный метод закончит свою работу, то его состояние будет удалено из списка.

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

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

Спецификация VES предполагает, что для удаления ненужных объектов из кучи будет использоваться какой-либо алгоритм автоматического управления памятью. При этом детали такого алгоритма не рассматриваются, то есть в реализациях CLI могут применяться различные алгоритмы сборки мусора.

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

Состояние метода

На рис. 1.10 изображена схема состояния метода. Элементы состояния метода можно условно разделить на две группы: изменяемые данные и неизменяемые данные. Изменяемые данные доступны из тела метода для чтения и записи, в то время как неизменяемые данные либо доступны только для чтения либо вообще предназначены для внутреннего использования в системе выполнения.

(рис 1.10) Состояние метода

Давайте перечислим элементы состояния метода, входящие в группу изменяемых данных:

  • Указатель инструкции (Instruction Pointer).

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

  • Стек вычислений (Evaluation Stack).

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

    Каждое состояние метода имеет собственный стек вычислений, содержимое которого сохраняется при вызове методов (то есть, если наш метод вызывает другой метод, то по завершении работы вызванного метода содержимое стека никуда не денется).

  • Локальные переменные (Local Variable Array).

    Для хранения локальных переменных в состоянии метода предусмотрена отдельная область памяти, состоящая из так называемых слотов (slots). Каждой локальной переменной соответствует свой слот. Значения локальных переменных сохраняются при вызове методов аналогично содержимому стека вычислений.

  • Параметры (Argument Array).

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

  • Область локальных данных (Local Memory Pool).

    В языке CIL предусмотрена инструкция localloc, которая позволяет динамически размещать объекты в области памяти, локальной для метода. Объекты в этой области живут до тех пор пока метод не завершится.

  • Обратите внимание, что стек вычислений, локальные переменные и параметры, а также локальные данные метода представляют собой логически отдельные области памяти. Каждая конкретная реализация CLI самостоятельно решает вопрос, где размещать эти области.

    В группу неизменяемых данных входят следующие элементы состояния метода:

  • Описатель метода (methodInfo handle).

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

    Описатель метода доступен из кода метода, но в основном он используется системой выполнения при сборке мусора и обработке исключений.

  • Описатель безопасности (Security Descriptor).

    Используется системой безопасности CLI и недоступен из кода метода.

  • Состояние возврата (Return State Handle).

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

  • Стек вычислений

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

    Стек вычислений в VES состоит из слотов. При этом глубина стека (максимальное количество слотов) всегда ограничена и задается статически в заголовке метода. Решение ограничить глубину стека было принято разработчиками спецификации CLI для того, чтобы облегчить создание JIT-компиляторов.

    На входе метода стек вычислений всегда пуст. Затем он используется для передачи операндов инструкциям CIL, для передачи фактических параметров вызываемым методам, а также для получения результатов выполнения инструкций и вызываемых методов. Если метод возвращает какое-то значение, то оно кладется на стек вычислений перед завершением метода.

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

    Каждый слот стека вычислений может содержать ровно одно значение одного из следующих типов:

  • int64 - 8-байтовое целое со знаком;
  • int32 - 4-байтовое целое со знаком;
  • native int - знаковое целое, разрядность которого зависит от аппаратной платформы (может быть 4 или 8 байт);
  • F - число с плавающей точкой, разрядность которого зависит от аппаратной платформы (не может быть меньше 8 байт);
  • - управляемый указатель;
  • O - объектная ссылка;
  • Пользовательский тип-значение.
  • Таким образом, слоты стека вычислений могут иметь различный размер в зависимости от типов записанных в них значений.

    Также мы можем видеть, что допустимые типы значений для стека вычислений не совпадают с общей системой типов CTS. Например, в CTS существуют целые типы разрядности 1 и 2 байта, которые не могут содержаться на стеке вычислений. И наоборот, тип F стека вычислений не имеет аналога в CTS. Кроме того, для стека вычислений все управляемые указатели и объектные ссылки отображаются в два типа: и O соответственно.

    Давайте обсудим, как в VES осуществляется работа с типами данных, не поддерживаемыми напрямую стеком вычислений.

    Во-первых, короткие целые типы ( bool, char, int8, int16, unsigned int8, unsigned int16 ) при загрузке на стек вычислений расширяются до int32. При этом знаковые короткие целые типы ( int8, int16 ) расширяются с сохранением знака, а беззнаковые расширяются путем добавления нулевых битов. При сохранении значения со стека вычислений в переменной, параметре, поле объекта или элементе массива происходит обратное сужающее преобразование.

    Во-вторых, беззнаковый тип unsigned int32 при загрузке на стек вычислений становится знаковым int32, и аналогично, беззнаковый unsigned int64 становится знаковым int64. При этом, естественно, никаких преобразований не происходит - просто последовательность бит, которая раньше считалась беззнаковым целым, копируется на стек вычислений. Вообще говоря, утверждение, что целые типы int32, int64 и native int на стеке вычислений имеют знак, достаточно спорно. Правильнее было бы сказать, что они могут представлять как знаковые, так и беззнаковые целые числа в зависимости от того, какие инструкции CIL используются для их обработки.

    В-третьих, типы float32 и float64 при копировании на стек вычислений преобразуются к типу F. Разрядность этого типа определяется конкретной реализацией CLI, которая, однако, должна гарантировать, что точность типа F не ниже, чем точность типа float64.

    В-четвертых, типы-перечисления при копировании на стек вычислений автоматически превращаются в целые типы. Вообще, VES устроена таким образом, что типы-перечисления и целые типы являются совместимыми по присваиванию. Этим они отличаются от обычных типов-значений, которые при копировании на стек сохраняют свой тип и не совместимы с целыми типами.

    И, наконец, для VES не имеет значения, какой точный тип имеют управляемые указатели и объектные ссылки. Любой управляемый указатель считается имеющим тип , а любая объектная ссылка представляется типом O. Это означает, что согласно спецификации CLI система выполнения не обязана отслеживать правильность типов управляемых указателей и объектных ссылок. Действительно, контроль за правильностью типов находится в компетенции верификатора.

    Локальные переменные и параметры

    Для хранения локальных переменных и параметров метода используются два массива, которые, как и стек вычислений, состоят из слотов. При этом каждой переменной и каждому параметру соответствует ровно один слот.

    Для доступа к локальным переменным и параметрам используются их индексы в массивах переменных и параметров. При этом нумерация осуществляется с нуля.

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

    Слоты, из которых состоят массивы переменных и параметров, адресуемы. Это означает, что в языке CIL существуют специальные инструкции, позволяющие получить адрес локальной переменной или параметра метода в виде управляемого указателя.

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

    Область локальных данных

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

    Память из области локальных данных может быть явно выделена с помощью инструкции localloc. Так как в языке CIL не существует инструкции, освобождающей память в области локальных данных, то эту область невозможно использовать для реализации менеджера памяти общего назначения.

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

    В верифицированном коде использование области локальных данных запрещено.

    Автоматическое управление памятью

    Одной из основных особенностей платформы .NET, делающих ее привлекательной для разработки приложений, является механизм автоматического управления памятью, известный как сборка мусора (garbage collection).

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

    В .NET реализован так называемый сборщик мусора с поколениями (generational garbage collector), работающий на основе построения графа достижимости объектов.

    Выделение памяти в управляемой куче

    Под управляемую кучу резервируется непрерывная область адресного пространства процесса. Система выполнения поддерживает специальный указатель (назовем его HeapPtr), содержащий адрес, по которому будет выделена память для следующего объекта. Когда куча не содержит ни одного объекта, HeapPtr указывает на начало кучи. Выделение памяти для объекта заключается в увеличении HeapPtr на количество байт, занимаемое этим объектом в куче.

    Для некоторых объектов определены методы Finalize, выполняющие некие действия при удалении объекта из кучи. Эти методы являются аналогами деструкторов языка C++ и используются главным образом для освобождения системных ресурсов, связанных с объектами. В целях повышения эффективности сборщика мусора при выделении памяти для объекта, имеющего метод Finalize, адрес этого объекта заносится в список завершения (finalization list).

    Если сравнить механизм выделения памяти в управляемой куче .NET с работой функции malloc языка C, можно прийти к выводу, что функция malloc работает гораздо менее эффективно. Причина в том, что исполняющая среда языка C организует кучу в виде связного списка блоков памяти. При этом размеры блоков в общем случае различны. Функции malloc приходится выполнять поиск свободного блока нужного размера, разбивать этот блок и затем вносить необходимые изменения в список блоков. Ясно, что выполнение этих действий требует значительно больше времени, чем простое увеличение указателя HeapPtr.

    Алгоритм сборки мусора

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

  • она исполняет не CIL-код, а порожденный JIT-компилятором код процессора семейства Intel x86;
  • для каждого потока выполнения существует стек, в котором расположены фреймы вызванных методов. Каждый фрейм содержит адрес возврата, адрес фрейма предыдущего метода в стеке, а также локальные переменные и параметры метода;
  • стеки вычислений в явном виде отсутствуют. Вместо них используются регистры процессора и стек потока;
  • объектные ссылки представляют собой обычные указатели на объекты в управляемой куче.
  • Ключевую роль в работе сборщика мусора играет понятие корень (root). Корнем считается указатель на объект кучи, расположенный вне кучи. Таким образом, корнями являются глобальные переменные, статические поля классов, локальные переменные и параметры методов, а также регистры процессора, содержащие указатели на объекты кучи.

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

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

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

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

    Среди объектов, не попавших в граф достижимости, сборщик мусора ищет такие объекты, адреса которых записаны в список завершения. Эти адреса добавляются в очередь завершения, а сами объекты считаются достижимыми и не подлежащими удалению. Методы Finalize объектов, попавших в очередь завершения, выполняются затем в отдельном потоке.

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

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

    Основные приемы повышения эффективности сборки мусора

    Проведение сборки мусора только для части объектов кучи позволяет существенно сократить время работы сборщика. Поэтому все объекты делятся на три категории, называемые поколениями. В поколении 0 сборка мусора проводится чаще всего. Объекты, пережившие сборку мусора в поколении 0, переводятся в поколение 1, в котором сборка мусора осуществляется реже. Объекты, не удаленные после сборки мусора в поколении 1, переводятся в поколение 2. Сборка мусора в поколении 2 выполняется совсем редко.

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

    Еще одним способом увеличения производительности сборщика мусора является выделение отдельной кучи для больших объектов. Большими считаются объекты, размер которых превышает 85000 байт. Куча больших объектов никогда не дефрагментируется, и все объекты в ней считаются принадлежащими поколению 2.

    Страницы:

    Виртуальная система выполнения (Virtual Execution System - VES) представляет собой абстрактную виртуальную машину, способную выполнять управляемый код. Можно сказать, что виртуальная система выполнения существует только "на бумаге", потому что ни одна из реализаций CLI не содержит интерпретатора CIL-кода (вместо этого используется JIT-компилятор, транслирующий инструкции CIL в команды процессора). Другими словами, виртуальная система выполнения не зря называется виртуальной (то есть мнимой), ее предназначение - служить образцом, которому должна соответствовать любая реализация CLI. Какую бы технологию ни использовала эта реализация для выполнения программ, эта технология должна работать так же, как работала бы виртуальная система выполнения.

    Если сравнить CLI с ее ближайшим конкурентом - платформой Java, можно прийти к выводу, что VES является значительно более абстрактной моделью, чем виртуальная машина Java (Java Virtual Machine - JVM). Причина такого отличия кроется в том, что изначально Java была ориентирована на реализацию в бытовых приборах. При этом, естественно, подразумевалось, что байт-код Java будет непосредственно выполняться специальными процессорами, и поэтому JVM является фактически спецификацией такого процессора. Аппаратная реализация VES никогда даже не предполагалась, и это позволило избежать при составлении ее спецификации ненужных деталей, дав тем самым каждой реализации CLI большую свободу выбора наиболее оптимальной стратегии выполнения CIL-кода.

    Состояние виртуальной машины

    Изучение работы виртуальной машины CLI заключается в том, чтобы понять, что представляет собой состояние виртуальной машины и как это состояние меняется во времени. В этом разделе мы не будем затрагивать вопрос изменения состояния, так как оно связано с выполнением инструкций языка CIL, разговор о котором мы отложим до третьей главы нашего учебника.

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

    (рис 1.9) Состояние виртуальной машины

    Состояние виртуальной машины является совокупностью состояний нитей и состояния кучи.

    Состояние нити представляет собой односвязный список состояний методов. Метод, состояние которого находится в самом конце этого списка, является активным, то есть выполняемым в данный момент времени. Если активный метод вызовет другой метод, то в конец списка будет добавлено новое состояние для вызываемого метода. Если же активный метод закончит свою работу, то его состояние будет удалено из списка.

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

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

    Спецификация VES предполагает, что для удаления ненужных объектов из кучи будет использоваться какой-либо алгоритм автоматического управления памятью. При этом детали такого алгоритма не рассматриваются, то есть в реализациях CLI могут применяться различные алгоритмы сборки мусора.

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

    Состояние метода

    На рис. 1.10 изображена схема состояния метода. Элементы состояния метода можно условно разделить на две группы: изменяемые данные и неизменяемые данные. Изменяемые данные доступны из тела метода для чтения и записи, в то время как неизменяемые данные либо доступны только для чтения либо вообще предназначены для внутреннего использования в системе выполнения.

    (рис 1.10) Состояние метода

    Давайте перечислим элементы состояния метода, входящие в группу изменяемых данных:

  • Указатель инструкции (Instruction Pointer).

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

  • Стек вычислений (Evaluation Stack).

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

    Каждое состояние метода имеет собственный стек вычислений, содержимое которого сохраняется при вызове методов (то есть, если наш метод вызывает другой метод, то по завершении работы вызванного метода содержимое стека никуда не денется).

  • Локальные переменные (Local Variable Array).

    Для хранения локальных переменных в состоянии метода предусмотрена отдельная область памяти, состоящая из так называемых слотов (slots). Каждой локальной переменной соответствует свой слот. Значения локальных переменных сохраняются при вызове методов аналогично содержимому стека вычислений.

  • Параметры (Argument Array).

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

  • Область локальных данных (Local Memory Pool).

    В языке CIL предусмотрена инструкция localloc, которая позволяет динамически размещать объекты в области памяти, локальной для метода. Объекты в этой области живут до тех пор пока метод не завершится.

  • Обратите внимание, что стек вычислений, локальные переменные и параметры, а также локальные данные метода представляют собой логически отдельные области памяти. Каждая конкретная реализация CLI самостоятельно решает вопрос, где размещать эти области.

    В группу неизменяемых данных входят следующие элементы состояния метода:

  • Описатель метода (methodInfo handle).

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

    Описатель метода доступен из кода метода, но в основном он используется системой выполнения при сборке мусора и обработке исключений.

  • Описатель безопасности (Security Descriptor).

    Используется системой безопасности CLI и недоступен из кода метода.

  • Состояние возврата (Return State Handle).

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

  • Стек вычислений

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

    Стек вычислений в VES состоит из слотов. При этом глубина стека (максимальное количество слотов) всегда ограничена и задается статически в заголовке метода. Решение ограничить глубину стека было принято разработчиками спецификации CLI для того, чтобы облегчить создание JIT-компиляторов.

    На входе метода стек вычислений всегда пуст. Затем он используется для передачи операндов инструкциям CIL, для передачи фактических параметров вызываемым методам, а также для получения результатов выполнения инструкций и вызываемых методов. Если метод возвращает какое-то значение, то оно кладется на стек вычислений перед завершением метода.

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

    Каждый слот стека вычислений может содержать ровно одно значение одного из следующих типов:

  • int64 - 8-байтовое целое со знаком;
  • int32 - 4-байтовое целое со знаком;
  • native int - знаковое целое, разрядность которого зависит от аппаратной платформы (может быть 4 или 8 байт);
  • F - число с плавающей точкой, разрядность которого зависит от аппаратной платформы (не может быть меньше 8 байт);
  • - управляемый указатель;
  • O - объектная ссылка;
  • Пользовательский тип-значение.
  • Таким образом, слоты стека вычислений могут иметь различный размер в зависимости от типов записанных в них значений.

    Также мы можем видеть, что допустимые типы значений для стека вычислений не совпадают с общей системой типов CTS. Например, в CTS существуют целые типы разрядности 1 и 2 байта, которые не могут содержаться на стеке вычислений. И наоборот, тип F стека вычислений не имеет аналога в CTS. Кроме того, для стека вычислений все управляемые указатели и объектные ссылки отображаются в два типа: и O соответственно.

    Давайте обсудим, как в VES осуществляется работа с типами данных, не поддерживаемыми напрямую стеком вычислений.

    Во-первых, короткие целые типы ( bool, char, int8, int16, unsigned int8, unsigned int16 ) при загрузке на стек вычислений расширяются до int32. При этом знаковые короткие целые типы ( int8, int16 ) расширяются с сохранением знака, а беззнаковые расширяются путем добавления нулевых битов. При сохранении значения со стека вычислений в переменной, параметре, поле объекта или элементе массива происходит обратное сужающее преобразование.

    Во-вторых, беззнаковый тип unsigned int32 при загрузке на стек вычислений становится знаковым int32, и аналогично, беззнаковый unsigned int64 становится знаковым int64. При этом, естественно, никаких преобразований не происходит - просто последовательность бит, которая раньше считалась беззнаковым целым, копируется на стек вычислений. Вообще говоря, утверждение, что целые типы int32, int64 и native int на стеке вычислений имеют знак, достаточно спорно. Правильнее было бы сказать, что они могут представлять как знаковые, так и беззнаковые целые числа в зависимости от того, какие инструкции CIL используются для их обработки.

    В-третьих, типы float32 и float64 при копировании на стек вычислений преобразуются к типу F. Разрядность этого типа определяется конкретной реализацией CLI, которая, однако, должна гарантировать, что точность типа F не ниже, чем точность типа float64.

    В-четвертых, типы-перечисления при копировании на стек вычислений автоматически превращаются в целые типы. Вообще, VES устроена таким образом, что типы-перечисления и целые типы являются совместимыми по присваиванию. Этим они отличаются от обычных типов-значений, которые при копировании на стек сохраняют свой тип и не совместимы с целыми типами.

    И, наконец, для VES не имеет значения, какой точный тип имеют управляемые указатели и объектные ссылки. Любой управляемый указатель считается имеющим тип , а любая объектная ссылка представляется типом O. Это означает, что согласно спецификации CLI система выполнения не обязана отслеживать правильность типов управляемых указателей и объектных ссылок. Действительно, контроль за правильностью типов находится в компетенции верификатора.

    Локальные переменные и параметры

    Для хранения локальных переменных и параметров метода используются два массива, которые, как и стек вычислений, состоят из слотов. При этом каждой переменной и каждому параметру соответствует ровно один слот.

    Для доступа к локальным переменным и параметрам используются их индексы в массивах переменных и параметров. При этом нумерация осуществляется с нуля.

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

    Слоты, из которых состоят массивы переменных и параметров, адресуемы. Это означает, что в языке CIL существуют специальные инструкции, позволяющие получить адрес локальной переменной или параметра метода в виде управляемого указателя.

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

    Область локальных данных

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

    Память из области локальных данных может быть явно выделена с помощью инструкции localloc. Так как в языке CIL не существует инструкции, освобождающей память в области локальных данных, то эту область невозможно использовать для реализации менеджера памяти общего назначения.

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

    В верифицированном коде использование области локальных данных запрещено.

    Автоматическое управление памятью

    Одной из основных особенностей платформы .NET, делающих ее привлекательной для разработки приложений, является механизм автоматического управления памятью, известный как сборка мусора (garbage collection).

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

    В .NET реализован так называемый сборщик мусора с поколениями (generational garbage collector), работающий на основе построения графа достижимости объектов.

    Выделение памяти в управляемой куче

    Под управляемую кучу резервируется непрерывная область адресного пространства процесса. Система выполнения поддерживает специальный указатель (назовем его HeapPtr), содержащий адрес, по которому будет выделена память для следующего объекта. Когда куча не содержит ни одного объекта, HeapPtr указывает на начало кучи. Выделение памяти для объекта заключается в увеличении HeapPtr на количество байт, занимаемое этим объектом в куче.

    Для некоторых объектов определены методы Finalize, выполняющие некие действия при удалении объекта из кучи. Эти методы являются аналогами деструкторов языка C++ и используются главным образом для освобождения системных ресурсов, связанных с объектами. В целях повышения эффективности сборщика мусора при выделении памяти для объекта, имеющего метод Finalize, адрес этого объекта заносится в список завершения (finalization list).

    Если сравнить механизм выделения памяти в управляемой куче .NET с работой функции malloc языка C, можно прийти к выводу, что функция malloc работает гораздо менее эффективно. Причина в том, что исполняющая среда языка C организует кучу в виде связного списка блоков памяти. При этом размеры блоков в общем случае различны. Функции malloc приходится выполнять поиск свободного блока нужного размера, разбивать этот блок и затем вносить необходимые изменения в список блоков. Ясно, что выполнение этих действий требует значительно больше времени, чем простое увеличение указателя HeapPtr.

    Алгоритм сборки мусора

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

  • она исполняет не CIL-код, а порожденный JIT-компилятором код процессора семейства Intel x86;
  • для каждого потока выполнения существует стек, в котором расположены фреймы вызванных методов. Каждый фрейм содержит адрес возврата, адрес фрейма предыдущего метода в стеке, а также локальные переменные и параметры метода;
  • стеки вычислений в явном виде отсутствуют. Вместо них используются регистры процессора и стек потока;
  • объектные ссылки представляют собой обычные указатели на объекты в управляемой куче.
  • Ключевую роль в работе сборщика мусора играет понятие корень (root). Корнем считается указатель на объект кучи, расположенный вне кучи. Таким образом, корнями являются глобальные переменные, статические поля классов, локальные переменные и параметры методов, а также регистры процессора, содержащие указатели на объекты кучи.

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

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

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

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

    Среди объектов, не попавших в граф достижимости, сборщик мусора ищет такие объекты, адреса которых записаны в список завершения. Эти адреса добавляются в очередь завершения, а сами объекты считаются достижимыми и не подлежащими удалению. Методы Finalize объектов, попавших в очередь завершения, выполняются затем в отдельном потоке.

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

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

    Основные приемы повышения эффективности сборки мусора

    Проведение сборки мусора только для части объектов кучи позволяет существенно сократить время работы сборщика. Поэтому все объекты делятся на три категории, называемые поколениями. В поколении 0 сборка мусора проводится чаще всего. Объекты, пережившие сборку мусора в поколении 0, переводятся в поколение 1, в котором сборка мусора осуществляется реже. Объекты, не удаленные после сборки мусора в поколении 1, переводятся в поколение 2. Сборка мусора в поколении 2 выполняется совсем редко.

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

    Еще одним способом увеличения производительности сборщика мусора является выделение отдельной кучи для больших объектов. Большими считаются объекты, размер которых превышает 85000 байт. Куча больших объектов никогда не дефрагментируется, и все объекты в ней считаются принадлежащими поколению 2.

    Вернуться к учебному плану