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

Утечки в языке со сборкой мусора

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

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

Node-сервис стартует с 180 МБ RSS и набирает 40 МБ в день. Ни падения, ни ошибки, ни очевидного виновника — код «не аллоцирует ничего, что не освобождает», ведь в JavaScript вы ничего не освобождаете. GC работает безупречно. В этом-то и проблема: каждый байт, который он отказывается забрать, достижим, и что-то в вашем коде держит его таким. Утечка в языке с GC — это никогда не пропущенный free; это ссылка, о которой вы забыли, что всё ещё держите.

Удержание, а не «забыл освободить»

Из урока 01: GC держит всё достижимое от корня, потому что достижимость завышает живость. Утечка в языке со сборкой мусора — это та самая завышенная оценка, кусающая в ответ: объект, с которым вы логически закончили, остаётся достижимым через какую-то ссылку, которую вы не собирались держать. GC прав, удерживая его; баг в вашем графе ссылок, а не в сборщике.

Поэтому охота на утечки — это никогда не «где я забыл освободить?». Это «какой retainer path?» — цепочка ссылок от корня вниз к утёкшему объекту. Оборвите любое звено на этом пути, и объект становится недостижимым и собирается. Лечение всегда — обрыв ссылки, никогда не освобождение.

Это напрямую опирается на замыкания, закрепляющие свой Context (05-closures-scope/02-closure-memory): замыкание — один из самых частых случайных удержателей, потому что оно держит всё захваченное окружение живым, пока само замыкание достижимо.

Полевой справочник по реальным удержателям

1. Таймеры и интервалы, которые никогда не очищаются. setInterval держит свой колбэк достижимым навсегда из таблицы таймеров рантайма; замыкание колбэка держит живым всё, что захватило.

function startPolling(bigState) {
  setInterval(() => check(bigState), 1000);  // bigState pinned forever
}
// fix: const id = setInterval(...); clearInterval(id) on teardown

2. Слушатели событий, которые никогда не снимаются. el.addEventListener('x', handler) заставляет цель удерживать handler, а замыкание handler удерживает всё, что захватило. На сменах маршрутов SPA это накапливается.

window.addEventListener('resize', onResize);  // держит onResize + его замыкание
// исправление: window.removeEventListener('resize', onResize) при размонтировании

3. Неограниченные кэши — Map/массив/Set как кэш, который только растёт. Контейнер в области модуля (корень), поэтому каждая запись удерживается, пока не удалена. Без ограничения или TTL он растёт монотонно.

const cache = new Map();
function memo(key, compute) {
  if (!cache.has(key)) cache.set(key, compute(key));  // никогда не вытесняется
  return cache.get(key);
}
// исправление: LRU с ограничением размера, TTL, или WeakMap если ключ — объект

4. Замыкания, делящие Context, закрепляющий большую переменную. Несколько замыканий из одной области делят один объект Context; если хоть одно замыкание долгоживущее, весь Context — включая огромную переменную, которой пользовалось лишь другое замыкание, — остаётся живым. (См. 05-closures-scope/02-closure-memory.)

5. Отсоединённые узлы DOM, всё ещё ссылаемые из JS. Вы удаляете узел из документа, но переменная JS, массив или замыкание всё ещё на него ссылаются. Узел — и всё его поддерево — нельзя собрать. В браузерах это также держит живыми отсоединённые окна/iframe — тяжёлая утечка.

6. SlicedString, закрепляющий огромного родителя. huge.slice(0, 10) в V8 может породить SlicedString (внутреннее представление подстроки, хранящее смещение и длину в родительской строке вместо копии символов), который ссылается на исходную строку вместо копирования 10 символов, поэтому крошечная подстрока держит многомегабайтного родителя живым. Принудительно копируйте, когда держите маленький срез большой строки.

7. Глобальное накопление. Всё, повешенное на globalThis / window (или модульный синглтон), закреплено на время жизни процесса. Случайная глобальная (x = 1 без объявления в sloppy mode) — классика.

8. «Собрано, но не возвращено ОС». Иногда объект собран, но RSS не падает: куча была фрагментирована, поэтому V8 держит страницы. Это не утечка уровня JS — это фрагментация (территория compaction из урока 03), диагностируется иначе (RSS высок, heapUsed низок).

Все восемь паттернов объединяет одно: каждый — это непреднамеренный путь от корня к объекту. Без него GC собирал бы свободно; с ним GC просто делает свою работу. Когда RSS ползёт вверх в продакшене, мысленно трассируйте цепочку — контейнер → замыкание → объект — пока не найдёте звено, которого там быть не должно.

Слабые ссылки: дайте GC выиграть ничью

