open atlas
↑ К треку
Внутренности движка JavaScript JSE · 06 · 06

Измерение кучи

Инструменты, превращающие «память всё растёт» в названного удержателя: heap snapshots (shallow vs retained size, доминаторы, техника трёх снимков), allocation timeline, process.memoryUsage(), --trace-gc, v8.getHeapStatistics()

JSE Senior ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

«Память всё растёт» — это ощущение. «47 000 удержанных объектов Session, каждый закреплён через app.activeSockets, держат 12 МБ на всех» — это баг-репорт, который можно починить. Расстояние между этими двумя предложениями — целиком инструментарий: heap snapshots, dominator trees и пара флагов Node. Прошлый урок сказал, что каждая утечка — это retainer path; этот — о том, как заставить движок показать вам этот путь вместо догадок.

Shallow size против retained size — различие, которое важно

Этот урок — компаньон уровня движка к performance/04-gc/06-gc-production (которое покрывает сторону эксплуатации — алерты, лимиты, рестарты). Здесь — как читать кучу напрямую.

Heap snapshot — это полный дамп графа объектов в один миг: каждый объект, его размер и его ссылки. Два числа размера на объект, и путаница между ними тратит часы:

  • Shallow size — память, которую объект занимает сам (его собственные поля), исключая то, на что он ссылается. У массива shallow size крошечный, даже если он держит миллионы огромных объектов.
  • Retained size — память, которая освободилась бы при удалении этого объекта, то есть объект плюс всё достижимое только через него. Это число говорит вам стоимость утечки: маленький объект с огромным retained size якорит большое поддерево.

Утечки ищут по retained size, а не shallow size. У утёкшего Map тривиальный shallow size, но retained size — каждая запись, которую он один держит живой.

Dominator tree

Чтобы вычислить retained size, V8 строит dominator tree графа объектов. Объект A доминирует объект B, если каждый путь от корня к B проходит через A — то есть, если A освобождён, B неизбежно тоже становится недостижимым. Retained size объекта — это суммарный shallow size всего, что он доминирует.

Dominator tree — карта охотника за утечками: идите вниз от корней и найдите объект, чей retained size велик и неожидан. Его доминируемое поддерево — ровно то, что он держит живым, а путь от корня к нему — это retainer path, который надо оборвать.

Техника трёх снимков

Один снимок показывает, что в куче, но не что растёт. Классический метод поиска утечек — три снимка:

  1. Снимок 1 — задать базовую линию после разогрева.
  2. Погонять подозреваемую операцию N раз (навигация, запрос и т.д.).
  3. Снимок 2.
  4. Погонять её ещё N раз.
  5. Снимок 3.

Сравнение DevTools «Objects allocated between snapshots» показывает объекты, рождённые после снимка 1 и всё ещё живые в снимке 3. В здоровой операции их должно быть около нуля (всё недолговечное собрано); утечка показывает число, растущее линейно с N. Отсортируйте выживших по retained size, откройте одного и прочитайте его retainer path до корня. Эта последовательность — diff, сортировка по retained, чтение retainer — и есть весь процесс.

Allocation timeline и sampling

Снимки — это точка во времени; allocation timeline (DevTools) и allocation sampling отвечают «откуда текучка?», относя аллокации к стекам вызовов, которые их сделали, во времени. Sampling достаточно низкооверхедный для около-продакшен использования. Так находят горячее место аллокации, гонящее давление GC (проблема minor-GC из урока 02), отличную от утечки удержания.

Счётчики Node и программные

DevTools не всегда подключён. Числа, которые можно логировать:

  • process.memoryUsage(){ rss, heapTotal, heapUsed, external, arrayBuffers }. heapUsed = живые объекты кучи V8; heapTotal = выделенная куча V8; rss = вся резидентная память процесса (включая нативную, стеки, код); external / arrayBuffers = память, удерживаемая C++-объектами, привязанными к JS (Buffer, ArrayBuffer). Растущий heapUsed ⇒ утечка удержания JS; растущий external/arrayBuffers при плоском heapUsed ⇒ утечка Buffer/нативная; высокий rss при плоском heapUsed ⇒ фрагментация.
  • v8.getHeapStatistics() / getHeapSpaceStatistics() — размеры по пространствам (new vs old vs large-object), used_heap_size, heap_size_limit. Позволяет следить именно за old space.
  • --trace-gc / --trace-gc-verbose — логировать каждое событие GC с heap-before/heap-after и временем паузы. Постройте old-space-after во времени: монотонный рост = утечка; колебание вокруг среднего = GC справляется.
  • --max-old-space-size=<MB> — потолок old space; его поднятие покупает запас, но не чинит утечку (лишь оттягивает OOM и удлиняет паузы major GC). По умолчанию это примерно 2 ГБ на современном 64-битном Node, но зависит от версии — измеряйте, не предполагайте.
  • v8.writeHeapSnapshot() — выгрузить .heapsnapshot изнутри процесса (например, по сигналу), чтобы загрузить в DevTools офлайн. Бесценно в продакшене, где нельзя подключить отладчик.

