7 июня 2022 г.

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

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

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

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

  1. Приложение, в котором посетитель все время на одной странице и работает со сложным JavaScript-интерфейсом. В этом случае утечки могут постепенно съедать доступную память.
  2. Страница регулярно делает что-то, вызывающее утечку памяти. Посетитель (например, менеджер) оставляет компьютер на ночь включённым, чтобы не закрывать браузер с кучей вкладок. Приходит утром – а браузер съел всю память и рухнул и сильно тормозит.

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

Коллекция утечек в IE

Утечка DOM ↔ JS в IE8-

IE до версии 8 не умел очищать циклические ссылки, появляющиеся между DOM-объектами и объектами JavaScript. В результате и DOM и JS оставались в памяти навсегда.

В браузере IE8 была проведена серьёзная работа над ошибками, но утечка в IE8- появляется, если круговая ссылка возникает «через объект».

Чтобы было понятнее, о чём речь, посмотрите на следующий код. Он вызывает утечку памяти в IE8-:

function leak() {
  // Создаём новый DIV, добавляем к BODY
  var elem = document.createElement('div');
  document.body.appendChild(elem);

  // Записываем в свойство жирный объект
  elem.__expando = {
    bigAss: new Array(1000000).join('lalala')
  };

  // Создаём круговую ссылку. Без этой строки утечки не будет.
  elem.__expando.__elem = elem;

  // Удалить элемент из DOM. Браузер должен очистить память.
  elem.parentElement.removeChild(elem);
}

Полный пример (только для IE8-, а также IE9 в режиме совместимости с IE8):

Результат
index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=8">
</head>

<body>


  <script>
    // Утечка в IE8 standards mode, а также в IE9 в режиме IE8
    // См. http://blog.j15r.com/2009/07/memory-leaks-in-ie8.html
    function leak() {
      // Создаём новый DIV, добавляем к BODY
      var elem = document.createElement('div');
      document.body.appendChild(elem);

      // Записываем в свойство жирный объект
      elem.__expando = {
        bigAss: new Array(1000000).join('lalala')
      };

      // Создаём круговую ссылку. Без этой строки утечки не будет.
      elem.__expando.__elem = elem;

      // Удалить элемент из DOM. Браузер должен очистить память.
      elem.parentElement.removeChild(elem);
    }
  </script>

  <p>Нажимайте на кнопку и наблюдайте, как увеличивается количество занимаемой браузером памяти.</p>
  <button onclick="leak()">Создать утечку!</button>


</body>

</html>

Круговая ссылка и, как следствие, утечка может возникать и неявным образом, через замыкание:

function leak() {
  var elem = document.createElement('div');
  document.body.appendChild(elem);

  elem.__expando = {
    bigAss: new Array(1000000).join('lalala'),
    method: function() {} // создаётся круговая ссылка через замыкание
  };

  // Удалить элемент из DOM. Браузер должен очистить память.
  elem.parentElement.removeChild(elem);
}

Полный пример (IE8-, IE9 в режиме совместимости с IE8):

Результат
index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=8">
</head>

<body>


  <script>
    // Утечка в IE8 standards mode, а также в IE9 в режиме IE8
    // См. http://blog.j15r.com/2009/07/memory-leaks-in-ie8.html
    function leak() {
      // Создаём новый DIV, добавляем к BODY
      var elem = document.createElement('div');
      document.body.appendChild(elem);

      elem.__expando = {
        bigAss: new Array(1000000).join('lalala'),
        method: function() {} // создаётся круговая ссылка через замыкание
      };

      // Удалить элемент из DOM. Браузер должен очистить память.
      elem.parentElement.removeChild(elem);
    }
  </script>

  <p>Нажимайте на кнопку и наблюдайте, как увеличивается количество занимаемой браузером памяти.</p>
  <button onclick="leak()">Создать утечку!</button>

</body>

</html>

Без привязки метода method к элементу здесь утечки не возникнет.

Бывает ли такая ситуация в реальной жизни? Или это – целиком синтетический пример, для заумных программистов?

Да, конечно бывает. Например, при разработке графических компонент – бывает удобно присвоить DOM-элементу ссылку на JavaScript-объект, который представляет собой компонент. Это упрощает делегирование и, в общем-то, логично, что DOM-элемент знает о компоненте на себе. Но в IE8- прямая привязка ведёт к утечке памяти!

Примерно так:

function Menu(elem) {
  elem.onclick = function() {};
}

