Книга обліку памʼяті 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 байт на символ;
- Двобайтові рядки, коли вміст включає китайські символи, емодзі чи щось поза 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. Обʼєкти: кожен ключ займає слот
Звичайний обʼєкт містить щонайменше три речі: покажчик на його прихований клас (мапу), покажчик на сховище властивостей і покажчик на сховище елементів. Зі стисненням покажчиків кожен покажчик — 4 байти, а заголовок heap-обʼєкта додає більше, тож навіть порожній обʼєкт коштує приблизно шістнадцять–двадцять з лишком байтів. На 64-бітовій збірці без стиснення покажчиків подвойте.
Іменовані властивості потрапляють в одне з двох місць:
- Властивості в обʼєкті: слоти, розміщені безпосередньо після заголовка обʼєкта, що дає найшвидший доступ;
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-байтовим слотом плюс heap-обʼєктом».
Це також корінь проблеми точності великих цілих 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 інструментів розробника й читайте
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 під час розбору, нормально в усталеному стані | Пік розбору перекривається старими даними | Звільніть старі обʼєкти перед розбором нового файлу |
| Памʼять повільно зростає й ніколи не падає | Зріз, кеш або замикання тримає посилання | Зніміть знімок купи й сортуйте за утримуваним розміром |
| Масиви більші за очікувані | Десяткове число деградувало вид елементів | Уніфікуйте числові типи; уникайте delete |
| Додавання кешу сповільнило роботу | Ключі й значення споживають памʼять, підвищуючи тиск GC | Обмежте кеш і зберігайте лише підсумки |
Чому після JSON.parse памʼять зростає в три-чотири рази?
Бо текст — це лише вхід; продукт розбору — граф обʼєктів. Кожен обʼєкт має заголовок, кожна властивість — слот, а кожен рядок — власний заголовок. За достатньої кількості вузлів загальні накладні витрати перевищують кількість байтів тексту. Множник залежить від форми даних, і пласкі масиви обʼєктів сильно різняться від глибоко вкладених документів.
Файл JSON на 10 МБ розбирати повністю чи потоково?
Спершу подивіться на мету. Якщо потрібен довільний доступ, потрібна більшість полів і файл уміщується в памʼять — використовуйте повний JSON.parse. Якщо потрібно лише кілька полів або файл не вміщується — потоково. 10 МБ — не межа; потокова обробка зазвичай стає обовʼязковою десь вище 100 МБ.
Чому process.memoryUsage().heapUsed не збігається з системним моніторингом?
heapUsed охоплює лише використану частину JS-купи, виключаючи external, arrayBuffers і простір коду. Системний моніторинг повідомляє rss, що є іншим виміром. Відстежуйте heapUsed і rss разом; велика розбіжність указує на буфери й нативні обʼєкти.
Чи є JSON.parse(JSON.stringify(x)) безпечною глибокою копією?
Ні. Він тримає оригінальний граф, тимчасовий текст і новий граф одночасно, тож пік приблизно втричі більший за усталений стан, і він відкидає undefined, функції та семантику Date. Передавайте посилання для роботи лише для читання; коли потрібна ізоляція, використовуйте structuredClone і прийміть ту саму ціну перебудови.