В браузере performance.measureUserAgentSpecificMemory() даёт cross-origin-isolated оценку памяти страницы с разбивкой по типам — поддерживаемого преемника старого нестандартного performance.memory.

Шпаргалка измерения кучи
Искать утечки по
retained size, не shallow
Retained size из
dominator tree
Что растёт?
diff трёх снимков
heapUsed растёт
утечка удержания JS
external/arrayBuffers растёт
утечка Buffer / нативная
rss высок, heapUsed плоский
фрагментация
Викторина

У массива shallow size 200 байт, но retained size 80 МБ. О чём это говорит?

Викторина

process.memoryUsage() показывает растущий rss и растущий external, но heapUsed плоский. Какова вероятная причина?

Расставь шаги по порядку

Расставьте по порядку процесс поиска утечки тремя снимками в DevTools.

  1. 1 Снять снимок 1 как базовую линию после разогрева
  2. 2 Погонять подозреваемую операцию N раз
  3. 3 Снять снимок 2
  4. 4 Погонять операцию ещё N раз, затем снять снимок 3
  5. 5 Сравнить «объекты, аллоцированные между 1 и 2 и всё ещё живые в 3», отсортировать по retained size
  6. 6 Открыть верхнего удержателя и прочитать его retainer path до корня GC, чтобы найти, что обрывать
Ещё практика

Безопасная для продакшена рутина: зарегистрируйте обработчик SIGUSR2, вызывающий v8.writeHeapSnapshot(), и петлю метрик, логирующую process.memoryUsage() плюс v8.getHeapStatistics().used_heap_size каждую минуту. Когда срабатывают алерты RSS, выгрузите два снимка с разницей в несколько минут и сделайте diff офлайн в DevTools. Это даёт retainer path из живого, неотлаживаемого процесса без подключения —inspect (что под нагрузкой обычно нельзя). Сочетайте с —trace-gc, перенаправленным в лог, чтобы постфактум скоррелировать рост с поведением GC.

Вспомните перед уходом
  1. 01
    В чём разница между shallow и retained size и почему утечки ищут по retained size?
  2. 02
    Что такое dominator tree и как оно связано с retainer paths?
  3. 03
    Как отличить утечку удержания JS, нативную/Buffer утечку и фрагментацию через process.memoryUsage()?
Итог

Измерение кучи превращает «память всё растёт» в названного удержателя. Heap snapshot выгружает весь граф объектов; важное число — retained size (память, освобождаемая при удалении объекта, то есть объект плюс его доминируемое поддерево), а не shallow size, который лишь собственные байты объекта. V8 вычисляет retained size из dominator tree, где A доминирует B, если каждый путь корень→B идёт через A; маленький объект с огромным retained size — якорь вашей утечки, а путь от корня к нему — retainer path, который надо оборвать. Чтобы найти, что растёт, а не что просто существует, используйте технику трёх снимков: базовая линия, погонять, снимок, погонять, снимок, затем diff на объекты, аллоцированные рано и всё ещё живые поздно, отсортированные по retained size. Allocation timeline и sampling относят текучку к стекам вызовов для отдельной проблемы давления minor GC. Без отладчика process.memoryUsage() разделяет утечку удержания JS (растёт heapUsed), нативную/Buffer утечку (растут external/arrayBuffers) и фрагментацию (rss высок, heapUsed плоский); v8.getHeapStatistics() даёт размеры по пространствам и лимит кучи; —trace-gc логирует каждую сборку для построения роста old space; —max-old-space-size ограничивает old space (оттягивая, а не починяя утечку); а v8.writeHeapSnapshot() плюс measureUserAgentSpecificMemory() захватывают кучу в продакшене и в браузере. Теперь, когда срабатывает RSS-алерт, у вас есть повторяемая процедура: прочитать memoryUsage() для классификации типа утечки, выгрузить два снимка, сделать diff по retained size, прочитать retainer path — и оборвать его.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено
Связанные уроки
встречается в3

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.