Из чего состоит память

Любой процесс в Node.js использует несколько разделов памяти:

  • Стек (stack) — область для хранения вызовов функций и локальных переменных, управляется ОС.
  • Куча (heap) — основная память для JS-объектов, управляется движком V8 и сборщиком мусора (GC).
  • Нативная память — сюда входят буферы (Buffer в Node.js), C++-addons, другие служебные сущности (для libuv, Event Loop и т.д.).

Все эти разделы вместе образуют RSS (Resident Set Size) — общий объём памяти, выделенный процессу ОС.

Чтобы посмотреть, сколько памяти сейчас используется, можно вызвать process.memoryUsage().

Этот метод возвращает объект с такими полями:

  • rss — вся память процесса, включая всё нативное.
  • heapTotal — объём JS-кучи, зарезервированный V8.
  • heapUsed — реально используемая память в куче.
  • external — нативная память.
  • arrayBuffers — конкретно память ArrayBuffer и Buffer (часть external).

Сборка мусора в V8

Когда мы говорим о сборке мусора в JavaScript, мы имеем в виду то, как это реализовано в движке. В случае и Node.js, и большинства браузеров (например, Chrome) речь идёт про один и тот же механизм: сборку мусора в V8.

Сердцем движка V8 является Generational GC — поколенческая сборка мусора. Её основная идея: объекты, которые живут долго, — редкость, большинство умирает вскоре после появления (феномен «высокой детской смертности»). Чтобы использовать это наблюдение, V8 делит память (heap) на поколения:

  • New space — молодое поколение. Здесь появляются все новые объекты. Это небольшая область памяти, которую можно быстро и часто очищать.
  • Old space — старое поколение. Если объект прожил несколько циклов сборки в New space и всё ещё нужен — его «повышают» и переносят сюда. Эта область больше по размеру и чистится реже.

Внутри New space работает Scavenge — быстрый алгоритм копирования. Его реализация основана на двух зонах:

  • From-space — где живут текущие объекты.
  • To-space — пустая зона, куда будут скопированы только «живые» объекты.

Когда запускается сборка, GC перебирает объекты из from-space, и те, которые всё ещё достижимы (то есть на них есть ссылки), копируются в to-space. Остальные просто забываются — они исчезают вместе со старой областью. Затем роли зон меняются. Такой подход прост и быстр: не нужно ничего удалять, достаточно скопировать нужное.

Но этот трюк работает, пока объектов немного — он отлично подходит для New space, где всё ещё «свежее». А вот для Old space он неэффективен: там уже много данных, копировать всё — слишком дорого.

Для старого поколения V8 использует алгоритмы Mark-Sweep и Mark-Compact:

  • Mark-Sweep: сначала проходит по графу объектов, помечая «живые». Потом всё, что не помечено, удаляется. Это экономно по времени, но может оставить фрагментированное пространство.
  • Mark-Compact: дополнительно уплотняет память, передвигая объекты в один конец heap-а, чтобы не оставалось дыр. Это происходит небыстро, но снижает фрагментацию.

Обе эти стратегии медленнее, чем Scavenge, но подходят для устойчивых структур данных, которые не хочется копировать по несколько раз.

Чтобы сборка не вызывала внезапных тормозов, V8 применяет ещё и инкрементальные и конкурентные оптимизации:

  • Incremental GC разбивает большие этапы сборки на мелкие порции и выполняет их между основными задачами.
  • Concurrent GC работает в отдельных потоках, параллельно с кодом, стараясь вообще не останавливать исполнение.

Таким образом, сборка мусора в V8 — это не один алгоритм, а комбинация техник, каждая из которых применяется в нужное время и в нужном месте.

И, в отличие от браузеров, в Node.js у нас есть возможность чуть больше контролировать процесс: если запустить скрипт с флагом --expose-gc, можно вручную вызывать в коде global.gc() — это бывает полезно, например, в нагрузочных тестах.

Как профилировать память

Один из базовых способов — heap snapshot через Chrome DevTools. Для этого нужно запустить Node с флагом --inspect: node --inspect app.js.

После запуска можно открыть в браузере chrome://inspect и подключиться к процессу в DevTools и на вкладке Memory снять снимок кучи. Он показывает, какие объекты занимают память.

Если же мы хотим отслеживать метрики памяти в реальном времени, то можно сделать это с помощью PerformanceObserver. Он слушает события, создаваемые системой perf_hooks. Например, можно смотреть, как растёт heap и когда происходят сборки мусора:

import { PerformanceObserver, performance } from 'node:perf_hooks';

const obs = new PerformanceObserver((items) => {
  for (const entry of items.getEntries()) {
    console.log(`[${entry.entryType}]`, entry.name, entry.duration.toFixed(2), 'ms');
  }
});

obs.observe({ entryTypes: ['gc'] });

Если нужно больше деталей о работе V8 и нет времени писать лишний код, можно просто запустить процесс с флагом --trace_gc: node --trace_gc app.js.

В консоли появятся строки вроде:

[89452:0xa81400000]     3103 ms: Scavenge 18.9 (27.7) -> 18.7 (50.7) MB, pooled: 0 MB, 4.29 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;

Здесь видно тип сборки мусора, её длительность и изменение размера кучи.

Ну а самый простой и быстрый способ получить обзор текущего состояния — уже знакомый нам метод process.memoryUsage. Он возвращает данные по основным сегментам памяти:

const mem = process.memoryUsage();
console.log(`Heap used: ${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB`);
console.log(`RSS: ${(mem.rss / 1024 / 1024).toFixed(2)} MB\n`);

Заметка собрана из постов в Telegram-канале @startpoint_dev: первый, второй, третий.