Про сборщик мусора сейчас почти не говорят, при этом я часто слышу от коллег, что на собеседованиях про него спрашивают. И ещё заметил, что это один из популярных вопросов. Нормальных статей или обзоров про это почти нет, а если и есть, то они тяжёлые и очень нагруженные технической частью, которую понимают только сами создатели статей.
Эта статья – моя попытка объяснить вам доходчивым языком, что же такое 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 обходятся так дёшево.
Источники
Vyacheslav Egorov. Introduction to Dart VM, раздел Garbage Collection: https://mrale.ph/dartvm/gc.html
Конфигурация сборки Flutter, где включается сжатие указателей: https://github.com/flutter/flutter/blob/master/engine/src/flutter/tools/gn
Исходный код Dart SDK (runtime/vm/heap): scavenger, marker, sweeper, compactor.