Типы утечек памяти в JavaScript и как от них избавиться
Введение
Утечка памяти - проблема, с которой рано или поздно приходится сталкиваться каждому разработчику. Даже при работе с языками, управляемыми памятью, бывают случаи утечки памяти. Утечки являются причиной целого класса проблем: замедление, сбоев, больших задержек и даже проблем с другими приложениями.
Что такое утечка памяти?
По сути, утечки памяти можно определить как память, которая больше не требуется приложению и по какой-то причине не возвращается в операционную систему или в пул свободной памяти. Языки программирования отдают предпочтение различным способам управления памятью. Эти способы могут снизить вероятность утечки памяти.
Но только разработчики могут дать понять, можно ли вернуть часть памяти операционной системы или нет. Некоторые языки программирования предоставляют функции, которые помогают разработчикам в этом. Другие же ожидают, что разработчики будут полностью четко указывать, когда часть памяти не используется.
Управление памятью в JavaScript
JavaScript- это один из так называемых языков со сборкой мусора. Языки со сборщиков мусора помогают разработчикам управлять памятью, периодически проверяя, как ранее выделенные участки памяти все еще могут быть “доступны” из других частей приложения. Другими словами, языки со сборщиком мусора уменьшают проблему управления памятью с точки зрения “какая память все еще требуется?” на “какая память еще может быть доступна из других частей приложения?” Разница тонкая, но важная: пока только разработчик знает, потребуется ли кусок выделенной памяти может быть определена алгоритмически и помечена для возврата ОС.
Утечки в JavaScript
Основной причиной утечек в языках со сборщиком мусора являются нежелательные ссылки. Чтобы понять, что такое нежелательные ссылки, сначала нам нужно понять, как сборщик мусора определяет, доступен ли участок памяти или нет.
Отметить и развернуть
Большинство сборщиков мусора используют алгоритм, известна как пометка и очистка. Алгоритм состоит из следующих шагов:
- Сборщик мусора строит список “корней”. Корни обычно представляют собой глобальные переменные, ссылки на которые хранятся в коде. В JavaScript объект “окно” является примером глобальной переменной, которая может действовать как корень. Объект окна присутствует всегда, поэтому сборщик мусора может считать его и все его дочерние элементы всегда присутствующими( т.е. не мусором).
- Все корни проверяются и помечаются как активные(т.е не мусорные). Все дети также проверяются рекурсивно. Все, до чего можно добраться из корня, мусором не считается.
- Все участки памяти, не помеченные как активные, теперь можно считать мусором. Сборщик теперь может освободить эту память и вернуть ее ОС.
Современные сборщики мусора по-разному улучшают этот алгоритм, но суть одна: доступные участки памяти помечаются как таковые, а остальная часть считается мусором.
Нежелательные ссылки- это ссылки на фрагменты памяти, которые, как известно разработчику, ему больше не понадобятся, но которые по какой-то причине хранятся внутри дерева активного корня. В контексте JavaScript нежелательные ссылки- это переменные, хранящиеся где-то в коде, которые больше не будут использоваться и указывающие на часть памяти, которую в противном случае можно было бы освободить. Кто-то скажет, что это ошибки разработчиков.
Таким образом, чтобы понять какие утечки в JavaScript являются более распространенными, нам нужно знать, как обычно забываются ссылки.
Три типа распространенных утечек JavaScript
- Случайные глобальные переменные
Одной из целей JavaScript была разработка языка, который выглядел бы как Java, но был бы достаточно либеральным, чтобы его могли использовать новички. Одним из способов разрешающей способности JavaScript является то, как он обрабатывает необъявленные переменные: ссылка на необъявленную переменную создает новую переменную внутри глобального объекта. В случае браузеров глобальным объектов является window. Другими словами:
function foo(arg) {
bar = "this is a hidden global variable";
}
На самом деле:
function foo(arg) {
window.bar = "this is an explicit global variable";
}
Если bar предполагалось, что ссылка на переменную
будет храниться только внутри области действие foo функции, и вы забыли использовать var ее для объявления, создается непредвиденная глобальная переменная. В этом примере утечка простой строки не принесет большого вреда, но определенно может быть хуже.
Другой способ создания случайной глобальной переменной:
function foo() {
this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
Чтобы предотвратить эти ошибки, добавьте 'use strict'; в начале ваших файлов JavaScript. Это включает более строгий режим разбора JavaScript, который предотвращает случайные глобальные переменные.
- Примечания глобальных переменных
Несмотря на то, что мы говорим о неожиданных глобальных переменных, большая часть кода замусорена явными глобальными переменными. Они по определению не подлежат сбору (если только они не обнулены или не переназначены). В частности, вызывают озабоченность глобальные переменные, используемые для временного хранения и обработки больших объемов информации. Если вы должны использовать глобальную переменную для хранения большого количества данных, обязательно обнулите ее или переназначьте ее после того, как вы закончите с ней. Одной из частых причин повышенного потребления памяти в связи с глобальными переменными являются кеши). Кэши хранят данные, которые повторно используются. Чтобы это было эффективно, кэши должны иметь верхнюю границу своего размера. Кэши, которые растут без ограничений, могут привести к высокому потреблению памяти, поскольку их содержимое невозможно собрать.
2. Забытые таймеры или обратные вызовы
Использование setInterval довольно распространено в JavaScript. Другие библиотеки предоставляют наблюдателей и другие средства, которые принимают обратные вызовы. Большинство этих библиотек позаботятся о том, чтобы сделать любые ссылки на обратный вызов недоступными после того, как их собственные экземпляры также станут недоступными. Однако в случае setInterval такой код довольно распространен:
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// Do stuff with node and someResource.
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
Этот пример иллюстрирует, что может произойти с оборванными таймерами: таймерами, которые ссылаются на узлы или данные, которые больше не требуются. Объект, представленный узлом, может быть удален в будущем, что сделает ненужным весь блок внутри интервального обработчика. Однако обработчик, так как интервал все еще активен, не может быть собран (для этого интервал должен быть остановлен). Если обработчик интервала не может быть собран, его зависимости также не могут быть собраны. Это означает, что someResource, который предположительно хранит значительные данные, также не может быть собран.
В случае наблюдателей важно делать явные вызовы для их удаления, когда они больше не нужны (или связанный объект вот-вот станет недоступным). В прошлом это было особенно важно, поскольку некоторые браузеры (Internet Explorer 6) не могли хорошо управлять циклическими ссылками (дополнительную информацию об этом см. ниже). В настоящее время большинство браузеров могут и будут собирать обработчики наблюдателей, как только наблюдаемый объект становится недостижимым, даже если приемник явно не удален. Однако хорошей практикой остается явное удаление этих наблюдателей перед удалением объекта. Например:
var element = document.getElementById('button');
function onClick(event) {
element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.
Примечание о наблюдателях объектов и циклических ссылках
Наблюдатели и циклические ссылки раньше были бичом разработчиков JavaScript. Это произошло из-за ошибки (или конструктивного решения) в сборщике мусора Internet Explorer. Старые версии Internet Explorer не могли обнаруживать циклические ссылки между узлами DOM и кодом JavaScript. Это типично для наблюдателя, который обычно сохраняет ссылку на наблюдаемое (как в примере выше). Другими словами, каждый раз, когда наблюдатель добавлялся к узлу в Internet Explorer, это приводило к утечке. По этой причине разработчики начали явно удалять обработчики перед узлами или обнулять ссылки внутри наблюдателей. В настоящее время современные браузеры (включая Internet Explorer и Microsoft Edge) используют современные алгоритмы сборки мусора, которые могут обнаруживать эти циклы и корректно с ними справляться. Другими словами, нет строгой необходимости вызывать removeEventListener перед тем, как сделать узел недоступным.
Фреймворки и библиотеки, такие как jQuery, удаляют прослушиватели перед удалением узла (при использовании для этого своих конкретных API). Это обрабатывается внутри библиотек и гарантирует отсутствие утечек, даже при запуске в проблемных браузерах, таких как старый Internet Explorer.
3: Вне ссылок DOM
Иногда может быть полезно хранить узлы DOM внутри структур данных. Предположим, вы хотите быстро обновить содержимое нескольких строк в таблице. Может иметь смысл хранить ссылку на каждую строку DOM в словаре или массиве. Когда это происходит, сохраняются две ссылки на один и тот же элемент DOM: одна в дереве DOM, а другая в словаре. Если в какой-то момент в будущем вы решите удалить эти строки, вам нужно сделать обе ссылки недоступными.
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
// Much more logic
}
function removeButton() {
// The button is a direct child of body.
document.body.removeChild(document.getElementById('button'));
// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.
}
Дополнительное соображение для этого связано со ссылками на внутренние или конечные узлы внутри дерева DOM. Предположим, вы храните ссылку на определенную ячейку таблицы (тег <td>) в своем коде JavaScript. В какой-то момент в будущем вы решите удалить таблицу из DOM, но сохраните ссылку на эту ячейку. Интуитивно можно предположить, что сборщик мусора соберет все, кроме этой ячейки. На практике этого не произойдет: ячейка является дочерним узлом этой таблицы, а дочерние элементы сохраняют ссылки на своих родителей. Другими словами, ссылка на ячейку таблицы из кода JavaScript приводит к тому, что вся таблица остается в памяти. Учитывайте это внимательно при сохранении ссылок на элементы DOM.
4: Закрытие
Ключевым аспектом разработки JavaScript являются замыкания: анонимные функции, которые захватывают переменные из родительских областей. Разработчики Meteor обнаружили частный случай, когда из-за деталей реализации среды выполнения JavaScript возможна незаметная утечка памяти:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
Этот фрагмент делает одну вещь: каждый раз, когда replaceThing вызывается, theThing получает новый объект, который содержит большой массив и новое замыкание (someMethod). В то же время переменная unused содержит замыкание, имеющее ссылку на originalThing (вещь из предыдущего вызова replaceThing). Уже несколько запутанно, да? Важно то, что после создания области для замыканий, находящихся в одной и той же родительской области, эта область становится общей. В этом случае область, созданная для закрытия someMethod, разделяется неиспользуемыми. unused имеет ссылку на originalThing. Несмотря на то, что неиспользованное никогда не используется, некоторые Методы можно использовать через Вещь. А так как someMethod делит область закрытия с unused, даже если unused никогда не используется, его ссылка на originalThing заставляет его оставаться активным (предотвращает его сбор). При повторном запуске этого фрагмента можно наблюдать постоянное увеличение использования памяти. Это не становится меньше, когда GC работает. По сути, создается связанный список замыканий (с корнем в виде переменной theThing), и области действия каждого из этих замыканий содержат косвенную ссылку на большой массив, что приводит к значительной утечке.