Немного о сборке мусора и поколениях

в 15:58, , рубрики: .net, memory management, метки: ,

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

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

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

Давайте рассмотрим такой пример. Предположим, у нас есть некоторый объект “A”, который инициализирует свое свойство отложенным образом:

public class B
{ }
 
public class A
{
    private Lazy<B> _lazyB = new Lazy<B>(
            () => new B());
 
    public B B
    {
        get { return _lazyB.Value; }
    }
}

И доступ к свойству B, а значит и создание этого объекта, происходит уже тогда, когда объект “A” находится во втором поколении:

var a = new A();
GC.Collect();
GC.Collect();
 
// output: A resides in Gen 2, A.B resides in Gen 0
Console.WriteLine("A resides in Gen {0}, A.B resides in Gen {1}",
GC.GetGeneration(a), GC.GetGeneration(a.B));
 
GC.Collect();

Итак, сборка мусора нулевого поколения заключается в анализе только лишь объектов этого поколения. Это означает, что при запуске сборке мусора в строке (3) все объекты нулевого поколения помечаются как недостижимые, включая вновь созданный объект “B”. Затем анализируются все корневые ссылки для определения достижимости объектов этого поколения; если объект достижим, то он считается живым, а все остальные объекты, к которым нет доступа из корневых ссылок, считаются мусором и удаляются.

Немного о сборке мусора и поколениях

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

Для решения этой проблемы, нам нужно как-то добавить объект “A” в список объектов, которые нужно проанализировать для определения достижимости объекта “B”. Однако вместо того, чтобы сохранять список всех «грязных» объектов, большинство реализаций сборщиков мусора с поддержкой поколений используют специальную структуру данных под названием card table, в которой хранится адрес объекта, создавшего «молодого» потомка.

Card table представляет собой простую структуру данных, которая представляет собой битовую маску, каждый бит которой говорит о том, что объект, расположенным по адресу с определенным диапазоном, является «грязным» и содержит ссылку на «молодой» объект. На данный момент один бит битовой маски представляет собой диапазон в 128 байт, а значит, каждый байт битовой маски содержит информацию о диапазоне в 1К. Данный подход является компромиссом между эффективностью и объемом дополнительной памяти, требуемой сборщику мусора для поддержания этой таблицы в актуальном состоянии. Таким образом, для 32 битовой системы, в которой пользовательскому режиму доступно 2 ГБ адресного пространства, размер card table составит 2 Мб. Однако поскольку один бит в card table помечает диапазон в 128 байт адресного пространства, каждый раз при сборке мусора придется проанализировать десятки других объектов, которые могут и не содержать ссылки на молодые поколения.

Немного о сборке мусора и поколениях

Для поддержки данной структуры данных в актуальном состоянии, каждый раз при записи поля объекта, JIT компилятор генерирует так называемый «барьер записи» (write barrier), который сводится к обновлению card table, если адрес записываемого объекта находится в эфемерном сегменте (ephemeral segment), т.е. является молодым объектом 0-го или 1-го поколений.

Теперь, если вернуться к нашему случаю, то объект “B” не будет собран сборщиком мусора, поскольку для анализа достижимости будут анализироваться не только корневые ссылки (которые на него не ссылаются), но и все объекты, расположенные по младшим 128 байтам второго поколения, куда попадает наш объект “A”.

Для чего мне все это?

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

Кстати, у данной реализации есть и небольшое практическое следствие: несмотря на то, что объекты старшего поколения создают новые объекты и сохраняют их в полях не так и часто (британские ученые установили, что не более 1% объектов второго поколения поступают таким образом), любая запись в поле объекта требует некоторых дополнительных затрат на тот самый хак, требуемый для обновления card table. Это, например, делает запись поля в управляемом мире немного более дорогостоящей операцией, по сравнению с неуправляемым миром (например, с языком С++).

Дополнительные ссылки

Автор: SergeyT


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js