Обложка статьи «Управление памятью в JavaScript»

Управление памятью в JavaScript

Автор перевода: Account Manager Noveo Виктория

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

Однако рано или поздно вы столкнетесь с некоторыми проблемами – например, с утечками памяти, – и избавиться от них получится только тогда, когда вы поймете, как именно работает выделение памяти.

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

Жизненный цикл памяти

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

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

Каждый раз, когда мы объявляем переменную или создаем функцию, необходимая для этого память проходит следующие этапы:

  • Выделение памяти. JavaScript берет эту задачу на себя: он выделяет память, которая понадобится для созданного нами объекта.
  • Использование памяти. Это то, что мы явно прописываем в коде: чтение и запись в память – не что иное, как чтение и запись в переменную.
  • Освобождение памяти. Этот шаг также выполняет движок JavaScript: сразу после освобождения память можно использовать для других целей.

Примечание В контексте управления памятью «объекты» включают в себя не только объекты JavaScript, но и функции с областями их видимости.

Стек и куча

Теперь мы знаем, что движок JavaScript выделяет память под все, что мы определяем в коде, и освобождает ее, когда в этой памяти больше нет нужды. Из этого следует вопрос: а где же все хранится?

В JavaScript есть два варианта хранения данных: в стеке и в куче; и то, и другое – названия структур данных, которые используются движком для различных целей.

Стек: статическое выделение памяти

Все примитивные значения сохраняются в стеке.

Стек (англ. stack) – это структура данных, которая используется для хранения статических данных, т.е. тех, чей размер известен во время компиляции. В JavaScript сюда включаются примитивные значения (string, number, boolean, undefined и null) и ссылки на функции и объекты.

Движок знает, что размер данных не изменится, и поэтому выделяет фиксированный объем памяти для каждого значения.

Процесс выделения памяти прямо перед выполнением называется статическим.

Так как движок выделяет фиксированный объем памяти для подобных значений, логично предположить, что существует лимит на размер примитивных значений. В зависимости от используемого браузера эти ограничения, а также максимально допустимый размер всего стека могут варьироваться.

Куча: динамическое выделение памяти

Куча (англ. memory heap) используется для хранения таких данных, как объекты и функции.

В отличие от случая со стеком, движок не знает, какой объем памяти понадобится для тех или иных объектов, а потому выделяет память по мере необходимости.

Такое выделение памяти называют динамическим.

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

Примеры

Рассмотрим несколько примеров (в комментариях указано, как происходит выделение памяти):

const person = {
  name: 'John',
  age: 24,
};

// JavaScript выделяет память под этот объект в куче. 
// Сами же значения являются примитивными, поэтому храниться они будут в стеке.

const hobbies = ['hiking', 'reading'];

// Массивы – тоже объекты, значит, они сохраняются в куче.

let name = 'John'; // выделяет память для строки
const age = 24; // выделяет память для числа
name = 'John Doe'; // выделяет память для новой строки
const firstName = name.slice(0,4); // выделяет память для новой строки

// Примитивные значения по своей природе иммутабельны: вместо того, чтобы изменить начальное значение,
// JavaScript создает еще одно.

Ссылки в JavaScript

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

В куче нет какого-либо определенного порядка, в связи с чем ссылка на нужную нам область памяти должна храниться в стеке: в этом смысле объект в куче похож на дом, а ссылка – на его адрес.

Примечание JavaScript хранит объекты и функции в куче, в то время как примитивные значения и ссылки находятся в стеке.

На этой картинке показано, как организовано хранение различных значений. Обратите внимание, что person и newPerson указывают здесь на один и тот же объект.

Пример

const person = {
  name: 'John',
  age: 24,
};

// В куче создается новый объект, а в стеке – ссылка на него.

Ссылки – одно из центральных понятий в работе JavaScript. Однако их более детальное изучение – тема для отдельного разговора.

Сборка мусора

Итак, мы рассмотрели, как JavaScript выделяет память для всевозможных типов объектов; теперь же, если вернемся к жизненному циклу памяти, нам остается последний этап – ее освобождение.

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

Как только движок понимает, что в переменной или функции больше нет необходимости, он освобождает ранее выделенную под нее память.

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

В то же время есть несколько алгоритмов, которые представляют собой если не точное решение вопроса, то довольно неплохую аппроксимацию. В этой части я расскажу о наиболее популярных из них: сборке мусора, основанной на подсчете ссылок, и так называемом «алгоритме пометок» (англ. mark and sweep).

Сборка мусора, основанная на подсчете ссылок

Данный алгоритм – самый простой из существующих. Он уничтожает те объекты, на которые не указывает ни одна ссылка.

Обратимся к следующему примеру (ссылки представлены в виде линий):

Обратите внимание на то, как в конце остается только hobbies – это единственный объект в куче, на который сохранилась ссылка в стеке.

