en.javascript.info/01-js/05-functions-closures/06-memory-management/article.md
Ilya Kantor f301cb744d init
2014-10-26 22:10:13 +03:00

17 KiB
Raw Blame History

Управление памятью в JavaScript

Управление памятью обычно незаметно. Мы создаём примитивы, объекты, функции.. Всё это занимает память.

Что происходит с объектом, когда он становится "не нужен"? Возможно ли "переполнение" памяти? Для ответа на эти вопросы -- залезем "под капот" интерпретатора.

[cut]

Управление памятью в JavaScript

Главной концепцией управления памятью в JavaScript является принцип достижимости (англ. reachability).

  1. Определённое множество значений считается достижимым изначально, в частности:
    • Значения, ссылки на которые содержатся в стеке вызова, то есть -- все локальные переменные и параметры функций, которые в настоящий момент выполняются или находятся в ожидании окончания вложенного вызова.
    • Все глобальные переменные.

    Эти значения гарантированно хранятся в памяти. Мы будем называть их корнями.

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

Для очистки памяти от недостижимых значений в браузерах используется автоматический Сборщик мусора (англ. Garbage collection, GC), встроенный в интерпретатор, который наблюдает за объектами и время от времени удаляет недостижимые.

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

Достижимость и наличие ссылок

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

  • **Верно -- в том плане, что если на значение не остаётся ссылок, то память из-под него очищается.**

    Например, была создана ссылка в переменной, и эту переменную тут же перезаписали:

    var user = { name: "Вася" };
    user = null;
    

    Теперь объект { name: "Вася" } более недоступен. Память будет освобождена.

  • **Неверно -- может быть так, что ссылка есть, но при этом значение недостижимо и должно быть удалено из памяти.**

    Такая ситуация возникает с объектами, при наличии ссылок друг на друга:

    var vasya = {};
    var petya = {};
    vasya.friend = petya;
    petya.friend = vasya;
    
    vasya = petya = null;
    

    Несмотря на то, что на объекты vasya, petya ссылаются друг на друга через ссылку friend, то есть можно сказать, что на каждый из них есть ссылка, последняя строка делает эти объекты в совокупности недостижимыми.

    Поэтому они будут удалены из памяти.

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

Управление памятью в картинках

Рассмотрим пример объекта "семья":

Код Структура в памяти
var family = { };

family.father = {
  name: "Вася"
};

family.mother = {
  name: "Маша"
};
Этот код создаёт объект `family` и два дополнительных объекта, доступных по ссылкам `family.father` и `family.mother`.

Недостижимый объект

Теперь посмотрим, что будет, если удалить ссылку family.father при помощи delete:

Код Структура в памяти
var family = { };

family.father = {
  name: "Вася"
};

family.mother = {
  name: "Маша"
};

*!*
delete family.father;
*/!*

Пришёл сборщик мусора

Сборщик мусора ищет недоступные объекты. Базовый алгоритм поиска -- это идти от корня (window) по ссылкам и помечать все объекты, которые встретит. Тогда после окончания обхода непомеченными останутся как раз недостижимые объекты.

В нашем случае таким объектом будет бывший family.father. Он стал недостижимым и будет удалён вместе со своим "поддеревом", которое также более недоступно из программы.

Код Структура в памяти
var family = {
  father: {
    name: "Вася"
  },

  mother: {
    name: "Маша"
  }
};

*!*
delete family.father;
*/!*

После сборщика

После того, как сработает сборщик мусора, картина в памяти будет такой:

Код Структура в памяти
var family = {
  father: {
    name: "Вася"
  },

  mother: {
    name: "Маша"
  }
};

*!*
delete family.father;
*/!*

Достижимость -- только по входящим ссылкам

Вернёмся к исходному коду.

Пусть внутренние объекты ссылаются друг на друга:

Код Структура в памяти
var family = {
  father: {
    name: "Вася"
  },

  mother: {
    name: "Маша"
  }
};

// добавим перекрёстных ссылок
*!*
family.father.wife = family.mother;
family.mother.husband = family.father;
family.father.we = family;
family.mother.we = family;
*/!*

Получилась сложная структура, с круговыми ссылками.

Если удалить ссылки family.father и family.mother.husband (см. иллюстрацию ниже), то получится объект, который имеет исходящие ссылки, но не имеет входящих:

