JSON Toolkit

Реестр памяти JSON: сколько реально стоят строки, объекты и большие массивы

Главное: JSON-файл в 10 МБ может стать 30–50 МБ в куче V8. Узнайте, сколько реально стоят строки, объекты и числа, как снять базовый уровень через process.memoryUsage и когда помогает потоковая обработка.

Продвинутый~11 мин чтенияОпубликовано 2026-10-31Обновлено 2026-10-31
Реестр памяти JSON: стоимость кучи строк, объектов и массивов в V8

Главное: сначала снимайте базовый уровень через process.memoryUsage, записывая текст и граф объектов как два отдельных приращения, затем уберите одну полную копию, прежде чем пробовать что-либо ещё — никакого JSON.parse(JSON.stringify(x)), никакой реактивной обёртки сырого документа. Дальше сократите долгоживущие ссылки, и потоковую обработку рассматривайте последней: её плоская кривая памяти стоит потери произвольного доступа только когда файл не помещается или нужны всего несколько полей.

Документ JSON в 10 МБ может стать 30–50 МБ в куче после JSON.parse, а переходный пик ещё выше. Без этой арифметики «оптимизация» — догадки.

1. Текст и граф объектов — два разных реестра

На диске и в теле запроса JSON — последовательность байтов. Попав в память, он становится двумя вещами: строкой и графом объектов, состоящим из объектов, массивов, чисел и булевых. Разделение этих двух реестров — единственный способ объяснить, почему файл в 10 МБ может заставить процесс потреблять 80 МБ.

Форма данных Граф объектов против размера текста Доминирующая стоимость
Плоский массив объектов (примерно по 8 полей) Примерно 2.5–4x Один заголовок объекта и слоты свойств на элемент
Глубоко вложенная конфигурация Примерно 3–5x Много маленьких объектов, каждый со своим заголовком
Массив очень длинных строк Примерно 1.2–2x Заголовки строк плюс символьные данные
Большие числа с десятичными дробями Примерно 2–3x Один HeapNumber на значение
Документы с множеством повторяющихся ключей Пропорционально числу ключей Хеширование ключей и настройка свойств при парсинге

Эти множители — ссылки на порядок величины, зависящие от версии V8, сжатия указателей, числа полей и содержимого строк. Таблица делает одну мысль: размер графа объектов определяется «числом узлов x накладными расходами на узел», а не числом байтов в тексте.

2. Строки: самая недооценённая статья расходов

Строка V8 — не голый массив символов. Она несёт заголовок объекта (указатель на карту, поле хеша и длину), что стоит примерно дюжину байтов в типичной 64-битной сборке со сжатием указателей, до любых символов. Символы хранятся одним из двух способов:

  • Однобайтовые строки для символов в диапазоне Latin-1: 1 байт на символ;
  • Двухбайтовые строки, когда содержимое включает CJK-символы, эмодзи или что-либо вне Latin-1: 2 байта на символ.

Китайский символ занимает 3 байта в UTF-8-файле, но 2 байта на символ в двухбайтовой строке плюс заголовок. Размер текста и размер кучи — не одно и то же измерение, и они не всегда движутся в одном направлении.

Три детали напрямую важны для работы с JSON:

  1. Срезы держат родителя живым. V8 представляет длинные подстроки как SlicedString, который хранит ссылку на родителя плюс смещение и длину. Сохраните 200 символов, срезанных из лога в 80 МБ, и все 80 МБ останутся достижимыми до освобождения среза.
  2. Конкатенация создаёт ConsString. Повторное += на больших строках строит дерево фрагментов, которое уплощается в непрерывную память только когда символы фактически читаются. Конкатенация десять тысяч раз, а затем одно чтение порождает большой скачок аллокации в этот момент.
  3. Повторяющиеся значения не разделяются автоматически. Десять тысяч записей с "status": "paid" обычно получают десять тысяч независимых строковых объектов; парсер не указывает их на одну константу. Чтобы сохранить эту стоимость, вы должны сами отобразить частые строки на перечисления или числа.

Когда нужно хранить короткий фрагмент долго, превратите его в независимую строку и освободите оригинал:

