open atlas
↑ К треку
Внутренности движка JavaScript JSE · 06 · 01

Зачем нужен GC и достижимость

В JavaScript вы никогда не освобождаете память — вы делаете объекты недостижимыми. V8 использует трассирующий GC: от фиксированного набора корней он помечает всё транзитивно достижимое; остальное — мусор. Трассировка против подсчёта ссылок и почему живость — это достижимость.

JSE Middle ◷ 12 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

В C вы пишете malloc, затем free, и забыть второе — это утечка; освободить дважды — испортить кучу; освободить слишком рано — получить use-after-free, который атакующий превращает в исполнение кода. Теперь добавьте замыкания, захватывающие переменные, объекты, разделяемые между тремя колбэками, и Map, который кто-то где-то держит. Решить вручную точный момент, когда каждую аллокацию безопасно освободить, не просто трудно — для столь динамичного языка это безнадёжно. Поэтому JavaScript вас об этом не просит. Он задаёт другой вопрос: может ли программа ещё дотянуться до этого объекта?

Ручное управление памятью не выживает при разделяемых ссылках

Урок performance/04-gc/01-gc-basics смотрит на GC со стороны эксплуатации; этот трек берёт взгляд движка. Начнём с того, почему движок вообще обязан это делать.

Ручной free() требует единственного, известного владельца для каждой аллокации — места, отвечающего за её освобождение. JavaScript намеренно разрушает это допущение. Замыкание захватывает переменную и переживает создавшую его функцию. Объект кладут в массив, передают в колбэк и сохраняют на this — три ссылки одновременно, единого владельца нет. Цепочка Promise удерживает значение живым через тики цикла событий, которых из места вызова не видно.

function makeCounter() {
  let count = 0;                 // captured by the closure
  return () => ++count;          // this fn keeps `count` alive
}
const next = makeCounter();      // who is allowed to free `count`?

count живёт ровно столько же, сколько next, — а next может оказаться в глобальной области, прикреплён к узлу DOM или отброшен на следующей строке. Ни один человек надёжно не знает когда. Поэтому движок отвечает на механически разрешимый суррогатный вопрос: достижим ли этот объект оттуда, куда программа ещё может дотянуться? Если нет, он уже никак не повлияет на будущее программы, значит, его память можно забрать обратно.

Достижимость: сначала корни, затем транзитивное замыкание

Трассирующий сборщик мусора моделирует кучу как ориентированный граф: объекты — узлы, ссылки (свойство, держащее другой объект, элемент массива, захваченная переменная) — рёбра. У сборки две концептуальные фазы — найти живое, освободить остальное.

«Живое» определяется как достижимость от корней. Корни — это точки входа, к которым работающая программа имеет доступ по своей природе и которые GC считает всегда живыми:

  • стек вызовов — каждая локальная переменная и временное значение в каждом активном фрейме плюс значения в регистрах CPU;
  • глобальный объект (globalThis / window) и граф его свойств;
  • handle / persistent handle от встраивающей стороны — хост на C++ (Node, Chrome) держит объекты V8 через handle scope; Persistent handle закрепляет объект как корень даже без JS-ссылки на него.

Отталкиваясь от этих корней, GC транзитивно проходит по каждому ребру. Всё, до чего он может дотянуться, — живое; всё, до чего не может, — мусор. Обратите внимание на асимметрию: сборщик никогда не перечисляет мёртвые объекты, он перечисляет живые, а мёртвые — это просто всё, что осталось.

Остров справа важен: X ссылается на Y, а Y — на X, так что у каждого есть входящая ссылка, и всё же ни один корень не достигает ни одного из них. Трассирующий GC собирает их не задумываясь. Запомните этот пример — именно здесь ломается более старый подход.

Трассировка против подсчёта ссылок

Другая классическая стратегия — подсчёт ссылок (reference counting): каждый объект несёт счётчик того, сколько ссылок на него указывает; когда счётчик доходит до нуля, объект освобождается немедленно. Это просто, даёт быстрое освобождение и равномерно распределяет стоимость. CPython использует это как основной механизм; ARC в Swift и shared_ptr в C++ — это подсчёт ссылок.

У него есть один фатальный изъян для языка, полного разделяемых, циклических структур: он не может освободить циклы. На острове выше X ссылается на Y, а Y — на X, так что каждый счётчик навсегда остаётся равным 1 и ни один объект не освобождается, хотя программа уже никогда их не коснётся. Циклы есть везде в реальном коде: родительский узел держит детей, которые держат обратный указатель на родителя; двусвязный список; Promise, захватывающий замыкание, которое захватывает этот промис.

