Интро: почему я написал эту статью
Меня зовут Виктор, я разрабатываю страницу результатов поиска Яндекса. Несмотря на внешнюю простоту, поисковая выдача — сложная штука: на каждый запрос генерируется своя уникальная страница, на которой в зависимости от запроса может присутствовать блок Картинок, Карты, Переводчик, видеоплеер и многие другие компоненты. Все они должны запускаться и работать в памяти обычных бюджетных телефонов, которые использует большинство наших пользователей. Браузерам должно хватать ресурсов, чтобы пользователь не видел вот такого:

На своих серверах мы должны генерировать сотни миллионов уникальных страниц в сутки — это сложнее, чем просто отдавать одни и те же ресурсы. Генерация страницы не должна быть слишком требовательной к памяти сервера.
Разрабатывая проект на JavaScript (TypeScript, ClojureScript или каком-то другом языке, транслируемом в JavaScript), мы привыкли создавать объекты, массивы, строки и вообще писать код, как будто память бесконечна. Это не так. Я расскажу о видах проблем с памятью, о том, какие ограничения мы часто забываем и как их можно преодолеть. В ответ браузеры и пользователи скажут вам спасибо.
- Категории проблем с памятью
- Ограничения по памяти для разных типов данных
Heap
Buffer, TypedArray
String
Map, Set
Call stack
Типичные задачи, в которых можно наткнуться на ограничения по памяти - Soft-утечки
Пример из продакшена
Как получаются soft-утечки
Как их обнаружить
Как найти причину
1. Memory Allocation Timeline
2. Техника трёх снапшотов
3. queryObjects
Тренируемся находить утечки - Hard-утечки
Пример из продакшена
Как бороться - Нестандартные оптимизации памяти в Node.js
Исходный код
Module._pathCache
Несколько версий пакета в node_modules
require('./data.json') - Заключение
Категории проблем с памятью
JavaScript наряду с Java, C# и Python принадлежит к языкам с автоматической сборкой мусора.
Проблемы с памятью в таких языках можно разделить на три категории:
- Не-утечки: код и данные, которые упираются в ограничения по памяти. Ограничения могут быть либо искусственными — заложенными в язык или среду выполнения, либо естественными — вытекающими из характеристик железа, на котором выполняется код.
- Soft-утечки (мягкие, локальные утечки): что-то мешает сборщику мусора освободить память, например, список содержит в одном из элементов ссылку на давно не нужный объект.
- Hard-утечки (жёсткие, глобальные утечки): память не освобождается, пока браузер или вся операционная система не будут перезапущены.
Важно отличать утечки от неоптимального кода с высоким потреблением памяти: иногда слова «у меня страница течёт» некорректно используются там, где открыто жадное до памяти приложение, но утечки при этом отсутствуют.
Далее я подробно расскажу о каждой из трёх категорий: что она из себя представляет, как её обнаружить, как с ней бороться.
Ограничения по памяти для разных типов данных
Систематизировать свои знания по ограничениям меня побудила эта заметка Романа Дворнова.
Heap
Во многих реализациях языков с автоматической сборкой мусора — хоть и не во всех — для динамического выделения памяти используется куча, она же heap. Например, это языки на основе JVM — Java, Kotlin и так далее. Кучу использует и движок JavaScript V8. Соответственно, размер кучи — самое главное ограничение, с которого надо начинать разбираться.
Компактное и интересное описание того, как в V8 устроена память вообще и куча в частности, можно найти в этой статье. Вот самая ценная картинка оттуда:

В Chrome и других браузерах, основанных на Chromium, текущее состояние хипа можно узнать из performance.memory:
> console.log(performance.memory)
MemoryInfo {totalJSHeapSize: 10000000, usedJSHeapSize: 10000000, jsHeapSizeLimit: 3760000000}- Описание возвращаемого результата на MDN.
- Поддержка в браузерах на сайте Can I use.
С появлением performance.memory в Chrome связана интересная история. Во время роста популярности Gmail в 2010-2012 годах пользователи начали всё чаще жаловаться на высокое потребление памяти браузером. В Google разработчики Gmail пришли к разработчикам Chrome и убедили тех добавить возможность получать из JS информацию о состоянии памяти и усовершенствовать инструменты работы с памятью в DevTools. После этого разработчики Gmail добавили в своё почтовое приложение сбор данных о памяти у клиентов, нашли и исправили несколько утечек. И вообще — в разы уменьшили потребление памяти: примерно в два раза на медиане и в пять раз на 99-й процентили. Кроме исправления уже существующих багов, мониторинг памяти в Gmail помогает оперативно находить и устранять проблемы с памятью в новых версиях приложения и даже баги в сборщике мусора в новых версиях Chrome (обратите внимание, на вертикальной оси отложены не абсолютные значения в мегабайтах, а кратные какой-то базовой величине x):

