Зачем нужен GC и достижимость
В JavaScript вы никогда не освобождаете память — вы делаете объекты недостижимыми. V8 использует трассирующий GC: от фиксированного набора корней он помечает всё транзитивно достижимое; остальное — мусор. Трассировка против подсчёта ссылок и почему живость — это достижимость.
В 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;
Persistenthandle закрепляет объект как корень даже без 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 Определить корни: стек вызовов, глобальный объект, handle встраивающей стороны
- 2 Пометить достижимым каждый объект, на который ссылается корень
- 3 Транзитивно пройти по каждому ребру ссылки, помечая каждый достигнутый объект
- 4 Считать каждый объект, НЕ помеченный достижимым, мусором и освободить его
▸Почему это работает
Почему бы просто не дать free() и не довериться программисту? Потому что та же выразительность, что делает JavaScript приятным, — функции первого класса, замыкания, свободно разделяемые объекты — делает единоличное владение невозможным для ручного отслеживания в масштабе. Языки, сохраняющие ручной контроль (C, C++), платят за это целым классом багов (use-after-free, double-free, утечки), которых в языке с трассирующим GC попросту не может быть. GC меняет эти баги на время пауз и более тонкую утечку: непреднамеренную достижимость.
- 01Что такое корни GC в V8 и почему GC стартует от них?
- 02Почему V8 — трассирующий сборщик, а не на подсчёте ссылок?
- 03В чём разница между «живым» и «достижимым» и почему это важно?
JavaScript не позволяет освобождать память, потому что разделяемые ссылки и замыкания делают единоличное владение невозможным для ручного отслеживания. Вместо этого V8 запускает трассирующий сборщик мусора: он моделирует кучу как граф и определяет «живое» как достижимое от фиксированного набора корней — стека вызовов и регистров, глобального объекта и persistent handle встраивающей стороны. От корней он помечает всё транзитивно достижимое; всё, что осталось, — мусор. Поэтому трассировка освобождает циклы ссылок, которые подсчёт ссылок (освобождающий объект, когда его счётчик доходит до нуля) собрать не может: взаимно ссылающийся остров навсегда держит каждый счётчик выше нуля, но ни один путь от корня его не достигает, поэтому трассировка его собирает. Достижимость — консервативная завышенная оценка истинной живости: каждая утечка в языке с GC — это объект, мёртвый по духу, но всё ещё достижимый через какую-то забытую ссылку. Вы не освобождаете; вы обрываете последнюю ссылку и даёте следующей сборке это заметить. Теперь, когда RSS Node-сервиса ползёт вверх без ошибок, вы знаете: первый вопрос — не «где сломался GC?», а «какую ссылку я всё ещё держу?»
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в184
- Почему GraphQL получает N+1junior
- Механика DataLoader: батчинг на границе тикаmiddle
- Контракты batch-функции: порядок, формы, ошибкиmiddle
- Federation и lookahead: батчинг за пределами DataLoadermiddle
- Защита сложности запросов: depth, cost, persisted queriesmiddle
- Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемостьsenior
- Зачем идемпотентность: безопасные retryjunior
- Серверный state machine: четыре состояния idempotency keymiddle
- Outbox и inbox: effectively-once через dual-write границуmiddle
- Конкурентность и архитектура кеша для идемпотентности на масштабеsenior
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Event loop: один поток, три очередиjunior
- Задачи, микрозадачи и scheduler.yield()middle
- Голодание микрозадач, длинные задачи и LoAFsenior
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- React, Vue и наблюдаемость INP в продакшенеsenior
- Render pipeline: шесть стадий от байтов до пикселейjunior
- Цена стадий и модель процесса рендерераmiddle
- Инвалидация, dirty-биты и containmiddle
- Слои композитора: продвижение, перекрытие и память GPUmiddle
- Флейм-стрип DevTools и жизненный цикл кадраmiddle
- Layout thrash: форсированная синхронная компоновкаsenior
- BeginMainFrame, анимации на потоке compositor и память GPUsenior
- Observability в проде: LoAF, INP и полная поверхность атакиsenior
- Что такое V8 и почему производительность различается в 100 разjunior
- Четырёхуровневый JIT-конвейер V8 и профилированная тиеризацияmiddle
- Hidden classes, деревья переходов и расположение в памятиmiddle
- Inline caches, состояния IC и деоптимизацияmiddle
- Orinoco GC: параллельный scavenger, конкурентная разметка и барьеры записиmiddle
- Спекулятивный движок TurboFan и ловушка deopt-loopsenior
- V8 в production: Isolates, сжатие указателей и реальные аварииsenior
- Жизненный цикл service worker и стратегии кешированияmiddle
- Граничные случаи service worker: version skew, долговременность и ловушка навигацииsenior
- Что делает реконсилер: render vs commitjunior
- Объект fiber и дерево с двойной буферизациейmiddle
- Чистота фазы render и подшаги фазы commitmiddle
- Реконсиляция: эвристики диффа и ловушка ключейmiddle
- Приоритетные lanes, time-slicing и useTransitionmiddle
- Bailout, мемоизация и tearingsenior
- React Profiler, компилятор и продакшн-наблюдаемостьsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Hydration mismatch: причины, обнаружение и правило детерминизмаsenior
- RSC, стратегия на маршрут и production-наблюдаемостьsenior
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- CLS: почему происходят сдвиги лейаута и как их остановитьmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое cache stampede и почему он делает всё хужеjunior
- Лок и single-flight: ограничение параллельных rebuildmiddle
- XFetch: вероятностное раннее истечение без координацииmiddle
- Stale-while-revalidate и CDN request coalescingmiddle
- Детектирование stampede и дизайн TTL для продакшенаmiddle
- Метастабильный сбой, fencing-токены и production-постмортемыsenior
- Что такое отношение: таблицы, строки, ключи и ограниченияjunior
- Ограничения, ключи и типы данных Postgresmiddle
- Нормальные формы, денормализация и почему схемы «прилипают»middle
- JSONB, массивы и когда side table побеждаетmiddle
- Heap-хранилище, TOAST и выравнивание колонокsenior
- Целостность схемы: deferral, версионирование и сбои в продакшнеsenior
- Реляционная модель vs документные, wide-column, граф и key-valuesenior
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Производственные режимы отказа и стабильность плановsenior
- MVCC: как Postgres раздаёт согласованные снимкиjunior
- Заголовок tuple и механика снимковmiddle
- HOT-обновления и уровни изоляцииmiddle
- VACUUM, bloat и autovacuummiddle
- CLOG, XID wraparound и MultiXactsenior
- SSI и production-тюнинг autovacuumsenior
- Реальные провалы MVCC, deployment-паттерны и распределённые снимкиsenior
- Connection pool: зачем амортизировать стоимость backend Postgresjunior
- Режимы PgBouncer: session, transaction и statementmiddle
- Размер пула: формула (ядра × 2) + шпинделей и двухуровневый стекmiddle
- Исчерпание пула и idle-in-transaction: сценарий отказа в 3 ночиmiddle
- Миграция на transaction mode: план развёртывания и prepared statements в PgBouncer 1.21middle
- Процессная модель Postgres и почему увеличение max_connections снижает производительностьsenior
- Ландшафт пулеров 2026, serverless connection storms и полная таксономия отказовsenior
- Что такое миграция схемы и почему она заменяет ad-hoc DDLjunior
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Expand-contract: нулевой простой для ломающих изменений схемыmiddle
- Advisory-блокировки, инструменты миграций и координация деплояsenior
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Зачем нужно шардирование: потолок одного Postgresjunior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Партиционирование против шардирования: одно слово, два разных понятияmiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Schema-based шардирование и альтернативы мультиарендностиsenior
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Роли Raft, term и почему majority-кворум предотвращает split brainjunior
- Как Raft реплицирует log entry и решает, что его безопасно коммититьmiddle
- Выборы лидера в Raft: таймауты, правила голосования и четыре свойства безопасностиmiddle
- Raft в реальном мире: partition, медленный диск и клиентская маршрутизацияmiddle
- Расширения Raft: pre-vote, learner, snapshot и линеаризуемые чтенияsenior
- Raft в production: membership change, Multi-Raft и observabilitysenior
- Где происходит data fetching — и почему это решает LCPjunior
- Fetch waterfall''''ы — диагностика и лечение через Promise.allmiddle
- React Server Components и Suspense streamingmiddle
- Клиентский кэш: TanStack Query, SWR и stale-while-revalidatemiddle
- LCP, prefetch и race conditions в интерактивном fetchingmiddle
- Senior internals: RSC payload, слои кэша и production паденияsenior
- Трёхстороннее рукопожатие TCPjunior
- Номера последовательности и состояние соединенияmiddle
- DNS: что делает и зачем существуетjunior
- Обход резолвера: перенаправления, типы записей и gluemiddle
- TTL, кеширование и распространение DNSmiddle
- Рукопожатие за 1 RTT: key share и ECDHEmiddle
- Возобновление сессии и 0-RTTmiddle
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- Формат WebSocket-фрейма: opcodes, маскирование, фрагментацияmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Health checks, connection draining и slow startmiddle
- Session affinity, consistent hashing и правильное решениеmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- Connection ID и миграция сетиmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- PII-редакция и log injectionsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- SLI, SLO и error budget: надёжность в числахjunior
- Error budget policy, latency SLO и составные journeysmiddle
- Продакшн-отказы SLO, самонаблюдаемость, безопасность и общая картинаsenior
- Петля инцидента: от пейджера до постмортема до предотвращенияmiddle
- Cache lines и false sharing: когда параллелизм замедляет кодmiddle
- SIMD и data layout: AoS vs SoA и разница в 4–8xmiddle
- Cache-oblivious алгоритмы, PGO и production failuressenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
- At-most-once, at-least-once, exactly-once: три контракта доставкиjunior
- Три ножки сбоя — где реально происходят дубликаты и потериmiddle
- Consumer-side dedup: самый дешёвый путь к exactly-once processingmiddle
- Kafka exactly-once semantics: idempotent producer и транзакцииmiddle
- SQS visibility timeout, DLQ и outbox patternmiddle
- Exactly-once в production: impossibility-доказательство, гибридные паттерны и реальные инцидентыsenior
- Что такое OAuth и почему пароли — не ответjunior
- Authorization code flow с PKCEmiddle
- Валидация ID-токена и управление JWKS-кешемmiddle
- Ротация refresh-токенов и scope-based least privilegemiddle
- Sender-constrained токены: DPoP и mTLSsenior
- OAuth в production: audience атаки, observability и реальные провалыsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.