// Плохо: срез ссылается на весь документ, поэтому logText не может быть собран
const summary = logText.slice(0, 200);

// Хорошо: создать независимую строку, затем освободить logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');

3. Объекты: каждый ключ занимает слот

Обычный объект содержит как минимум три вещи: указатель на свой скрытый класс (map), указатель на хранилище свойств и указатель на хранилище элементов. Со сжатием указателей каждый указатель — 4 байта, а заголовок объекта кучи добавляет больше, поэтому даже пустой объект стоит примерно шестнадцать–двадцать с лишним байтов. В 64-битной сборке без сжатия указателей — вдвое больше.

Именованные свойства попадают в одно из двух мест:

  • Внутренние свойства (in-object): слоты, расположенные прямо после заголовка объекта, что даёт самый быстрый доступ;
  • PropertyArray: когда внутренние слоты исчерпаны, свойства переезжают в отдельный массив, и объект держит указатель на него.

Каждый слот — 4 или 8 байтов, и строка ключа учитывается отдельно. Вот почему длинные имена полей становятся реальной стоимостью на сотнях тысяч записей, а не просто эстетическим вопросом.

Хорошая новость — скрытые классы разделяются: миллион объектов с одинаковыми ключами в одном порядке разделяют одну карту вместо копирования структурной информации. Плохая новость — эти шаблоны ломают разделение или толкают объект в более дорогое представление:

  • У некоторых записей есть поле remark, у других нет, что порождает две формы и дополнительную проверку формы при каждом доступе;
  • Разные порядки вставки могут порождать разные формы;
  • Очень большое число свойств (за пределами примерно тысячи) или частые операции delete толкают объект в режим словаря, где каждое свойство также несёт запись хеш-таблицы;
  • Object.create(null) находится в режиме словаря с рождения. Это хорошая таблица поиска и плохой выбор для записи с фиксированной схемой, к которой обращаются в горячем цикле.

Одно распространённое заблуждение заслуживает исправления: JSON допускает дублирующиеся ключи, и {"a":1,"a":2} — валидный документ, результат которого a === 2. Итоговый объект держит одно значение, но парсер всё равно сканирует и присваивает каждое вхождение. Дублирующиеся ключи не экономят память; они только стоят времени парсинга.

4. Массивы и числа: виды элементов задают ширину

V8 отслеживает несколько «видов элементов» для массивов, и у каждого разная ширина на слот:

Содержимое массива Вид элемента Примерная стоимость на слот
Маленькие целые (в диапазоне Smi) PACKED_SMI_ELEMENTS 4 байта
Десятичные дроби или очень большие целые PACKED_DOUBLE_ELEMENTS 8 байтов
Строки или объекты PACKED_ELEMENTS 4-байтовый указатель плюс само значение
Массивы с дырами от delete HOLEY_* То же плюс проверки дыр
Очень разреженные массивы DICTIONARY_ELEMENTS Записи хеш-таблицы, обычно 20+ байтов

Два правила стоит запомнить:

Первое, виды элементов только деградируют. Массив, начинающийся с маленьких целых, становится массивом double в момент вставки 0.5, и массивом обобщённых элементов при вставке строки. Он никогда не восстанавливается автоматически; нужно строить новый массив.

Второе, куда попадает число JSON, зависит от его значения. Целые в диапазоне Smi (примерно плюс-минус 1.07 миллиарда) хранятся напрямую как Smi и занимают один указательный слот. Числа с десятичными дробями или экспонентами, или значения вне этого диапазона, аллоцируют HeapNumber около 16 байтов, с указателем на него в массиве. Поэтому [1, 2, 3] и [0.1, 0.2, 0.3] различаются не только по значению, но и «один 4-байтовый слот» против «8-байтовый слот плюс объект кучи».

Это также корень проблемы точности больших целых ID: за пределами диапазона безопасных целых числа JSON трактуются как double, и младшие цифры просто исчезают. Массивы JSON приходят упакованными и без дыр, поэтому не делайте delete элементов после парсинга; это толкает весь массив к более дорогому представлению.

