Поколенческий GC и Scavenger
Большинство объектов умирает молодыми, поэтому V8 делит кучу по возрасту. New space — это два semi-space, собираемые Scavenger'ом по копирующему алгоритму Чейни: скопировать выживших, поменять пространства местами, повысить дважды выживших в old space.
Профилируйте горячий обработчик запроса — и увидите, что он аллоцирует безостановочно: распарсенное тело, несколько промежуточных строк, объект ответа, десятки временных значений — и отбрасывает каждое из них до следующего запроса. Если бы GC приходилось сканировать всю кучу в несколько сотен мегабайт каждый раз, чтобы освободить эту текучку, вы бы это почувствовали. Он этого не делает. Он ставит на то, что самые молодые объекты почти все мертвы, сканирует лишь крошечную область и копирует горстку выживших. Эта ставка — важнейшее проектное решение во всём сборщике.
Поколенческая гипотеза
Урок browser/03-v8-internals/05-gc-orinoco даёт обзор; здесь мы вскрываем механику Scavenger’а.
Эмпирический фундамент каждого продакшен-GC — поколенческая гипотеза (generational hypothesis): большинство объектов умирает молодыми. Распределение времён жизни аллокаций резко бимодально — подавляющее большинство недолговечны (временные значения запроса, промежуточные строки, React-элементы на рендер) и становятся недостижимыми за несколько миллисекунд, тогда как небольшое меньшинство (кэши, граф модулей, пулы соединений) живёт весь процесс. Почти ничто не умирает посередине.
V8 эксплуатирует это, деля кучу по возрасту:
- New space (young generation) — где рождается каждый обычный объект. Маленький: бюджет, растущий по требованию примерно до 1–8 МБ на isolate (настраивается через
--max-semi-space-size). Собирается часто и дёшево minor GC. - Old space (old generation) — долгоживущие выжившие. Может быть от сотен МБ до гигабайт (
--max-old-space-size). Собирается нечасто major GC (следующий урок).
Поскольку new space мал и почти всё в нём мертво к моменту сборки, minor GC касается очень малого объёма живых данных — в этом вся выгода.
New space — это два semi-space; аллокация — это bump pointer
New space физически состоит из двух равных половин, называемых semi-space: to-space (активная) и from-space (простаивающая). Аллокация в to-space — самая дешёвая операция в движке — bump-pointer allocator: держим указатель на следующий свободный байт, выдаём его, продвигаем указатель на размер объекта. Никакого поиска по free-list, никакой фрагментации — просто сложение и проверка границ.
// Концептуально, каждый `new`/объектный литерал делает:
// if (top + size > limit) triggerScavenge();
// addr = top;
// top += size; // продвижение указателя (bump)
// return addr;
const point = { x: 1, y: 2 }; // аллокация bump-pointer'ом в to-spaceКогда bump pointer достигает предела (to-space заполнен), срабатывает Scavenge (minor GC).
Scavenger: копирующий алгоритм Чейни
Scavenger — это копирующий сборщик (copying collector), исполняющий алгоритм Чейни. Ключевая инверсия по сравнению с mark-sweep: он вообще не смотрит на мёртвые объекты. Он копирует живые и оптом бросает остальное.
- Flip. Роли меняются: текущая to-space становится from-space, пустая половина — новой to-space.
- Эвакуация корней. Сканируем корни (стек, глобальные, handle) плюс remembered set указателей old→new (см. урок 04 — old space может ссылаться на молодые объекты, а мы old space не сканируем). Копируем каждый живой молодой объект, на который ссылаются, из from-space в to-space, оставляя в старом слоте forwarding pointer.
- Скан Чейни. Обходим только что скопированные объекты в to-space «вширь» как рабочую очередь; для каждого копируем любые объекты from-space, на которые он ссылается (или идём по существующему forwarding pointer, если уже скопирован), обновляя указатель на новое место. Это продолжается, пока указатель скана не догонит указатель аллокации — очередь пуста.
- Освобождение неявно. Всё, что не скопировано, просто остаётся в from-space, которая теперь одним махом объявляется пустой. Освобождение мёртвых объектов не стоит ничего — их никогда не посещают.
Работа пропорциональна живому множеству, а не аллоцированному. Пока поколенческая гипотеза держится, живое множество крошечно, поэтому Scavenge быстр.
Promotion: выжить — значит постареть
Объект, переживший Scavenge, ещё не признаётся долгоживущим; его копируют в to-space и дают ещё один шанс. Если он переживает второй Scavenge, V8 заключает, что он, вероятно, долгоживущий, и повышает его (promotion): копирует в old space, а не обратно в semi-space. (V8 также повышает раньше под давлением «промежуточного» поколения и аллоцирует очень большие объекты сразу в old/large-object space, но «выжить дважды → повысить» — это модель, которую стоит держать в голове.)
Promotion — причина, по которой Scavenge может оставить new space почти пустым: временные объекты никогда не копировались (мертвы), а немногие настоящие выжившие окончили в old space. New space остаётся маленьким, и следующий Scavenge остаётся дешёвым.
- Размер new space (semi-space)
- ~1–8 МБ
- Пауза minor GC (Scavenge)
- доли мс — единицы мс
- Порог promotion
- пережить ~2 Scavenge
- Стоимость аллокации
- bump pointer (~O(1))
- Работа пропорциональна
- живому множеству, не аллоцированному
- Параллельный scavenger с
- V8 6.2 (2017)
Orinoco: делаем параллельным
Orinoco — зонтичное имя современного GC-проекта V8: набора техник, превращающих stop-the-world сборщик в по большей части конкурентный, параллельный и инкрементальный. Конкретно для Scavenger Orinoco сделал его параллельным: несколько вспомогательных потоков делят работу копирования через динамический work-stealing, так что время паузы по стенным часам падает, хотя сырой работы становится больше. Поскольку new space мал, minor-паузы укладываются в диапазон от долей миллисекунды до единиц миллисекунд — достаточно коротко, чтобы комфортно поместиться в кадр анимации 16,6 мс. Более тяжёлые конкурентность и инкрементальность относятся в основном к major GC — это следующие два урока.
Цикл аллоцирует 1 000 000 недолговечных объектов, из которых при каждом Scavenge достижимо менее 100. Примерно с чем масштабируется стоимость каждого Scavenge?
Объект, аллоцированный в new space, всё ещё достижим после двух Scavenge. Куда он попадёт и почему?
Расставьте по порядку один цикл Scavenge (minor GC).
- 1 Bump pointer достигает предела to-space; аллокация запускает Scavenge
- 2 Flip: to-space становится from-space, пустая половина — новой to-space
- 3 Скопировать живые молодые объекты корней и remembered set в to-space, оставляя forwarding pointer
- 4 Скан Чейни: обойти скопированные объекты, скопировать то, на что они ссылаются, обновить указатели
- 5 Повысить объекты, пережившие второй Scavenge, в old space
- 6 Бросить from-space оптом — мёртвые объекты освобождены неявно
▸Граничные случаи
В «копировать дёшево» спрятана реальная стоимость: только что повышенный объект может нести указатели в ставшие устаревшими места, а насыщенные указателями выжившие делают Scavenge медленнее, чем чисто временная нагрузка с тем же числом аллокаций. Быстрый путь предполагает низкую долю выживания. Нагрузка, которая аллоцирует и удерживает большую долю новых объектов (строит, скажем, один гигантский массив), побеждает поколенческую ставку — выжившие копируются раз за разом, пока не будут повышены, и вы видите больше времени minor GC, чем предсказывает одно лишь число аллокаций.
- 01Пройдите по одному циклу Scavenge (minor GC) в V8.
- 02Почему копирующий сборщик делает недолговечный мусор по сути бесплатным для освобождения?
- 03Что такое поколенческая гипотеза и как её эксплуатирует раскладка кучи V8?
Поколенческая гипотеза — большинство объектов умирает молодыми — определяет двухобластную кучу V8. New space мал (~1–8 МБ) и держит свежеаллоцированные объекты; аллокация там — это bump pointer, самая дешёвая операция в движке. Когда он заполняется, запускается minor GC (Scavenge), исполняющий копирующий алгоритм Чейни: сделать flip двух semi-space, скопировать живые молодые объекты из from-space в to-space (оставляя forwarding pointer и следуя remembered set для ссылок old→young), обойти копии вширь, чтобы эвакуировать то, на что они ссылаются, затем бросить from-space оптом. Поскольку копируются только живые объекты, мёртвые не стоят ничего, и Scavenge масштабируется с числом выживших — крошечным по гипотезе — удерживая minor-паузы субмиллисекундными. Объекты, пережившие два Scavenge, повышаются в большой old space, собираемый отдельно и редко через major GC. Orinoco, GC-проект V8, сделал Scavenger параллельным через work-stealing по вспомогательным потокам, ещё сильнее снизив время паузы по стенным часам. Теперь, когда в профиле скачет время minor GC, вы знаете, что проверить: доля выживших — если нагрузка удерживает большую часть того, что аллоцирует, поколенческая ставка сломана, и --trace-gc покажет это напрямую.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в208
- Путь запроса: семь остановок от сокета до ответаjunior
- Accept и парсинг: от очереди ядра до типизированного запросаmiddle
- Маршрутизация и middleware: что выполняется и в каком порядкеmiddle
- Обработчик и ответ: от бизнес-логики до байтов на проводеmiddle
- Стриминг и backpressure: когда клиент читает медленнее, чем вы пишетеsenior
- Таймауты и хвостовая задержка: бюджеты, дедлайны и ловушка fan-outsenior
- Middleware и DI: два паттерна, формирующие любой backendjunior
- Пишем middleware: сигнатуры, next() и три модели фреймворковmiddle
- Инверсия управления: как зависимости добираются до классаmiddle
- Скоупы и время жизни DI: singleton, request, transientmiddle
- DI как шов для тестов: фейки, моки и граница, которая важнаsenior
- DI-контейнеры в продакшене: графы разрешения, циклы и когда не стоитsenior
- Блокирующий vs неблокирующий I/O: два способа ждатьjunior
- Event loop: один поток, упорядоченные фазыmiddle
- Что блокирует цикл: CPU-работа и синхронные вызовыmiddle
- Вынос CPU-работы: worker threads и пул libuvmiddle
- Backpressure и ограниченная конкурентностьsenior
- Пропускная способность под нагрузкой: хвостовая задержка и насыщениеsenior
- Зачем пул: цена создания соединенияjunior
- Размер пула: почему больше не значит быстрееmiddle
- Взятие и таймауты: очередь ожидания — настоящий дроссель задержкиmiddle
- Стратегии retry: backoff, jitter и thundering herdmiddle
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Задачи, микрозадачи и scheduler.yield()middle
- Точность таймеров, троттлинг и фоновая работаmiddle
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- LCP: четыре фазы, одна доминирующая стоимостьmiddle
- INP: input delay, processing, presentationmiddle
- Lab vs field: почему они расходятся и как использовать каждыйmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое индекс и как он ускоряет запросыjunior
- Leading-column rule: почему порядок столбцов в composite-индексе важенmiddle
- Partial, expression и covering-индексыmiddle
- Типы индексов: GIN, GiST, BRIN, Hash, Bloom и HOT-обновленияmiddle
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- Упражнение по проектированию индексов: стратегия полнотекстового поискаsenior
- EXPLAIN и планы выполнения: что решает планировщик и почемуjunior
- Типы сканирования: Seq, Index, Bitmap, Index-Onlymiddle
- Алгоритмы соединения и каскад ошибок оценки строкmiddle
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Расширенная статистика: исправление ошибок оценки для коррелированных колонокsenior
- Кеш планов, настройка константных стоимостей и внутренности планировщикаsenior
- Производственные режимы отказа и стабильность планов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
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Биты в проводеjunior
- Математика задержкиmiddle
- Bufferbloat и перегрузкаsenior
- Граница физического уровняsenior
- Номера последовательности и состояние соединенияmiddle
- Управление потоком и перегрузкойmiddle
- BBR, производственная наблюдаемость и за пределами TCPsenior
- CDN: контент по соседствуjunior
- Anycast и GeoDNS: маршрутизация к ближайшему edgemiddle
- Многоуровневый кеш и Cache-Controlmiddle
- Заголовок Vary и cache keysmiddle
- Stale-while-revalidate и cache stampedesenior
- Edge workers и edge-side compositionsenior
- CDN: операции и observabilitysenior
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- WebSocket vs SSE vs long-polling: выбор правильного транспортаmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Алгоритмы балансировки: от round-robin до power-of-two-choicesmiddle
- L4 vs L7 балансировка и сохранение IP клиентаmiddle
- Health checks, connection draining и slow startmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- QUIC-потоки и head-of-line blockingjunior
- Объединённое рукопожатие и 1-RTTmiddle
- Connection ID и миграция сетиmiddle
- Обнаружение потерь и управление перегрузкойmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- Развёртывание и стоимость CPUsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- Двенадцать слоёв: один URL, семь действующих лицjunior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Критический путь рендеринга и Core Web Vitalsmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Метрики и cardinality: cost-модель time-series databasemiddle
- Логи и объём: cost-модель структурного логированияmiddle
- Трейсы и сэмплирование: cost-модель distributed tracingmiddle
- Join-ключи и exemplar''''ы: как три сигнала становятся компонуемымиmiddle
- Observability 2.0: широкие события и сдвиг стоимостиsenior
- Режимы сбоя и инженерная практика: cardinality budget''''ы, PII и сэмплированиеsenior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- Log levels и маршрутизация алертовmiddle
- Стратегии sampling и стоимость логовmiddle
- PII-редакция и log injectionsenior
- Propagation trace-контекста в логахsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- Сигналы OTel, Semantic Conventions и проводной формат OTLPmiddle
- Авто-инструментирование и ручные спаны: правило 80/20 в OTelmiddle
- Collector OTel: receivers, processors, exporters и паттерны развёртыванияmiddle
- Стратегии сэмплирования: head, tail и parent-basedmiddle
- Vendor-нейтральность, eBPF-инструментирование, Operator и OTel в браузере и serverlesssenior
- Эксплуатация OTel Collector: надёжность, version skew, режимы отказа и управлениеsenior
- RED и USE: два чек-листа, одна дисциплина триажаjunior
- Инструментация RED в Prometheus: счётчики, гистограммы и дисциплина cardinalitymiddle
- USE на Linux: CPU, память, диск, сеть и PSImiddle
- Golden signals, структура дашборда и auto-RED в service meshmiddle
- Cardinality как драйвер затрат: label, PII, exemplars и семплированиеmiddle
- Native histograms, SLO и паттерны production-сбоевmiddle
- Выбор SLI и SLO-целей: отношения, не ощущенияmiddle
- Multi-window multi-burn-rate-алертинг: почему AND лучше ORmiddle
- Error budget policy, latency SLO и составные journeysmiddle
- Iceberg SLI, математика составного SLO и SLA vs SLOsenior
- Flame graph: читаем картинку, которая показывает, куда ушло времяjunior
- Sampling vs instrumentation profiling: почему 99 Гц побеждает в productionmiddle
- Типы профилей: CPU, память, off-CPU, mutex — какой когда братьmiddle
- Continuous profiling: always-on flame graphs с eBPF и корреляцией trace-idmiddle
- Как flame graph строится из сэмплов и как использовать его в productionmiddle
- Linux perf, внутренности eBPF, PGO и ограничения sampling''''аsenior
- Profiling в production: безопасность, war stories, OTel profiles и дизайн инфраструктурыsenior
- Debugging-воронка: SLO → RED → trace → profilejunior
- Архитектура OTel: один SDK, четыре сигнала, один wire-форматmiddle
- Экономия на observability: удерживаем затраты в пределах 5% inframiddle
- Масштаб, безопасность и ROI наблюдаемых системsenior
- Сначала профиль: измерь куда реально уходит времяjunior
- Закон Амдала и self-time: потолок любого ускорения, которое ты можешь выпуститьmiddle
- Измерительный цикл: микробенч, макробенч, prod-профиль, эффект наблюдателяmiddle
- Чтение флейм-графов: формы, профайлеры по языкам и 60-секундный сканmiddle
- Статистические baseline''''ы: почему один запуск — не измерениеmiddle
- История профайлеров и ловушки микробенчей: от Кнута до GWPsenior
- Hardware counters, профили холодного старта и безопасность профилейsenior
- Непрерывное профилирование в масштабе: затраты, CI-гейты, корреляция с трейсами и антипаттерныsenior
- Что делает путь горячим: симптом против причиныjunior
- Пять форм hotspot''''а: CPU, аллокации, кэш, лок, syscallmiddle
- Чтение parent и child chains: где применять правкуmiddle
- JIT deopt, цикл fix-and-verify и PR-time профилированиеmiddle
- Аппаратные счётчики и Intel TMA: диагностика подкатегорийsenior
- False sharing и горячие пути нативных мостовsenior
- Горячие пути в production: безопасность, хвостовая латентность и происхождение инструментовsenior
- Иерархия памяти: почему расстояние важнее числа операцийjunior
- Row-major vs column-major: порядок доступа и разрыв в 9xjunior
- Branch prediction: 10–30 циклов штрафа за неожиданный ifmiddle
- Hardware prefetcher, TLB и memory-level parallelismsenior
- Основы GC: за что рантайм берёт налогjunior
- Алгоритмы GC: поколенческая гипотеза, concurrent marking и write barriermiddle
- GC tradeoffs: пауза, throughput, память и давление аллокацийmiddle
- Настройка GC: пейсинг, форма кучи и наблюдаемость аллокацийmiddle
- Внутреннее устройство GC: tri-color инвариант, write barriers и глубокое погружение в рантаймыsenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- N+1: одна логическая операция, много round-trip''''овjunior
- Семейства фиксов: JOIN, IN, preload и DataLoadermiddle
- Обнаружение N+1: query logs, APM traces и CI gatesmiddle
- DataLoader: батчинг по дереву резолверовmiddle
- Кросс-протокольный N+1: HTTP fan-out и Redis MGETmiddle
- N+1 в масштабе: исчерпание пула, изменения планов и денормализацияsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- Что на самом деле стоит bundle: download, parse, compile, executejunior
- Core Web Vitals: LCP, INP и CLSmiddle
- Code splitting: route-level, component-level, vendor splittingmiddle
- Tree shaking и compression: удаляем то, что не используемmiddle
- Third-party scripts: тихий убийца бюджетаmiddle
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.