lenec ru

← все посты

Garbage collection в Node.js: V8 heap, generational GC и тюнинг

14K

Garbage collection в V8 — это не просто «автоматическая очистка памяти». Это сложная система с несколькими поколениями объектов, разными алгоритмами сборки и прямым влиянием на latency вашего приложения. Разберём, как устроен GC в Node.js, как его мониторить и как снизить паузы с сотен миллисекунд до десятков.

Архитектура V8 heap: поколения объектов

V8 использует generational garbage collection — память разделена на два поколения:

  • Young generation (New Space) — сюда попадают все новые объекты. Размер ~1-8 MB (по умолчанию ~2 MB на полупространство). Делится на два полупространства: from-space и to-space
  • Old generation (Old Space) — долгоживущие объекты, пережившие несколько циклов GC в young generation. Размер по умолчанию ~1.4 GB (на 64-битных системах), настраивается флагом --max-old-space-size

Идея проста: большинство объектов умирают молодыми (temporary variables, промежуточные результаты), поэтому их дёшево собирать в маленьком young generation. Объекты, прожившие долго (глобальные переменные, кеши, замыкания), переезжают в old generation и собираются реже.

Дополнительные пространства

  • Large Object Space — объекты больше ~256 KB (большие массивы, буферы). Не перемещаются, собираются отдельно
  • Code Space — скомпилированный JIT-код
  • Map Space — hidden classes (внутренние структуры V8 для оптимизации доступа к свойствам)

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

Scavenge (для young generation)

Быстрый алгоритм на основе Cheney's copying collector. Работает так:

  1. Все живые объекты из from-space копируются в to-space
  2. Мёртвые объекты просто игнорируются — они остаются в from-space, который потом целиком очищается
  3. from-space и to-space меняются местами

Объекты, пережившие два Scavenge-цикла, продвигаются (promote) в old generation. Scavenge очень быстрый (1-5 ms), но требует в два раза больше памяти (два полупространства).

Mark-Sweep-Compact (для old generation)

Трёхфазный алгоритм для old generation:

  1. Mark — обход графа объектов от корней (global, stack, handles), пометка всех достижимых объектов
  2. Sweep — проход по памяти, удаление непомеченных объектов, добавление освободившихся блоков в free list
  3. Compact (опционально) — дефрагментация памяти, перемещение живых объектов в начало, чтобы избежать фрагментации

Mark-Sweep медленнее Scavenge (10-100+ ms), но работает с большим объёмом памяти. V8 использует incremental marking — разбивает Mark-фазу на маленькие шаги, чередуя их с выполнением JavaScript, чтобы снизить паузы.

Concurrent и parallel GC

Современный V8 (с Node.js 12+) использует:

  • Parallel GC — несколько потоков работают одновременно во время GC pause (основной поток JS остановлен)
  • Concurrent marking — marking идёт в фоновых потоках параллельно с выполнением JS (основной поток не блокируется)
  • Concurrent sweeping — sweep тоже в фоне

Благодаря этому major GC pause (для old generation) снизились с сотен миллисекунд до 10-50 ms в типичных случаях.

Мониторинг GC: флаги и метрики

--trace-gc

Самый простой способ увидеть, что происходит:

node --trace-gc server.js

Вывод:

[12345:0x5a2b3c4d5e6f]       42 ms: Scavenge 2.1 (3.2) -> 1.8 (4.2) MB, 1.2 / 0.0 ms  (average mu = 0.995, current mu = 0.995) allocation failure
[12345:0x5a2b3c4d5e6f]      156 ms: Mark-sweep 15.3 (18.5) -> 12.1 (17.5) MB, 23.4 / 0.0 ms  (average mu = 0.912, current mu = 0.887) allocation failure scavenge might not succeed

Расшифровка:

  • Scavenge — тип GC (young generation)
  • 2.1 (3.2) → 1.8 (4.2) MB — heap used до/после (в скобках — heap total)
  • 1.2 / 0.0 ms — время pause / время в фоне
  • allocation failure — причина запуска GC (не хватило места для аллокации)

