Поколения объектов

115

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

Предположения в основе модели поколений

В отличие от мира животных, в .NET «юные» объекты «умирают» чаще, а «старые» - реже. Эти два предположения сделаны на основе распределения объектов по их возрасту и продолжительности жизни, как показано на рисунке ниже:

Продолжительность жизни объекта, как функция от его возраста

Определение «юный»/«старый» зависит от частоты сборки мусора в приложении. В приложении, где сборка мусора выполняется раз в минуту, объект, созданный 5 секунд назад, будет считаться «юным». В другом приложении, интенсивно работающим с памятью, где сборка мусора выполняется десятки раз в секунду, объект такого же возраста будет считаться «старым». Но как бы то ни было, в большинстве приложений временные объекты (например, локальные переменные в методах) обычно умирают «юными», а объекты, созданные в момент инициализации приложения, живут намного дольше.

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

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

Реализация поколений в .NET

Согласно модели поколений, динамическая память, обслуживаемая сборщиком мусора, делится на три области: поколение 0, поколение 1 и поколение 2. Эти области соответствуют ожидаемой продолжительности жизни объектов, находящихся в них: поколение 0 включает самые «юные» объекты, а поколение 2 - самые «старые».

Поколение 0

К поколению 0 относятся все вновь созданные объекты (далее в этом разделе будет показано, что объекты также делятся по размеру, что делает это утверждение верным лишь отчасти). Эта область динамической памяти имеет небольшой объем и не может занимать всю динамическую память, даже в небольших приложениях. Обычно для поколения 0 изначально отводится от 256 Кбайт до 4 Мбайт памяти, но этот объем может расти, в зависимости от потребностей.

Помимо разрядности операционной системы, на размер области, выделяемой для поколения 0, также влияют размеры кешей L2 и L3 процессора, потому что к этому поколению обычно относятся наиболее часто используемые объекты, живущие в течение короткого периода времени. Размер этой области может также динамически изменяться сборщиком мусора и устанавливаться средой выполнения CLR в момент запуска приложения, исходя из настроек пороговых значений. Суммарный размер областей, выделяемых для поколений 0 и 1 не может превышать размер одного сегмента (обсуждается ниже).

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

Сборка мусора в поколении 0 является очень дешевой и эффективной операцией по следующим причинам:

Как мы установили, большинство объектов в поколении 0 будет утилизировано в первом же цикле сборки мусора. Однако некоторые объекты могут продолжить существование по тем или иным причинам:

  1. приложение может быть плохо спроектировано и создавать временные объекты, живущие дольше одного цикла сборки мусора;

  2. приложение находится на этапе инициализации, когда создаются долгоживущие объекты;

  3. приложение создало несколько временных, короткоживущих объектов непосредственно перед запуском сборщика мусора.

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

Перемещение закрепленных объектов между поколениями

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

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

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

Следующий код демонстрирует, как закрепленные объекты могут переноситься из поколения в поколение с помощью метода GC.GetGeneration():

static void Main(string[] args)
{
    byte[] bytes = new byte[128];
    GCHandle gch = GCHandle.Alloc(bytes, GCHandleType.Pinned);

    GC.Collect();
    Console.WriteLine("Генерация: " + GC.GetGeneration(bytes));

    gch.Free();
    GC.KeepAlive(bytes);
}

Если заглянуть в динамическую память перед сборкой мусора, поколения будут располагаться, как показано ниже:

Генерация 0 starts at 0x02791030
Генерация 1 starts at 0x02791018
Генерация 2 starts at 0x02791000

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

Генерация 0 starts at 0x02795df8
Генерация 1 starts at 0x02791018
Генерация 2 starts at 0x02791000

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

Поколение 1

Поколение 1 - это буфер между поколением 0 и поколением 2, содержащий объекты, пережившие один цикл сборки мусора. Эта область динамической памяти немного больше области поколения 0, но все еще меньше на несколько порядков объема всей доступной памяти. Обычно для поколения 1 изначально отводится от 512 Кбайт до 4 Мбайт памяти.

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

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

