В первой части мы разобрали, как Dart отличает число от объекта, а во второй – как убирается молодое поколение. Там все держалось на одном приеме: живых переносим в соседнее полупространство, а всю старую половину выбрасываем целиком.

Почему в старом поколении переезд не подходит

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

Mark-sweep

Уборка старого поколения происходит в две фазы.

Фаза 1. Пометка (mark)

У объекта в заголовке есть служебный бит пометки (mark bit). В начале уборки он у всех обнулен. Сборщик идет от корней по ссылкам и каждому объекту, до которого дотянулся и помечает их “живыми”, если объект достижим, т.е проставляет бит пометки. Когда сборщик обошел всех, то можем наблюдать картину, что у всех достижимых объектов стоит бит, а у остальных по нулям. 

Обходя поля объектов, сборщик по последнему биту сразу понимает, что есть число, а что есть объект.

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

Фаза 2. Очистка (sweep)

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


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

Фрагментация

После очистки старое поколение выглядит как дырявый сыр. Живые объекты стоят там, где стояли, а между ними остались дыры от мертвых. Свободной памяти может быть много, но она разрезана на мелкие куски. И когда программа просит место под крупный объект, может оказаться, что цельного куска нужного размера не будет. Это и называется фрагментацией. Для борьбы с этой проблемой Dart использует mark Compact. 

Mark-compact

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

Dart использует скользящий уплотнитель(sliding compactor). Все объекты сдвигаются в начало, сохраняя свой порядок. Уплотнение делается только когда фрагментация начинает мешать.

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

Как ссылки находят объекты после сдвига

Для переноса используется forwarding table (таблица пересылки). Dart делит кучу на блоки и для каждого блока хранит новый адрес и битовый вектор. Чтобы вычислить адрес нового объекта – нужно взять адрес блока, рассчитать, сколько живых клеток лежит до нужного объекта и получить конечный адрес.

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

Откуда берутся GC-паузы

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

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

Если все выше понятно, то возникает логичный вопрос: почему же эти паузы не превращаются в секундные фризы? Eve будет посвящена четвертая и финальная часть статьи, где мы рассмотрим Concurrent Marking, барьеры записи и финализаторы.

Источники:

  • Исходный код Dart SDK (runtime/vm/heap): marker, sweeper, compactor, freelist.

Комментарии (0)