Как работает JavaScript: часть вторая

Управление памятью в JavaScript, утечки памяти и как с ними справляться.
25 октября 2017225057Андрей Никифоров1520213

Здравствуйте!

Как и обещал, новая статья про внутреннее устройство JavaScript. На этот раз разговор пойдет об управлении памятью, сборке мусора и утечках. Оригинал статьи: «How JavaScript works: memory management + how to handle 4 common memory leaks».

В прошлой статье мы поговорили об общих принципах JavaScript и внутреннем устройстве движка V8.

Обзор

С-подобные языки используют низкоуровневые примитивы типа malloc и free для управления памятью. Эти примитивы используются разработчиками для явного выделения и освобождения памяти.

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

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

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

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

Вот что происходит на каждом шаге:

  • Выделение памяти — память выделяется операционной системой, которая позволяет вашей программе использовать ее. В низкоуровневых языках это явная операция, которую вы как разработчик должны вызвать. В высокоуровневых языках, однако, среда выполнения делает это за вас.
  • Использование памяти — время, когда ваша программа на самом деле использует предварительно выделенную память. Операции чтения и записи происходят, когда вы используете объявленные переменные в коде.
  • Освобождение памяти — приходит время освободить память, которая больше вам не нужна, что сделает ее свободной и доступной для повторного выделения. Как и выделение памяти, это явная операция в низкоуровневых языках.

Мы говорили об стеке вызовов и куче в первой статье.

Что такое память

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

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

Как люди, мы не очень хорошо думаем и считаем в системе битов, так что мы организуем их в группы, которые вместе используются для отображения чисел. 8 бит мы называем байтом. Выше байт находятся слова, которые иногда занимают 16, а иногда 32 бита.

Что хранится в памяти:

  1. Все переменные и прочие данные, используемые всеми программами.
  2. Код программ, включая операционную систему.

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

Когда вы компилируете код, компилятор обходит все примитивные типы и вычисляет, сколько всего памяти понадобится для их размещения. Необходимая память выделяется в стек. Это место называется стеком, потому что по мере выполнения функций их память добавляется на вершину стека, и удаляется оттуда после выхода из функции в LIFO порядке.

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

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

Компилятор сразу понимает, что этот код требует 4 + 4 × 4 + 8 = 28 байт. Так это работает сейчас для типов integer и double. Лет 20 назад integer обычно был размером в два байта, а double — в четыре. Ваш код не должен полагаться на размер типов, принятый в данный момент.

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

В примере выше компилятор знает точный адрес в памяти для каждой переменной. По факту, когда мы присваиваем переменной n значение, внутри это преобразуется во что-то вроде «адрес памяти 4127963».

Заметьте, что когда мы пытаемся получить значение x[4], мы получаем значение m. Это происходит из-за того, что желаемый элемент массива не существует, так как последний элемент — x[3], и по факту мы читаем (или записываем) часть байт m. С очень большой вероятностью это будет иметь ощутимые последствия до самого конца программы.

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

Динамическое выделение

К сожалению, все усложняется, когда мы не можем определить в процессе компиляции, сколько памяти понадобится для переменной. Допустим, у нас есть такой код:

int n = readInput(); // reads input from the user
...
// create an array with "n" elements

В процессе компиляции компилятор не знает, сколько памяти понадобится для массива, так как его размер определяется пользовательским вводом. Таким образом, компилятор не сможет выделить память на стеке. Вместо этого наша программа обратится за памятью к операционной системе в процессе выполнения. Эта память будет выделена из кучи. Разница между статическим и динамическим выделением памяти показана в таблице:

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

Выделение памяти в JavaScript

Теперь мы разберемся, как работает выделение памяти в JavaScript. Разработчикам на этом языке не нужно собственноручно выделять память, JavaScript делает это за них, используя объявление переменных.

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string

var o = {
a: 1,
b: null
}; // allocates memory for an object and its contained values

var a = [1, null, 'str'];  // (like object) allocates memory for the
                         // array and its contained values

function f(a) {
return a + 3;
} // allocates a function (which is a callable object)

// function expressions also allocate an object
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);

// Some function calls result in object allocation as well:
var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element

// Methods can allocate new values or objects:
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string

// Since strings are immutable,
// JavaScript may decide to not allocate memory,
// but just store the [0, 3] range.

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];

var a3 = a1.concat(a2);

// new array with 4 elements being
// the concatenation of a1 and a2 elements

