Node.js Internals

Memory Management: Управление памятью

Сервер крашится каждую ночь в 3:47. Логи чистые. CPU в норме. Disk в порядке. Только одна строчка перед падением: `FATAL ERROR: JavaScript heap out of memory`. Память утекла. Но куда? И главное - как найти дырку в абстракции, которая должна «сама управлять памятью»?

  • **Production OOM crisis:** Деплой новой фичи, всё работает на staging. Через 3 дня в продакшене heap растёт на 50MB/час. Через неделю сервер падает. Rollback невозможен - уже есть данные в новом формате. Heap snapshot показывает 100K объектов UserSession в Map. Оказалось, забыли вызвать `sessions.delete()` при logout. Фикс - одна строчка кода, но downtime стоил 50K
  • **WebSocket memory leak:** Real-time чат для 10K пользователей. При каждом connection регистрируется `eventBus.on('message')`, но при disconnect не вызывается `off()`. Через месяц работы - 500K listeners висят в памяти. Event loop тормозит, latency растёт до 5 секунд. Пользователи уходят. Поиск утечки через heap snapshot comparison - 2 часа, фикс - 10 минут
  • **Streaming спасает сервер:** API для генерации отчётов. Изначально загружали весь CSV в память (500MB), потом отдавали клиенту. Два параллельных запроса = 1GB heap = OOM. Переписали на streaming через `Transform` stream - heap usage упал до 10MB, можно обрабатывать 100 параллельных запросов. Профит: scaling из одного инстанса вместо 10

Память как ресурс

**Память - это то, о чём не задумываются до тех пор, пока она не закончится.** В Node.js память управляется автоматически через V8 garbage collector, но это не значит, что можно забыть о ней полностью. В продакшене утечки памяти приводят к падениям сервера, а неоптимальное использование - к лагам и дорогим инстансам.

Сценарий: API обрабатывает миллион запросов в день. Каждый запрос создаёт объекты, замыкания, промисы. Если хотя бы 0.1% объектов не освобождаются - через неделю сервер съест все 2GB heap и рухнет. **Garbage collector не волшебник** - он освобождает только недостижимые объекты. Если в глобальном массиве «на всякий случай» хранится ссылка - память течёт.

**V8 heap limit по умолчанию:** ~2GB на 64-bit системах. Это не жёсткое ограничение RAM, а лимит для управляемой памяти (managed heap). Превышение вызывает `FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory`.

**Почему именно 2GB?** V8 изначально разрабатывался для браузера, где вкладка не должна жрать всю RAM. Для серверов это ограничение устарело, но остаётся default для совместимости. В продакшене для тяжёлых сервисов используют `--max-old-space-size=4096` или больше.

Что произойдёт, если Node.js процесс превысит heap limit?

Архитектура V8 Heap

**V8 heap - это не монолитный блок памяти, а иерархическая структура.** Понимание этой архитектуры критично для оптимизации, потому что разные части heap обрабатываются разными алгоритмами GC с разной скоростью.

**New Space:** Здесь создаются все новые объекты. Маленький размер (1-8MB) обеспечивает быструю сборку мусора (Scavenge). 90% объектов умирают молодыми и не доживают до Old Space. **Old Space:** Сюда попадают объекты, пережившие 2+ цикла Scavenge. Очистка медленнее, но выполняется реже. **Large Object Space:** Массивы, буферы >1MB хранятся отдельно и никогда не перемещаются.

**Два полупространства в New Space:** Semi-space архитектура. Всегда есть From-space (активное) и To-space (резервное). При Scavenge живые объекты копируются из From в To, затем они меняются местами. Это дешевле, чем компактация: копирование только живых объектов вместо перемещения всех.

Почему New Space намеренно делается маленьким (1-8MB)?

Алгоритмы сборки мусора

**V8 использует два принципиально разных алгоритма GC:** Scavenge для New Space (быстрый, частый) и Mark-Sweep-Compact для Old Space (медленный, редкий). Это компромисс между латентностью и пропускной способностью.

