Сборщик мусора в Dart. Часть 2: поколения и scavenge

от автора

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

Перед нами стоит самый главный вопрос: А как сборщик все-таки за собой убирает? 

Молодое и старое поколение.

Что происходит в обычном build() у Flutter-виджета? За один кадр создается куча временных объектов: EdgeInsets, Padding, промежуточные TextStyle, какие-то списки, замыкания. Живут они до следующего ребилда(Не учитывая const).

Также у нас есть и другая категория объектов, которые живут долго: синглтоны, кэши, репозитории, состояние приложения.  Они создаются один раз  и живут все время работы.
Отсюда вывод, если молодые объекты пересоздаются пачками, а старые в основном живут, то убирать их одинаково не выгодно. Именно поэтому Dart делит кучу на 2 поколения: 

new-space(молодое поколение) – сюда попадают почти все новорождённые объекты.

old-space(старое поколение) – сюда попадают все, кто прожил достаточно долго. 

Scavenge: уборка молодого поколения (перемещением)

Молодое поколение поделено на два равных полупространства. Назовем их From и To.
Новые объекты создаются в From. Полупространство постепенно заполняется, и когда места не остается – запускается scavenge.

Сборщик из полупространства From переносит в To только живые объекты, до которых еще можно добраться по ссылкам. Мертвые игнорируются, с ними он ничего не делает. Когда перенос всех живых закончился, то все полупространство From объявляется пустым. Пустым оно не становится физически, просто в будущем содержимое перезапишется новыми объектами. Теперь To и From меняются ролями: бывшая To становится рабочей, а бывшая From ждет следующего переезда.

Достижимый объект – это объект, до которого программа может дотянуться, если пойдет по цепочке ссылок. На объект кто-то ссылается, а на того, кто ссылается, – тоже кто-то, и так по цепочке до живой переменной.

Пример: 

var user = User();

Пока есть переменная user, объект достижим – программа в любой момент может к нему обратиться.

user = null;  

Теперь на объект User никто не ссылается. Дотянуться до него нельзя. Он стал недостижимым.

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

Forwarding pointer (указатель пересылки)

Проблема, объект переехал в To и получил новый адрес. Но на него могли ссылаться другие объекты или переменные, и они все ещё помнят старый адрес в From. 

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

Продвижение(Promotion)

Самые живучие объекты, которые пережили несколько уборок GC молодого поколения, переезжают в old-space(старое поколение), потому что гонять объекты туда-сюда между половинами молодого поколения на каждой уборке становится неэффективно. 

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

Безопасные точки (Safepoint) 

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

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

Для такого решения существует специальный механизм – безопасная точка. Это согласованный момент, когда все мутаторы приостановлены. Только в такие моменты сборщик может безопасно двигать объекты и чинить ссылки. Dart VM расставляет такие точки в исполняемом коде (независимо от нас). Например, на границах вызовов или в циклах. 

Scavenge в несколько потоков

Чтобы уложиться в паузу из-за safepoint быстрее, Dart запускает scavenge в несколько воркеров параллельно. Они наперегонки перетаскивают живые объекты. Если кто-то из них закончил быстрее и поставил forwarding pointer, то остальные откатывают свою работу и берут следующий объект. На деле откат — это редкое исключение и воркеры обычно работают над разными объектам. 

Полезное на практике

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

В следующей части я разберу подробно как работает старое поколение. Там всё устроено иначе. Расскажу про Mark-sweep, фрагментацию памяти, уплотнение и GC-паузы в профайлере. 

Источники
Вячеслав Егоров. Introduction to Dart VM, раздел Garbage Collection: https://mrale.ph/dartvm/gc.html
Исходный код Dart SDK (runtime/vm/heap): scavenger, marker, sweeper, compactor.

ссылка на оригинал статьи https://habr.com/ru/articles/1066560/