Il bilancio di memoria di JSON: quanto costano davvero stringhe, oggetti e array grandi
In sintesi: Un file JSON da 10 MB può diventare 30-50 MB nell'heap di V8. Scopri quanto costano davvero stringhe, oggetti e numeri, come stabilire una baseline con process.memoryUsage e quando lo streaming aiuta.

In sintesi: stabilisci prima una baseline con process.memoryUsage, registrando il testo e il grafo di oggetti come due incrementi separati, poi rimuovi una copia completa prima di provare qualsiasi altra cosa — niente JSON.parse(JSON.stringify(x)), nessun wrapping reattivo del documento grezzo. Riduci poi i riferimenti di lunga durata, e considera lo streaming per ultimo: la sua curva di memoria piatta vale la perdita dell'accesso casuale solo quando il file non ci sta o ti servono solo pochi campi.
Un documento JSON da 10 MB può diventare da 30 MB a 50 MB nell'heap dopo
JSON.parse, e il picco transitorio sale ancora di più. Senza fare questa aritmetica, l'"ottimizzazione" è congettura.
1. Testo e grafo di oggetti sono due bilanci diversi
Su disco e in un corpo di richiesta, JSON è una sequenza di byte. Una volta entrato in memoria, diventa due cose: una stringa, e un grafo di oggetti fatto di oggetti, array, numeri e booleani. Tenere separati questi due bilanci è l'unico modo per spiegare perché un file da 10 MB può far consumare a un processo 80 MB.
| Forma dei dati | Grafo di oggetti vs dimensione del testo | Costo dominante |
|---|---|---|
| Array piatto di oggetti (circa 8 campi ciascuno) | Circa 2,5-4x | Un'intestazione di oggetto e slot di proprietà per elemento |
| Configurazione profondamente annidata | Circa 3-5x | Molti piccoli oggetti, ciascuno con la propria intestazione |
| Array di stringhe molto lunghe | Circa 1,2-2x | Intestazioni di stringa più dati dei caratteri |
| Numeri grandi con decimali | Circa 2-3x | Un HeapNumber per valore |
| Documenti con molte chiavi ripetute | Proporzionale al numero di chiavi | Hashing delle chiavi e setup delle proprietà durante il parsing |
Questi moltiplicatori sono riferimenti per ordini di grandezza che dipendono dalla versione di V8, dalla pointer compression, dal numero di campi e dal contenuto delle stringhe. La tabella fa un punto: il grafo di oggetti è dimensionato da "numero di nodi x overhead per nodo", non dal numero di byte nel testo.
2. Stringhe: la voce più sottovalutata
Una stringa V8 non è un array nudo di caratteri. Porta un'intestazione di oggetto (un puntatore alla map, un campo hash e una lunghezza), che costa circa una dozzina di byte su una tipica build a 64 bit con pointer compression, prima di qualsiasi carattere. I caratteri sono memorizzati in uno dei due modi:
- Stringhe a un byte per i caratteri nell'intervallo Latin-1: 1 byte per carattere;
- Stringhe a due byte quando il contenuto include caratteri CJK, emoji o qualsiasi cosa fuori da Latin-1: 2 byte per carattere.
Un carattere cinese occupa 3 byte in un file UTF-8 ma 2 byte per carattere in una stringa a due byte, più l'intestazione. Dimensione del testo e dimensione nell'heap non sono la stessa misura, e non sempre si muovono nella stessa direzione.
Tre dettagli contano direttamente per il lavoro su JSON:
- Le slice mantengono vivo il genitore. V8 rappresenta le sottostringhe più lunghe come
SlicedString, che memorizza un riferimento al genitore più un offset e una lunghezza. Memorizza 200 caratteri estratti da un log da 80 MB e tutti gli 80 MB restano raggiungibili finché quella slice non viene rilasciata. - La concatenazione crea
ConsString. Un+=ripetuto su stringhe grandi costruisce un albero di frammenti che viene appiattito in memoria contigua solo quando i caratteri vengono davvero letti. Concatenare diecimila volte e poi leggere una volta produce un grande picco di allocazione in quel momento. - I valori ripetuti non sono condivisi automaticamente. Diecimila record che contengono
"status": "paid"di solito ottengono diecimila oggetti stringa indipendenti; il parser non li fa puntare a un'unica costante. Per risparmiare quel costo devi mappare tu stesso le stringhe frequenti su enum o numeri.
Quando devi conservare un breve estratto per molto tempo, trasformalo in una stringa indipendente e rilascia l'originale:
// Bad: the slice references the whole document, so logText cannot be collected
const summary = logText.slice(0, 200);
// Good: create an independent string, then release logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Oggetti: ogni chiave occupa uno slot
Un oggetto semplice contiene almeno tre cose: un puntatore alla sua classe nascosta (map), un puntatore al suo archivio di proprietà e un puntatore al suo archivio di elementi. Con la pointer compression ogni puntatore è di 4 byte, e l'intestazione dell'oggetto nell'heap aggiunge altro, quindi perfino un oggetto vuoto costa circa sedici-venti byte. Su una build a 64 bit senza pointer compression, il doppio.
Le proprietà con nome finiscono in uno di due posti:
- Proprietà in-object: slot disposti direttamente dopo l'intestazione dell'oggetto, che danno l'accesso più veloce;
- Un
PropertyArray: una volta esauriti gli slot in-object, le proprietà si spostano in un array separato e l'oggetto ne tiene un puntatore.
Ogni slot è di 4 o 8 byte, e la stringa della chiave è contabilizzata separatamente. È per questo che i nomi di campo lunghi diventano un costo reale su centinaia di migliaia di record, non solo una questione estetica.
La buona notizia è che le classi nascoste sono condivise: un milione di oggetti con le stesse chiavi nello stesso ordine condividono una map invece di copiare le informazioni strutturali. La cattiva notizia è che questi pattern rompono quella condivisione o spingono l'oggetto in una rappresentazione più costosa:
- Alcuni record hanno un campo
remarke altri no, producendo due forme e un controllo di forma extra a ogni accesso; - Ordini di inserimento diversi possono produrre forme diverse;
- Conteggi di proprietà molto grandi (oltre circa mille) o frequenti operazioni
deletespingono l'oggetto in modalità dizionario, dove ogni proprietà porta anche una voce di tabella hash; Object.create(null)è in modalità dizionario dalla nascita. È una buona tabella di lookup e una scelta scarsa per un record a schema fisso acceduto in un ciclo caldo.
Un equivoco comune merita una correzione: JSON ammette chiavi duplicate, e {"a":1,"a":2} è un documento valido il cui risultato è a === 2. L'oggetto finale conserva un valore, ma il parser scansiona e assegna comunque ogni occorrenza. Le chiavi duplicate non risparmiano memoria; costano solo tempo di parsing.
4. Array e numeri: i tipi di elemento fissano la larghezza
V8 traccia diversi "element kind" per gli array, e ognuno ha una larghezza diversa per slot:
| Contenuto dell'array | Element kind | Costo approssimativo per slot |
|---|---|---|
| Interi piccoli (entro l'intervallo Smi) | PACKED_SMI_ELEMENTS |
4 byte |
| Decimali o interi molto grandi | PACKED_DOUBLE_ELEMENTS |
8 byte |
| Stringhe o oggetti | PACKED_ELEMENTS |
Puntatore di 4 byte più il valore stesso |
Array con buchi creati da delete |
HOLEY_* |
Lo stesso, più controlli sui buchi |
| Array molto sparsi | DICTIONARY_ELEMENTS |
Voci hash, tipicamente 20+ byte |
Due regole valgono la pena di essere memorizzate:
Primo, gli element kind possono solo degradare. Un array che inizia con interi piccoli diventa un array double nel momento in cui inserisci 0.5, e un array di elementi generici quando inserisci una stringa. Non si riprende mai automaticamente; devi costruire un nuovo array.
Secondo, dove finisce un numero JSON dipende dal suo valore. Gli interi entro l'intervallo Smi (circa più o meno 1,07 miliardi) sono memorizzati direttamente come Smi e occupano uno slot di puntatore. I numeri con decimali o esponenti, o i valori fuori da quell'intervallo, allocano un HeapNumber di circa 16 byte, con un puntatore memorizzato nell'array. Quindi [1, 2, 3] e [0.1, 0.2, 0.3] differiscono non solo nel valore ma in "uno slot di 4 byte" versus "uno slot di 8 byte più un oggetto nell'heap".
Questa è anche la radice del problema di precisione degli ID interi grandi: oltre l'intervallo degli interi sicuri, i numeri JSON sono trattati come double e le cifre di ordine inferiore semplicemente scompaiono. Gli array JSON arrivano packed e senza buchi, quindi non fare delete degli elementi dopo il parsing; questo spinge l'intero array verso una rappresentazione più costosa.
5. Picchi di parsing e serializzazione: il momento in cui un processo muore davvero
La memoria a regime stazionario è un bilancio; il picco è un altro. Mentre JSON.parse(text) gira, il processo tiene tre cose contemporaneamente:
- La stringa di input
text; - Il grafo di oggetti in costruzione e non ancora completo;
- Le strutture temporanee del parser stesso.
Il picco di parsing è approssimativamente "testo + grafo di oggetti + overhead temporaneo". Dopo il parsing, se text è ancora referenziato da una variabile, una closure, una cache a livello di modulo o uno store reattivo, quella memoria del testo non viene mai restituita.
La serializzazione ha anch'essa un picco. JSON.stringify(data) tiene l'intero grafo di oggetti mentre costruisce la stringa di output, con buffer di concatenazione nel mezzo. Quando l'output si avvicina al limite di lunghezza delle stringhe di V8 (circa 500 milioni di caratteri), lancia un RangeError; questo errore di solito compare in script che esportano un'intera base di dati come un unico documento JSON.
Il pattern comune più costoso usa la serializzazione come deep clone:
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
Quella singola riga memorizza gli stessi dati tre volte. Per lavoro in sola lettura, passa un riferimento. Quando l'isolamento è davvero richiesto, usa structuredClone e accetta che ricostruisca anche l'intero grafo di oggetti.
6. Misurare con process.memoryUsage
In Node, process.memoryUsage() restituisce cinque numeri in una volta: rss (memoria fisica usata dal processo), heapTotal, heapUsed (heap JS usato), external (memoria tenuta da oggetti C++) e arrayBuffers (memoria di ArrayBuffer e Buffer). Quando studi la gestione JSON, heapUsed è la lente giusta per il grafo di oggetti e rss ti dice se l'intero processo è fuori controllo.
Uno script di misura richiede tre discipline: chiama prima global.gc(), campiona per fasi e cambia una variabile alla volta. Questo script registra separatamente gli incrementi per "leggi il testo" e "analizza in oggetti":
// Run: 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,
});
Alcune trappole:
--expose-gcdeve essere abilitato esplicitamente; altrimentiglobal.gcnon esiste;heapUsedcopre solo l'heap JS. I Buffer della lettura dei file vivono sottoexternalearrayBuffers, quindi non usare un solo numero per spiegare tutto;- Non fare
console.logdi un oggetto grandi tra i campioni; mantiene vivo un riferimento temporaneo; - La prima esecuzione include il warm-up del JIT e la compilazione lazy. Esegui almeno tre round e prendi la mediana;
- Per confrontare nel browser, prendi heap snapshot nel pannello Memory di DevTools e leggi
Retained Sizeinvece di fissare il dente di sega nell'Allocation Timeline.
Senza questa baseline, affermazioni come "passare allo streaming risolverà" o "una cache lo renderà più veloce" sono solo sensazioni.
7. Lo streaming compra memoria di picco, non tempo totale
Il compromesso del parsing in streaming è esplicito: accesso casuale in cambio di memoria che non scala più con la dimensione del file.
| Approccio | Memoria di picco | Accesso casuale | Migliore per |
|---|---|---|---|
JSON.parse completo |
Testo più l'intero grafo di oggetti | Sì | Sotto circa 100 MB e servono la maggior parte dei campi |
Un parser SAX come stream-json |
Un chunk più l'elemento corrente | No | File enormi, o servono solo pochi campi |
| JSON Lines riga per riga | L'oggetto per una riga | No | Dati che sono naturalmente un lotto di record |
| Indice esterno o database | Nulla caricato in pieno | Sì | Interrogazioni ripetute sugli stessi dati |
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);
}
Questo codice non costruisce mai l'albero completo, quindi la curva di memoria resta piatta. I costi sono anche reali: non puoi tornare indietro per un campo che hai saltato, le aggregazioni globali diventano difficili, il recupero dagli errori è tua responsabilità, e una scansione carattere per carattere in puro JS è di solito un ordine di grandezza più lenta di JSON.parse nativo.
La decisione non è "il file ha superato i 10 MB?" Fai due domande: Mi serve la maggior parte dei campi dopo il parsing? Posso vivere con l'accesso sequenziale? Se entrambe le risposte sono sì, il parsing completo è corretto e il tuo sforzo appartiene alla riduzione delle copie e al rendering. Se una delle due risposte è no, considera lo streaming. Per la strategia a strati completa, vedi Parsing, rendering e streaming di JSON di grandi dimensioni.
8. Meno copie, rilascio più anticipato
Nel lavoro sulla memoria, rimuovere una copia completa spesso vale più che cambiare parser:
- Passa riferimenti per il lavoro in sola lettura. Non fare deep clone "per sicurezza", soprattutto non con
JSON.parse(JSON.stringify(x)); - Non avvolgere il documento grezzo in un sistema reattivo.
reactivedi Vue fa proxy dell'intero grafo di oggetti ricorsivamente, e lo strato proxy ha il proprio costo di memoria; - Calcola e metti in cache separatamente i dati derivati. Se una lista necessita solo di id, title e status, non mettere interi record nello store;
- Normalizza le stringhe ad alta frequenza. Mappa valori come
"paid"e"pending"su enum numerici per risparmiare sia gli oggetti stringa sia il costo dei confronti; - Taglia i riferimenti di lunga durata. Azzera i grandi oggetti quando hai finito, svuota le cache e annulla la registrazione dei callback così le closure non li portano alla pagina successiva;
- Tieni conto dei costi di copia dei Worker.
postMessageusa la clonazione strutturata per impostazione predefinita; inviare un grafo di oggetti da 50 MB al thread principale costruisce una seconda copia. Restituisci un riassunto ogni volta che è possibile.
L'altra metà del bilancio della dimensione vive in Smetti di fare compressione JSON nell'ordine sbagliato: i byte su disco e i byte sul filo sono un conto diverso dal grafo di oggetti nell'heap, e nessuna singola percentuale spiega entrambi. Per come si comportano editor e strumenti CLI con la memoria, vedi Editor, strumenti da riga di comando e automazione.
9. Lista di controllo
| Sintomo | Causa più probabile | Prima azione |
|---|---|---|
| Un file da 10 MB spinge il processo a 50 MB | Grafo di oggetti e testo restano entrambi vivi | Misura per fasi; controlla se il testo è trattenuto |
| OOM al momento del parsing, normale a regime | Il picco di parsing si sovrappone ai vecchi dati | Rilascia i vecchi oggetti prima di analizzare il nuovo file |
| La memoria sale lentamente e non scende mai | Una slice, cache o closure tiene un riferimento | Prendi un heap snapshot e ordina per dimensione trattenuta |
| Gli array sono più grandi del previsto | Un decimale ha degradato l'element kind | Unifica i tipi numerici; evita delete |
| Aggiungere una cache ha rallentato le cose | Chiavi e valori consumano memoria, aumentando la pressione del GC | Limita la cache e memorizza solo riassunti |
Perché la memoria cresce di tre o quattro volte dopo JSON.parse?
Perché il testo è solo l'input; il prodotto del parsing è un grafo di oggetti. Ogni oggetto ha un'intestazione, ogni proprietà ha uno slot, e ogni stringa ha la propria intestazione. Con abbastanza nodi, l'overhead totale supera il conteggio dei byte del testo. Il moltiplicatore dipende dalla forma dei dati, e gli array piatti di oggetti differiscono molto dai documenti profondamente annidati.
Un file JSON da 10 MB dovrebbe essere analizzato in pieno o in streaming?
Guarda prima lo scopo. Se ti serve l'accesso casuale, la maggior parte dei campi e il file ci sta in memoria, usa JSON.parse completo. Se ti servono solo pochi campi, o il file non ci sta, usalo in streaming. 10 MB non è un confine; lo streaming di solito diventa obbligatorio da qualche parte oltre i 100 MB.
Perché process.memoryUsage().heapUsed non concorda con il monitoraggio di sistema?
heapUsed copre solo la porzione usata dell'heap JS, escludendo external, arrayBuffers e lo spazio del codice. Il monitoraggio di sistema riporta rss, che è una misura diversa. Traccia heapUsed e rss insieme; un grande divario indica Buffer e oggetti nativi.
JSON.parse(JSON.stringify(x)) è un deep clone sicuro?
No. Tiene il grafo originale, il testo temporaneo e il nuovo grafo allo stesso tempo, quindi il picco è circa tre volte il regime stazionario, e perde undefined, funzioni e la semantica di Date. Passa riferimenti per il lavoro in sola lettura; quando serve isolamento, usa structuredClone e accetta lo stesso costo di ricostruzione.