Доброго времени суток, друзья!
В подавляющем большинстве случаев нам, как JavaScript-разработчикам, не нужно беспокоиться о работе с памятью. Движок это делает за нас.
Тем не менее, однажды вы столкнетесь с проблемой под названием «утечка памяти», которую можно решить только обладая знаниями о том, как распределяется память в JavaScript.
В этой статье я расскажу о том, как работает выделение памяти (memory allocation) и сборка мусора (garbage collection), а также о том, как избежать некоторых распространенных проблем, связанных с утечкой памяти.
Жизненный цикл памяти
При создании переменной или функции движок JavaScript выделяет под нее память и освобождает ее, когда она больше не нужна.
Выделение памяти — это процесс резервирования определенного пространства в памяти, а освобождение памяти — это освобождение данного пространства, чтобы оно могло быть использовано для других целей.
Каждый раз при создании переменной или функции память проходит через следующие стадии:
- Выделение памяти — движок автоматически выделяет память для создаваемого объекта
- Использование памяти — чтение и запись данных в память есть не что иное как запись и чтение данных из переменной
- Освобождение памяти — данный шаг также автоматически выполняется движком. Как только память освобождена, она может быть использована для других целей
Куча и стек
Следующий вопрос: что значит память? Где на самом деле хранятся данные?
Движок имеет два таких места: кучу (heap) и стек (stack). Куча и стек — структуры данных, которые используются движком для разных целей.
Стек: статическое выделение памяти
Все данные в примере сохраняются в стеке, поскольку являются примитивами
Стек — это структура данных, используемая для хранения статических данных. Статические данные — это данные, размер которых известен движку на стадии компиляции кода. В JavaScript такими данными являются примитивы (строки, числа, логические значения, undefined и null) и ссылки, указывающие на объекты и функции.
Поскольку движок знает, что размер данных не изменится, он выделяет фиксированный размер памяти для каждого значения. Процесс выделения памяти перед выполнением кода называется выделением статической памяти. Поскольку движок выделяет фиксированный размер памяти, существуют определенные ограничения на этот размер, которые сильно зависят от браузера.
Куча: динамическое выделение памяти
Куча предназначена для хранения объектов и функций. В отличие от стека, движок не выделяет фиксированный размер памяти для объектов. Память выделяется по необходимости. Такое выделение памяти называется динамическим. Вот небольшая сравнительная таблица:
Стек | Куча |
---|---|
Примитивные значения и ссылки | Объекты и функции |
Размер известен во время компиляции | Размер известен во время выполнения |
Выделяется фиксированный размер памяти | Размер памяти для каждого объекта не ограничен |
Примеры
Рассмотрим парочку примеров.
const person = {
name: "John",
age: 24,
};
Движок выделяет память для этого объекта в куче. Однако, значения свойств сохраняются в стеке
const hobbies = ["hiking", "reading"];
Массивы — это объекты, так что они хранятся в куче
let name = "John";
const age = 24;
name = "John Doe";
const firstName = name.slice(0, 4);
Примитивы иммутабельны. Это означает, что вместо изменения оригинального значения, JavaScript создает новое
Ссылки
Все переменные хранятся в стеке. В случае с не примитивными значениями, в стеке хранятся ссылки на объект в куче. Память в куче неупорядоченна. Вот почему нам нужны ссылки в стеке. Вы можете думать о ссылках как об адресах, а об объектах как о домах, находящихся по определенному адресу.
На изображении выше мы можем видеть как хранятся различные значения. Обратите внимание, что person и newPerson указывают на один и тот же объект
Примеры
const person = {
name: "John",
age: 24,
};
Это создает новый объект в куче и ссылку на него в стеке
Сборка мусора
Как только движок замечает, что переменная или функция больше не используются, он освобождает занимаемую ими память.
На самом деле проблема освобождения неиспользуемой памяти является неразрешимой: идеального алгоритма для ее решения не существует.
В данной статье мы рассмотрим два алгоритма, которые предлагают наилучшее на сегодняшний день решения: сборка мусора методом подсчета ссылок (reference counting) и алгоритм пометки и очистки (mark and sweep).
Сборка мусора посредством подсчета ссылок
Тут все просто — из памяти удаляются объекты, на которые не указывает ни одна ссылка. Рассмотрим пример. Линии обозначают ссылки.
Обратите внимание, что в куче остается только объект «hobbies», поскольку только на него имеется ссылка в стеке.
Циклические ссылки
Проблемой указанного способа сборки мусора является невозможность определения циклических ссылок. Это ситуация, когда два или более объекта указывают друг на друга, но не имеют внешних ссылок. Т.е. к этим объектам нельзя получить доступ извне.
const son = {
name: "John",
};
const dad = {
name: "Johnson",
};
son.dad = dad;
dad.son = son;
son = null;
dad = null;
Поскольку объекты «son» и «dad» ссылаются друг на друга, алгоритм подсчета ссылок не сможет освободить память. Однако, эти объекты больше недоступны для внешнего кода
Алгоритм пометки и очистки
Данный алгоритм решает проблему циклических ссылок. Вместо подсчета ссылок, указывающих на объект, он определяет доступность объекта из корневого объекта. Корневым объектом является объект «window» в браузере или «global» в Node.js.
Алгоритм помечает объекты как недостижимые и удаляет их. Таким образом, циклические ссылки больше не являются проблемой. В приведенном примере объекты «dad» и «son» являются недостижимыми из корневого объекта. Они будут помечены как мусор и удалены. Рассматриваемый алгоритм реализован во всех современных браузерах, начиная с 2012 года. Улучшения, произведенные с тех пор, касаются особенностей реализации и повышения производительности, но не ключевой идеи алгоритма.
Компромиссы
Автоматическая сборка мусора позволяет нам сосредоточиться на создании приложений и не тратить время на управление памятью. Однако, за все приходится платить.
Использование памяти
Учитывая, что алгоритмам требуется некоторое время на определение того, что память больше не используется, JavaScript-приложения, как правило, используют больше памяти, чем им требуется в действительности.
Несмотря на то, что объекты помечены как мусор, сборщик должен решить, когда их собирать, чтобы не блокировать поток выполнения программы. Если вам требуется, чтобы приложение было максимально эффективным с точки зрения использования памяти, вам лучше использовать язык программирования более низкого уровня. Но имейте ввиду, что такие языки имеют свои компромиссы.
Производительность
Алгоритмы, собирающие мусор, запускаются периодически для очистки неиспользуемых объектов. Проблема в том, что мы, как разработчики, не знаем, когда именно это произойдет. Сбор большого количества мусора или частая сборка мусора могут влиять на производительность, поскольку для этого требуется определенная вычислительная мощность. Тем не менее, обычно, это происходит незаметно для пользователя и разработчика.
Утечки памяти
Кратко рассмотрим наиболее распространенные проблемы, связанные с утечкой памяти.
Глобальные переменные
Если объявить переменную без использования одного из ключевых слов (var, let или const), такая переменная станет свойством глобального объекта.
users = getUsers();
Выполнение кода в строгом режиме позволяет этого избежать.
Порой мы объявляем глобальные переменные намеренно. В этом случае для того, чтобы освободить память, занимаемую такой переменной, необходимо присвоить ей значение «null»:
window.users = null;
Забытые таймеры и функции обратного вызова
Если вы будете забывать о таймерах и колбэках, то использование памяти вашим приложением может сильно увеличиться. Будьте внимательны, особенно при создании одностраничных приложений (SPA), где обработчики событий и колбэки добавляются динамически.
Забытые таймеры
const object = {};
const intervalId = setInterval(function () {
// все, что здесь находится, не может быть удалено сборщиком мусора,
// до тех пор, пока таймер не будет остановлен
doSomething(object);
}, 2000);
Приведенный выше код запускает функцию каждые 2 секунды. Если таймер вам больше не нужен, его следует отменить посредством:
clearInterval(intervalId);
Это особенно важно для SPA. Даже при переходе на другую страницу, где таймер не используется, он будет работать в фоновом режиме.
Забытые колбэки
Предположим, что вы регистрируете обработчик нажатия кнопки, которую позже удаляете. На самом деле это больше не является проблемой, но все же рекомендуется удалять обработчики, которые стали не нужны:
const element = document.getElementById("button");
const onClick = () => alert("hi");
element.addEventListener("click", onClick);
element.removeEventListener("click", onClick);
element.parentNode.removeChild(element);
Ссылки за пределами DOM
Эта утечка памяти аналогична предыдущим, она возникает при хранении элементов DOM в JavaScript:
const elements = [];
const element = document.getElementById("button");
elements.push(element);
function removeAllElements() {
elements.forEach((item) => {
document.body.removeChild(document.getElementById(item.id));
});
}
При удалении любого из этих элементов, следует также удалить его из массива. В противном случае, такие элементы не могут быть удалены сборщиком мусора:
const elements = [];
const element = document.getElementById("button");
elements.push(element);
function removeAllElements() {
elements.forEach((item, index) => {
document.body.removeChild(document.getElementById(item.id));
elements.splice(index, 1);
});
}
Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.
kahi4
Занятно, да и большинство выводов правильные (хоть и известные), но все же часть неправды тут есть.
Зависит от движка, а так же контекста (например, если у нас замыкание на объект). В v8 все значения без исключения хранятся в куче (heap), обернутые в специальные объекты, например
Не отходя от кассы, в js так же нет "передачи по ссылке" (pass by reference), только "копирование по ссылке" (copy by reference). Иными словами, при вызове функции, передается не сама ссылка, а её копия, так что присвоив новое значение ссылке, оригинальная переменная остается нетронутой.
Ну и еще, рассказывать про утечку памяти в js, не упомянув замыкания — очень лениво, как минимум, ведь большинство проблем именно из-за замыкания, сам по себе таймер в 3 строчки (пусть будет 100 байт) ничего у вас не там не утечет, а вот то что doSomething() в данном примере через замыкания может ссылаться на большие структуры — совсем другое.
Ну и если мы уже пишем статью про утечки памяти в js в 2020 году, я бы еще упомянул про WeakMap.