--trace-gc-verbose

Ещё больше деталей — показывает время каждой фазы (mark, sweep, compact):

node --trace-gc --trace-gc-verbose server.js

--max-old-space-size

Ограничивает размер old generation (в мегабайтах):

node --max-old-space-size=4096 server.js  # 4 GB

По умолчанию ~1.4 GB на 64-битных системах. Увеличение даёт больше памяти, но GC pause становятся длиннее (больше объектов для обхода). Уменьшение заставляет GC запускаться чаще, но паузы короче.

--max-semi-space-size

Размер одного полупространства в young generation (в мегабайтах):

node --max-semi-space-size=8 server.js  # 8 MB на полупространство

Больше semi-space — реже Scavenge, но дольше паузы. Меньше — чаще Scavenge, короче паузы, но больше overhead.

Программный мониторинг

Через v8.getHeapStatistics():

const v8 = require('v8');

const stats = v8.getHeapStatistics();
console.log({
  total_heap_size: `${(stats.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
  used_heap_size: `${(stats.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
  heap_size_limit: `${(stats.heap_size_limit / 1024 / 1024).toFixed(2)} MB`,
  malloced_memory: `${(stats.malloced_memory / 1024 / 1024).toFixed(2)} MB`
});

Для отслеживания GC pause используйте performance.measure или библиотеку gc-stats:

const gc = require('gc-stats')();

gc.on('stats', (stats) => {
  console.log(`GC ${stats.gctype}: pause ${stats.pause / 1e6} ms, heap ${stats.after.usedHeapSize / 1024 / 1024} MB`);
  
  if (stats.pause / 1e6 > 50) {
    console.warn('Long GC pause detected!');
  }
});

GC pause и влияние на latency

Во время GC pause основной поток JavaScript полностью остановлен — запросы не обрабатываются, таймеры не срабатывают. Для high-load API это критично:

  • Scavenge pause — обычно 1-5 ms, почти незаметно
  • Major GC pause — 10-100+ ms, может вызвать timeout на клиенте

Если у вас SLA 99th percentile latency < 100 ms, а major GC pause 200 ms — вы нарушаете SLA при каждом major GC.

Пример замера latency с учётом GC:

const http = require('http');
const v8 = require('v8');

let requestCount = 0;
let totalLatency = 0;

http.createServer((req, res) => {
  const start = Date.now();
  
  // имитация работы
  const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, value: Math.random() }));
  const result = data.filter(x => x.value > 0.5);
  
  const latency = Date.now() - start;
  totalLatency += latency;
  requestCount++;
  
  res.writeHead(200);
  res.end(JSON.stringify({ count: result.length, avgLatency: totalLatency / requestCount }));
}).listen(3000);

setInterval(() => {
  const stats = v8.getHeapStatistics();
  console.log(`Heap: ${(stats.used_heap_size / 1024 / 1024).toFixed(2)} MB, Avg latency: ${(totalLatency / requestCount).toFixed(2)} ms`);
}, 5000);