let a = {};
let b = {};
a.peer = b;
b.peer = a;     // refcount(a) = 1, refcount(b) = 1
a = null;
b = null;       // оба всё ещё указывают друг на друга -> счётчики никогда не достигнут 0

У трассировки такой проблемы нет: как только a и b сброшены из своих корней, ни один путь от корня не достигает пары, поэтому трассировка объявляет оба мёртвыми независимо от цикла. Это та самая причина, по которой V8 (и каждый продакшен-движок JS) — трассирующий, а не на счётчиках ссылок. Цена в том, что трассировка — это дискретное событие с паузой, а не работа, амортизированная по каждой ссылке, — и это весь предмет следующих пяти уроков.

Трассировка против подсчёта ссылок
Освобождает циклы ссылок
только трассировка
Быстрое (немедленное) освобождение
подсчёт ссылок
Модель стоимости (трассировка)
пакетная пауза
Модель стоимости (счётчики)
inc/dec на запись
V8 / SpiderMonkey / JSC
трассировка
Основной механизм CPython
счётчики + сбор циклов

Живость — это завышенная оценка (over-approximation)

Есть точное значение «этот объект больше никогда не будет использован» — но оно невычислимо; оно потребовало бы предсказать будущее программы. Поэтому GC использует достижимость как консервативную, вычислимую замену. Достижимость завышает живость: если объект достижим, GC держит его, независимо от того, будет ли программа использовать его снова.

Этот зазор — источник почти всех «утечек памяти» в языке с GC. Объект на самом деле мёртв по духу — вы с ним закончили, — но какая-то ссылка (запись в кэше, устаревший слушатель, захваченная переменная) держит его достижимым, поэтому GC корректно и исправно держит его живым. Это чинится не «освобождением»; это чинится обрывом последней ссылки. Как именно течёт достижимость, мы каталогизируем в уроке 05.

Викторина

Два объекта ссылаются друг на друга, и больше ни на один из них ничто не ссылается. Запускаются сборщик на счётчиках ссылок и трассирующий сборщик. Что произойдёт?

Викторина

Нативное C++-расширение Node держит Persistent handle V8 на объект, но ни одна переменная JavaScript на него не ссылается. Собираем ли объект?

Расставь шаги по порядку

Расставьте по порядку концептуальные шаги, которые делает трассирующий сборщик, решая, что освободить.

  1. 1 Определить корни: стек вызовов, глобальный объект, handle встраивающей стороны
  2. 2 Пометить достижимым каждый объект, на который ссылается корень
  3. 3 Транзитивно пройти по каждому ребру ссылки, помечая каждый достигнутый объект
  4. 4 Считать каждый объект, НЕ помеченный достижимым, мусором и освободить его
Почему это работает

Почему бы просто не дать free() и не довериться программисту? Потому что та же выразительность, что делает JavaScript приятным, — функции первого класса, замыкания, свободно разделяемые объекты — делает единоличное владение невозможным для ручного отслеживания в масштабе. Языки, сохраняющие ручной контроль (C, C++), платят за это целым классом багов (use-after-free, double-free, утечки), которых в языке с трассирующим GC попросту не может быть. GC меняет эти баги на время пауз и более тонкую утечку: непреднамеренную достижимость.

Вспомните перед уходом
  1. 01
    Что такое корни GC в V8 и почему GC стартует от них?
  2. 02
    Почему V8 — трассирующий сборщик, а не на подсчёте ссылок?
  3. 03
    В чём разница между «живым» и «достижимым» и почему это важно?
Итог

JavaScript не позволяет освобождать память, потому что разделяемые ссылки и замыкания делают единоличное владение невозможным для ручного отслеживания. Вместо этого V8 запускает трассирующий сборщик мусора: он моделирует кучу как граф и определяет «живое» как достижимое от фиксированного набора корней — стека вызовов и регистров, глобального объекта и persistent handle встраивающей стороны. От корней он помечает всё транзитивно достижимое; всё, что осталось, — мусор. Поэтому трассировка освобождает циклы ссылок, которые подсчёт ссылок (освобождающий объект, когда его счётчик доходит до нуля) собрать не может: взаимно ссылающийся остров навсегда держит каждый счётчик выше нуля, но ни один путь от корня его не достигает, поэтому трассировка его собирает. Достижимость — консервативная завышенная оценка истинной живости: каждая утечка в языке с GC — это объект, мёртвый по духу, но всё ещё достижимый через какую-то забытую ссылку. Вы не освобождаете; вы обрываете последнюю ссылку и даёте следующей сборке это заметить. Теперь, когда RSS Node-сервиса ползёт вверх без ошибок, вы знаете: первый вопрос — не «где сломался GC?», а «какую ссылку я всё ещё держу?»

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.