var menu = new Menu(elem); // Menu содержит ссылку на elem
elem.menu = menu; // такая привязка или что-то подобное ведёт к утечке в IE8

Полный пример (IE8-, IE9 в режиме совместимости с IE8):

Результат
index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=8">
</head>

<body>


  <script>
    // Утечка в IE8 standards mode, а также в IE9 в режиме IE8
    // См. http://blog.j15r.com/2009/07/memory-leaks-in-ie8.html
    function leak() {
      // Создаём новый DIV, добавляем к BODY
      var elem = document.createElement('div');
      document.body.appendChild(elem);

      // Записываем в свойство ссылку на объект
      var menu = new Menu(elem);
      elem.menu = menu;

      // Удалить элемент из DOM. Браузер должен очистить память.
      elem.parentElement.removeChild(elem);
    }

    function Menu(elem) {
      elem.onclick = function() {};
      this.bigAss = new Array(1000000).join('lalala');
    }
  </script>

  <p>Нажимайте на кнопку и наблюдайте, как увеличивается количество занимаемой браузером памяти.</p>
  <button onclick="leak()">Создать утечку!</button>


</body>

</html>

Утечка IE8 при обращении к коллекциям таблицы

Эта утечка происходит только в IE8 в стандартном режиме. В нём при обращении к табличным псевдо-массивам (напр. rows) создаются и не очищаются внутренние ссылки, что приводит к утечкам.

Также воспроизводится в новых IE в режиме совместимости с IE8.

Код:

var elem = document.createElement('div'); // любой элемент

function leak() {

  elem.innerHTML = '<table><tr><td>1</td></tr></table>';

  elem.firstChild.rows[0]; // просто доступ через rows[] приводит к утечке
  // при том, что мы даже не сохраняем значение в переменную

  elem.removeChild(elem.firstChild); // удалить таблицу (*)
  // alert(elem.childNodes.length) // выдал бы 0, elem очищен, всё честно
}

Полный пример (IE8):

Результат
index.html
<!DOCTYPE HTML>
<html>

<body>

  <script>
    var elem = document.createElement('div'); // любой элемент

    // Течёт в настоящем IE8, Standards Mode
    // Не течёт в других IE. Не течёт в IE9 в режиме совместимости с IE8
    function leak() {

      for (var i = 0; i < 2000; i++) {

        elem.innerHTML = '<table><tr><td>1</td></tr></table>';

        elem.firstChild.rows[0]; // просто доступ через rows[] приводит к утечке
        // при том, что мы даже без сохраненяем значение в переменную

        elem.removeChild(elem.firstChild); // удалить таблицу
        // elem.innerHTML = '' очистил бы память, он работает по-другому, см. главу "управление памятью"
      }

    }
  </script>


  <p>Нажимайте на кнопку и наблюдайте, как увеличивается количество занимаемой браузером памяти.</p>
  <button onclick="leak()">Создать утечку!</button>


</body>

</html>

Особенности:

  • Если убрать отмеченную строку, то утечки не будет.
  • Если заменить строку (*) на elem.innerHTML = '', то память будет очищена, т.к. этот способ работает по-другому, нежели просто removeChild (см. главу Управление памятью в JavaScript).
  • Утечка произойдёт не только при доступе к rows, но и к другим свойствам, например elem.firstChild.tBodies[0].

Эта утечка проявляется, в частности, при удалении детей элемента следующей функцией:

function empty(elem) {
  while (elem.firstChild) elem.removeChild(elem.firstChild);
}

Если идёт доступ к табличным коллекциям и регулярное обновление таблиц при помощи DOM-методов – утечка в IE8 будет расти.

Более подробно вы можете почитать об этой утечке в статье Утечки памяти в IE8, или страшная сказка со счастливым концом.

Утечка через XmlHttpRequest в IE8-

Следующий код вызывает утечки памяти в IE8-:

function leak() {
  var xhr = new XMLHttpRequest();

  xhr.open('GET', '/server.do', true);

  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      // ...
    }
  }

  xhr.send(null);
}

Как вы думаете, почему? Если вы внимательно читали то, что написано выше, то имеете информацию для ответа на этот вопрос…

Посмотрим, какая структура памяти создаётся при каждом запуске:

Когда запускается асинхронный запрос xhr, браузер создаёт специальную внутреннюю ссылку (internal reference) на этот объект и будет поддерживать её, пока он находится в процессе коммуникации. Именно поэтому объект xhr будет жив после окончания работы функции.

Когда запрос завершён, браузер удаляет внутреннюю ссылку, xhr становится недостижимым и память очищается… Везде, кроме IE8-.

