Účet paměti JSON: co řetězce, objekty a velká pole skutečně stojí
Shrnutí: 10MB soubor JSON může v haldě V8 zabrat 30–50 MB. Zjistěte, co skutečně stojí řetězce, objekty a čísla, jak stanovit baseline pomocí process.memoryUsage a kdy pomáhá streamování.

Stručně: nejprve si stanovte baseline pomocí process.memoryUsage, přičemž text a graf objektů zaznamenejte jako dva oddělené přírůstky, a poté odstraňte jednu plnou kopii, než zkusíte cokoli jiného – žádné JSON.parse(JSON.stringify(x)), žádné reaktivní obalování surového dokumentu. Následně ořežte dlouhověké reference a streamování zvažte až nakonec: jeho plochá paměťová křivka má cenu ztráty náhodného přístupu jen tehdy, když se soubor nevejde, nebo když potřebujete jen několik polí.
10MB dokument JSON může v haldě po
JSON.parsezabrat 30 až 50 MB a přechodný vrchol jde ještě výš. Bez této aritmetiky je „optimalizace“ hádáním.
1. Text a graf objektů jsou dva různé účty
Na disku a v těle požadavku je JSON posloupnost bajtů. Jakmile vstoupí do paměti, stane se dvěma věcmi: řetězcem a grafem objektů složeným z objektů, polí, čísel a booleanů. Držet tyto dva účty oddělené je jediný způsob, jak vysvětlit, proč 10MB soubor může přimět proces spotřebovat 80 MB.
| Tvar dat | Graf objektů vs. velikost textu | Dominantní náklad |
|---|---|---|
| Ploché pole objektů (asi 8 polí každý) | Zhruba 2,5–4x | Jedna hlavička objektu a sloty vlastností na prvek |
| Hluboce vnořená konfigurace | Zhruba 3–5x | Mnoho malých objektů, každý s vlastní hlavičkou |
| Pole velmi dlouhých řetězců | Zhruba 1,2–2x | Hlavičky řetězců plus data znaků |
| Velká čísla s desetinnými místy | Zhruba 2–3x | Jedno HeapNumber na hodnotu |
| Dokumenty s mnoha opakovanými klíči | Úměrné počtu klíčů | Hashing klíčů a nastavení vlastností během parsování |
Tyto násobitele jsou řádové reference závislé na verzi V8, kompresi ukazatelů, počtu polí a obsahu řetězců. Tabulka ukazuje jednu věc: graf objektů je dimenzován podle „počet uzlů × režie na uzel“, nikoli podle počtu bajtů v textu.
2. Řetězce: nejvíce podceňovaná položka
Řetězec ve V8 není holé pole znaků. Nese hlavičku objektu (ukazatel na mapu, hash pole a délku), která na typickém 64bitovém buildu s kompresí ukazatelů stojí zhruba tucet bajtů, ještě před jakýmikoli znaky. Znaky se ukládají jedním ze dvou způsobů:
- Jednobajtové řetězce pro znaky v rozsahu Latin-1: 1 bajt na znak;
- Dvoubajtové řetězce, když obsah zahrnuje CJK znaky, emoji nebo cokoli mimo Latin-1: 2 bajty na znak.
Čínský znak zabírá 3 bajty v souboru UTF-8, ale 2 bajty na znak ve dvoubajtovém řetězci, plus hlavičku. Velikost textu a velikost v haldě nejsou stejné měření a ne vždy se pohybují stejným směrem.
Tři detaily přímo záleží pro práci s JSON:
- Slices udržují rodiče naživu. V8 reprezentuje delší podřetězce jako
SlicedString, který ukládá referenci na rodiče plus offset a délku. Uložte 200 znaků vyříznutých z 80MB logu a celých 80 MB zůstane dostupných, dokud se ten slice neuvolní. - Spojování vytváří
ConsString. Opakované+=na velkých řetězcích staví strom fragmentů, který se do souvislé paměti slisuje až tehdy, když se znaky skutečně čtou. Spojení desetitisíckrát a poté jedno přečtení produkuje v tu chvíli velký alokační skok. - Opakované hodnoty nejsou automaticky sdílené. Deset tisíc záznamů obsahujících
"status": "paid"obvykle získá deset tisíc nezávislých string objektů; parser je neukazuje na jednu konstantu. Abyste tuto cenu ušetřili, musíte časté řetězce sami zmapovat na výčty nebo čísla.
Když potřebujete krátký výňatek udržet dlouho, udělejte z něj nezávislý řetězec a původní uvolněte:
// Špatně: slice odkazuje na celý dokument, takže logText nelze uvolnit
const summary = logText.slice(0, 200);
// Dobře: vytvořte nezávislý řetězec a poté uvolněte logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Objekty: každý klíč zabírá slot
Obyčejný objekt obsahuje alespoň tři věci: ukazatel na svou skrytou třídu (map), ukazatel na úložiště vlastností a ukazatel na úložiště elementů. S kompresí ukazatelů má každý ukazatel 4 bajty a hlavička objektu v haldě přidává další, takže i prázdný objekt stojí zhruba šestnáct až dvacet něco bajtů. Na 64bitovém buildu bez komprese ukazatelů to zdvojnásobte.
Pojmenované vlastnosti přistanou na jednom ze dvou míst:
- Vlastnosti v objektu: sloty umístěné přímo za hlavičkou objektu, což dává nejrychlejší přístup;
PropertyArray: jakmile se sloty v objektu vyčerpají, vlastnosti se přesunou do samostatného pole a objekt drží ukazatel na něj.
Každý slot má 4 nebo 8 bajtů a klíčový řetězec se účtuje zvlášť. Proto se dlouhé názvy polí stávají skutečným nákladem napříč stovkami tisíc záznamů, nejen estetickým problémem.
Dobrou zprávou je, že skryté třídy jsou sdílené: milion objektů se stejnými klíči ve stejném pořadí sdílí jednu mapu, místo aby kopírovaly strukturální informace. Špatnou zprávou je, že tyto vzory sdílení rozbíjejí nebo objekt tlačí do dražší reprezentace:
- Některé záznamy mají pole
remarka jiné ne, což vytváří dva tvary a kontrolu navíc při každém přístupu; - Různá pořadí vkládání mohou vytvořit různé tvary;
- Velmi vysoké počty vlastností (nad zhruba jeden tisíc) nebo časté operace
deletetlačí objekt do slovníkového režimu, kde každá vlastnost nese navíc i položku hash tabulky; Object.create(null)je ve slovníkovém režimu od narození. Je dobrá vyhledávací tabulka a špatná volba pro záznam s pevným schématem přístupný v horké smyčce.
Jedno běžné nedorozumění si zaslouží opravu: JSON povoluje duplicitní klíče a {"a":1,"a":2} je platný dokument, jehož výsledkem je a === 2. Výsledný objekt drží jednu hodnotu, ale parser přesto skenuje a přiřazuje každý výskyt. Duplicitní klíče neušetří paměť; stojí jen čas parsování.
4. Pole a čísla: druh elementů určuje šířku
V8 sleduje několik „druhů elementů“ polí a každý má jinou šířku slotu:
| Obsah pole | Druh elementů | Přibližný náklad na slot |
|---|---|---|
| Malá celá čísla (v rozsahu Smi) | PACKED_SMI_ELEMENTS |
4 bajty |
| Desetinná čísla nebo velmi velká celá čísla | PACKED_DOUBLE_ELEMENTS |
8 bajtů |
| Řetězce nebo objekty | PACKED_ELEMENTS |
4bajtový ukazatel plus hodnota samotná |
Pole s dírami vzniklými delete |
HOLEY_* |
Stejné, plus kontroly děr |
| Velmi řídká pole | DICTIONARY_ELEMENTS |
Hash položky, typicky 20+ bajtů |
Dvě pravidla stojí za zapamatování:
Za prvé, druhy elementů se jen zhoršují. Pole, které začíná malými celými čísly, se stane polem desetinných čísel ve chvíli, kdy vložíte 0.5, a generickým polem elementů, když vložíte řetězec. Nikdy se automaticky nezotaví; musíte postavit nové pole.
Za druhé, kam číslo JSON přistane, závisí na jeho hodnotě. Celá čísla v rozsahu Smi (zhruba plus mínus 1,07 miliardy) se ukládají přímo jako Smi a zabírají jeden ukazatelský slot. Čísla s desetinnými místy nebo exponenty, nebo hodnoty mimo tento rozsah, alokují HeapNumber o velikosti asi 16 bajtů, s ukazatelem na něj uloženým v poli. Takže [1, 2, 3] a [0.1, 0.2, 0.3] se liší nejen hodnotou, ale i „jedním 4bajtovým slotem“ versus „8bajtovým slotem plus objektem v haldě“.
To je také kořen problému přesnosti velkých celých ID: za rozsahem bezpečných celých čísel se čísla JSON považují za doubly a číslice nižších řádů prostě zmizí. Pole JSON přicházejí zabalená a bez děr, takže po parsování nedělejte delete; to tlačí celé pole k dražší reprezentaci.
5. Vrcholy parsování a serializace: kdy proces skutečně umírá
Ustálená paměť je jeden účet; vrchol je jiný. Zatímco běží JSON.parse(text), proces drží tři věci najednou:
- Vstupní řetězec
text; - Graf objektů, který se staví a ještě není kompletní;
- Vlastní dočasné struktury parseru.
Vrchol parsování je zhruba „text + graf objektů + dočasná režie“. Po parsování, pokud je text stále referencován proměnnou, closure, module-level cache nebo reaktivním storem, se ta paměť textu nikdy nevrátí.
Serializace má vrchol také. JSON.stringify(data) drží celý graf objektů, zatímco staví výstupní řetězec, mezi tím s buffery pro spojování. Když se výstup blíží limitu délky řetězce ve V8 (kolem 500 milionů znaků), vyhodí RangeError; toto selhání se obvykle objeví ve skriptech, které exportují celou databázi jako jediný dokument JSON.
Nejdražší běžný vzor používá serializaci jako hluboký klon:
// Vrchol je zhruba: původní graf objektů + dočasný text + nový graf objektů
const safeCopy = JSON.parse(JSON.stringify(bigData));
Ten jediný řádek ukládá stejná data třikrát. Pro práci jen pro čtení předávejte referenci. Když je izolace skutečně nutná, použijte structuredClone a přijměte, že také znovu postaví celý graf objektů.
6. Měření pomocí process.memoryUsage
V Node process.memoryUsage() vrací najednou pět čísel: rss (fyzická paměť používaná procesem), heapTotal, heapUsed (použitá JS halda), external (paměť držená C++ objekty) a arrayBuffers (paměť ArrayBufferů a Bufferů). Při studiu zpracování JSON je heapUsed správná čočka pro graf objektů a rss vám řekne, zda je celý proces mimo kontrolu.
Měřicí skript potřebuje tři zásady: nejprve zavolat global.gc(), vzorkovat po fázích a měnit vždy jednu proměnnou. Tento skript zaznamenává přírůstky pro „načtení textu“ a „parsování do objektů“ odděleně:
// Spustit: 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,
});
Několik pastí:
--expose-gcmusí být explicitně zapnuto; jinakglobal.gcneexistuje;heapUsedpokrývá jen JS haldu. Buffery pro čtení souborů žijí podexternalaarrayBuffers, takže jedno číslo nevysvětlí všechno;- Nedělejte
console.logvelkého objektu mezi vzorky; udržuje dočasnou referenci naživu; - První běh zahrnuje zahřívání JIT a línou kompilaci. Spusťte alespoň tři kola a vezměte medián;
- Pro srovnání v prohlížeči pořizujte snapshoty haldy v panelu Memory v DevTools a čtěte
Retained Size, místo abyste zírali na pilovitý graf v Allocation Timeline.
Bez této baseline jsou tvrzení jako „přechod na streamování to opraví“ nebo „cache to zrychlí“ jen pocity.
7. Streamování kupuje vrchol paměti, nikoli celkový čas
Kompromis streamovaného parsování je explicitní: náhodný přístup výměnou za paměť, která už neškáluje s velikostí souboru.
| Přístup | Vrchol paměti | Náhodný přístup | Nejlepší pro |
|---|---|---|---|
Úplný JSON.parse |
Text plus kompletní graf objektů | Ano | Pod zhruba 100 MB a většina polí je potřeba |
SAX parser jako stream-json |
Jeden blok plus aktuální element | Ne | Obrovské soubory, nebo když je potřeba jen několik polí |
| Řádkově JSON Lines | Objekt pro jeden řádek | Ne | Data, která jsou přirozeně dávkou záznamů |
| Externí index nebo databáze | Nic nenačteno celé | Ano | Opakované dotazy nad stejnými daty |
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);
}
Tento kód nikdy nepostaví celý strom, takže paměťová křivka zůstává plochá. Cena je také skutečná: nemůžete se vrátit pro přeskočené pole, globální agregace je obtížná, obnova po chybě je na vás a čistě JS skenování znak po znaku je obvykle o řád pomalejší než nativní JSON.parse.
Rozhodnutí není „překročil soubor 10 MB“. Zeptejte se na dvě otázky: Potřebuji po parsování většinu polí? Vyhoví mi sekvenční přístup? Pokud je odpověď na obě ano, úplné parsování je správné a vaše úsilí patří do snižování kopií a vykreslování. Pokud je některá odpověď ne, zvažte streamování. Úplnou vrstvenou strategii najdete v Parsování, vykreslování a streamování velkého JSON.
8. Méně kopií, dřívější uvolnění
V paměťové práci je odstranění jedné plné kopie často cennější než výměna parserů:
- Pro práci jen pro čtení předávejte reference. Nedělejte hluboké kopie „pro bezpečí“, zejména ne pomocí
JSON.parse(JSON.stringify(x)); - Nebalte surový dokument do reaktivního systému. Vue
reactiveproxi celý graf objektů rekurzivně a proxy vrstva má vlastní paměťové náklady; - Odvozená data počítejte a cachujte zvlášť. Pokud seznam potřebuje jen id, název a stav, nedávejte do store celé záznamy;
- Normalizujte časté řetězce. Mapujte hodnoty jako
"paid"a"pending"na číselné výčty, čímž ušetříte string objekty i náklady na porovnání; - Ořežte dlouhověké reference. Velké objekty po dokončení vynulujte, vymažte caches a zrušte registrace callbacků, aby je closures netahly na další stránku;
- Počítejte náklady kopírování ve Workeru.
postMessageve výchozím nastavení používá strukturované klonování; poslání 50MB grafu objektů zpět do hlavního vlákna staví druhou kopii. Vracejte shrnutí, kdykoli je to možné.
Druhá polovina velikostního účtu žije v Přestaňte komprimovat JSON ve špatném pořadí: bajty na disku a bajty na drátě jsou jiný účet než graf objektů v haldě a žádné jediné procento nevysvětlí oboje. Jak se chovají editory a CLI nástroje s pamětí, najdete v Editory, nástroje příkazového řádku a automatizace.
9. Kontrolní seznam
| Příznak | Pravděpodobnější příčina | První akce |
|---|---|---|
| 10MB soubor žene proces na 50 MB | Graf objektů i text zůstávají naživu | Měřte po fázích; zkontrolujte, zda je text zadržován |
| OOM při parsování, normální ve ustáleném stavu | Vrchol parsování se překrývá s old daty | Uvolněte old objekty před parsováním nového souboru |
| Paměť pomalu roste a nikdy neklesne | Slice, cache nebo closure drží referenci | Pořiďte snapshot haldy a seřaďte podle retained size |
| Pole jsou větší, než se čekalo | Desetinné číslo zhoršilo druh elementů | Sjednoťte číselné typy; vyhněte se delete |
| Přidání cache věci zpomalilo | Klíče a hodnoty spotřebovávají paměť a zvyšují tlak GC | Ohraničte cache a ukládejte jen shrnutí |
Proč paměť po JSON.parse naroste třikrát nebo čtyřikrát?
Protože text je jen vstup; produktem parsování je graf objektů. Každý objekt má hlavičku, každá vlastnost slot a každý řetězec vlastní hlavičku. Při dostatku uzlů celková režie převyšuje počet bajtů textu. Násobitel závisí na tvaru dat a plochá pole objektů se silně liší od hluboce vnořených dokumentů.
Má se 10MB soubor JSON parsovat úplně, nebo streamovat?
Podívejte se nejprve na účel. Pokud potřebujete náhodný přístup, většinu polí a soubor se vejde do paměti, použijte úplný JSON.parse. Pokud potřebujete jen několik polí, nebo se soubor nevejde, streamujte. 10 MB není hranice; streamování se obvykle stává povinným někde nad 100 MB.
Proč se process.memoryUsage().heapUsed neshoduje s monitoringem systému?
heapUsed pokrývá jen použitou část JS haldy, s vyloučením external, arrayBuffers a prostoru pro kód. Monitoring systému hlásí rss, což je jiné měření. Sledujte heapUsed a rss společně; velká mezera ukazuje na Buffery a nativní objekty.
Je JSON.parse(JSON.stringify(x)) bezpečný hluboký klon?
Ne. Drží najednou původní graf, dočasný text i nový graf, takže vrchol je zhruba trojnásobek ustáleného stavu, a zahazuje undefined, funkce i sémantiku Date. Pro práci jen pro čtení předávejte reference; když je nutná izolace, použijte structuredClone a přijměte stejné náklady na přestavbu.