Циклы

Минус рассматриваемого алгоритма в том, что он не учитывает циклические ссылки: они возникают в случае, когда один или более объектов ссылаются друг на друга, при этом оказываясь вне зоны досягаемости с точки зрения кода.

let son = {
  name: 'John',
};
let dad = {
  name: 'Johnson',
}

son.dad = dad;
dad.son = son;
son = null;
dad = null;

Так как son и dad ссылаются друг на друга, алгоритм не станет освобождать выделенную под них память. Тем не менее, доступ к обоим объектам для нас уже навсегда утерян.

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

«Алгоритм пометок»

Проблему циклических зависимостей может решить «алгоритм пометок», или метод mark and sweep. Вместо обычного подсчета ссылок данный алгоритм определяет, возможно ли получить доступ к тому или иному объекту через корневой объект (в браузере таковым является window, а в Node.js – global).

Алгоритм помечает (mark) недосягаемые объекты как «мусор», после чего «выметает» (sweep) их; корневые объекты при этом никогда не уничтожаются.

Как видим, такие циклические зависимости, как в примере выше, – больше не проблема: у нас нет доступа ни к dad, ни к son, следовательно, оба объекта будут помечены и обработаны, а память – возвращена системе.

С 2012 года все браузеры оснащаются сборщиками мусора, работающими по принципу mark and sweep: со временем были улучшены производительность и реализация алгоритма, однако его основная идея осталась неизменной.

Оборотная сторона медали

Благодаря автоматической сборке мусора мы можем полностью сосредоточиться на создании продукта и не тратить свое драгоценное время на управление памятью. Однако не стоит забывать и о том, что у всего есть свои недостатки.

Использование памяти

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

Если для вас важно, чтобы разрабатываемое приложение использовало память максимально эффективно, возможно, вместо JavaScript вам следует обратиться к низкоуровневому языку – но помните, что этот вариант тоже по-своему неидеален.

Производительность

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

Частая и/или «масштабная» сборка мусора может негативно отразиться на производительности, так как для выполнения алгоритма требуется определенная вычислительная мощность. Тем не менее, последствия в большинстве своем остаются незамеченными – как со стороны пользователя, так и со стороны разработчика.

Утечки памяти

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

Глобальные переменные

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

В браузере, к примеру, использование var вместо const или let (не говоря уже об отсутствии ключевого слова в принципе) приведет к тому, что движок присоединит переменную к объекту window. То же самое произойдет и с функциями, определенными словом function.

user = getUser();
var secondUser = getUser();
function getUser() {
  return 'user';
}

// Все три переменных – user, secondUser и 
// getUser – будут присоединены к объекту window.

Такой сценарий применим только по отношению к функциям и переменным, объявленным в глобальной области видимости; избежать проблем вам поможет выполнение кода в строгом режиме (англ. strict mode).

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

Для этого присвойте глобальной переменной null:

window.users = null;

Таймеры и коллбэки

Забытые таймеры и коллбэки могут привести к тому, что приложение начнет использовать бо́льший объем памяти. В этом контексте следует быть особенно внимательными с одностраничными приложениями (SPA) и динамическим добавлением коллбэков и наблюдателей событий.

Забытые таймеры

const object = {};
const intervalId = setInterval(function() {
  // сборщик мусора не обработает ничего, что используется здесь,
  // пока интервал не будет очищен
  doSomething(object);
}, 2000);

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

Объекты, на которые есть ссылка в интервале, не будут уничтожены, пока не произойдет очистка интервала. Поэтому помните, что необходимо своевременно прописывать:

clearInterval(intervalId);

Особую важность это действие приобретает в SPA: даже если вы покидаете страницу, на которой нужен тот или иной интервал, его выполнение все равно продолжается в фоновом режиме.

Забытые коллбэки

Допустим, вы назначили наблюдателя onClick на кнопку, которую позже удалили за ненадобностью.

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

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))
  });
}

Когда вы удаляете любой из элементов выше, позаботьтесь и об его удалении из массива – иначе сборщик мусора не станет обрабатывать соответствующие DOM-элементы.

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);
 });
}


// Удаление элемента из массива позволяет 
// синхронизировать последний с DOM.

Так как каждый элемент DOM, ко всему прочему, содержит ссылку на родительский узел, своими действиями вы можете помешать сборщику мусора освободить память, занятую детьми и родителем элемента.

Заключение

Итак, в статье были кратко описаны ключевые понятия, положенные в основу управления памятью в JavaScript. В ходе подготовки материала мне удалось разобраться с несколькими принципами, четкого представления о которых у меня раньше не было, – надеюсь, и вы почерпнете из этого обзора что-нибудь полезное для себя.

Перевод статьи «JavaScript's Memory Management Explained»