17 KiB
Управление памятью в JavaScript
Управление памятью обычно незаметно. Мы создаём примитивы, объекты, функции.. Всё это занимает память.
Что происходит с объектом, когда он становится "не нужен"? Возможно ли "переполнение" памяти? Для ответа на эти вопросы -- залезем "под капот" интерпретатора.
[cut]
Управление памятью в JavaScript
Главной концепцией управления памятью в JavaScript является принцип достижимости (англ. reachability).
- Определённое множество значений считается достижимым изначально, в частности:
- Значения, ссылки на которые содержатся в стеке вызова, то есть -- все локальные переменные и параметры функций, которые в настоящий момент выполняются или находятся в ожидании окончания вложенного вызова.
- Все глобальные переменные.
Эти значения гарантированно хранятся в памяти. Мы будем называть их корнями.
- **Любое другое значение сохраняется в памяти лишь до тех пор, пока доступно из корня по ссылке или цепочке ссылок.**
Для очистки памяти от недостижимых значений в браузерах используется автоматический Сборщик мусора (англ. 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
, то есть можно сказать, что на каждый из них есть ссылка, последняя строка делает эти объекты в совокупности недостижимыми.Поэтому они будут удалены из памяти.
Чтобы отследить такие сложные случаи, придуман сборщик мусора, который время от времени перебирает объекты и ищет недоступные, с использованием хитрых алгоритмов и оптимизаций, чтобы это было быстро и незаметно.
Управление памятью в картинках
Рассмотрим пример объекта "семья":
Код | Структура в памяти |
---|---|
|
![]() |
Недостижимый объект
Теперь посмотрим, что будет, если удалить ссылку family.father
при помощи delete
:
Код | Структура в памяти |
---|---|
|
![]() |
Пришёл сборщик мусора
Сборщик мусора ищет недоступные объекты. Базовый алгоритм поиска -- это идти от корня (window
) по ссылкам и помечать все объекты, которые встретит. Тогда после окончания обхода непомеченными останутся как раз недостижимые объекты.
В нашем случае таким объектом будет бывший family.father
. Он стал недостижимым и будет удалён вместе со своим "поддеревом", которое также более недоступно из программы.
Код | Структура в памяти |
---|---|
|
![]() |
После сборщика
После того, как сработает сборщик мусора, картина в памяти будет такой:
Код | Структура в памяти |
---|---|
|
![]() |
Достижимость -- только по входящим ссылкам
Вернёмся к исходному коду.
Пусть внутренние объекты ссылаются друг на друга:
Код | Структура в памяти |
---|---|
|
![]() |
Получилась сложная структура, с круговыми ссылками.
Если удалить ссылки family.father
и family.mother.husband
(см. иллюстрацию ниже), то получится объект, который имеет исходящие ссылки, но не имеет входящих:
Код | Структура в памяти |
---|---|
|
![]() |
При стандартном алгоритме очистки памяти, сборщик мусора пойдёт от корня и не сможет достичь объект, помеченный серым. Поэтому он будет удалён.
И совершенно неважно, что из объекта выходят какие-то ссылки wife
, we
, они не влияют на достижимость этого объекта.
Недостижимый остров
Всё "семейство" объектов, которое мы рассматривали выше, достижимо исключительно через глобальную переменную family
или, иными словами, через свойство window.family
.
Если записать в window.family
что-то ещё, то все они, вместе со своими внутренними ссылками станут "недостижимым островом" и будут удалены:
Код | Структура в памяти |
---|---|
|
![]() |
Замыкания
Замыкания следуют тем же правилам, что и обычные объекты.
Объект переменных внешней функции существует в памяти до тех пор, пока существует хоть одна внутренняя функция, ссылающаяся на него через свойство [[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.