← Все статьи

Утечки памяти в JavaScript: механика V8 и охота в Chrome DevTools

Как V8 решает, что ещё «живо», семь типичных утечек и как найти удерживающую ссылку через вкладку производительности и снимки кучи.

Утечки памяти в JavaScript: механика V8 и охота в Chrome DevTools
Содержание

Коротко

В современном JavaScript редко выделяют память вручную: за уборку отвечает сборщик мусора.

Утечки чаще всего появляются не потому, что «забыли освободить», а потому что объект остаётся достижимым дольше, чем нужно.

Исходная статья разбирает механику кучи V8, семь частых сценариев утечек и маршрут поиска через Chrome DevTools: сначала сигнал на графике памяти, потом снимок кучи и цепочка удержания.

Что произошло

Автор начинает с простого правила движка: объект живёт, пока от корня сборщика до него тянется цепочка ссылок.

Корнями служат глобальный объект, стек вызовов и активные системные дескрипторы. Пока путь от корня существует, данные остаются в куче.

Представьте съёмную квартиру, где вы съехали, но ключ всё ещё лежит у знакомого: пока ключ не сдан, помещение формально занято. Так же и с лишней ссылкой в коде.

V8 делит объекты по возрасту. Молодое поколение чистится часто и быстро: большинство выделений короткоживущие.

То, что пережило несколько проходов, переезжает в старое поколение. Там работают более тяжёлые циклы разметки и уплотнения — в том числе параллельно и по частям, чтобы реже останавливать весь мир.

Поколение меняет стоимость уборки, но не отменяет правило достижимости: лишняя ссылка от корня — и объект остаётся в куче.

Дальше — семь типовых сценариев. Необъявленная переменная в нестрогом режиме «протекает» в глобальную область: функция завершилась, а большой массив всё ещё висит на window.

Узел убрали из DOM через .remove() или .removeChild(), но массив или кэш держат его — получается отсоединённое дерево. В примере автора у узла мелкий «свой» размер, а удержанный размер раздувает почти всю кучу из‑за массива на миллион элементов.

Слушатель на window или document без removeEventListener сохраняет и колбэк, и всё, что он замкнул. Компонент уже размонтирован, а resize всё ещё тянет тяжёлые метаданные.

Забытый setInterval или бесконечный requestAnimationFrame делают то же через таймеры браузера. Колбэк остаётся достижимым, пока не вызвать clearInterval или cancelAnimationFrame.

Долгоживущее замыкание тащит за собой гигантский массив, хотя нужен был только один размер: достаточно сохранить число, а не весь массив.

Обычный Map как бесконечный кэш по объектам не отпускает ключи, пока не вызвать .delete(). Здесь уместны WeakMap / WeakSet, если ключи — объекты и итерация по записям не нужна.

Наконец, console.log крупных структур в открытых DevTools может удерживать объекты на время отладки и раздувать замеры кучи. Для боя и для сессии профилирования это разные истории.

Общий мотив у всех семи случаев один: кто‑то держит «ключ» после того, как жилец съехал. Инструменты нужны, чтобы назвать этого держателя по имени, а не гадать по симптомам.

Почему это важно

Утечка памяти редко кричит сразу: страница «тяжелеет» после навигации по SPA, вкладка ест гигабайты после часа работы, мобильный браузер убивает процесс.

Разработчик смотрит на CPU и сеть, а корень — в ссылке, которую забыли отпустить при размонтировании компонента или смене маршрута.

Понимание достижимости меняет вопрос с «куда делась память?» на три конкретных. Какой объект удерживается? Какой корень его держит? На каком шаге жизненного цикла ссылку следовало снять?

Без этого снимки кучи превращаются в список имён классов без действия. С этим — появляется место для правки: очистка таймера, снятие слушателя, слабый кэш, отказ от захвата лишних данных в замыкании.

Для полного стека это ещё и напоминание: та же модель достижимости работает в Node.js на V8.

Глобальные кэши, забытые таймеры и «вечные» подписки на события сервера дают тот же эффект, что и слушатели на window — только без вкладки браузера, которую пользователь может просто закрыть.

На практике

Сначала подтвердите сигнал, потом ищите цепочку — иначе легко «оптимизировать» то, чего нет.

Автор предлагает два шага в инструментах, а затем дисциплину в коде.

Повторяйте одно и то же действие пользователя несколько раз между снимками — так проще отделить шум от настоящей утечки.

  1. Откройте вкладку производительности, включите учёт памяти, запишите сценарий и несколько раз нажмите принудительную сборку мусора (иконка корзины). Если базовая линия кучи после сборки ползёт вверх «ступеньками» — это сильный намёк на утечку.
  2. Во вкладке памяти сделайте снимок кучи до и после повторяемого действия пользователя; сравните объекты, выделенные между снимками, или фильтр вроде объектов, удержанных отсоединёнными узлами.
  3. В списке удерживающих ссылок пройдите путь от объекта к корню: часто всплывает глобальный кэш, замыкание слушателя или массив «на всякий случай».
  4. В коде заведите явный разбор жизненного цикла: clearInterval / cancelAnimationFrame, парный removeEventListener, обнуление ссылок на DOM после .remove().
  5. Для метаданных по объектам и узлам предпочитайте WeakMap, если не нужна итерация по ключам; для обычного Map — лимит и вытеснение.
  6. На время профилирования не логируйте гигантские структуры в консоль — иначе замеры кучи врут.
  7. В нестрогом коде ловите «протечки» в глобал: const / let и 'use strict' закрывают целый класс случайных утечек ещё до профилировщика.

Итог

Утечка в JavaScript — это почти всегда лишняя достижимость, а не «забытый free».

V8 честно оставляет всё, до чего можно дотянуться от корня; Chrome DevTools помогает увидеть ступеньки на графике и назвать удерживающую ссылку.

Дисциплина жизненного цикла — таймеры, слушатели, слабые коллекции — закрывает большинство бытовых случаев из исходной статьи.

Если после принудительной сборки мусора базовая линия кучи всё ещё растёт — сначала ищите удерживающую ссылку, а не «оптимизацию» алгоритмов.

Комментарии

Загрузка комментариев…