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

Эта статья – моя попытка объяснить вам доходчивым языком, что же такое Garbage Collector в Dart, объяснить почему эти некоторые знания нам нужны на практике и что из всей этой тяжелой, технической внутрянки вам необходимо знать.

Эта статья опирается на документацию Вячеслава Егорова о внутренностях Dart VM. Я пересказываю её простым языком и добавляю то, что важно на практике.

Биты наше все

Да, все настолько будет разжевано, что придется вспомнить основы основ. Что же такое биты и зачем они нам:

Если брать простой пример, то память компьютера можно визуализировать как длинный ряд ячеек, внутри каждой такой ячейки лежит бит, а бит это 0 или 1.
С битами процессор работает не по одному, а группами. И если мы возьмем «современные» 64-битные процессоры, то увидим, что все они работают с группами битов размером 64 бита(8 байт). Одну такую группу можно называть машинным словом.
Указатель же в Dart помещается ровно в это одно машинное слово, то есть 8 байт.

Оговорка: В Dart есть режим сжатых указателей (pointer compression). Ссылка, которая лежит внутри другого объекта, хранится не в 8 байтах, а в 4. Полный адрес получают путем сложения с базовым адресом кучи. В регистрах процессора и на стеке указатель при этом остается 64-битным, сжатие касается только того, что лежит в куче. Flutter включает сжатые указатели по умолчанию для 64-битных Android-сборок. На iOS режим выключен - там нет возможности зарезервировать адресное пространство, не заняв под него сразу память.

За такую экономию на Android приходится платить:

  • Куча не может вырасти больше 4 ГБ, дальше смещение просто не влезет в 4 байта.

  • Вдвое сужается слово, в котором Dart носит число прямо слоте: под значение остается 30 бит вместо 62. Диапазон при этом падает не вдвое, а примерно в четыре миллиарда раз – про это подробно позже.


Итак, два важных утверждения из описанного:

  • Бит 0 или 1 

  • Указатель = 8 байт.

Что такое указатель

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

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

А может ли в слоте лежать не адрес?

Может. И это гениальное решение. В коде мы используем разные данные. Разного вида числа и объекты. 

Возьмем пример: 

int age = 7;

User user = User();

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

Как работает последний бит

Адрес любого объекта в куче Dart всегда кратен 8. Процессор должен читать 8-байтовые поля объекта одной инструкцией, а для этого объект должен начинаться на границе восьми байт.

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

Рассмотрим конкретный пример на произвольном адресе:
Допустим у нас объект лежит по адресу 0x00A03F50 – этот адрес кратен 8.

В двоичном виде последний байт этого адреса выглядит так:

0101 0000
↑ Последний бит, он же флажок

Видим на конце нули. А раз мы знаем, что они там будут всегда, это место можно занять под свои нужды. В данном случае Dart сделает это так:

0101 0000 → 0101 0001

0x00A03F50 → 0x00A03F51
↑ Пометит, что это адрес объекта.

Именно такое значение и лежит в слоте.

Хорошо, теперь обратное действие, нам надо дойти до реального объекта
Убираем нашу единицу:

0101 0001 → 0101 0000
0x00A03F51 → 0x00A03F50

Строго говоря, Dart выравнивает объекты по 16 байт, а не по 8. Лишний бит занят под то, в какой части кучи живет объект: куча разделена на область для новых объектов и область для долгоживущих, и по этому биту сборщик отличает одни от других, не заглядывая в память. Про то, зачем куче две области, – вторая часть.

Smi: число прямо в слоте

Если число достаточно маленькое, Dart не заводит под него объект в куче, а кладет прямо в слот, где для объекта лежал бы адрес. Такое число называется Smi(small integer). Smi живет прямо слоте, а значит искать его по адресу и убирать за ним не нужно

Возьмем пример, число 7 = 111 (в битах)
Когда Dart упаковывает 7 в Smi, он сдвигает биты влево на 1 позицию. Сдвиг влево это умножение на два. Получаем 1110. Ноль на конце означает, что Dart распакует в будущем это значение как число smi, сдвинув биты обратно вправо:

(1110 / 2 = 111). 

Насколько маленьким должно быть число

Из 64 бит слота один занят флажком и еще один знаком числа. Остается 62 бита, это примерно ±4.6 квинтиллиона.

А теперь вспомним про сжатые указатели, о которых говорили в начале. Слот в куче на Android – это 32 бита, и расклад другой: убираем флажок, убираем знак, остается 30 бит, то есть чуть больше миллиарда. Битов стало вдвое меньше, а вот диапазон упал не вдвое, а примерно в четыре миллиарда раз – потому что каждый бит удваивает количество чисел, которые можно записать.

без сжатия: 2⁶² = 4 611 686 018 427 387 904 (~4.6 квинтиллиона)
со сжатием: 2³⁰ = 1 073 741 824 (~1.07 миллиарда)

Например, DateTime.now().millisecondsSinceEpoch – сегодня это около 1.79 триллиона при пределе в миллиард. Если число не влезет, то тогда Dart заводит полноценный объект в куче – Mint(medium integer) за которым GC будет убирать. На iOS такое число спокойно живет в слоте, а на Android уже нет.

Handles: зачем нужен мостик для C++


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

Но вот что делать с кодом на C++, это сам движок и нативные библиотеки. Сборщик понятия не имеет, где у этого кода лежат ссылки. Если объект переедет, C++ останется со старым адресом, и программа упадёт.
Для решения этой проблемы придумали handle. Handle это ссылка на ссылку. Код на C++ держит не сам объект, а handle с адресом объекта. Когда объект переезжает, то сборщик обновляет адрес внутри handle. А так как C++ всегда смотрит на ссылку, которая ссылается на ссылку, то мы исключаем проблемы с пропажей адреса.

Что же из первой части может быть полезно на практике


1. Числа почти бесплатны. Int: индексы, счетчики и т.д все это smi. В память ничего из этого не попадает, сборщик за ними не следит.
2. Много чисел в списке будет накладно для памяти, если используется Android. Если используете числа в списке, то лучшим решением будет Int64List из dart:typed_data, т.к в таком случае список будет состоять только из сырых чисел без резервирования памяти под каждое число.
3. Любой новый созданный объект – это работа для сборщика. Чем больше бездумных объектов мы создаем, тем больше давим на память и работу сборщика.


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

Источники