Слабые ссылки и финализаторы
В общем случае ссылки на объекты в JavaScript являются сильно удерживаемыми, что означает, что пока у вас есть ссылка на объект, он не будет удален сборщиком мусора.
const ref = { x: 42, y: 51 };
// Пока у вас есть доступ к `ref` (или любой другой ссылке на
// тот же объект), объект не будет удален сборщиком мусора.
На данный момент WeakMap
и WeakSet
являются единственными способами частично слабой ссылки на объект в JavaScript: добавление объекта в качестве ключа в WeakMap
или WeakSet
не предотвращает его удаление сборщиком мусора.
const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// У нас больше нет ссылки на `ref` в этой области видимости блока, поэтому он
// может быть удален сборщиком мусора, даже если он является ключом в `wm`, к
// которому у нас все еще есть доступ.
<!--truncate-->
const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// У нас больше нет ссылки на `ref` в этой области видимости блока, поэтому он
// может быть удален сборщиком мусора, даже если он является ключом в `ws`, к
// которому у нас все еще есть доступ.
Примечание: Вы можете рассматривать WeakMap.prototype.set(ref, metaData)
как добавление свойства со значением metaData
к объекту ref
: пока у вас есть ссылка на объект, вы можете получить метаданные. Как только у вас больше нет ссылки на объект, сборщик мусора может удалить его, даже если у вас все еще есть ссылка на WeakMap
, в который он был добавлен. Аналогично, вы можете рассматривать WeakSet
как частный случай WeakMap
, где все значения являются булевыми.
JavaScript WeakMap
на самом деле не является слабым: он фактически сильно ссылается на содержимое до тех пор, пока ключ жив. WeakMap
начинает слабо ссылаться на содержимое только после удаления ключа сборщиком мусора. Более точное название данного типа отношений — эфемерон.
WeakRef
— это более продвинутый API, который предоставляет настоящие слабые ссылки, позволяя определять время жизни объекта. Давайте рассмотрим пример.
В качестве примера предположим, что мы работаем над веб-приложением для чата, которое использует веб-сокеты для общения с сервером. Вообразим класс MovingAvg
, который для диагностической оценки производительности сохраняет набор событий из веб-сокета для вычисления простого скользящего среднего времени задержки.
class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}
compute(n) {
// Вычислить простое скользящее среднее для последних n событий.
// …
}
}
Он используется классом MovingAvgComponent
, который позволяет вам контролировать, когда начинать и заканчивать мониторинг простого скользящего среднего времени задержки.
class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}
start() {
this.movingAvg = new MovingAvg(this.socket);
}
stop() {
// Позволить сборщику мусора освободить память.
this.movingAvg = null;
}
render() {
// Выполнить рендеринг.
// …
}
}
Мы знаем, что сохранение всех серверных сообщений внутри экземпляра MovingAvg
использует много памяти, поэтому мы заботимся о том, чтобы установить this.movingAvg
в null
при остановке мониторинга, чтобы позволить сборщику мусора освободить память.
Однако после проверки панели памяти в DevTools мы обнаружили, что память вовсе не освобождается! Опытный веб-разработчик, возможно, уже заметил ошибку: слушатели событий являются сильными ссылками и должны быть явно удалены.
Давайте сделаем это явным с помощью диаграмм достижимости. После вызова start()
наш граф объектов выглядит следующим образом, где сплошная стрелка обозначает сильную ссылку. Все, что доступно через сплошные стрелки от экземпляра MovingAvgComponent
, не может быть удалено сборщиком мусора.
После вызова stop()
мы удалили сильную ссылку от экземпляра MovingAvgComponent
к экземпляру MovingAvg
, но не через слушатель сокета.
Таким образом, слушатель в экземплярах MovingAvg
, ссылаясь на this
, удерживает весь экземпляр живым, пока слушатель событий не будет удален.
До сих пор решение заключается в ручной отмене регистрации обработчика событий с помощью метода dispose
.
class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}
dispose() {
this.socket.removeEventListener('message', this.listener);
}
// …
}
Недостаток такого подхода заключается в том, что это ручное управление памятью. MovingAvgComponent
и все другие пользователи класса MovingAvg
должны помнить о вызове dispose
, чтобы избежать утечек памяти. Более того, ручное управление памятью имеет каскадный характер: пользователи MovingAvgComponent
должны помнить о вызове stop
, чтобы избежать утечек памяти, и так далее. Поведение приложения не зависит от обработчика событий этого диагностического класса, а обработчик потребляет много памяти, но не вычислительных ресурсов. Нам действительно нужно, чтобы продолжительность жизни обработчика событий была логически связана с экземпляром MovingAvg
, чтобы MovingAvg
можно было использовать как любой другой объект JavaScript, память которого автоматически освобождается сборщиком мусора.
WeakRef
позволяет разрешить дилемму, создавая слабую ссылку на фактический обработчик событий, а затем оборачивая этот WeakRef
в внешний обработчик событий. Таким образом, сборщик мусора может освободить фактический обработчик событий и память, которую он удерживает, например, экземпляр MovingAvg
и его массив events
.
function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}
class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}
Примечание: Слабые ссылки WeakRef
на функции должны использоваться с осторожностью. Функции JavaScript являются замыканиями и сильно ссылаются на внешние окружения, которые содержат значения свободных переменных, используемых внутри функций. Эти внешние окружения могут содержать переменные, которые также используются другими замыканиями. То есть, при работе с замыканиями их память часто может быть сильно ссылаемой другими замыканиями в тонких случаях. Именно поэтому addWeakListener
является отдельной функцией, и wrapper
не является локальным для конструктора MovingAvg
. В V8, если бы wrapper
был локальным для конструктора MovingAvg
и делил лексическую область с обработчиком, который обернут в WeakRef
, то экземпляр MovingAvg
и все его свойства стали бы доступны через разделяемое окружение от обработчика-обертки, делая экземпляр недоступным для сборщика мусора. Помните об этом, разрабатывая код.
Сначала мы создаем обработчик событий и присваиваем его this.listener
, чтобы он был сильно связан с экземпляром MovingAvg
. Другими словами, пока экземпляр MovingAvg
существует, обработчик событий тоже существует.
Затем, в addWeakListener
, мы создаем WeakRef
, целевым объектом которого является фактический обработчик событий. Внутри wrapper
мы выполняем deref
. Поскольку WeakRef
не предотвращает сбор мусора для своих целевых объектов, если у них нет других сильных ссылок, мы должны вручную разыменовывать их, чтобы получить целевой объект. Если целевой объект был собран сборщиком мусора за это время, deref
возвращает undefined
. В противном случае возвращается изначальная цель, то есть функция listener
, которую мы затем вызываем, используя опциональную цепочку.
Поскольку обработчик событий обернут в WeakRef
, единственная сильная ссылка на него — это свойство listener
в экземпляре MovingAvg
. То есть, нам удалось связать продолжительность жизни обработчика событий с продолжительностью жизни экземпляра MovingAvg
.
Возвращаясь к диаграммам достижимости, наш граф объектов выглядит следующим образом после вызова start()
с реализацией на основе WeakRef
, где пунктирная стрелка обозначает слабую ссылку.
После вызова stop()
мы удаляем единственную сильную ссылку на обработчик:
Со временем, после выполнения сбора мусора, экземпляр MovingAvg
и обработчик будут собраны:
Но здесь остается проблема: мы добавили уровень косвенности к listener
, обернув его в WeakRef
, но wrapper
в addWeakListener
все еще создает утечку по той же причине, по которой изначально создавал утечку listener
. Допустим, это меньшая утечка, поскольку утечка состоит только из обертки, а не целого экземпляра MovingAvg
, но это все равно утечка. Решением этой проблемы является сопряженная функция для WeakRef
, а именно FinalizationRegistry
. С новым API FinalizationRegistry
мы можем зарегистрировать обратный вызов, который будет выполнен, когда сборщик мусора уничтожит зарегистрированный объект. Такие обратные вызовы называются финализаторами.
Примечание: Колбек финализации не запускается сразу после сборки мусора для слушателя событий, поэтому не используйте его для важной логики или метрик. Время сборки мусора и вызовов колбеков финализации не определено. На самом деле, движок, который никогда не запускает сборку мусора, будет полностью совместим. Однако можно предположить, что движки будут собирать мусор, и колбеки финализации будут вызываться позднее, если только среда не будет уничтожена (например, закрытие вкладки или завершение работы worker'а). Учитывайте эту неопределенность при написании кода.
Мы можем зарегистрировать колбек с помощью FinalizationRegistry
, чтобы удалить wrapper
из сокета, когда внутренний слушатель событий будет собран сборщиком мусора. Наш окончательный вариант реализации выглядит следующим образом:
const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});
function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}
class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}
Примечание: gListenersRegistry
— это глобальная переменная для обеспечения выполнения финализаторов. Объект FinalizationRegistry
не сохраняется сам по себе объектами, зарегистрированными в нем. Если сам реестр будет собран сборщиком мусора, его финализатор может не запуститься.
Мы создаем слушатель событий и присваиваем его this.listener
, чтобы он имел сильную ссылку на экземпляр MovingAvg
(1). Затем мы оборачиваем слушатель событий, который выполняет работу, в WeakRef
, чтобы его можно было собрать сборщиком мусора, и чтобы он не предотвращал сборку MovingAvg
из-за ссылки через this
(2). Мы создаем обертку, которая вызывает deref
для проверки, все еще ли доступен объект, и если да, то вызывает его (3). Мы регистрируем внутренний слушатель в FinalizationRegistry
, передавая удерживающее значение { socket, wrapper }
для регистрации (4). Затем мы добавляем возвращенную обертку в качестве слушателя событий на socket
(5). Через некоторое время после того, как экземпляр MovingAvg
и внутренний слушатель будут собраны сборщиком мусора, финализатор может быть вызван с переданным ему удерживающим значением. Внутри финализатора мы также удаляем обертку, обеспечивая сборку всех связанных с использованием экземпляра MovingAvg
данных сборщиком мусора (6).
С учетом всего вышесказанного, наша первоначальная реализация MovingAvgComponent
больше не приводит к утечкам памяти и не требует ручной утилизации.
Не переусердствуйте
После ознакомления с этими новыми возможностями может возникнуть соблазн применить WeakRef
ко всем элементам™. Однако, это, вероятно, не лучшая идея. Некоторые случаи явно не подходят для использования WeakRef
и финализаторов.
В общем, избегайте написания кода, полагающегося на то, что сборщик мусора очистит WeakRef
или вызовет финализатор в предсказуемое время — это невозможно! Более того, возможность сборки объекта мусором может зависеть от деталей реализации, таких как представление замыканий, которые сложны и могут различаться между JavaScript-движками и даже между их версиями. В частности, вызовы колбеков финализатора:
- Могут не происходить сразу после сборки мусора.
- Могут происходить не в том же порядке, что и сама сборка мусора.
- Могут не происходить вовсе, например, если окно браузера закрыто.
Поэтому не размещайте важную логику в траектории выполнения финализатора. Они полезны для выполнения очистки в ответ на сборку мусора, но вы не можете использовать их надежно, например, для записи значимых метрик использования памяти. Для этого случая используйте performance.measureUserAgentSpecificMemory
.
WeakRef
и финализаторы могут помочь сэкономить память, и работают лучше, когда используются умеренно, как способ постепенного улучшения. Поскольку это функции для опытных пользователей, мы ожидаем, что большинство их применений будет происходить внутри фреймворков или библиотек.