Использование выделенной памяти

Использование памяти в JavaScript означает чтение из и запись в память. Это делается посредством чтения или записи переменной или свойства объекта, или передачей аргумента в функцию.

Освобождение памяти

Большинство проблем с управлением памятью наступает здесь.

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

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

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

Сборка мусора

Принимая во внимание то, что определение «ненужной» памяти алгоритмически неразрешимо, сборщики мусора реализуют ограничения общей проблемы. В этом разделе мы разберемся с общими понятиями, алгоритмами сборки мусора и их ограничениями.

Ссылки

Основной концепт, на котором основаны алгоритмы сборки мусора — ссылки.

В контексте управления памятью объект имеет ссылку на другой объект, если первый имеет доступ к последнему, явный или неявный. Например, объект в JavaScript неявно ссылается на прототип и явно ссылается на свои свойства. В этом контексте идея «объекта» становится шире, чем просто JavaScript-объект, и включает в себя так же области видимости функции, или глобальную область видимости.

Сборка мусора через подсчет ссылок

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

var o1 = {
o2: {
  x: 1
}
};

// 2 objects are created.
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that
          // has a reference to the object pointed by 'o1'.
o1 = 1;      // now, the object that was originally in 'o1' has a 
          // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
              // This object has now 2 references: one as
              // a property.
              // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
          // references to it.
          // It can be garbage-collected.
          // However, what was its 'o2' property is still
          // referenced by the 'o4' variable, so it cannot be
          // freed.

o4 = null; // what was the 'o2' property of the object originally in
         // 'o1' has zero references to it.
         // It can be garbage collected.

Проблема циклов

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

