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

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

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


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

Собственно, выделением памяти можно назвать процесс резервирования определенного объема памяти. Ну а освобождение ее — это возвращение резерва системе. Использовать ее можно повторно сколько угодно раз.

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



Здесь в блоках:

  • Allocate — выделение памяти, что делает движок. Он выделяет память, которая требуется для созданного объекта.
  • Use — использование памяти. За этот момент отвечает разработчик, прописывая в коде чтение и запись в память.
  • Release — освобождение памяти. Здесь снова наступает «зона ответственности» JavaScript. После того, как резерв высвобожден, память можно использовать и для других целей.

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

Стек памяти и куча


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

Есть два варианта — в стеке (stack) памяти и в куче (heap). Что первое, что второе — название структур данных, которые используются движком для разных целей.

Стек (stack) — это статическое выделение памяти



Определение стека известно многим. Это структура данных, которая используется для хранения статических данных, их размер всегда известен во время компиляции. В JS сюда включили примитивные значения, например string, number, boolean, undefined и null, а также ссылки на функции и объекты.

Движок «понимает», что размер данных не меняется, поэтому выделяет фиксированный объем памяти для каждого из значений. Процесс выделения памяти до исполнения называется статическим выделением памяти (static memory allocation).

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

Куча (heap) — динамическое выделение памяти

Что касается кучи, то она знакома многим не хуже, чем стек. Используется она для хранения объектов и функций.

Но в отличие от стека движок не может «знать», какой объем памяти необходим для того либо иного объекта, поэтому память выделяется по мере необходимости. И этот способ выделения памяти называется «динамическим» (dynamic memory allocation).

Несколько примеров

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

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


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

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

JS сохраняет объекты и функции в куче. А вот примитивные значения и ссылки — в стеке.



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

Пример

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

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

В целом, ссылки крайне важны в JavaScript.

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


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

Движок JavaScript отвечает не только за выделение памяти, но и за освобождение. При этом память системе возвращает сборщик мусора (garbage collector).

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

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

Правда, есть просто хорошо работающие алгоритмы, которые позволяют делать это. Они не идеальны, но все же гораздо лучше, чем многие другие. Ниже — рассказ о сборке мусора, которая основана на подсчете ссылок, а также об «алгоритме пометок».

Что там насчет ссылок?


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

Если вы просмотрели видео, то, вероятно, заметили, что 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.



Если объект недоступен, то алгоритм помечает его, после чего убирает. Корневые объекты при этом никогда не уничтожаются. Проблема циклических ссылок здесь не актуальна — алгоритм позволяет понять, что ни dad, ни son уже недоступны, поэтому их можно «вымести», а память — вернуть системе.

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

Не обошлось без недостатков и здесь



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

Использование большого объема памяти

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

Лучше JavaScript с управлением памятью справляются низкоуровневые языки. Но и здесь есть свои недостатки, что нужно иметь в виду. В частности, JS не даёт инструментов управления памятью, в отличие от низкоуровневые языков, в которых программист «вручную» занимается выделением и высвобождением памяти.

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

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

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

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


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

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

Утечки памяти чаще всего случаются из-за хранения данных в глобальных переменных.

В браузере, если ошибиться и использовать 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);

Эта функция выполняется каждые две секунды. Ее выполнение не должно быть бесконечным. Проблема в том, что объекты, на которые есть референс в интервале, не уничтожаются до тех пор, пока не выполняется очистка интервала. Поэтому нужно своевременно прописывать:
clearInterval(intervalId);

Забытый коллбек

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

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

const element = document.getElementById('button');
const onClick = () => alert('hi');
element.addEventListener('click', onClick);
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);


Забытые DOM-элементы в переменных

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

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

Удаляя элемент из массива вы актуализируете его содержимое с списком элементов на странице.

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

В сухом остатке


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

Комментирует Глеб Михеев, программный директор Frontend-направления образовательной платформы Skillbox:

Знание общих принципов выделения памяти важны практически с самого начала карьеры, потому что большую популярность сейчас получили веб-приложения (в прошлом их называли SPA — Single Page Applications).

Основной ключевой особенностью этих приложений является то, что они “живут во времени” — при переходе между страницами не происходит полного сброса состояния (технически страница не перезагружается, а меняется на лету), поэтому утечки памяти накапливаются, что может приводить к затормаживанию работы вкладки, браузера и компьютера пользователя.

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

Мы как разработчики должны следить за тем, как мы выделяем память при часто повторяемых операциях (рендеринг компонентов, обход циклов, объявления переменных в обработчиках событий). Потому что, если мы слишком часто будем объявлять переменные у нас будет постоянно выделяться новая память под хранение их значений, приложение будет “пухнуть” в памяти и как следствие — будет чаще срабатывать garbage collector.

Из-за этого мы будем испытывать постоянные микрофризы (js однопоточен, поэтому все процессы заблокируются на время работы garbage collector’a). Это сильно влияет на качество пользовательского опыта, и ухудшает качество приложений.