Автор: Сампат Гаджавада✏️
Если в вашем приложении JavaScript наблюдаются частые сбои, высокая задержка и низкая производительность, одной из потенциальных причин могут быть утечки памяти. Управление памятью часто игнорируется разработчиками из-за неправильного представления об автоматическом распределении памяти движками JavaScript, что приводит к утечкам памяти и, в конечном счете, к низкой производительности.
В этой статье мы рассмотрим управление памятью, типы утечек памяти и поиск утечек памяти в JavaScript с помощью Chrome DevTools. Давайте начнем!
- Что такое утечки памяти?
- Жизненный цикл памяти
- Распределение памяти
- Стек
- Куча
- Сборщик мусора
- Подсчет ссылок
- Алгоритм маркировки и подметания
- Типы утечек памяти
- Необъявленные или случайные глобальные переменные
- Закрытия
- Забытые таймеры
- Ссылка вне DOM
- Выявление утечек памяти с помощью Chrome DevTools
- Визуализация потребления памяти с помощью профилировщика производительности
- Определение отсоединенных узлов DOM
- Что такое утечки памяти?
- Жизненный цикл памяти
- Распределение памяти
- Стек
- Heap
- Сборщик мусора
- Подсчет ссылок
- Алгоритм Mark-and-sweep
- Типы утечек памяти
- Необъявленные или случайные глобальные переменные
- Замыкания
- Забытые таймеры
- Ссылка вне DOM
- Выявление утечек памяти с помощью Chrome DevTools
- Визуализация потребления памяти с помощью профилировщика производительности
- Определение отсоединенных узлов DOM
- Заключение
- LogRocket: Отлаживайте ошибки JavaScript проще, понимая контекст
Что такое утечки памяти?
Простыми словами, утечка памяти – это выделенный участок памяти, который движок JavaScript не может вернуть. Движок JavaScript выделяет память, когда вы создаете объекты и переменные в своем приложении, и он достаточно умен, чтобы очистить память, когда объекты больше не нужны. Утечки памяти возникают из-за недостатков в вашей логике, и они приводят к низкой производительности вашего приложения.
Прежде чем перейти к рассмотрению различных типов утечек памяти, давайте составим четкое представление об управлении памятью и сборке мусора в JavaScript.
Жизненный цикл памяти
В любом языке программирования жизненный цикл памяти состоит из трех этапов:
- Выделение памяти: операционная система выделяет программе память во время выполнения по мере необходимости.
- Использование памяти: ваша программа использует ранее выделенную память. Ваша программа может выполнять над памятью действия
read
иwrite
. - Освободить память: как только ваша задача завершена, выделенная память освобождается и становится свободной. В языках высокого уровня, таких как JavaScript, освобождением памяти занимается сборщик мусора.
Если вы понимаете, как происходит выделение и освобождение памяти в JavaScript, вам будет очень легко решить проблему утечки памяти в вашем приложении.
Распределение памяти
В JavaScript есть два варианта хранения данных для распределения памяти. Первый – это стек, а второй – куча. Все примитивные типы, такие как number
, Boolean
, или undefined
будут храниться на стеке. Куча – это место для ссылочных типов, таких как объекты, массивы и функции.
Стек
Стек использует подход LIFO для выделения памяти. Все примитивные типы, такие как number
, Boolean
, и undefined
могут храниться в стеке:
Heap
Ссылочные типы, такие как объекты, массивы и функции, хранятся на куче. Размер ссылочных типов не может быть определен во время компиляции, поэтому память выделяется на основе использования объектов. Ссылка на объект хранится в стеке, а сам объект – в куче:
На рисунке выше переменная otherStudent
создается путем копирования переменной student. В этом сценарии otherStudent
создается на стеке, но указывает на ссылку student на куче.
Мы видели, что основная проблема при распределении памяти в цикле памяти заключается в том, когда освободить выделенную память и сделать ее доступной для других ресурсов. В этом сценарии на помощь приходит сборка мусора.
Сборщик мусора
Основной причиной утечки памяти в приложении являются нежелательные ссылки. Сборщик мусора находит память, которая больше не используется программой, и возвращает ее операционной системе для дальнейшего распределения.
Чтобы понять, что такое нежелательная ссылка, сначала нужно получить представление о том, как сборщик мусора определяет, что часть памяти недоступна. Сборка мусора использует два основных алгоритма для поиска нежелательных ссылок и недоступного кода: подсчет ссылок и mark-and-sweep.
Подсчет ссылок
Алгоритм подсчета ссылок ищет объекты, на которые нет ссылок. Объект может быть освобожден, если на него указывает ноль ссылок.
Давайте лучше поймем это на примере ниже. Есть три переменные, student
, otherStudent
, которая является копией student, и sports
, которая берет массив видов спорта из объекта student
:
let student = {
name: 'Joe',
age: 15,
sports: ['soccer', 'chess']
}
let otherStudent = student;
const sports = student.sports;
student = null;
otherStudent = null;
В приведенном выше фрагменте кода мы присвоили переменным student
и otherStudent
значения nulls, что говорит нам об отсутствии ссылок на эти объекты. Память, выделенная для них в куче, выделенная красным цветом, может быть легко освобождена, так как она имеет нулевые ссылки.
С другой стороны, у нас есть еще один блок памяти в куче, который не может быть освобожден, потому что в нем есть ссылка на объект sports
.
Когда два объекта ссылаются сами на себя, возникает проблема с алгоритмом подсчета ссылок. Проще говоря, если есть циклические ссылки, этот алгоритм не может определить свободные объекты.
В приведенном ниже примере person
был присвоен employee
, а employee
– person
, поэтому эти объекты ссылаются друг на друга:
let person = {
name: 'Joe'
};
let employee = {
id: 123
};
person.employee = employee;
employee.person = person;
person = null;
employee = null;
После создания этих объектов null
, они потеряют ссылку на стеке, но объекты все еще остаются на куче, поскольку у них есть циклическая ссылка. Алгоритм ссылок не может освободить эти объекты, так как у них есть ссылка. Проблема циклических ссылок может быть решена с помощью алгоритма mark-and-sweep.
Алгоритм Mark-and-sweep
Алгоритм mark-and-sweep сводит определение ненужного объекта к определению недостижимого объекта. Если объект недостижим, алгоритм считает этот объект ненужным:
Алгоритм mark-and-sweep состоит из двух шагов. Во-первых, в JavaScript корень – это глобальный объект. Сборщик мусора периодически начинает с корня и находит все объекты, на которые есть ссылки из корня. Он пометит все доступные объекты active
. Затем сборщик мусора освобождает память для всех объектов, которые не помечены как active
, возвращая память операционной системе.
Типы утечек памяти
Мы можем предотвратить утечки памяти, понимая, как создаются нежелательные ссылки в JavaScript. Следующие сценарии вызывают нежелательные ссылки.
Необъявленные или случайные глобальные переменные
Одним из способов, с помощью которых JavaScript является разрешительным, является способ обработки необъявленных переменных. Ссылка на необъявленную переменную создает новую переменную внутри глобального объекта. Если вы создадите переменную без какой-либо ссылки, ее корнем будет глобальный объект.
Как мы только что видели в алгоритме mark-and-sweep, ссылки, непосредственно указывающие на корень, всегда активны
, и сборщик мусора не может их очистить, что приводит к утечке памяти:
function foo(){
this.message = 'I am accidental variable';
}
foo();
В качестве решения, попробуйте обнулять эти переменные после использования, или добавьте use strict
, чтобы включить более строгий режим JavaScript, который предотвращает случайные глобальные переменные.
Замыкания
Замыкание – это комбинация функций, объединенных вместе или окруженных ссылками на окружающее их состояние, лексическое окружение. Проще говоря, закрытие – это внутренняя функция, которая имеет доступ к области видимости внешней функции.
Переменные в области видимости функции очищаются после выхода функции из стека вызовов, в то время как закрытие сохраняет ссылки на переменные внешней области видимости после своего выполнения. Переменные внешней области видимости остаются в памяти, даже если они не используются, поэтому это распространенная причина утечек памяти:
function outer(){
const largeArray = []; // unused array
return function inner(num){
largeArray.push(num);
}
}
const appendNumbers = outer(); // get the inner function
// call the inner function repeatedly
for (let i=0; i< 100000000; i++){
appendNumbers(i);
}
В приведенном выше примере largeArray
никогда не возвращается и не может быть достигнут сборщиком мусора, значительно увеличивая свой размер за счет повторных вызовов внутренних функций, что приводит к утечке памяти.
Закрытия неизбежны, поэтому убедитесь, что переменные во внешней области видимости либо используются, либо возвращаются.
Забытые таймеры
setTimeout
и setInterval
– это два события тайминга, доступные в JavaScript. Функция setTimeout
выполняется по истечении заданного времени, тогда как setInterval
выполняется многократно в течение заданного интервала времени. Эти таймеры являются наиболее распространенной причиной утечек памяти.
Если мы установим повторяющийся таймер в нашем коде, ссылка на объект из обратного вызова таймера останется активной до тех пор, пока таймер не остановится:
function generateRandomNumbers(){
const numbers = []; // huge increasing array
return function(){
numbers.push(Math.random());
}
}
setInterval((generateRandomNumbers(), 2000));
В приведенном выше примере generateRandomNumbers
возвращает функцию, которая добавляет случайные числа в массив чисел внешней области видимости. При использовании setInterval
для этой функции, она периодически вызывает указанный интервал, что приводит к огромному размеру массива чисел.
Для решения этой проблемы лучшая практика требует предоставления ссылок внутри вызовов setTimeout
или setInterval
. Затем сделайте явный вызов для очистки таймеров. Для приведенного выше примера решение приведено ниже:
const timer = setInterval(generateRandomNumbers(), 2000); // save the timer
// on any event like button click or mouse over etc
clearInterval(timer); // stop the timer
Ссылка вне DOM
Out of DOM reference указывает на узлы, которые были удалены из DOM, но все еще доступны в памяти. Сборщик мусора не может освободить эти объекты DOM, поскольку они ссылаются на память графа объектов. Давайте разберемся в этом на примере ниже:
let parent = document.getElementById("#parent");
let child = document.getElementById("#child");
parent.addEventListener("click", function(){
child.remove(); // removed from the DOM but not from the object memory
});
В приведенном выше коде мы удалили дочерний элемент из DOM по щелчку родителя, но переменная child все еще хранит память, потому что слушатель событий всегда активен
, и в нем хранится ссылка на child. По этой причине сборщик мусора не может освободить дочерний объект и будет продолжать потреблять память.
Вы всегда должны отменять регистрацию слушателей событий, когда они больше не нужны, создавая ссылку на слушатель событий и передавая ее в метод removeEventListener
:
function removeChild(){
child.remove();
}
parent.addEventListener("click", removeChild);
// after completing required action
parent.removeEventListener("click", removeChild);
Выявление утечек памяти с помощью Chrome DevTools
Отладка проблем с памятью – действительно сложная работа, но мы можем определить график памяти и несколько утечек памяти с помощью Chrome DevTools. Мы сосредоточимся на двух важных аспектах нашей повседневной жизни как разработчиков:
- Визуализация потребления памяти с помощью профилировщика производительности
- Определение отсоединенных узлов DOM.
Визуализация потребления памяти с помощью профилировщика производительности
В качестве примера рассмотрим следующий фрагмент кода. Есть две кнопки, Print Numbers
и Clear
. При нажатии на кнопку Print Numbers, числа от 1
до 10,000
добавляются в DOM путем создания узлов параграфа и выталкивания некоторых огромных строк в глобальную переменную.
Кнопка Очистить очистит глобальную переменную и переопределит тело документа, но не удалит узлы, созданные при нажатии кнопки Печать:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Memory leaks</title>
</head>
<body>
<button id="print">Print Numbers</button>
<button id="clear">Clear</button>
</body>
</html>
<script>
var longArray = [];
function print() {
for (var i = 0; i < 10000; i++) {
let paragraph = document.createElement("p");
paragraph.innerHTML = i;
document.body.appendChild(paragraph);
}
longArray.push(new Array(1000000).join("y"));
}
document.getElementById("print").addEventListener("click", print);
document.getElementById("clear").addEventListener("click", () => {
window.longArray = null;
document.body.innerHTML = "Cleared";
});
</script>
Анализируя скриншот ниже, который является графиком производительности для приведенного выше фрагмента кода, мы видим, что куча JavaScript, окрашенная в синий цвет, увеличивается при каждом нажатии кнопки Print. Эти скачки естественны, поскольку JavaScript создает узлы DOM и добавляет символы в глобальный массив.
Куча JavaScript постепенно увеличивалась при каждом нажатии кнопки Print и стала нормальной после нажатия кнопки Clear. В реальном сценарии можно считать, что произошла утечка памяти, если наблюдается постоянный всплеск памяти, и если потребление памяти не уменьшается.
С другой стороны, мы можем наблюдать непрерывное увеличение количества узлов, показанное зеленым графиком, поскольку мы их не удаляли:
Определение отсоединенных узлов DOM
Как мы обсуждали ранее, узел считается отделенным, когда он удален из дерева DOM, но некоторый код JavaScript все еще ссылается на него.
Давайте проверим отсоединенные узлы DOM с помощью приведенного ниже фрагмента кода. Нажатием кнопки мы можем добавить элементы списка к его родителю и присвоить родителя глобальной переменной. Проще говоря, глобальная переменная хранит ссылки на DOM:
var detachedElement;
function createList(){
let ul = document.createElement("ul");
for(let i=0; i<5; i++){
ul.appendChild(document.createElement("li"));
}
detachedElement = ul;
}
document.getElementById("createList").addEventListener("click", createList);
Мы можем использовать снимок кучи для обнаружения отсоединенных узлов DOM. Перейдите в Chrome DevTools → Memory → Heap Snapshot → Take Snapshot:
Как только кнопка будет нажата, сделайте снимок. Вы можете найти отсоединенные узлы DOM, отфильтровав Detached
в разделе сводки, как показано ниже:
Мы исследовали отсоединенные узлы DOM с помощью Chrome DevTools. Вы можете попробовать определить другие утечки памяти, используя этот метод.
Заключение
В этом руководстве мы узнали об утечках памяти, их предотвращении и поиске утечек памяти с помощью Chrome DevTools.
Утечки памяти часто возникают из-за недостатков в вашей логике. Избежав всех возможных утечек, можно значительно повысить производительность приложения и сэкономить память. Надеюсь, вам понравилось это руководство, и удачного кодинга!
LogRocket: Отлаживайте ошибки JavaScript проще, понимая контекст
Отладка кода – это всегда утомительное занятие. Но чем больше вы понимаете ошибки, тем легче их исправить.
LogRocket позволяет понять эти ошибки новыми и уникальными способами. Наше решение для мониторинга фронтенда отслеживает взаимодействие пользователей с вашими JavaScript-фронтендами, чтобы дать вам возможность узнать, что именно пользователь сделал, что привело к ошибке.
LogRocket записывает журналы консоли, время загрузки страниц, трассировку стека, медленные сетевые запросы/ответы с заголовками + телами, метаданные браузера и пользовательские журналы. Понимание влияния вашего кода JavaScript никогда не будет таким простым!
Попробуйте бесплатно.