В других браузерах — Firefox, Safari — память устроена аналогично. В Firefox даже есть специальная страница about:memory, на которой можно увидеть детальное состояние памяти и вызвать сборку мусора. Проблема в том, что в этих браузерах неизвестен способ из кода на JS получить состояние хипа. Если у вас есть такая информация — пишите в комментариях.
В Node.js состояние памяти можно узнать вызовом process.memoryUsage():
$ node
Welcome to Node.js v16.8.0.
Type ".help" for more information.
> console.log(process.memoryUsage())
{
rss: 26689536,
heapTotal: 6656000,
heapUsed: 4633936,
external: 893129,
arrayBuffers: 11158
}Документация по process.memoryUsage() находится здесь.
С получением текущего состояния хипа разобрались, теперь к ограничениям. В Chrome и Node.js максимальный размер хипа определяется при старте:
- Если в командной строке указаны параметры, настраивающие размер хипа, то значение берётся оттуда.
- Иначе значение вычисляется в зависимости от архитектуры (32- или 64-битная) и объёма физической памяти.
Насколько мне удалось разобраться, максимальный размер хипа определяется так (если у вас есть дополнения и уточнения — пишите, исправлю):
| Версия | Heap size 32-bit | Heap size 64-bit |
|---|---|---|
| Node.js <= 11 | 700 МБ | 1400 МБ |
| Chrome <= 82, Node.js 12 | Физ. память / 4, но не меньше 128 МБ и не больше 1 ГБ | Физ. память / 2, но не меньше 256 МБ и не больше 2 ГБ |
| Chrome >= 83, Node.js >= 14 | См. выше | См. выше. Если физ. памяти >= 16 ГБ, то максимум увеличен до 4 ГБ |
Подробности о значениях и логике выбора в Node.js можно почитать в этом комментарии к пул-реквесту. Соответствующий код в движке V8: Heap::HeapSizeFromPhysicalMemory, ResourceConstraints::ConfigureDefaults.
Параметры командной строки для V8, управляющие размерами областей хипа:
$ node --v8-options | grep -- -size
…
--min-semi-space-size (min size of a semi-space (in MBytes), the new space consists of two semi-spaces)
--max-semi-space-size (max size of a semi-space (in MBytes), the new space consists of two semi-spaces)
--max-old-space-size (max size of the old space (in Mbytes))
--max-heap-size (max size of the heap (in Mbytes) both max_semi_space_size and max_old_space_size take precedence. All three flags cannot be specified at the same time.)
--initial-heap-size (initial size of the heap (in Mbytes))
--huge-max-old-generation-size (Increase max size of the old space to 4 GB for x64 systems withthe physical memory bigger than 16 GB)
--initial-old-space-size (initial old space size (in Mbytes))
…
--stack-size (default size of stack region v8 is allowed to use (in kBytes))Самый известный и широко используемый из них — это --max-old-space-size, позволяющий увеличить размер Old Space. Например, если на устройстве достаточно физической памяти (пусть будет 32 ГБ) и нашей программе не хватает 4 ГБ, выделенных ей по умолчанию, то мы можем запустить её так:
node --max-old-space-size=8000 index.jsО тонкостях настройки памяти, если Node.js выполняется в контейнере Docker, можно почитать в этой хабрастатье.
Buffer, TypedArray
Максимальная длина одного буфера или типизированного массива в Node.js ограничена константой. Сама константа добавлена в Node.js v8.2.0, но ограничение существовало и до этого, насколько я знаю.
require('buffer').constants.MAX_LENGTHЗначение константы зависит от версии и разрядности Node.js:
| Версия Node.js | 32-bit | 64-bit |
|---|---|---|
| 8.2.0…13 | 2**30-1 (~1 ГБ) | 2**31-1 (~2 ГБ) |
| 14 | 2**30-1 (~1 ГБ) | 2**32-1 (~4 ГБ) |
| 15…16 | 2**30-1 (~1 ГБ) | 2**32 (4 ГБ) |
Пример кода:
// Node.js v12 64-bit
new Int8Array(2**31-1)
// Int8Array(2147483647)
new Int8Array(2**31)
// Uncaught RangeError: Invalid typed array length: 2147483648
// at new Int8Array (<anonymous>)
new Uint32Array(2**31-1)
// Uint32Array(2147483647)
new Uint32Array(2**31)
// Uncaught RangeError: Invalid typed array length: 2147483648
// at new Uint32Array (<anonymous>)Теперь о браузере Chrome. В исходном коде V8 есть максимальное разрешённое значение длины типизированного массива v8::TypedArray::kMaxLength:
/*
* The largest typed array size that can be constructed using New.
*/
static constexpr size_t kMaxLength =
internal::kApiSystemPointerSize == 4
? internal::kSmiMaxValue // 2147483647 (но это не точно)
: static_cast<size_t>(uint64_t{1} << 32); // 4294967296К сожалению, v8::TypedArray::kMaxLength никак не прочитать из JS. Единственное, что я смог найти: в Node.js начиная с 14 версии значения JSArrayBuffer::kMaxByteLength и JSTypedArray::kMaxLength (оно равно v8::TypedArray::kMaxLength) доступны при включённой опции --allow-natives-syntax в виде функций %ArrayBufferMaxByteLength() и %TypedArrayMaxLength():
$ nvm use v16 && node --allow-natives-syntax
Welcome to Node.js v16.8.0.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
9007199254740991
> %TypedArrayMaxLength()
4294967296
> .exit
$ nvm use v14 && node --allow-natives-syntax
Welcome to Node.js v14.17.5.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
9007199254740991
> %TypedArrayMaxLength()
4294967295
> .exit
$ nvm use v12 && node --allow-natives-syntax
Welcome to Node.js v12.18.1.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
%ArrayBufferMaxByteLength()
^
Uncaught SyntaxError: ArrayBufferMaxByteLength is not defined
> %TypedArrayMaxLength()
%TypedArrayMaxLength()
^
Uncaught SyntaxError: TypedArrayMaxLength is not defined
> .exitНо вернёмся к браузеру Chrome. При попытке создать типизированный массив с длиной, превышающей v8::TypedArray::kMaxLength, браузер выбросит ошибку:
> new Int8Array(2**33)
Uncaught RangeError: Invalid typed array length: 8589934592Если длина будет меньше максимальной, но массив не поместится в память, браузер выбросит ошибку с другим сообщением:
> new Int8Array(2**32)
Uncaught RangeError: Array buffer allocation failedВ браузере Firefox максимальный объём типизированного массива в байтах maxByteLength() вычисляется как равный максимальному размеру внутреннего буфера ArrayBufferObject::maxBufferByteLength(). В 64-битной архитектуре с поддержкой больших буферов он равен 8 ГБ, в остальных случаях — 2 ГБ (2147483647, константа INT32_MAX). Максимальная разрешённая длина массива рассчитывается как maxByteLength() / BYTES_PER_ELEMENT, причём эти расчёты разбросаны по всему коду, например здесь и здесь. Как и в Chrome, эти значения недоступны из JS.
При невозможности создать типизированный массив желаемой длины N можно увидеть такие сообщения об ошибке (обратите внимание, что в Node.js и Chrome число N может попасть в сообщение, и это может помешать группировке и подсчёту однотипных ошибок):
| Платформа | Сообщение |
|---|---|
| Node.js | RangeError: Invalid typed array length: N |
| Chrome | RangeError: Invalid typed array length: N |
| Chrome | RangeError: Array buffer allocation failed |
| Safari | Error: Out of memory |
| Firefox | RangeError: invalid array length |
С помощью этого и этого скрипта я определил максимальные достижимые размеры Int8Array и Int32Array:
| Версия | Макс. длина Int8Array |
Макс. длина Int32Array |
|---|---|---|
| Chrome 99…102 MacOS и Windows | 2_145_386_496 (2**31-2_097_152, ~2 ГБ) | 536_346_624 (2**29-524_288, ~512 МБ) |
| Chrome 99 Android | 1_073_741_823 (2**30-1, ~1 ГБ) | 334_082_048 |
| Safari 13 MacOS | 2_147_483_647 (2**31-1, ~2 ГБ) | 536_870_911 (2**29-1, ~512 МБ) |
| Firefox 96…100 MacOS и Windows | 8_589_934_592 (2**33, 8 ГБ) | 2_147_483_648 (2**31, 2 ГБ) |
Для всех браузеров кроме Chrome Android максимальная длина Int32Array в четыре раза меньше длины Int8Array (Int8 — это один байт, Int32 — четыре байта, поэтому элементов Int32 в той же памяти может поместиться в четыре раза меньше, чем элементов Int8). В Chrome Android я, скорее всего, столкнулся с нехваткой физической памяти телефона.
String
Длина строки находится в поле length. В Node.js максимальное значение length ограничено константой.
require('buffer').constants.MAX_STRING_LENGTHОбратите внимание, что это не количество букв в строке, а именно число в поле length (в строгой формулировке — количество UTF-16 code units). Разные буквы и символы могут состоять из одного или двух code units:
console.log('Z'.length); // 1
console.log('Я'.length); // 1
console.log(' 