Memory leaks

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

Независимо от языка программирования, жизненный цикл памяти практически всегда один и тот же:

  1. Выделение необходимой памяти.

  2. Её использование (чтение, запись).

  3. Освобождение выделенной памяти, когда в ней более нет необходимости.

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

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

Алгоритм пометок (Mark-and-sweep)

Большинство сборщиков мусора используют алгоритм пометок (mark-and-sweep):

  1. Сборщик мусора строит список «корневых объектов», или «корней». Как правило ими становятся объявленные в коде глобальные переменные. В JavaScript типичный корень — объект window. Так как window существует на протяжении всей работы страницы, сборщик мусора поймёт, что этот объект и его потомки всегда будут присутствовать в среде исполнения программы (т.е. не станут мусором).

  2. Сборщик рекурсивно обходит корни и их потомков, помечая их как активные (т.е. не мусор). Всё, до чего можно добраться из корня, не рассматривается в качестве мусора.

  3. После второго шага фрагменты памяти, не помеченные как активные, могут считаться мусором. Теперь сборщик может освободить эту память и вернуть в ОС.

Современные сборщики мусора улучшают этот алгоритм, но его суть остаётся прежней: пометить достижимые фрагменты памяти, а остальное объявить мусором. Теперь можно дать определение нежелательным ссылкам — это ссылки, достижимые из корня, но ссылающиеся на фрагменты памяти, которые точно никогда больше не понадобятся. В JavaScript нежелательными ссылками станут потерявшие актуальность переменные, забытые в коде, удерживающие в памяти ненужные более объекты. Кстати, некоторые считают, что это ошибки разработчиков, а не языка.

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

Четыре самых распространённых вида утечек памяти в JavaScript:

1: Случайные глобальные переменные

function foo(arg) {
    bar = "скрытая глобальная переменная";
}
function foo() {
    this.variable = "potential accidental global";
}

// Если foo вызвать саму по себе, this будет указывать 
// на глобальный объект (window), 
// вместо того, чтобы быть undefined.
foo();

В данном случае утечку памяти создаст простая строка. Много вреда это не причинит, но, конечно, ситуация могла бы быть намного хуже. Чтобы избежать подобных ошибок, добавляйте 'use strict'; в начало JavaScript-файлов. Это директива, включающая строгий режим парсинга JavaScript, препятствующий возникновению случайных глобальных переменных.

Замечание о глобальных переменных

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

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

2. Забытые таймеры или забытые коллбэки

В JS-программах использование функции setInterval — обычное явление.

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

Объект, представленный переменной renderer, может быть, в будущем, удалён, что сделает весь блок кода внутри обработчика события срабатывания таймера ненужным. Однако, обработчик нельзя уничтожить, освободив занимаемую им память, так как таймер всё ещё активен. Таймер, для очистки памяти, надо остановить. Если сам таймер не может быть подвергнут операции сборки мусора, это будет касаться и зависимых от него объектов. Это означает, что память, занятую переменной serverData, которая, надо полагать, хранит немалый объём данных, так же нельзя очистить.

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

Рассмотрим теперь ситуацию с обработчиками событий. Обработчики следует удалять, когда они становятся не нужны, или ассоциированные с ними объекты становятся недоступны. В прошлом это было критично, так как некоторые браузеры (Internet Explorer 6) не умели грамотно обрабатывать циклические ссылки (см. заметку ниже). Большинство современных браузеров удаляет обработчики событий, как только объекты становятся недостижимы. Однако по-прежнему правилом хорошего тона остаётся явное удаление обработчиков событий перед удалением самого объекта. Например:

var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// Какие-то действия.
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Теперь, когда элемент удаляется из области видимости,
// сборщиком будут очищены и сам элемент, и onClick.
// Даже если код работает в старом браузере, 
// не умеющем правильно обрабатывать такие циклы.

Заметка об обработчиках событий и циклических ссылках

Обработчики событий и циклические ссылки издавна считались проблемой JavaScript-разработчиков. Это было связано с ошибкой (или дизайнерским решением) сборщика мусора в Internet Explorer. Старые версии Internet Explorer не могли обнаружить циклические ссылки между DOM-элементами и JavaScript кодом. Добавим к этому, что в обработчиках событий обычно содержится ссылка на объект события (как в примере выше). Это означает, что каждый раз, когда в Internet Explorer на DOM-узел добавлялся слушатель, возникала утечка памяти. Поэтому веб-разработчики начали явно удалять обработчики событий до удаления DOM-узлов или обнулять ссылки внутри обработчиков. Современные браузеры (включая Internet Explorer и Microsoft Edge) используют алгоритмы, находящие циклические ссылки и правильно их обрабатывающие. Теперь не обязательно вызывать removeEventListener перед удалением узла.

Если два объекта ссылаются друг на друга (создавая таким образом циклическую ссылку), они не могут быть уничтожены сборщиком мусора, даже если "более не нужны".

3. Замыкания

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

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

Например:

  • Обычно объект переменных удаляется по завершении работы функции. Даже если в нём есть объявление внутренней функции:

    function f() {
      var value = 123;
    
      function g() {} // g видна только изнутри
    }
    
    f();

    В коде выше value и g являются свойствами объекта переменных. Во время выполнения f() её объект переменных находится в текущем стеке выполнения, поэтому жив. По окончанию, он станет недостижимым и будет убран из памяти вместе с остальными локальными переменными.

  • …А вот в этом случае лексическое окружение, включая переменную value, будет сохранено:

    function f() {
      var value = 123;
    
      function g() {}
    
      return g;
    }
    
    var g = f(); // функция g будет жить и сохранит ссылку на объект переменных

    В скрытом свойстве g.[[Scope]] находится ссылка на объект переменных, в котором была создана g. Поэтому этот объект переменных останется в памяти, а в нём – и value.

  • Если f() будет вызываться много раз, а полученные функции будут сохраняться, например, складываться в массив, то будут сохраняться и объекты LexicalEnvironment с соответствующими значениями value:

    function f() {
      var value = Math.random();
    
      return function() { return value; };
    }
    
    // 3 функции, каждая ссылается на свой объект переменных,
    // каждый со своим значением value
    var arr = [f(), f(), f()];
  • Объект LexicalEnvironment живёт ровно до тех пор, пока на него существуют ссылки. В коде ниже после удаления ссылки на g умирает:

    function f() {
      var value = 123;
    
      function g() {}
    
      return g;
    }
    
    var g = f(); // функция g жива
    // а значит в памяти остаётся соответствующий объект переменных f()
    
    g = null; // ..а вот теперь память будет очищена

4. Ссылки на удалённые из DOM элементы

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

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    elements.image.src = 'http://some.url/image';
    elements.button.click();
    console.log(elements.text.innerHTML);
    // Остальная логика.
}

function removeButton() {
    // Кнопка находится непосредственно в body.
    document.body.removeChild(document.getElementById('button'));

    // В этом случае мы всё равно ссылаемся на #button 
    // в глобальном объекте elements. 
    // Т.е. кнопка до сих пор находится в памяти 
    // и не может быть удалена сборщиком мусора.
}

Last updated