**Scavenge (Cheney's algorithm):** Копирование живых объектов из From-space в To-space. Сложность O(живые объекты), мёртвые игнорируются. Пауза ~1-5ms. **Mark-Sweep-Compact:** 1) Mark - пометка достижимых объектов (обход графа) 2) Sweep - освобождение непомеченных 3) Compact - дефрагментация памяти. Пауза ~100-500ms, но выполняется инкрементально.

**Incremental Marking:** V8 не останавливает приложение на 500ms для Mark-Sweep. Вместо этого marking выполняется маленькими порциями (5-10ms) между тактами event loop. Это называется **Tri-color marking:** объекты помечаются белым (не посещены), серым (в очереди), чёрным (обработаны).

**Concurrent и Parallel GC:** Современные V8 версии используют многопоточность. **Parallel** - несколько потоков GC работают во время stop-the-world паузы. **Concurrent** - GC работает параллельно с JS execution (без паузы). Incremental marking теперь выполняется concurrent - приложение почти не тормозит.

Почему Scavenge GC работает быстрее Mark-Sweep?

Паттерны утечек памяти

**Утечка памяти в JS - это не забытый free(), а недостижимый объект, на который всё ещё есть ссылка.** GC не телепат - если массив с миллионом объектов лежит в глобальной переменной «на всякий случай», GC не догадается, что он больше не нужен. Три главных паттерна утечек: **замыкания, event listeners, глобальные переменные**.

**Leak Pattern #1: Замыкания.** Функция захватывает scope родителя. Если она долгоживущая (event handler, timer), весь scope не освобождается. **Leak Pattern #2: Event Listeners.** Забытый `addEventListener` без `removeEventListener` держит ссылку на callback и его scope. **Leak Pattern #3: Глобальные переменные.** `global.cache = []` никогда не освобождается.

**Детектирование утечек в продакшене:** Мониторинг `process.memoryUsage().heapUsed` каждые 10 секунд. Если heap растёт линейно без plateau - это утечка. **Heap snapshot comparison:** делаете snapshot до и после нагрузочного теста, сравниваете - объекты, которых стало больше, и есть подозреваемые.

Почему забытый event listener вызывает утечку памяти?

Heap Snapshots и профилирование

**Heap snapshot - это полный дамп памяти V8 heap в момент времени.** Он содержит все объекты, их размеры, ссылки между ними, retention paths (кто держит кого в живых). Chrome DevTools умеет визуализировать snapshots и сравнивать их - это основной инструмент для поиска утечек.

**Как работает snapshot:** V8 приостанавливает выполнение, обходит весь heap, сериализует объекты в .heapsnapshot файл (JSON). Размер файла ~= heap size. Для 2GB heap → snapshot весит ~2GB. **Retention path:** цепочка ссылок от GC root до объекта. Если path существует → объект достижим → GC не удалит его.

**Анализ в Chrome DevTools:** Открываете DevTools → Memory → Load Snapshot → выбираете файл. Переключаетесь в режим **Comparison** → видите diff между snapshots. Столбцы: **# New** (новые объекты), **# Deleted** (удалённые), **# Delta** (разница), **Retained Size** (сколько памяти держит объект с зависимостями).

Что показывает retention path в heap snapshot?

Оптимизация и best practices

**Оптимизация памяти - это не микрооптимизации, а системный подход.** 80% проблем решаются правильной архитектурой: streaming вместо buffering, пулы объектов вместо аллокаций, bounded queues вместо unbounded. Оставшиеся 20% - это понимание V8 internals и тонкая настройка GC.

**Best Practices:** 1) **Streaming over buffering** - не загружайте 100MB файл в память, стримьте его 2) **Object pooling** - переиспользуйте объекты вместо создания новых (критично для hot path) 3) **Bounded structures** - ограничивайте размер queue/cache через LRU или max size 4) **WeakMap/WeakRef** - для кэшей, где объекты могут умереть независимо.

**Когда увеличивать --max-old-space-size:** Если видите частые Major GC (каждые 10-30 секунд) и heap используется >80% лимита. Увеличение лимита → реже GC → меньше паузы, но больше latency при OOM. **Когда уменьшать:** Если приложение использует <1GB, а лимит 4GB - уменьшите до 2GB. Меньше heap → быстрее Mark-Sweep.

GC автоматически решает все проблемы с памятью, поэтому не нужно о ней думать

GC освобождает только недостижимые объекты. Если ссылки удерживаются (в Map, closure, listeners), GC ничего не сделает. Утечки возможны даже с GC

Garbage Collection - это автоматизация free(), но не автоматизация архитектурных решений. Если код создаёт unbounded Map или забывает removeListener - память будет течь независимо от GC. Программист всё ещё отвечает за lifecycle объектов через управление ссылками

В чём преимущество WeakMap перед обычной Map для кэша?

Ключевые идеи

  • **V8 heap состоит из New Space (1-8MB, Scavenge GC ~1-5ms) и Old Space (~2GB, Mark-Sweep ~100-500ms).** Молодые объекты умирают быстро в New Space, долгоживущие переходят в Old Space. Это компромисс между латентностью и throughput
  • **GC освобождает только недостижимые объекты.** Утечки возникают из-за забытых ссылок: замыкания захватывают весь scope, event listeners висят после disconnect, глобальные Map/Set растут бесконечно без eviction policy. WeakMap/WeakRef решают часть проблем
  • **Heap snapshots + comparison в Chrome DevTools - основной инструмент диагностики.** Retention path показывает цепочку ссылок от GC root до объекта. Если видите рост определённого constructor в comparison → ищите, где создаются объекты и почему не удаляются. Streaming, object pooling, bounded structures - 80% оптимизаций

Связанные темы

Memory Management связан со всем, что создаёт объекты: event loop, streams, concurrency. Понимание heap критично для оптимизации:

  • Event Loop & Async — Асинхронные операции создают промисы, callbacks, closures - всё это объекты в heap. Unhandled promise rejection может держать весь chain в памяти
  • Streams API — Streaming - ключевая техника для снижения heap usage. Transform/PassThrough streams создают backpressure, предотвращая buffering
  • Child Processes — Изоляция памяти через worker_threads/child_process. Каждый процесс имеет свой heap → утечка в одном не влияет на другой

Вопросы для размышления

  • Если API создаёт 1000 объектов на запрос и обрабатывает 100 req/sec, сколько объектов создаётся между Major GC циклами (раз в минуту)? Как это влияет на heap size?
  • Почему WeakMap не может использовать примитивы (string, number) как ключи, только объекты? Подсказка: как GC узнаёт, что объект больше не нужен?
  • В heap snapshot 50K объектов типа Promise с retention path через global.pendingRequests. Какие паттерны кода могли привести к этой утечке? Как исправить без изменения архитектуры?

Связанные уроки

  • arch-08-memory-hierarchy
Memory Management: Управление памятью

0

1

Войти