Загружаем научный разбор
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Автор: Казачкин Даниил Михайлович · Обновлено
Метод диагностики удержания памяти: одинаковые циклы жизненного цикла, контрольный режим очистки, пути ссылок, retained size и ограничения RSS и heap snapshots.
График памяти растёт во время работы страницы. Это наблюдение ещё не объясняет причину: приложение могло загрузить полезные данные, прогреть код, создать временные объекты или сохранить то, что давно должно было исчезнуть. Даже одинаковая высота двух графиков ничего не гарантирует. Один проект хранит ограниченный кеш, другой постепенно накапливает обработчики, но пока не успел пройти достаточно повторений.
Для диагностики нужна модель жизненного цикла: какие данные создаёт действие, кто ими владеет и после какого события они перестают быть нужны. Затем нужен опыт, который отличает конкурирующие объяснения. Эта статья предлагает такой метод и небольшой воспроизводимый стенд. Она не сообщает результаты обследования реальных сайтов. Основы сборки мусора и освобождения ресурсов вынесены в [урок о памяти JavaScript](/lessons/javascript/javascript-for-developers/javascript-memory); здесь внимание сосредоточено на качестве измерения и выводов.
Представим каталог, где пользователь открывает подробности карточки и закрывает их. Если каждое открытие добавляет новую карточку в историю по продуктовому требованию, рост истории сам по себе ожидаем. Если после закрытия должны исчезнуть временные данные просмотра, их накопление требует объяснения. Один и тот же объект может быть полезным состоянием в первом сценарии и лишним удержанием во втором.
Поэтому в протоколе пишем не «кликать десять раз», а «вернуться в то же логическое состояние после десяти циклов открытия и закрытия». Проверяем отсутствие активных запросов, фоновой загрузки и новых элементов истории. Без такого условия сравнение начала и конца смешивает утечку с полезной работой. Предположение о повторяемом состоянии становится частью доказательства, а не неявной надеждой исследователя.
Chrome DevTools различает проблемы накопления памяти, избыточного потребления и частых сборок мусора. Инструменты показывают распределение памяти, создание объектов и отсоединённые DOM-деревья, но выбор неприемлемого поведения остаётся связан с задачами пользователя. Chrome DevTools: Fix memory problems.
Сборщик мусора не знает, что пользователь закрыл панель и больше не хочет видеть её данные. Если доступный приложению владелец продолжает хранить ссылку, данные могут оставаться достижимыми. Нам нужен путь от живого владельца к нежелательному объекту и конкретное место, где этот путь должен разрываться. Размера объекта недостаточно: маленькая функция иногда удерживает большой набор данных через окружение.
Удобная рабочая схема для собственного стенда выглядит так:
реестр обработчиков → функция чтения → пакет данных → массив записейЭто объяснение структуры нашего примера, а не копия внутреннего графа V8. Его сила в проверяемом предсказании: пока функция доступна и может прочитать весь пакет, необходимые для такого чтения данные должны оставаться доступны. Удаление другой, уже не используемой переменной не разрывает показанный путь. Материал V8 о слабых ссылках рассматривает такие связи и отдельно предупреждает о тонкостях общих окружений замыканий. V8: Weak references and finalizers.
Сохраните код как retention.cjs. Он не подключается к сети и не использует данные приложения. Запуск node --expose-gc retention.cjs включает диагностическую возможность явно запросить сборку мусора в этом отдельном процессе. Для сравнения исправленного поведения используйте node --expose-gc retention.cjs clean. Не нужно добавлять этот флаг в обычную конфигурацию проекта.
const clean = process.argv.includes('clean');
const handlers = new Set();
function openPanel(seed) {
const rows = Array.from({ length: 5000 }, (_, index) => ({
index,
text: 'row-' + seed + '-' + index,
}));
const read = () => rows;
handlers.add(read);
return () => handlers.delete(read);
}
function sample(stage) {
if (typeof global.gc !== 'function') throw new Error('Use --expose-gc');
global.gc();
const { heapUsed, rss } = process.memoryUsage();
console.log(JSON.stringify({ stage, handlers: handlers.size, heapUsed, rss }));
}
function cycle(seed) {
const dispose = openPanel(seed);
if (clean) dispose();
}
sample('start');
for (let batch = 0; batch < 3; batch++) {
for (let index = 0; index < 10; index++) cycle(batch * 10 + index);
sample('batch-' + (batch + 1));
}
handlers.clear();
sample('released');Гипотеза: накопление связано с сохранением функций в реестре. Предсказание для первого запуска — размеры реестра 0, 10, 20, 30, 0. Для режима clean все размеры равны нулю. Эти числа определяются кодом и проверяются точно. Для heapUsed ожидаем рост удерживаемых данных в первом режиме и более стабильный уровень во втором; конкретные байты не фиксируем заранее.
Два режима отличаются вызовом функции завершения жизненного цикла. Строки создаются в обоих, поэтому исчезновение накопления нельзя объяснить отказом от полезной работы. После handlers.clear() первая версия также теряет созданный стендом путь удержания. Такой контроль помогает связать поведение с владельцем ссылок. Сам по себе он не доказывает, что в большом приложении нет других путей к тем же данным.
При автоматизированной проверке 8 сентября 2026 года на Node.js 24.13.0 оба режима дали предсказанные счётчики. В одном запуске без завершения подписок heapUsed вырос примерно с 3,5 до 15,8 миллиона байт и после очистки реестра снизился до 3,8 миллиона. В режиме clean после начального прогрева оставался около 3,8 миллиона. RSS сразу не вернулся к начальному значению. Это наблюдение одного небольшого стенда, а не норматив потребления памяти или сравнительный бенчмарк движков.
heapUsed относится к используемой памяти кучи V8, а RSS отражает занятую резидентную память процесса шире JavaScript-объектов. Для ArrayBuffer и Node Buffer существенны также arrayBuffers и external; первое значение входит во второе, поэтому их нельзя бездумно складывать. После освобождения объектов RSS не обязан немедленно вернуться к исходному уровню. Документация Node отдельно описывает влияние фрагментации распределителя памяти. Node.js: process.memoryUsage.
В стенде сознательно используются обычные объекты и строки, чтобы не начинать опыт с учёта внешних буферов. Но даже здесь на график влияют прогрев кода и служебные структуры. Полезно выполнить несколько отдельных запусков, сохранить версию Node и сравнить форму серий, а не искать одинаковые числа. Фактический размер реестра остаётся дополнительным свидетельством, которое не зависит от стратегии возврата страниц операционной системе.
Также стоит разделять скорость выделения и объём живых данных. Функция может создавать много краткоживущих объектов, вызывая нагрузку на сборку мусора, но не накапливать их после завершения цикла. Оптимизация числа временных объектов тогда решает другую задачу. Приписывать ей «исправление утечки» без доказанного удержания было бы неверно и затруднило бы проверку результата.
В браузере после воспроизводимого цикла можно сравнить heap snapshots и исследовать объекты, которые пережили завершение действия. Shallow size описывает собственную память объекта; retained size помогает оценить память, освобождение которой связано с исчезновением удерживающего объекта и недостижимостью зависимых данных. В представлении сгруппированных конструкторов и отдельных экземпляров столбцы требуют внимательного чтения. Retained size разных объектов нельзя считать независимыми слагаемыми. Chrome DevTools: Heap snapshots.
На собственном стенде полезно искать сначала функции из реестра, затем строки и массивы, до которых они дают доступ. В приложении путь может проходить через обработчик события, кеш или отладочную коллекцию. Найденный путь является объяснением наблюдаемой достижимости, но ещё не ответом, кто неправильно управляет ресурсом. Для исправления нужно вернуться к событию завершения жизненного цикла и владельцу подписки.
Термин detached тоже требует контекста. Узел, исключённый из DOM, может временно храниться для повторного вставления, и это допустимое решение. Если после закрытия редактора сохранение дерева не предусмотрено, тот же факт становится кандидатом на дефект. Сравнение нескольких циклов и проверка владельца делают вывод содержательнее, чем один фильтр по слову Detached.
Сохранённое значение в консоли, выбранный объект отладчика или дополнительная диагностическая коллекция могут удерживать то, что исследователь пытается освободить. В Chrome есть отдельный фильтр объектов, удерживаемых DevTools console. Поэтому в измерительной серии лучше печатать примитивные счётчики, а не большие объекты, и повторять опыт после чистого запуска страницы. Chrome DevTools: Heap snapshots.
Создание снимка тоже является вычислительной работой. Разбор команды V8 показывает, что формирование и сериализация больших снимков способны сами стать узким местом. Время записи профиля нельзя автоматически считать задержкой обычного пользовательского сценария. Для сравнения отзывчивости нужен отдельный прогон без тяжёлого профилирования. V8: Speeding up heap snapshots.
Поэтому протокол диагностики разделяет разведку и подтверждение. Сначала снимки помогают найти путь. Затем небольшой тест жизненного цикла проверяет исчезновение лишних обработчиков. Наконец, обычный сценарий на подходящем устройстве проверяет, улучшилось ли поведение для пользователя. Это три связанных наблюдения, у которых разные инструменты и разные границы вывода.
Если подписка должна прекратить обработку событий при закрытии панели, время её отключения является частью поведения приложения. Нельзя передавать эту обязанность сборщику мусора и ждать финализатора. Момент финализации не задан, а callback может вообще не состояться до уничтожения среды. Слабая ссылка также не устраняет другие сильные пути к объекту. V8: Weak references and finalizers.
В нашем примере явный dispose позволяет проверить завершение сразу и детерминированно. Это не ручное освобождение каждого байта: программа разрывает принадлежащую ей связь, а управление памятью остаётся у движка. Такое разделение удобно и для таймеров, и для подписок, и для запросов с отменой. Необходимо только определить, кто вызывает завершение и допускается ли повторный вызов.
Хороший отчёт содержит сценарий возврата в одно состояние, исходные условия, несколько повторений и путь удержания. Затем описывает единственное изменение и повтор той же серии. Если проблема исчезла только после удаления всей истории пользователя, такой опыт не доказывает правильность исправления: изменился сам продуктовый контракт. Нужен вариант, который сохраняет требуемые данные и убирает лишнюю связь.
Для практики замените неограниченный реестр стенда кешем максимум из трёх пакетов. Предскажите, когда рост должен остановиться, и проверьте четыре, десять и двадцать циклов. Это отделит ограниченное полезное удержание от бесконечного накопления. [Урок о замыканиях](/lessons/javascript/javascript-for-developers/javascript-closures) поможет прочитать путь к данным, а [урок об отмене запросов](/lessons/javascript/javascript-for-developers/javascript-fetch-abort) — связать завершение ресурса с действием пользователя. Диагностика заканчивается проверенным жизненным циклом, а не просто более низкой точкой на графике.
Авторский русскоязычный разбор. Перечисленные первичные источники использованы для проверки технических утверждений и библиографии; материал не является переводом или перепечаткой источников. Собственные примеры и рассуждения отделены от опубликованных эмпирических результатов.
Иллюстрации, таблицы и код первоисточников не воспроизводятся. Примеры и текстовые схемы созданы для этой статьи. Ссылки и библиографические сведения не предоставляют прав на перепубликацию материалов источников.