Объекты, пережившие цикл сборки мусора в поколении 1, переносятся в поколение 2. Этот перенос отражает тот факт, что теперь объекты будут считаться «старыми». Одним из основных рисков модели поколений является вероятность попадания в поколение 2 временных объектов, которые вскоре после этого должны стать неиспользуемыми; это называется явлением «кризиса среднего возраста». Чрезвычайно важно воспрепятствовать попаданию временных объектов в поколение 2. Далее мы исследуем отрицательное влияние явления «кризиса среднего возраста» и познакомимся со средствами его диагностики и профилактики.

Поколение 2

Поколение 2 - это последняя область памяти для объектов, переживших как минимум два цикла сборки мусора (а также для очень больших объектов). В модели поколений такие объекты считаются «старыми» и, согласно нашим предположениям, не должны выйти из употребление в ближайшем будущем. Объем памяти для объектов поколения 2 не ограничивается искусственно. Она может занимать все пространство, выделяемое операционной системой процессу, то есть, до 2 Гбайт в 32-разрядной системе и до 8 Гбайт в 64-разрядной.

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

Сборка мусора в поколении 2 является полной. Это самая дорогая операция сборки мусора, для выполнения которой может потребоваться ощутимое количество времени. На одном из наших тестовых компьютеров выполнение полной сборки мусора в объеме памяти 100 Мбайт заняло примерно 30 миллисекунд - на несколько порядков больше, чем сборку в поколении 0.

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

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

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

Куча больших объектов

Куча больших объектов (Large Object Heap, LOH) - это специальная область, зарезервированная для размещения очень больших объектов. Большими считаются объекты, занимающие больше 85 Кбайт памяти. Это - пороговое значение относится к одному объекту, а не к графу объектов с корнем в данном объекте, поэтому массив из 1000 строк (по 100 символов в каждой) не считается большим объектом, так как сам массив содержит лишь 4- или 8-байтные ссылки на строки, а вот массив из 50 000 целых чисел - это большой объект.

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

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

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

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

Сборка мусора в куче больших объектов выполняется по достижении порогового значения в поколении 2. Аналогично, по достижении порогового значения в куче больших объектов выполняется сборка и в поколении 2. Таким образом, создание множества больших временных объектов вызывает те же проблемы, что и эффект «кризиса среднего возраста» - полную сборку мусора для удаления этих объектов. Еще одна потенциальная проблема - фрагментирование кучи больших объектов, потому что дырки между объектами не удаляются автоматически в фазе чистки.

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

Создание очень большого объекта и распределение его фрагментов вручную через маленькие «объекты-окна»

Ссылки между поколениями

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

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

Пропуск ссылок между поколениями объектов

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

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

class Customer
{
    public Order LastOrder { get; set; }
}
class Order { }

class Program
{
    static void Main(string[] args)
    {
        Customer customer = new Customer();
        GC.Collect();
        GC.Collect();

        // теперь объект customer находится в поколении 2 
        customer.LastOrder = new Order();
    }
}

Когда JIT-компилятор встречает такие инструкции, он генерирует так называемый «барьер записи» (write barrier), перехватывающий запись ссылки во время выполнения и записывающий вспомогательную информацию в специальную таблицу. Барьер записи - это функция CLR, проверяющая, не присваивается ли ссылка полю объекта из поколения, более старого, чем поколение 0. В этом случае она обновляет байт в таблице, соответствующий диапазону адресов, в который попадает целевой объект операции присваивания:

Операция присваивания ссылки полю через барьер записи

Операция присваивания ссылки полю проходит через барьер записи, который обновляет соответствующий бит в таблице, соответствующий области памяти, где обновилась ссылка. Трассировку барьера записи легко выполнить с помощью отладчика. Фактическая операция присваивания в методе Main была скомпилирована JIT-компилятором в следующий код:

; ESI содержит указатель на объект customer, 
; ESI+4 - на LastOrder, EAX - на "new Order()"
lea edx, [esi+4]
call clr!JIT_WriteBarrierEAX