Код Структура в памяти
var family = {
  father: {
    name: "Вася"
  },

  mother: {
    name: "Маша"
  }
};

family.father.wife = family.mother;
family.mother.husband = family.father;
family.father.we = family;
family.mother.we = family;

*!*
delete family.father;
delete family.mother.husband;
*/!*

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

И совершенно неважно, что из объекта выходят какие-то ссылки wife, we, они не влияют на достижимость этого объекта.

Недостижимый остров

Всё "семейство" объектов, которое мы рассматривали выше, достижимо исключительно через глобальную переменную family или, иными словами, через свойство window.family.

Если записать в window.family что-то ещё, то все они, вместе со своими внутренними ссылками станут "недостижимым островом" и будут удалены:

Код Структура в памяти
var family = {
  father: {
    name: "Вася"
  },

  mother: {
    name: "Маша"
  }
};

family.father.wife = family.mother;
family.mother.husband = family.father;
family.father.we = family;
family.mother.we = family;

*!*
family = null;
*/!*

Замыкания

Замыкания следуют тем же правилам, что и обычные объекты.

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

Например:

  • Обычно объект переменных удаляется по завершении работы функции. Даже если в нём есть объявление внутренней функции:
    function f() {
      var value = Math.random();
    
      function g() { } // g видна только изнутри
    }
    
    f();
    

    В коде выше внутренняя функция объявлена, но она осталась внутри. После окончания работы f() она станет недоступной для вызовов, так что будет убрана из памяти вместе с остальными локальными переменными.

  • ...А вот в этом случае лексическое окружение, включая переменную `value`, будет сохранено:
    function f() {
      var value = Math.random();
    
      function g() { } 
    
    *!*
      return g;
    */!*
    }
    
    var g = f(); // функция g будет жить и сохранит ссылку на объект переменных
    

    Причина сохранения проста: в скрытом свойстве g.[[Scope]] находится ссылка на объект переменных, в котором была создана g.

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

    При этом совершенно не важно, имеет ли вложенная функция имя или нет.

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

Оптимизация в V8 и её последствия

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

В коде выше переменная value никак не используется. Поэтому она будет удалена из памяти.

Важный побочный эффект в V8 (Chrome, Opera) состоит в том, что удалённая переменная станет недоступна и при отладке!

Попробуйте запустить пример ниже с открытой консолью Chrome. Когда он остановится, в консоли наберите alert(value).

//+ run
function f() {
  var value = Math.random();

  function g() { 
    debugger; // выполните в консоли alert(value); Нет такой переменной!
  } 

  return g;
}

var g = f(); 
g();

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

//+ run
var value = "Сюрприз";

function f() {
  var value = "...";

  function g() { 
    debugger; // выполните в консоли alert(value); Сюрприз!
  } 

  return g;
}

var g = f(); 
g();

[warn header="Ещё увидимся"] Об этой особенности важно знать. Если вы отлаживаете под Chrome/Opera, то наверняка рано или поздно с ней встретитесь!

Это не глюк отладчика, а особенность работы V8, которая, возможно, будет когда-нибудь изменена. Вы всегда сможете проверить, не изменилось ли чего, запустив примеры на этой странице. [/warn]

Влияние управления памятью на скорость

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

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

Пример ниже тестирует сложение чисел до данного через рекурсию по сравнению с обычным циклом:

//+ run
function sumTo(n) {   // обычный цикл 1+2+...+n
  var result = 0;
  for (var i=1; i<=n; i++) {
    result += i;
  }
  return result;
}

function sumToRec(n) { // рекурсия sumToRec(n) = n+SumToRec(n-1)
  return n == 1 ? 1 : n + sumToRec(n-1);
}

var timeLoop = performance.now();
for (var i=1;i<1000;i++) sumTo(1000); // цикл
timeLoop = performance.now() - timeLoop; 

var timeRecursion = performance.now();
for (var i=1;i<1000;i++) sumToRec(1000); // рекурсия
timeRecursion = performance.now() - timeRecursion;

alert("Разница в " + ( timeRecursion / timeLoop ) + " раз");

Различие в скорости на таком примере может составлять, в зависимости от интерпретатора, 2-10 раз.

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