Перейти к содержанию

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

Низкоуровневые языки программирования (например, C) имеют низкоуровневые примитивы для управления памятью, такие как malloc() и free(). В JavaScript же память выделяется динамически при создании сущностей (объектов, строк и т. п.) и "автоматически" освобождается, когда они больше не используются. Последний процесс называется сборкой мусора. Слово "автоматически" является источником путаницы и зачастую создаёт у программистов на JavaScript (и других высокоуровневых языках) ложное ощущение, что они могут не заботиться об управлении памятью.

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

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

  1. Выделение необходимой памяти.
  2. Её использование (чтение, запись).
  3. Освобождение выделенной памяти, когда в ней более нет необходимости.

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

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

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

var n = 123; // выделяет память для типа number
var s = "azerty"; // выделяет память для типа string

var o = {
  a: 1,
  b: null
}; // выделяет память для типа object и всех его внутренних переменных

// (like object) выделяет память для array и его внутренних значений
var a = [1, null, "abra"];

function f(a){
  return a + 2;
} // выделяет память для function (которая представляет собой вызываемый объект)

// функциональные выражения также выделяют память под object
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

Вызовы некоторых функций также ведут к выделению памяти под объект:

var d = new Date();
var e = document.createElement('div'); // выделяет память под DOM элемент

Некоторые методы выделяют память для новых значений или объектов:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 это новый объект типа string
// Т.к. строки - это постоянные значения, интерпретатор может решить,
// что память выделять не нужно, но нужно лишь сохранить диапазон [0, 3].

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
// новый массив с 4 элементами в результате конкатенации элементов 'a' и 'a2'
var a3 = a.concat(a2); 

Использование значений

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

Освобождение памяти, когда она более не нужна

Именно на этом этапе появляется большинство проблем из области "управления памятью". Наиболее сложной задачей в данном случае является чёткое определение того момента, когда "выделенная память более не нужна". Зачастую программист сам должен определить, что в данном месте программы данная часть памяти более уже не нужна и освободить её.

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

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

Достижимость

Основной концепцией управления памятью в JavaScript является принцип достижимости.

Если упростить, то "достижимые" значения - это те, которые доступны или используются. Они гарантированно находятся в памяти.

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

    Например:

    • Локальные переменные и параметры текущей функции.
    • Переменные и параметры других функций в текущей цепочке вложенных вызовов.
    • Глобальные переменные.
    • (некоторые другие внутренние значения)

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

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

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

В интерпретаторе JavaScript есть фоновый процесс, который называется сборщик мусора. Он следит за всеми объектами и удаляет те, которые стали недостижимы.

Вот самый простой пример:

// в user находится ссылка на объект
let user = {
  name: "John"
};

простой пример

Здесь стрелка обозначает ссылку на объект. Глобальная переменная user ссылается на объект {name: "John"} (мы будем называть его просто "John"). В свойстве "name" объекта John хранится примитив, поэтому оно нарисовано внутри объекта.

Если перезаписать значение user, то ссылка потеряется:

user = null;

объект John становится недостижимым

Теперь объект John становится недостижимым. К нему нет доступа, на него нет ссылок. Сборщик мусора удалит эти данные и освободит память.

Две ссылки

Представим, что мы скопировали ссылку из user в admin:

// в user находится ссылка на объект
let user = {
  name: "John"
};

*!*
let admin = user;
*/!*

Теперь, если мы сделаем то же самое:

user = null;

...то объект John всё ещё достижим через глобальную переменную admin, поэтому он находится в памяти. Если бы мы также перезаписали admin, то John был бы удалён.

Взаимосвязанные объекты

Теперь более сложный пример. Семья:

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

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

В результате получаем такую структуру памяти:

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

На данный момент все объекты достижимы.

Теперь удалим две ссылки:

delete family.father;
delete family.mother.husband;

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

Но если мы удалим обе, то увидим, что у объекта John больше нет входящих ссылок:

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

После сборки мусора:

После сборки мусора

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

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

Возьмём объект family из примера выше. А затем:

family = null;

Структура в памяти теперь станет такой:

Структура в памяти

Этот пример демонстрирует, насколько важна концепция достижимости.

Объекты John и Ann всё ещё связаны, оба имеют входящие ссылки, но этого недостаточно.

У объекта family больше нет ссылки от корня, поэтому весь "остров" становится недостижимым и будет удалён.

Внутренние алгоритмы

Основной алгоритм сборки мусора - "алгоритм пометок" (англ. "mark-and-sweep").

Согласно этому алгоритму, сборщик мусора регулярно выполняет следующие шаги:

  • Сборщик мусора "помечает" (запоминает) все корневые объекты.
  • Затем он идёт по их ссылкам и помечает все найденные объекты.
  • Затем он идёт по ссылкам помеченных объектов и помечает объекты, на которые есть ссылка от них. Все объекты запоминаются, чтобы в будущем не посещать один и тот же объект дважды.
  • ...И так далее, пока не будут посещены все ссылки (достижимые от корней).
  • Все непомеченные объекты удаляются.

Например, пусть наша структура объектов выглядит так:

Явно виден "недостижимый остров" справа. Теперь посмотрим, как будет работать "алгоритм пометок" сборщика мусора.

На первом шаге помечаются корни:

Затем помечаются объекты по их ссылкам:

...а затем объекты по их ссылкам и так далее, пока это вообще возможно:

Теперь объекты, до которых не удалось дойти от корней, считаются недостижимыми и будут удалены:

Это и есть принцип работы сборки мусора.

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

Вот некоторые из оптимизаций:

  • Сборка по поколениям (Generational collection) - объекты делятся на "новые" и "старые". Многие объекты появляются, выполняют свою задачу и быстро умирают, их можно удалять более агрессивно. Те, которые живут достаточно долго, становятся "старыми" и проверяются реже.
  • Инкрементальная сборка (Incremental collection) - если объектов много, то обход всех ссылок и пометка достижимых объектов может занять значительное время и привести к видимым задержкам выполнения скрипта. Поэтому интерпретатор пытается организовать сборку мусора поэтапно. Этапы выполняются по отдельности один за другим. Это требует дополнительного учёта для отслеживания изменений между этапами, но зато теперь у нас есть много крошечных задержек вместо одной большой.
  • Сборка в свободное время (Idle-time collection) - чтобы уменьшить возможное влияние на производительность, сборщик мусора старается работать только во время простоя процессора.

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

Итого

Главное из того, что мы узнали:

  • Сборка мусора выполняется автоматически. Мы не можем ускорить или предотвратить её.
  • Объекты сохраняются в памяти, пока они достижимы.
  • Наличие ссылки не гарантирует, что объект достижим (от корня): несколько взаимосвязанных объектов могут стать недостижимыми как единое целое.

Современные интерпретаторы реализуют передовые алгоритмы сборки мусора.

Некоторые из них освещены в книге "The Garbage Collection Handbook: The Art of Automatic Memory Management" (R. Jones и др.).

Если вы знакомы с низкоуровневым программированием, то более подробная информация о сборщике мусора интерпретатора V8 находится в статье A tour of V8: Garbage Collection.