function f() {
 var o1 = {};
 var o2 = {};
 o1.p = o2; // o1 references o2
 o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

Алгоритм mark-and-sweep

Чтобы понять, нужен ли еще конкретный объект, этот алгоритм определяет, доступен ли объект. Работает это так:

  1. Сборщик создает список корневых объектов. Обычно это глобальные переменные, к которым есть ссылки в коде. В JavaScript window — один из примеров глобальной корневой переменной.
  2. Все корни проверяются и помечаются как активные, не подлежащие сборке. Все дочерние элементы рекурсивно проверяются, и все элементы, доступные из корней, помечаются как активные.
  3. Все остальные элементы подлежат очистке. Коллектор освобождает память и возвращает ее ОС.

Этот алгоритм лучше предыдущего, так как «ноль ссылок на объект» ведет к тому, что объект становится недоступен, но обратное не всегда верно, как мы видели в примере с циклами.

Начиная с 2012 года все современные браузеры предоставляют mark-and-sweep сборщик мусора. Все улучшения в поле сборщика мусора JavaScript, такие как инкрементальная, параллельная сборка мусора или стратегия поколений — все они улучшают реализацию алгоритма mark-and-sweep, но не меняют сам алгоритм.

В этой статье вы можете почитать очень детальный разбор трассирующих сборщиков мусора, в том числе и mark-and-sweep алгоритм с оптимизациями.

Циклы больше не проблема

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

Контринтуитивное поведение сборщика мусора

Хотя сборщики мусора удобны, у них есть свои компромиссы. Один из них — недетерминированность. Иными словами, сборщики непредсказуемы. Вы не можете определить, когда конкретно произойдет сборка. Это значит, что в некоторых случаях программы используют больше памяти, чем нужно. В иных случаях остановки на сбор мусора могут быть заметны с точки зрения производительности. Хотя недетерминированность означает, что нельзя быть уверенным в том, что сборка произойдет, большинство реализаций используют одну и ту же схему сборки мусора при выделении. Если выделения памяти не происходит, сборщик ожидает. Рассмотрим сценарий:

  1. Выделено значительное количество памяти.
  2. Большинство из этих элементов, или даже все, помечаются как недоступные.
  3. Больше не происходит выделений памяти.

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

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

В общем, утечки определяются как память, которая больше не требуется приложению, но по разным причинам не возвращена операционной системе или пулу свободной памяти.

Языки программирования поддерживают разные способы управления памятью. Однако я напомню, что определение того, нужен ли конкретный кусок памяти или нет — нерешаемая задача. Иными словами, только разработчики могут точно понимать, должен ли кусок памяти быть возвращен ОС или нет.

Определенные языки программирования предоставляют инструменты, помогающие разработчикам в этом. Другие языки оставляют эту проблему полностью на разработчике, обязывая явно определять возврат памяти. В википедии есть неплохие статьи об ручном и автоматическом управлении памятью.

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

Глобальные переменные
JavaScript обрабатывает необъявленные переменные интересным образом: ссылка на необъявленную переменную создает новую переменную внутри глобального объекта. В случае браузеров глобальный объект — window. Иными словами:

function foo(arg) {
  bar = "some text";
}
//эквивалентно
function foo(arg) {
  window.bar = "some text";
}

Если bar содержит ссылку на переменную в области видимости foo и вы забыли объявить ее через var, это создаст неожиданную глобальную переменную. В этом примере утечка в виде строки — не большая проблема, но это все равно плохо.

Другой путь к неожиданным глобальным переменным выглядит так:

function foo() {
  this.var1 = "potential accidental global";
}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

Чтобы избавиться от таких ошибок, используйте strict-режим (use strict;). Это строгий режим парсинга JavaScript, который предохраняет от нежелательных глобальных переменных. Вот материал, который подробно описывает режим strict.

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

Забытые таймеры или коллбеки
Использование setInterval — обычная практика в JavaScript. Большинство библиотек, предоставляющих наблюдателей или иные фабрики, принимающие коллбеки, обычно заботятся о ссылках на коллбеки после того, как их экземпляры закончат работу. Однако в случае с setInterval такой код встречается сплошь и рядом:

var serverData = loadData();

setInterval(function() {
  var renderer = document.getElementById('renderer');
  if(renderer) {
      renderer.innerHTML = JSON.stringify(serverData);
  }
}, 5000); //This will be executed every ~5 seconds.

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

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

В случае с наблюдателями важно явно удалять их, как только они перестанут быть нужными или связанный с ними объект перестанет быть доступным. В прошлом это было обязательной практикой для браузеров, которые не умели правильно обрабатывать циклические ссылки. Сейчас большинство браузеров может и собирает наблюдателей, объекты которых более не доступны, даже если слушатель не был явно удален. Однако удалять наблюдателей перед сбором объекта — все еще хорошая практика. Вот пример:

var element = document.getElementById('launch-button');
var counter = 0;

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

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.

Современные браузеры, включая IE и Edge, используют современные алгоритмы сборки мусора, которые корректно обрабатывают циклы. Иными словами, необязательно вызывать removeEventListener перед удалением ноды.

Фреймворки и библиотеки, такие как jQuery, удаляют слушателей перед удалением ноды, если вы используете их API. Это обрабатывается внутри библиотеки, которая кроме всего прочего удостоверяется в отсутствии утечек памяти.

Замыкания
Ключевой аспект разработки на JavaScript — замыкания: внутренняя функция, имеющая доступ к внешним переменным. В силу реализации среды выполнения JavaScript, возможны утечки памяти таким образом:

var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
  if (originalThing) // a reference to 'originalThing'
    console.log("hi");
};

theThing = {
  longStr: new Array(1000000).join('*'),
  someMethod: function () {
    console.log("message");
  }
};
};

setInterval(replaceThing, 1000);

Этот кусок кода делает одну вещь: каждый раз при вызове replaceThing, theThing получает новый объект, содержащий большой массив и новое замыкание (someMethod). В то же время unused содержит замыкание, которое содержит ссылку на originalThing (theThing из предыдущего вызова). Уже что-то странное, ха? Важно то, что как только для замыканий, принадлежащих родительской области видимости, создается своя область видимости, она становится общей.

В этом случае область видимости, созданная для someMethod, общая с unused, которая в свою очередь хранит ссылку на originaThing. Даже если unused никогда не используется, someMethod может использоваться снаружи области видимости replaceThing, то есть по сути глобально. И так как someMethod разделяет область видимости с unused, ссылка на originalThing защищает ее от сборщика мусора.

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

Эта проблема обнаружена разработчиками Meteor, и они написали развернутую статью об этом.

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

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

function doStuff() {
  elements.image.src = 'http://example.com/image_name.png';
}

function removeImage() {
  // The image is a direct child of the body element.
  document.body.removeChild(document.getElementById('image'));

  // At this point, we still have a reference to #button in the
  //global elements object. In other words, the button element is
  //still in memory and cannot be collected by the GC.
}

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

В следующей и пока последней статье из цикла поговорим о петле событий и асинхронных событиях в JavaScript. Статья выйдет в пятницу.

Новые комментарии