Запускаем с --trace-gc и нагружаем (например, wrk -t4 -c100 -d30s http://localhost:3000). Видим корреляцию: когда происходит major GC, latency скачет.

Оптимизация: снижаем GC pause

1. Object pooling

Вместо создания новых объектов каждый раз — переиспользуем старые:

class ObjectPool {
  constructor(factory, reset, size = 100) {
    this.factory = factory;
    this.reset = reset;
    this.pool = Array.from({ length: size }, factory);
    this.index = 0;
  }
  
  acquire() {
    if (this.index < this.pool.length) {
      return this.pool[this.index++];
    }
    return this.factory(); // fallback: создаём новый
  }
  
  release(obj) {
    if (this.index > 0) {
      this.reset(obj);
      this.pool[--this.index] = obj;
    }
  }
}

// использование
const bufferPool = new ObjectPool(
  () => Buffer.allocUnsafe(1024),
  (buf) => buf.fill(0),
  50
);

function handleRequest(data) {
  const buf = bufferPool.acquire();
  // работа с buf
  bufferPool.release(buf);
}

Меньше аллокаций — реже GC, короче паузы.

2. Избегаем больших объектов в young generation

Большие объекты (>256 KB) попадают в Large Object Space и не участвуют в Scavenge, но их копирование при promote дорого. Решение:

  • Используйте Buffer.allocUnsafe() вместо массивов для больших данных
  • Разбивайте большие структуры на маленькие чанки
  • Переиспользуйте буферы через pooling

3. Уменьшаем heap size

Парадоксально, но меньший --max-old-space-size может улучшить latency:

node --max-old-space-size=512 server.js

GC запускается чаще, но паузы короче (меньше объектов для обхода). Подходит для stateless-сервисов с малым объёмом данных в памяти.

4. Избегаем retention

Объекты, на которые есть ссылки, не собираются. Типичные ошибки:

  • Глобальные массивы/Map без ограничения размера
  • Замыкания, захватывающие большие объекты
  • Event listeners без отписки

Используйте WeakMap, WeakSet для кешей, где ключи — объекты. GC сам почистит, когда ключ станет недостижим.

Практический кейс: снижаем GC pause с 200ms до 20ms

Реальный проект: Express API для обработки JSON. Нагрузка 1000 RPS, 99th percentile latency ~150 ms, но периодически скачет до 300+ ms. Запускаем с --trace-gc:

node --trace-gc --trace-gc-verbose server.js

Видим major GC каждые 10-15 секунд с pause 180-220 ms. Heap used растёт до 800 MB перед GC.

Шаг 1: Профилируем аллокации

Используем clinic heapprofiler:

clinic heapprofiler -- node server.js
# нагружаем, останавливаем

Flamegraph показывает: 60% аллокаций — парсинг JSON и создание response-объектов.

Шаг 2: Object pooling для response

Вместо создания нового объекта на каждый запрос — пул из 200 объектов:

const responsePool = new ObjectPool(
  () => ({ status: 'ok', data: null, timestamp: 0 }),
  (obj) => { obj.data = null; obj.timestamp = 0; },
  200
);

app.post('/process', (req, res) => {
  const response = responsePool.acquire();
  response.data = processData(req.body);
  response.timestamp = Date.now();
  
  res.json(response);
  responsePool.release(response);
});

Шаг 3: Уменьшаем heap size

Приложение не держит большой state, поэтому снижаем лимит:

node --max-old-space-size=512 --trace-gc server.js

Результат:

  • Major GC pause: 180-220 ms → 15-25 ms (в 9 раз быстрее)
  • GC частота: каждые 10-15 сек → каждые 5-7 сек (чаще, но незаметно)
  • 99th percentile latency: 150 ms → 80 ms
  • Heap used: 800 MB → 350 MB

Ключевой фактор — меньше аллокаций (pooling) + меньший heap (короче обход графа объектов).

Подводные камни

  • Incremental marking не панацея — если аллокации идут быстрее, чем marking успевает пометить, V8 переключается на full stop-the-world GC
  • Concurrent GC использует CPU — фоновые потоки GC конкурируют с JS-потоком за ядра. На машинах с малым количеством ядер может быть хуже
  • Большие объекты в Large Object Space — не перемещаются, могут вызвать фрагментацию. Используйте streaming для больших данных
  • Native addons — память, выделенная в C++, не управляется V8 GC. Следите за утечками через Valgrind

Вывод

Garbage collection в V8 — это generational система с быстрым Scavenge для молодых объектов и медленным Mark-Sweep-Compact для старых. Современный V8 использует concurrent и parallel GC, снижая паузы до 10-50 ms в типичных случаях. Для мониторинга используйте --trace-gc и v8.getHeapStatistics(). Оптимизация — это object pooling, уменьшение heap size, избегание больших объектов и retention. В реальных проектах можно снизить GC pause в 5-10 раз, улучшив latency и стабильность под нагрузкой.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.