Как избежать утечки памяти в JavaScript

Автор: Сампат Гаджавада✏️

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

В этой статье мы рассмотрим управление памятью, типы утечек памяти и поиск утечек памяти в JavaScript с помощью Chrome DevTools. Давайте начнем!

  • Что такое утечки памяти?
  • Жизненный цикл памяти
  • Распределение памяти
    • Стек
    • Куча
  • Сборщик мусора
    • Подсчет ссылок
    • Алгоритм маркировки и подметания
  • Типы утечек памяти
  • Необъявленные или случайные глобальные переменные
  • Закрытия
  • Забытые таймеры
  • Ссылка вне DOM
  • Выявление утечек памяти с помощью Chrome DevTools
    • Визуализация потребления памяти с помощью профилировщика производительности
    • Определение отсоединенных узлов DOM

Что такое утечки памяти?

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

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

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

В любом языке программирования жизненный цикл памяти состоит из трех этапов:

  1. Выделение памяти: операционная система выделяет программе память во время выполнения по мере необходимости.
  2. Использование памяти: ваша программа использует ранее выделенную память. Ваша программа может выполнять над памятью действия read и write.
  3. Освободить память: как только ваша задача завершена, выделенная память освобождается и становится свободной. В языках высокого уровня, таких как 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, а employeeperson, поэтому эти объекты ссылаются друг на друга:

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. Мы сосредоточимся на двух важных аспектах нашей повседневной жизни как разработчиков:

  1. Визуализация потребления памяти с помощью профилировщика производительности
  2. Определение отсоединенных узлов 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 никогда не будет таким простым!

Попробуйте бесплатно.

Оцените статью
Procodings.ru
Добавить комментарий