Полный пример (IE8):

Результат
index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
</head>

<body>

  <p>Страница создаёт объект <code>XMLHttpRequest</code> каждые 50 мс.</p>

  <p>Нажмите на кнопку и смотрите на память, она течёт в IE&lt;9.</p>

  <button onclick="setInterval(leak, 50);">Запустить</button>

  <script>
    function leak() {
      var xhr = new XMLHttpRequest();

      xhr.open('GET', '?' + Math.random(), true);

      xhr.onreadystatechange = function() {
        if (xhr.readyState == 4 && xhr.status == 200) {
          document.getElementById('test').innerHTML++;
        }
      }

      xhr.send(null);
    }

  </script>

  <div>Количество запросов: <span id="test">0</span></div>

</body>

</html>

Чтобы это исправить, нам нужно разорвать круговую ссылку XMLHttpRequest ↔ JS. Например, можно удалить xhr из замыкания:

function leak() {
  var xhr = new XMLHttpRequest();

  xhr.open('GET', 'something.js?' + Math.random(), true);

  xhr.onreadystatechange = function() {
    if (xhr.readyState != 4) return;

    if (xhr.status == 200) {
      document.getElementById('test').innerHTML++;
    }

    xhr = null; // по завершении запроса удаляем ссылку из замыкания
  }

  xhr.send(null);
}

Теперь циклической ссылки нет – и не будет утечки.

Объёмы утечек памяти

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

Представьте, вы создали функцию, и одна из её переменных содержит очень большую по объёму строку (например, получает с сервера).

function f() {
  var data = "Большой объем данных, например, переданных сервером"

  /* делаем что-то хорошее (ну или плохое) с полученными данными */

  function inner() {
    // ...
  }

  return inner;
}

Пока функция inner остаётся в памяти, LexicalEnvironment с переменной большого объёма внутри висит в памяти.

Висит до тех пор, пока функция inner жива.

Как правило, JavaScript не знает, какие из переменных функции inner будут использованы, поэтому оставляет их все. Исключение – виртуальная машина V8 (Chrome, Opera, Node.JS), она часто (не всегда) видит, что переменная не используется во внутренних функциях, и очистит память.

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

Сэкономить память здесь вполне можно. Мы же знаем, что переменная data не используется в inner. Поэтому просто обнулим её:

function f() {
  var data = "Большое количество данных, например, переданных сервером"

  /* действия с data */

  function inner() {
    // ...
  }

  data = null; // когда data станет не нужна -

  return inner;
}

Поиск и устранение утечек памяти

Проверка на утечки

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

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

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

  • Если речь об IE, то надо смотреть «Виртуальную память» в списке процессов, а не только обычную «Память». Обычная может очищаться за счёт того, что перемещается в виртуальную (на диск).
  • Для простоты отладки, если есть подозрение на утечку конкретных объектов, в них добавляют большие свойства-маркеры. Например, подойдёт фрагмент текста: new Array(999999).join('leak').

Настройка браузера

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

Чтобы понять, в расширениях дело или нет, нужно отключить их:

  1. Отключить Flash.
  2. Отключить антивирусную защиту, проверку ссылок и другие модули, и дополнения.
  3. Отключить плагины. Отключить ВСЕ плагины.
  • Для IE есть параметр коммандной строки:
"C:\Program Files\Internet Explorer\iexplore.exe" -extoff
Кроме того необходимо отключить сторонние расширения в свойствах IE.

![](ie9_disable1.png)
  • Firefox необходимо запускать с чистым профилем. Используйте следующую команду для запуска менеджера профилей и создания чистого пустого профиля:
firefox --profilemanager

Инструменты

Пожалуй, единственный браузер с поддержкой отладки памяти – это Chrome. В инструментах разработчика вкладка Timeline – Memory показывает график использования памяти.

Можем посмотреть, сколько памяти используется и на что.

Также в Profiles есть кнопка Take Heap Snapshot, здесь можно сделать и исследовать снимок текущего состояния страницы. Снимки можно сравнивать друг с другом, выяснять количество новых объектов. Можно смотреть, почему объект не очищен и кто на него ссылается.

Замечательная статья на эту тему есть в документации: Chrome Developer Tools: Heap Profiling.

Утечки памяти штука довольно сложная. В борьбе с ними вам определённо понадобится одна вещь: Удача!

Карта учебника

Комментарии

перед тем как писать…
  • Если вам кажется, что в статье что-то не так - вместо комментария напишите на GitHub.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.