Куда уходит память: как движок хранит данные и чистит за собой
Как движок хранит массив и объект, почему замыкание держит переменные живыми и как сборщик мусора решает, что пора выкинуть. На простых аналогиях и примере V8 — без магии, только то, что происходит в памяти.
Обычно про массивы, объекты и замыкания думают на уровне синтаксиса: вот скобки квадратные, вот фигурные, вот функция вернула функцию. Всё работает, и ладно. Но стоит копнуть, что за этим лежит в памяти, — и становится понятно, почему одни вещи быстрые, другие медленные, а память иногда течёт там, где вроде бы ничего не течёт.
А темы эти на самом деле про одно — про то, как рантайм распоряжается памятью. Сначала надо понять, где данные вообще лежат: стек и куча. Массив и объект — это про то, как они разложены. Замыкание — про то, почему не пропадают. Сборщик мусора — про то, когда их можно выкинуть. Разберу всё по порядку, на примере V8 (движок Chrome и Node.js), там это видно нагляднее всего.
Где вообще лежат данные: стек и куча
Прежде чем спорить про массивы и объекты, надо разобраться, куда рантайм вообще складывает данные. Мест два, и они устроены по-разному.
Первое — стек (stack). Проще всего представить его как стопку тарелок. Кладёшь только наверх, берёшь только сверху. Каждый вызов функции кладёт на стопку свою тарелку — кадр: аргументы, локальные примитивы, адрес возврата. Функция закончилась — верхнюю тарелку сняли, и память под неё свободна. Никакого сборщика не нужно: раз всё складывается и снимается строго по порядку, понятно, что убирать и когда. Стек быстрый именно потому, что глупый — клади наверх, снимай сверху, ничего не ищи.
Но на стопку тарелок влезает не всё. Числа, булевы, короткие внутренние значения — да, они мелкие и с известным размером. А массив или объект туда не положишь: он может расти, менять размер, жить дольше своей функции. Тарелку такого не удержит. Поэтому всё крупное и живучее складывают во второе место — кучу (heap). Куча — это уже не стопка, а большой склад с ячейками: клади что хочешь и куда хочешь, но убирать за собой сама она не будет. За этим на склад и приходит сборщик мусора.
СТЕК КУЧА
кадры вызовов, примитивы объекты, массивы, функции
┌──────────────┐ ┌───────────────────────┐
│ кадр bar() │ │ { name: "..." } │
│ y = 2 │────ссылка───►│ │
├──────────────┤ │ [1, 2, 3] │
│ кадр foo() │ │ │
│ x = 1 ──────┼────ссылка───►│ function () {...} │
└──────────────┘ └───────────────────────┘
чистится сам чистит сборщик мусора
(снял кадр — и всё) (нужно понять, что уже не нужно)
Вся дальнейшая история — про правую часть картинки. Массив и объект лежат в куче. Замыкание не даёт выкинуть кусок кучи. Сборщик разбирается, что из кучи уже можно освободить. Стек в этом сюжете — отправная точка: именно с него сборщик начинает искать, что ещё живо.
Что внутри объекта и массива
Начну с простого вопроса, на который почти все отвечают неправильно. Чем массив отличается от объекта?
Обычный ответ: массив — упорядоченный список по индексам, объект — набор пар «ключ-значение». По смыслу верно. А вот внутри движка разница глубже и интереснее.
Возьмём объект. Наивно его можно представить как записку со свободным текстом: пишешь что попало и в любом порядке. Чтобы найти нужную строчку, приходится каждый раз перечитывать записку целиком. В терминах движка это хеш-таблица: кинул ключ, посчитал хеш, нашёл значение. Так и работают «трудные» объекты. Но это медленно — каждое чтение свойства заново считает хеш и прыгает по памяти. Поэтому V8 старается изо всех сил такого избегать.
Вместо записки движок хочет бланк. Заметь: если создавать объекты одинаково, у них одинаковая структура — те же поля в том же порядке. Как анкета с фиксированными графами: «имя» всегда первая строка, «возраст» всегда вторая. И раз графы у всех анкет одни и те же, само описание бланка можно вынести отдельно и хранить один на всех.
const a = { x: 1, y: 2 }
const b = { x: 3, y: 4 }
┌──────────────┐
a ─────►│ x → 1 │──┐
│ y → 2 │ │ оба ссылаются
└──────────────┘ ├─► на одно описание
┌──────────────┐ │ структуры (Shape):
b ─────►│ x → 3 │──┘ "поле x по смещению 0,
│ y → 4 │ поле y по смещению 1"
└──────────────┘
Это описание бланка в V8 называется «скрытым классом» или Shape. В нём нет самих значений — только карта граф: какое поле в какой строке. Тысяча объектов с полями x и y — это тысяча заполненных анкет по одному бланку. Бланк один на всех, заполнения разные. И тогда чтение a.x — не поиск по всей записке, а обращение сразу в нужную строку: «поле x — это графа номер 0». Быстро, как достать деталь из ячейки с известным номером.
Отсюда, кстати, растёт совет не лепить свойства объекту в случайном порядке и не удалять их через delete. Каждый раз, когда ты меняешь набор полей, ты по сути заводишь новый бланк — движок либо ищет подходящий среди готовых, либо создаёт ещё один. А delete вообще может забраковать бланк целиком и скинуть объект обратно в режим «свободной записки» (в V8 это называют dictionary mode). Один delete — и объект из быстрого стал медленным, причём навсегда.
У этой истории есть вторая половина, без которой она неполная. Shape объясняет, как объект разложен. А дальше — бытовая привычка, которая ускоряет всё остальное. Представь, что каждое утро берёшь молоко из холодильника. В первый раз ищешь: открыл, поводил глазами, нашёл на третьей полке. Но на второй день ты уже не осматриваешь весь холодильник — рука сама тянется на третью полку. Ты запомнил место.
Движок делает ровно это. Строчка obj.x в функции, которая крутится в цикле миллион раз. Первый заход — честный поиск: смотрит Shape, находит, что x в графе 0. Но знание не выбрасывает, а запоминает прямо в том месте кода: «объекты такого бланка — поле в графе 0».
первый заход: obj.x → смотрим Shape → x по смещению 0
│ запомнили прямо тут
следующие: obj.x → тот же Shape? → да → сразу берём смещение 0
│
└─ Shape другой? → ищем заново, перезапоминаем
На следующих заходах движок только сверяет: бланк тот же? Да — сразу лезет в графу 0, ничего не ищет. Это и есть «рука сама тянется на третью полку». Называется inline-кэш (inline cache), и на нём во многом держится скорость JavaScript. Работает ровно до тех пор, пока в одно место прилетают объекты одного бланка. А вот если кто-то каждый день переставляет молоко на новую полку — привычка не помогает, каждый раз ищешь заново. Так же и тут: скармливаешь одной функции объекты с разной структурой — кэш сбивается, движок откатывается к медленному поиску. Отсюда ещё одна причина держать однотипные объекты однотипными: не ради самого Shape, а чтобы inline-кэши поверх него не разваливались.
Теперь массив. В спецификации языка индекс массива — это тоже свойство, просто с числовым именем. То есть arr[0] формально ничем не отличается от obj["0"]. Но движок обходится с числовыми ключами особо: он хранит их не вместе с обычными свойствами, а отдельным непрерывным куском памяти. В V8 у этого куска даже своё имя — elements.
ОБЪЕКТ МАССИВ
свойства по именам элементы по индексам
┌─────────────┐ ┌───┬───┬───┬───┐
│ name → ... │ │ 0 │ 1 │ 2 │ 3 │
│ age → ... │ └───┴───┴───┴───┘
└─────────────┘ лежат подряд, вплотную
лежат по Shape (пока массив "плотный")
И вот тут прячется важная деталь. Движок следит, что именно лежит в массиве, и подбирает под это самую подходящую коробку — как органайзер, где ячейки нарезаны под конкретный размер деталей. Лежат только небольшие целые числа — коробка узкая и самая быстрая. Добавил дробное — детали больше не влезают, и массив молча переехал в коробку побольше, под числа с плавающей точкой. Положил строку или объект — переехал ещё раз, в самую общую коробку, куда влезает что угодно, но и работать с ней медленнее. Переезды идут только в одну сторону: из узкой в широкую. Обратно, в узкую, массив уже не вернётся, даже если ты снова оставишь в нём одни целые.
Ещё хуже — «дырки». Сделал arr[0] = 1, а потом сразу arr[100] = 1 — и между ними зияет дыра из девяноста девяти пустых ячеек. Это уже не плотный органайзер, а стеллаж, где занята первая ячейка и сотая, а между ними пусто. Каждый раз, доставая элемент, приходится сначала проверять, а есть ли он там вообще, а не искать ли его в другом месте (в цепочке прототипов). Стал «дырявым» — остаёшься таким навсегда, даже если потом все дырки заполнить.
Практический вывод простой. Массив стоит держать плотным и однородным по типу. Не создавать через new Array(100) заранее, если собираешься заполнять по одному. Не смешивать в одном массиве числа, строки и объекты без нужды. Всё это не про красоту кода, а про то, останется массив в быстром режиме или сползёт в медленный.
Ссылка, а не значение
Прежде чем идти к замыканиям, надо проговорить одну вещь, без которой дальше будет непонятно. Что вообще хранит переменная.
Мы уже знаем: примитивы — числа, строки, булевы — лежат прямо в кадре на стеке, переменная держит их в себе. А вот массив или объект живёт в куче, и переменная держит не сам объект, а ссылку на него. Хорошая аналогия — адрес дома. Дом стоит на складе-куче, а в переменной лежит только бумажка с адресом. Сам дом в карман не положишь — носишь адрес.
Из этого и растёт вся путаница с копированием. Переписал адрес на вторую бумажку — дом от этого не раздвоился. Обе бумажки ведут к одному дому.
let obj = { n: 1 }
let copy = obj
obj ──┐
├──► { n: 1 } ← один объект в куче,
copy ──┘ две ссылки на него
copy = obj не копирует объект. Копируется только адрес. Оба имени ведут к одному и тому же дому в куче, и если через copy поменять в нём мебель (copy.n = 2), то и obj увидит те же изменения — дом-то один. Отсюда все классические сюрпризы в духе «я же скопировал массив, почему меняется оригинал».
Держи эту картинку в голове: имя переменной и объект в куче — разные вещи, связанные ниточкой-ссылкой, той самой бумажкой с адресом. Дальше вся история про замыкания и сборщик мусора крутится именно вокруг этих ниточек — кто на кого показывает.
Замыкание — это захваченное окружение
Теперь любимая тема собеседований, которую половина людей объясняет заклинанием «функция запоминает переменные». Запоминает — но что именно и как?
Начнём с того, где вообще живут переменные функции. Когда функция вызывается, под неё заводится окружение (по спецификации — lexical environment): табличка её переменных плюс ссылка на окружение родителя. У родителя своя ссылка на его родителя, и так до самого верха. Получается цепочка.
функция bar функция foo глобальное
┌──────────┐ ┌──────────┐ ┌──────────┐
│ y = 2 │──outer──► │ x = 1 │──outer──►│ ... │──► null
└──────────┘ └──────────┘ └──────────┘
Когда внутри bar встречается имя x, движок ищет его по цепочке: нет у себя — идёт к родителю по ссылке outer, нет и там — выше, пока не найдёт или не упрётся в null (это и есть ReferenceError: x is not defined). Ровно так же, как поиск свойства по цепочке прототипов, только тут цепочка окружений.
Обычно, когда функция отработала и вернула управление, её окружение больше никому не нужно — комнату с её переменными можно освобождать. Но бывает иначе. Представь: сотрудник уволился, а ключ от рабочей комнаты унёс с собой и отдал коллеге снаружи. Пока этот ключ на руках, комнату снести нельзя — по нему в неё в любой момент зайдут. Замыкание — ровно про это. Вложенная функция, которую вынесли наружу, унесла с собой ключ (ссылку) от комнаты родителя. И пока живёт эта функция, окружение родителя выкинуть нельзя.
function makeCounter() {
let count = 0
return () => ++count ← эта функция уносит с собой
} ссылку на окружение, где живёт count
const next = makeCounter() ← makeCounter отработала и вышла,
но её count НЕ выкинут — на него
ещё показывает next
next() // 1
next() // 2 ← тот же самый count, он пережил свою функцию
Ключевой момент: замыкание держит не копию переменной, а именно её саму, живую. count не скопировался в момент создания функции — на него осталась ссылка. Поэтому счётчик и растёт: обе стрелки показывают на одну ячейку.
И тот же механизм объясняет классические грабли. Если в цикле насоздавать функций, которые ссылаются на одну общую переменную, они все будут смотреть в одну ячейку — и увидят её последнее значение, а не то, что было на своей итерации. Лечится это тем, что на каждой итерации заводят новую переменную (для того let в цикле и завезли отдельное поведение), то есть новую ячейку под каждое замыкание.
Для практики отсюда два вывода. Первый — приятный: замыкания это способ спрятать состояние, тот же count снаружи не достать, только через возвращённую функцию. Второй — тревожный: замыкание держит окружение живым, а значит, держит живым всё, на что то ссылается. Повесил обработчик события, который тянет за собой жирный объект, забыл его снять — и объект не уйдёт из памяти, потому что замыкание за него держится. Это прямая дорожка к утечкам, и подводит нас к последней теме.
Сборщик мусора — кто и когда всё это выкидывает
Итак, объекты копятся в куче. Замыкания держат окружения. Рано или поздно память кончится, если ничего не убирать. В C это делают руками. В JavaScript за тебя это делает сборщик мусора (garbage collector), и главный вопрос — как он понимает, что объект больше не нужен.
Наивная идея — считать ссылки: сколько бумажек с адресом на дом выписано. Ноль бумажек — сносим. Просто, но ломается на замкнутом круге. Два дома выписали адрес друг на друга, а больше их адрес никто не держит. У каждого по одной бумажке — по счётчику оба «жилые». А на деле дойти до них снаружи уже нельзя, они существуют только друг для друга. Утечка на ровном месте.
Поэтому в реальности берут другой критерий — достижимость. Представь город с одним входом. Живым считается тот дом, до которого можно дойти пешком от входа по улицам. Вход — это корни (roots): глобальный объект и то, что прямо сейчас на стеке вызовов. Идём от входа по всем бумажкам-адресам и отмечаем каждый дом, куда смогли добраться. До какого дома пешком от входа не дойти — тот под снос, даже если внутри квартала дома ссылаются друг на друга. Замкнутый круг из прошлого абзаца отсюда как раз и виден: от входа в него не попасть.
КОРНИ ──► A ──► B ──► C живы: до всех можно дойти
│
└──► D тоже жив
E ◄──► F мусор: ссылаются друг на друга,
но от корней не достижимы
Дальше сборщик работает в два больших шага. Сначала обход — пойти от корней по всем ссылкам и пометить всё, до чего дошёл, как живое. Потом уборка — пройтись по куче и освободить всё, что не помечено. Это и есть классический «пометь и подмети» (mark and sweep).
Но если так обходить и убирать весь склад разом, программа встанет колом на время уборки — большие паузы, всё замирает. Здесь спасает простое наблюдение про время жизни объектов, вроде того как продукты в холодильнике делятся на скоропортящиеся и долгосрочные. Большинство объектов умирает молодыми. Ответ функции, промежуточный массив, объект под один запрос — создались, поработали, стали не нужны почти сразу. Это как открытая пачка, которую съедают в тот же день. А то, что пережило пару уборок, обычно живёт долго — это уже консервы на дальней полке.
На этом и строят разделение кучи на поколения — как две зоны хранения. Молодое поколение — маленькая полка «съесть на этой неделе», туда кладут всё новое и проверяют часто. Старое — большой шкаф с долгосроком, туда переезжает то, что пережило несколько уборок, и туда заглядывают редко.
МОЛОДОЕ ПОКОЛЕНИЕ СТАРОЕ ПОКОЛЕНИЕ
маленькое, чистится часто большое, чистится редко
┌──────────────────┐ ┌──────────────────────┐
│ новые объекты │──пережил──►│ долгожители │
│ (почти все умрут │ пару │ (пережили несколько │
│ почти сразу) │ уборок │ уборок) │
└──────────────────┘ └──────────────────────┘
В молодом поколении V8 делает хитро: делит его пополам, объекты складывает в одну половину. Когда та заполнилась — пробегает по живым и переносит их во вторую половину, вплотную друг к другу, а первую целиком объявляет пустой. Платишь только за живые объекты, которых мало, а не за всю гору мусора. Пережил такой переезд дважды — переезжаешь в старое поколение, там уже работает полноценный mark and sweep.
Современные движки к тому же убирают не «стоп, все замерли», а по большей части параллельно и по кусочкам, в фоне, пока основной код продолжает крутиться. Паузы от этого крохотные, и потому мы обычно вообще не замечаем, что где-то там постоянно идёт уборка.
Тут возникает резонный вопрос. Сборщик в фоне помечает живые объекты, обходя ссылки. А основной код в это же время эти ссылки меняет — что-то создаёт, что-то перепривязывает. Как обход не собьётся? Ведь код может взять уже помеченный «готовый» объект и повесить на него новую ссылку на объект, который сборщик ещё не видел и уже не собирается смотреть. Тот выпадет из обхода, его сочтут мусором и выкинут — живой.
Спасает от этого барьер записи (write barrier). Вернёмся к городу с уборкой. Инспектор уже обошёл квартал и пометил, какие дома жилые, а какие под снос. Но пока он ходил, жильцы прописали новый адрес — связь, которой в момент обхода ещё не было. Чтобы такой дом не снесли по ошибке, вводят простое правило: любой, кто выписывает новую бумажку-адрес, обязан тут же сообщить инспектору — «появилась новая связь, проверь этот дом». Вот этот довесок к каждой записи адреса и есть барьер записи. Движок вешает его на саму операцию: прописал одному объекту ссылку на другой — сборщик автоматически берёт новый объект на заметку и не теряет его из обхода.
код: живой.поле = новый ← запись ссылки
│
└─► БАРЬЕР ЗАПИСИ: "новый" помечен на проверку,
из обхода не выпадет
Мы этот барьер не видим и напрямую с ним не работаем, но именно он делает возможной фоновую уборку без остановки программы. Небольшая плата на каждой записи — зато сборщик может спокойно работать вперемешку с нашим кодом и ничего живого не потерять.
Что из этого важно на практике. Управлять сборщиком напрямую нельзя, и не надо. Но можно ему мешать или не мешать. Мешаешь ты ровно тогда, когда держишь ненужные ссылки — забытый обработчик, разросшийся кеш без ограничения, глобальная переменная, замыкание, вцепившееся в жирный объект. Пока на объект есть путь от корней, он живой, и никакой сборщик его не тронет. Утечка в JavaScript — это почти всегда не «сборщик сломался», а «мы забыли ссылку, и объект честно остаётся достижимым».
Ссылки, которые не мешают уборке
Раз проблема в том, что мы держим объект достижимым, напрашивается вопрос: а можно ли ссылаться на объект так, чтобы это не мешало его выкинуть? Можно. Для этого есть слабые ссылки.
Обычная ссылка — сильная: пока есть хоть одна бумажка с адресом, дом стоит, точка. Слабая ссылка — это скорее заметка на полях: «где-то тут был такой дом». Она показывает на объект, но в глазах сборщика за причину его держать не считается. Пока на дом есть настоящие бумажки-адреса — он жив. Остались одни заметки на полях — сносим, а заметки просто перестают на что-либо указывать.
В языке это WeakMap и WeakSet. Снаружи почти как обычные Map и Set, но ключи они держат слабо. Классический пример — привязать какие-то данные к объекту, не продлевая ему жизнь.
ОБЫЧНЫЙ Map WeakMap
держит ключ сильно держит ключ слабо
┌──────────────┐ ┌──────────────┐
│ ключ → данные│ │ ключ → данные│
└──────┬───────┘ └──────┬───────┘
│ пока Map жив, │ ключ ушёл отовсюду ещё?
│ ключ НЕ выкинут │ да — пара сама исчезает
│ (частая утечка!) │ (утечки нет)
Разница на практике злая. Складываешь метаданные к объектам в обычный Map — и этот Map намертво держит все ключи, даже когда сами объекты давно никому не нужны. Он-то на них ссылается. Тот же код на WeakMap течь не будет: ушёл объект из остальной программы — пара из WeakMap пропала сама.
Есть ещё WeakRef для отдельного объекта — когда нужно подсмотреть за чем-то, но не мешать это выкинуть. Но с ним стоит быть осторожнее: он подпускает тебя близко к моменту уборки, а этот момент недетерминирован, и полагаться на него в логике — плохая идея. Обычно хватает WeakMap. Штука эта нужна нечасто, но когда упираешься в утечку через кеш или реестр объектов — она ровно про это.
Как всё это складывается вместе
Всё, с чего начал, — это одна история про жизнь объекта в памяти.
Данные лежат в двух местах: примитивы и кадры вызовов на стеке, который чистится сам, а объекты и массивы в куче, за которой приходит сборщик. Массив и объект — это про то, как они разложены в куче: объект по своей структуре-Shape, массив непрерывным куском, и оба тем быстрее, чем однороднее с ними обращаешься (за скорость повторного доступа отвечают inline-кэши поверх Shape). Ссылка — про то, что переменная держит не сам объект, а ниточку к нему. Замыкание — про то, что такая ниточка от живой функции не даёт выкинуть целое окружение. А сборщик мусора — про то, что выкидывается ровно то, до чего от корней уже не дотянуться по этим ниточкам; а если нужно ссылаться, не мешая уборке, — на то есть слабые ссылки.
Собранная вместе картинка выглядит так:
КОРНИ (глобальное + стек: кадры вызовов, примитивы)
│
├──► переменные ──► объекты и массивы в куче
│ (лежат по Shape / плотным куском,
│ доступ ускоряют inline-кэши)
│
└──► живые функции ──► захваченные окружения (замыкания)
│
└──► а за них цепляются ещё объекты
всё, до чего дотянулись от корней → живёт
всё остальное → мусор, будет убрано
слабая ссылка → не считается за "дотянулись"
Стоит увидеть эту механику под синтаксисом — где данные лежат, кто их держит, когда отпустит — и странные вещи перестают быть странными. Почему массив вдруг стал медленным. Откуда взялась утечка. Почему счётчик в замыкании общий на все вызовы. За всем этим стоит один и тот же механизм, просто повёрнутый разными боками.