Измерение кучи
Инструменты, превращающие «память всё растёт» в названного удержателя: heap snapshots (shallow vs retained size, доминаторы, техника трёх снимков), allocation timeline, process.memoryUsage(), --trace-gc, v8.getHeapStatistics()
«Память всё растёт» — это ощущение. «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 — задать базовую линию после разогрева.
- Погонять подозреваемую операцию N раз (навигация, запрос и т.д.).
- Снимок 2.
- Погонять её ещё N раз.
- Снимок 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 как базовую линию после разогрева
- 2 Погонять подозреваемую операцию N раз
- 3 Снять снимок 2
- 4 Погонять операцию ещё N раз, затем снять снимок 3
- 5 Сравнить «объекты, аллоцированные между 1 и 2 и всё ещё живые в 3», отсортировать по retained size
- 6 Открыть верхнего удержателя и прочитать его retainer path до корня GC, чтобы найти, что обрывать
▸Ещё практика
Безопасная для продакшена рутина: зарегистрируйте обработчик SIGUSR2, вызывающий v8.writeHeapSnapshot(), и петлю метрик, логирующую process.memoryUsage() плюс v8.getHeapStatistics().used_heap_size каждую минуту. Когда срабатывают алерты RSS, выгрузите два снимка с разницей в несколько минут и сделайте diff офлайн в DevTools. Это даёт retainer path из живого, неотлаживаемого процесса без подключения —inspect (что под нагрузкой обычно нельзя). Сочетайте с —trace-gc, перенаправленным в лог, чтобы постфактум скоррелировать рост с поведением GC.
- 01В чём разница между shallow и retained size и почему утечки ищут по retained size?
- 02Что такое dominator tree и как оно связано с retainer paths?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.