Внутри функции, реализующей барьер записи, проверка выполняется путем сравнения адреса присваивания с нижней границей области для поколения 1 (то есть, проверяется принадлежность к поколению 1 или 2). Если проверка дает положительный результат, производится обновление таблицы, присваиванием значения 0xff байту со смещением, полученным сдвигом адреса объекта на 10 разрядов вправо. (Если байт уже установлен в значение 0xff, повторная установка не производится, чтобы предотвратить принудительное обновление кешей других процессоров.)

mov dword ptr [edx], eax             ; фактическая запись
cmp eax, 0x272237C                   ; начальный адрес поколения 1
jb NoNeedToUpdate
shr edx, 0xA                         ; сдвинуть вправо на 10 разрядов
cmp byte ptr [edx+0x48639C], 0xFF    ; 0x48639C - начало таблицы
jne NeedUpdate
NoNeedToUpdate:
ret
NeedUpdate:
mov byte ptr [edx+0x48639C], 0xFF    ; обновить таблицу
ret

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

Каждый байт в таблице охватывает 1 Кбайт динамической памяти. То есть, таблица может занимать до 0.1% пространства от общего объема, но она дает огромный прирост производительности при сборке мусора в поколениях молодых объектов. Если бы в таблице можно было сохранять записи с информацией о каждой отдельной ссылке, скорость можно было бы увеличить еще больше (сборщик мусора мог бы сразу перейти к исследованию конкретного объекта, а не диапазона адресов 1024 байт), но сохранение такой информации во время выполнения - слишком дорогое удовольствие. Существующий подход отлично отражает компромисс между расходованием памяти и экономией времени выполнения.

Хотя такие микроскопические оптимизации редко стоят затраченных усилий, тем не менее, мы можем уменьшить накладные расходы на обновление и обход таблицы. Одно из возможных решений заключается в создании пула объектов и повторном их использовании вместо многократного создания. Это позволит уменьшить расходы на сборку мусора в целом. Другое решение - использовать типы значений везде, где только возможно, и уменьшить количество ссылок в графе объектов. При присваивании типу значения барьер записи не генерируется, потому что тип значения в динамической памяти всегда является частью некоторого ссылочного типа (или, в упакованной форме, частью самого себя).

Фоновый сборщик мусора

Параллельный сборщик мусора для рабочей станции, появившийся в версии CLR 1.0, имеет один существенный недостаток. Несмотря на то, что во время сборки мусора в поколении 2 прикладные потоки способны продолжать создавать новые объекты, запросы на выделение памяти будут удовлетворяться, только пока не будет исчерпана вся память, выделенная для поколений 0 и 1. Как только это произойдет, прикладные потоки блокируются до окончания сборки мусора.

Фоновый сборщик мусора, появившийся в версии CLR 4.0, позволяет среде выполнения CLR выполнять сборку мусора в поколениях 0 и 1, далее когда уже выполняется полная сборка мусора. Для этого CLR запускает два потока выполнения: основной поток сборщика мусора и фоновый. Фоновый поток производит сборку мусора в поколении 2 в фоновом режиме и периодически проверяет появление запросов на выполнение быстрой сборки в поколениях 0 и 1. Получив запрос (например, когда приложение исчерпает доступную память в молодых поколениях), фоновый поток приостанавливается и возобновляет работу основного потока, который быстро освобождает память и разблокирует прикладные потоки.

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

В CLR 4.5 фоновый сборщик мусора был дополнен серверной версией. Кроме того, в серверный сборщик мусора была добавлена поддержка параллельной сборки мусора. При использовании параллельного сборщика мусора для сервера в процессе, выполняющемся на N логических процессорах, среда выполнения CLR создает N основных потоков сборки мусора и N фоновых потоков. Фоновые потоки обслуживают динамическую память, выделенную для поколения 2, и позволяют прикладному коду выполняться параллельно.

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

  1. Параллельный сборщик мусора для рабочей станции - используется по умолчанию; имеет фоновый режим выполнения.

  2. Непараллельный сборщик мусора для рабочей станции - не имеет фонового режима.

  3. Непараллельный сборщик мусора для сервера - не имеет фонового режима.

  4. Параллельный сборщик мусора для сервера - имеет фоновый режим.

Пройди тесты
Лучший чат для C# программистов