Когда нужно ассоциировать данные с объектом без удержания его живым, используйте слабую (weak) ссылку, которую GC игнорирует при вычислении достижимости:

  • WeakMap / WeakSet — ключи (WeakMap) или значения (WeakSet) держатся слабо. Когда единственные оставшиеся ссылки на объект-ключ слабые, запись собирается автоматически. Идеально для кэшей метаданных по объекту, ключуемых самим объектом, — без ручного eviction, без утечки.
  • WeakRef — слабая ссылка на один объект; .deref() возвращает объект или undefined, если он собран. Для кэшей, где значение должно исчезнуть под давлением памяти.
  • FinalizationRegistry — регистрирует колбэк очистки, который может запуститься после сбора объекта. Используйте экономно: финализаторы не гарантированно запускаются, запускаются в неопределённое время и никогда не должны быть основой корректности — только best-effort очистка внешних ресурсов.
const meta = new WeakMap();          // keyed by DOM node, say
meta.set(node, { lastSeen: now });   // НЕ удерживает `node` живым
// когда `node` удалён и более не доступен по другим ссылкам, запись исчезает
Полевой справочник утечек
Корневая причина (всегда)
непреднамеренная достижимость
Лечение (всегда)
оборвать retainer path
Топ-утечка Node
неограниченный Map / Set кэш
Топ-утечка браузера
слушатели + detached DOM
Метаданные по объекту
WeakMap (авто-eviction)
RSS высок, heapUsed низок
фрагментация, не утечка
Викторина

Heap snapshot показывает 50 000 объектов «request context», которые должны быть недолговечными. На что одно полезнее всего посмотреть, чтобы починить утечку?

Викторина

Нужно кэшировать вычисленные метаданные по узлу DOM, но кэш не должен держать удалённые узлы живыми. Какая структура подходит?

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

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

  1. 1 Подтвердить рост: RSS / heapUsed монотонно лезет со временем
  2. 2 Снять heap snapshots с интервалами и сделать diff, чтобы найти тип объектов, растущий неограниченно
  3. 3 Открыть retainer path от растущего объекта вверх к его корню GC
  4. 4 Определить непреднамеренную ссылку на этом пути (таймер, слушатель, запись кэша, замыкание)
  5. 5 Оборвать её (clearInterval/removeEventListener/eviction/WeakMap), чтобы поддерево стало недостижимым
  6. 6 Снять снимок заново, чтобы подтвердить, что число объектов теперь ограничено
Частая ошибка

Тонкость: хвататься за WeakRef/FinalizationRegistry, чтобы «починить» утечку, которая на самом деле неограниченный кэш. Финализаторы не запускаются быстро и надёжно, поэтому не ограничат вашу память под нагрузкой — а код, зависящий от запуска финализатора, баговый по построению. Правильное лечение растущего кэша — настоящая политика eviction (LRU, max-size, TTL). Берегите WeakMap для ассоциативных данных по объекту, а WeakRef — для по-настоящему опциональных кэшей, а не как пластырь поверх отсутствующего eviction.

Вспомните перед уходом
  1. 01
    Почему в языке со сборкой мусора всё ещё можно утечь память и каково общее лечение?
  2. 02
    Перечислите самые частые конкретные удержатели и их лечение.
  3. 03
    Когда использовать WeakMap, WeakRef и FinalizationRegistry и в чём ловушка?
Итог

В трассирующем GC утечка памяти — это никогда не пропущенный free, а непреднамеренное удержание. Сборщик держит каждый объект, достижимый от корня, и достижимость завышает живость, поэтому объект, с которым вы логически закончили, остаётся живым всякий раз, когда забытая ссылка всё ещё на него указывает. Поэтому диагностика — про retainer path: цепочку ссылок от корня GC вниз к утёкшему объекту. Частые конкретные удержатели — неочищенные таймеры, неснятые слушатели событий, неограниченные Map/Set/массив кэши, замыкания, чей общий Context закрепляет большую захваченную переменную, отсоединённые узлы DOM (и окна/iframe), всё ещё ссылаемые из JS, SlicedString, держащий огромного родителя, и накопление в глобальной или модульной области. Каждое лечение одной формы: оборвать звено на retainer path, чтобы поддерево стало недостижимым, — clearInterval, removeEventListener, eviction, скопировать срез или ключевать данные слабо. WeakMap/WeakSet дают автоматические ассоциации по объекту, которые GC игнорирует, WeakRef даёт опциональные кэши, а FinalizationRegistry — best-effort очистку, но ни один не заменяет настоящую политику eviction, и на финализаторы нельзя опираться для корректности. Наконец, отличайте настоящую утечку удержания (heapUsed растёт) от фрагментации (RSS высок, heapUsed низок). Теперь, когда RSS ползёт на продакшене и GC-логи чистые, первый рефлекс — сделать heap snapshot и прочитать retainer path, а не форсировать GC или поднимать лимит кучи.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.