5. Пики парсинга и сериализации: момент, когда процесс действительно умирает

Установившаяся память — один реестр; пик — другой. Пока выполняется JSON.parse(text), процесс держит три вещи одновременно:

  1. Входную строку text;
  2. Граф объектов, который строится и ещё не завершён;
  3. Собственные временные структуры парсера.

Пик парсинга примерно равен «текст + граф объектов + временные накладные расходы». После парсинга, если text всё ещё удерживается переменной, замыканием, кешем уровня модуля или реактивным хранилищем, эта память текста никогда не возвращается.

У сериализации тоже есть пик. JSON.stringify(data) держит полный граф объектов, пока строит выходную строку, с буферами конкатенации между ними. Когда вывод приближается к лимиту длины строки V8 (около 500 миллионов символов), он выбрасывает RangeError; этот сбой обычно проявляется в скриптах, экспортирующих всю базу данных как один документ JSON.

Самый дорогой распространённый шаблон использует сериализацию как глубокое клонирование:

// Пик примерно: исходный граф объектов + временный текст + новый граф объектов
const safeCopy = JSON.parse(JSON.stringify(bigData));

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

6. Измерение через process.memoryUsage

В Node process.memoryUsage() возвращает пять чисел сразу: rss (физическая память, используемая процессом), heapTotal, heapUsed (используемая JS-куча), external (память, удерживаемая объектами C++), и arrayBuffers (память ArrayBuffer и Buffer). При изучении обработки JSON heapUsed — правильная линза для графа объектов, а rss говорит, вышел ли весь процесс из-под контроля.

Скрипту измерения нужны три дисциплины: сначала вызвать global.gc(), выбирать выборки по этапам и менять одну переменную за раз. Этот скрипт записывает приращения для «чтение текста» и «разбор в объекты» отдельно:

// Запуск: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';

global.gc();
const baseline = process.memoryUsage().heapUsed;

const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;

const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;

console.log({
  textOnHeap: afterRead - baseline,
  graphOnHeap: afterParse - afterRead,
  parsePeak: afterParse - baseline,
});

Несколько ловушек:

  • --expose-gc должен быть включён явно; иначе global.gc не существует;
  • heapUsed охватывает только JS-кучу. Буферы чтения файлов живут под external и arrayBuffers, поэтому не используйте одно число для объяснения всего;
  • Не вызывайте console.log для большого объекта между выборками; это держит временную ссылку живой;
  • Первый прогон включает JIT-разогрев и ленивую компиляцию. Запускайте хотя бы три раунда и берите медиану;
  • Для сравнения в браузере снимайте снимки кучи на панели Memory в DevTools и читайте Retained Size, а не вглядывайтесь в пилу на Allocation Timeline.

Без этого базового уровня утверждения вроде «переход на потоковую обработку исправит это» или «кеш сделает быстрее» — просто ощущения.

7. Потоковая обработка покупает пиковую память, а не общее время

Компромисс потокового парсинга явен: произвольный доступ в обмен на память, которая больше не масштабируется с размером файла.

Подход Пиковая память Произвольный доступ Лучше для
Полный JSON.parse Текст плюс полный граф объектов Да До примерно 100 МБ и нужны большинство полей
SAX-парсер вроде stream-json Один фрагмент плюс текущий элемент Нет Огромные файлы или нужны только несколько полей
Построчный JSON Lines Объект для одной строки Нет Данные, которые естественно являются пакетом записей
Внешний индекс или база данных Ничего не загружается полностью Да Повторные запросы по одним и тем же данным
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  handleItem(value);
}

Этот код никогда не строит полное дерево, поэтому кривая памяти остаётся плоской. Цены тоже реальны: вы не можете вернуться за пропущенным полем, глобальные агрегации становятся трудными, восстановление после ошибок — ваша ответственность, и чистый JS-скан символ за символом обычно на порядок медленнее нативного JSON.parse.

