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

Главное: сначала снимайте базовый уровень через 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:
- Срезы держат родителя живым. V8 представляет длинные подстроки как
SlicedString, который хранит ссылку на родителя плюс смещение и длину. Сохраните 200 символов, срезанных из лога в 80 МБ, и все 80 МБ останутся достижимыми до освобождения среза. - Конкатенация создаёт
ConsString. Повторное+=на больших строках строит дерево фрагментов, которое уплощается в непрерывную память только когда символы фактически читаются. Конкатенация десять тысяч раз, а затем одно чтение порождает большой скачок аллокации в этот момент. - Повторяющиеся значения не разделяются автоматически. Десять тысяч записей с
"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), процесс держит три вещи одновременно:
- Входную строку
text; - Граф объектов, который строится и ещё не завершён;
- Собственные временные структуры парсера.
Пик парсинга примерно равен «текст + граф объектов + временные накладные расходы». После парсинга, если 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 и примите ту же стоимость перестройки.