Решение — не «превысил ли файл 10 МБ». Задайте два вопроса: Нужны ли мне большинство полей после парсинга? Могу ли я жить с последовательным доступом? Если оба ответа да, полный парсинг правилен, и ваши усилия принадлежат сокращению копий и отрисовке. Если хотя бы один ответ нет, рассматривайте потоковую обработку. Полную многослойную стратегию см. в Парсинг, отрисовка и потоковая обработка большого JSON.

8. Меньше копий, раньше освобождение

В работе с памятью удаление одной полной копии часто стоит больше, чем замена парсеров:

  • Передавайте ссылки для работы только на чтение. Не глубоко клонируйте «для безопасности», особенно через JSON.parse(JSON.stringify(x));
  • Не оборачивайте сырой документ в реактивную систему. reactive в Vue проксирует весь граф объектов рекурсивно, а слой прокси имеет свою стоимость памяти;
  • Вычисляйте и кешируйте производные данные отдельно. Если списку нужны только id, title и status, не кладите целые записи в хранилище;
  • Нормализуйте высокочастотные строки. Отображайте значения вроде "paid" и "pending" на числовые перечисления, чтобы сохранить и строковые объекты, и стоимость сравнения;
  • Сокращайте долгоживущие ссылки. Обнуляйте большие объекты по завершении, очищайте кеши и отменяйте регистрацию колбэков, чтобы замыкания не несли их на следующую страницу;
  • Учитывайте стоимость копирования в Worker. postMessage использует структурированное клонирование по умолчанию; отправка графа объектов в 50 МБ обратно в главный поток строит вторую копию. Возвращайте сводку, когда это возможно.

Другая половина реестра размера живёт в Прекратите сжимать JSON в неправильном порядке: байты на диске и байты в передаче — другой счёт, чем граф объектов в куче, и ни один процент не объясняет оба. О том, как редакторы и инструменты CLI ведут себя с памятью, см. Редакторы, инструменты командной строки и автоматизация.

9. Контрольный список

Симптом Более вероятная причина Первое действие
Файл в 10 МБ толкает процесс до 50 МБ Граф объектов и текст оба остаются живыми Измеряйте по этапам; проверьте, удерживается ли текст
OOM при парсинге, норма на установившемся состоянии Пик парсинга перекрывается со старыми данными Освободите старые объекты перед парсингом нового файла
Память медленно растёт и никогда не падает Срез, кеш или замыкание держит ссылку Снимите снимок кучи и сортируйте по retained size
Массивы больше ожидаемого Десятичная дробь деградировала вид элемента Унифицируйте числовые типы; избегайте delete
Добавление кеша сделало всё медленнее Ключи и значения потребляют память, повышая давление GC Ограничьте кеш и храните только сводки

Почему память растёт в три-четыре раза после JSON.parse?

Потому что текст — только вход; продукт парсинга — граф объектов. Каждый объект имеет заголовок, каждое свойство имеет слот, и каждая строка имеет свой заголовок. При достаточном числе узлов общие накладные расходы превышают количество байтов текста. Множитель зависит от формы данных, и плоские массивы объектов сильно отличаются от глубоко вложенных документов.

Стоит ли разбирать JSON-файл в 10 МБ полностью или потоково?

Сначала посмотрите на цель. Если нужен произвольный доступ, нужно большинство полей и файл помещается в память, используйте полный JSON.parse. Если нужны только несколько полей или файл не помещается, используйте потоковую обработку. 10 МБ — не граница; потоковая обработка обычно становится обязательной где-то выше 100 МБ.

Почему process.memoryUsage().heapUsed расходится с системным мониторингом?

heapUsed охватывает только используемую часть JS-кучи, исключая external, arrayBuffers и пространство кода. Системный мониторинг сообщает rss, что является другим измерением. Отслеживайте heapUsed и rss вместе; большой разрыв указывает на Buffers и нативные объекты.

Является ли JSON.parse(JSON.stringify(x)) безопасным глубоким клонированием?

Нет. Оно держит оригинальный граф, временный текст и новый граф одновременно, поэтому пик примерно втрое больше установившегося состояния, и оно отбрасывает undefined, функции и семантику Date. Передавайте ссылки для работы только на чтение; когда нужна изоляция, используйте structuredClone и примите ту же стоимость перестройки.