JSON Toolkit

Die JSON-Speicherbilanz: was Strings, Objekte und große Arrays wirklich kosten

Fazit: Eine 10-MB-JSON-Datei kann im V8-Heap 30–50 MB werden. Erfahren Sie, was Strings, Objekte und Zahlen wirklich kosten, wie man mit process.memoryUsage eine Baseline legt und wann Streaming hilft.

Experte~11 Min. LesezeitVeröffentlicht 2026-10-31Aktualisiert 2026-10-31
JSON-Speicherbilanz: Heap-Kosten von Strings, Objekten und Arrays in V8

Kurz gesagt: Legen Sie zuerst mit process.memoryUsage eine Baseline, und erfassen Sie den Text und den Objektgraphen als zwei getrennte Zuwächse; entfernen Sie dann eine vollständige Kopie, bevor Sie irgendetwas anderes versuchen – kein JSON.parse(JSON.stringify(x)), kein Reaktivitäts-Wrapper um das Rohdokument. Kürzen Sie als Nächstes langlebige Referenzen und erwägen Sie Streaming zuletzt: seine flache Speicherkurve ist den Verlust wahlfreien Zugriffs nur wert, wenn die Datei nicht passt oder Sie nur wenige Felder brauchen.

Ein 10-MB-JSON-Dokument kann nach JSON.parse im Heap 30 MB bis 50 MB werden, und die transiente Spitze geht noch höher. Ohne diese Rechnung ist „Optimierung“ Stochern im Nebel.

1. Text und Objektgraph sind zwei verschiedene Bilanzen

Auf der Platte und in einem Request-Body ist JSON eine Bytefolge. Sobald es in den Speicher gelangt, wird es zu zwei Dingen: einem String und einem Objektgraphen aus Objekten, Arrays, Zahlen und Booleans. Diese beiden Bilanzen getrennt zu halten ist der einzige Weg zu erklären, warum eine 10-MB-Datei einen Prozess 80 MB verbrauchen lassen kann.

Datenform Objektgraph vs. Textgröße Dominante Kosten
Flaches Array aus Objekten (je etwa 8 Felder) Etwa das 2,5- bis 4-Fache Ein Objekt-Header und Property-Slots pro Element
Tief verschachtelte Konfiguration Etwa das 3- bis 5-Fache Viele kleine Objekte, jedes mit eigenem Header
Array aus sehr langen Strings Etwa das 1,2- bis 2-Fache String-Header plus Zeichendaten
Große Zahlen mit Dezimalstellen Etwa das 2- bis 3-Fache Ein HeapNumber pro Wert
Dokumente mit vielen wiederholten Schlüsseln Proportional zur Schlüsselzahl Key-Hashing und Property-Aufbau beim Parsen

Diese Multiplikatoren sind Größenordnungsreferenzen, die von V8-Version, Pointer Compression, Feldzahl und String-Inhalt abhängen. Die Tabelle macht einen Punkt: Der Objektgraph wird nach „Knotenzahl x Overhead pro Knoten“ dimensioniert, nicht nach der Bytezahl im Text.

2. Strings: die am meisten unterschätzte Position

Ein V8-String ist kein nacktes Zeichenarray. Er trägt einen Objekt-Header (einen Map-Pointer, ein Hash-Feld und eine Länge), der auf einem typischen 64-Bit-Build mit Pointer Compression etwa ein Dutzend Bytes kostet, bevor überhaupt Zeichen kommen. Zeichen werden auf eine von zwei Arten gespeichert:

  • Ein-Byte-Strings für Zeichen im Latin-1-Bereich: 1 Byte pro Zeichen;
  • Zwei-Byte-Strings, wenn der Inhalt CJK-Zeichen, Emoji oder irgendetwas außerhalb von Latin-1 enthält: 2 Bytes pro Zeichen.

Ein chinesisches Zeichen belegt in einer UTF-8-Datei 3 Bytes, in einem Zwei-Byte-String aber 2 Bytes pro Zeichen, plus den Header. Textgröße und Heap-Größe sind nicht dieselbe Messung, und sie bewegen sich nicht immer in dieselbe Richtung.

Drei Details zählen direkt für JSON-Arbeit:

  1. Slices halten den Elternteil am Leben. V8 stellt längere Substrings als SlicedString dar, der eine Elternreferenz plus Offset und Länge speichert. Speichern Sie 200 Zeichen, die aus einem 80-MB-Log geschnitten wurden, und die ganzen 80 MB bleiben erreichbar, bis dieser Slice freigegeben wird.
  2. Verkettung erzeugt ConsString. Wiederholtes += auf großen Strings baut einen Baum von Fragmenten, der erst in zusammenhängenden Speicher abgeflacht wird, wenn Zeichen tatsächlich gelesen werden. Zehntausendmal verketten und dann einmal lesen erzeugt in diesem Moment eine große Allokationsspitze.
  3. Wiederholte Werte werden nicht automatisch geteilt. Zehntausend Datensätze mit "status": "paid" bekommen meist zehntausend unabhängige String-Objekte; der Parser zeigt sie nicht auf eine Konstante. Um diese Kosten zu sparen, müssen Sie häufige Strings selbst auf Enums oder Zahlen abbilden.

Müssen Sie einen kurzen Auszug lange behalten, machen Sie daraus einen unabhängigen String und geben Sie das Original frei:

// 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. Objekte: jeder Schlüssel belegt einen Slot

Ein einfaches Objekt enthält mindestens drei Dinge: einen Pointer auf seine Hidden Class (Map), einen Pointer auf seinen Property-Store und einen Pointer auf seinen Element-Store. Mit Pointer Compression ist jeder Pointer 4 Bytes, und der Heap-Objekt-Header kommt dazu, selbst ein leeres Objekt kostet also grob sechzehn bis etwas über zwanzig Bytes. Auf einem 64-Bit-Build ohne Pointer Compression das Doppelte.

Benannte Eigenschaften landen an einem von zwei Orten:

  • In-Object-Properties: Slots direkt nach dem Objekt-Header, was den schnellsten Zugriff gibt;
  • Ein PropertyArray: Sind die In-Object-Slots erschöpft, wandern die Eigenschaften in ein separates Array, und das Objekt hält einen Pointer darauf.

Jeder Slot ist 4 oder 8 Bytes, und der Schlüsselstring wird separat verbucht. Deshalb werden lange Feldnamen über Hunderttausende Datensätze zu echten Kosten, nicht nur zu einer ästhetischen Frage.

Die gute Nachricht ist, dass Hidden Classes geteilt werden: Eine Million Objekte mit denselben Schlüsseln in derselben Reihenfolge teilen eine Map, statt Strukturinformation zu kopieren. Die schlechte Nachricht ist, dass diese Muster die Teilung brechen oder das Objekt in eine teurere Darstellung drängen:

  • Manche Datensätze haben ein remark-Feld, andere nicht, was zwei Formen und eine zusätzliche Formprüfung bei jedem Zugriff erzeugt;
  • Unterschiedliche Einfügereihenfolgen können unterschiedliche Formen erzeugen;
  • Sehr große Property-Zahlen (jenseits von grob eintausend) oder häufige delete-Operationen drängen das Objekt in den Dictionary-Modus, wo jede Eigenschaft zusätzlich einen Hash-Tabelleneintrag trägt;
  • Object.create(null) ist von Geburt an im Dictionary-Modus. Es ist eine gute Lookup-Tabelle und eine schlechte Wahl für einen Datensatz mit festem Schema, auf den in einer heißen Schleife zugegriffen wird.

Ein verbreiteter Irrtum verdient eine Korrektur: JSON erlaubt doppelte Schlüssel, und {"a":1,"a":2} ist ein gültiges Dokument, dessen Ergebnis a === 2 ist. Das endgültige Objekt behält einen Wert, aber der Parser scannt und weist trotzdem jedes Vorkommen zu. Doppelte Schlüssel sparen keinen Speicher; sie kosten nur Parse-Zeit.

4. Arrays und Zahlen: die Element-Kinds bestimmen die Breite

V8 verfolgt mehrere „Element Kinds“ für Arrays, und jede hat eine andere Breite pro Slot:

Array-Inhalt Element Kind Ungefähre Kosten pro Slot
Kleine Ganzzahlen (im Smi-Bereich) PACKED_SMI_ELEMENTS 4 Bytes
Dezimalzahlen oder sehr große Ganzzahlen PACKED_DOUBLE_ELEMENTS 8 Bytes
Strings oder Objekte PACKED_ELEMENTS 4-Byte-Pointer plus der Wert selbst
Arrays mit durch delete erzeugten Lücken HOLEY_* Gleich, plus Lückenprüfungen
Sehr sparse Arrays DICTIONARY_ELEMENTS Hash-Einträge, typischerweise 20+ Bytes

Zwei Regeln lohnen sich zu merken:

Erstens, Element Kinds degradieren nur. Ein Array, das mit kleinen Ganzzahlen beginnt, wird ein Double-Array, sobald Sie 0.5 einfügen, und ein generisches Element-Array, sobald Sie einen String einfügen. Es erholt sich nie automatisch; Sie müssen ein neues Array bauen.

Zweitens, wo eine JSON-Zahl landet, hängt von ihrem Wert ab. Ganzzahlen im Smi-Bereich (grob plus/minus 1,07 Milliarden) werden direkt als Smi gespeichert und belegen einen Pointer-Slot. Zahlen mit Dezimalstellen oder Exponenten oder Werte außerhalb dieses Bereichs allokieren ein HeapNumber von etwa 16 Bytes, mit einem Pointer darauf im Array. [1, 2, 3] und [0.1, 0.2, 0.3] unterscheiden sich also nicht bloß im Wert, sondern in „ein 4-Byte-Slot“ gegenüber „ein 8-Byte-Slot plus ein Heap-Objekt“.

Das ist auch die Wurzel des Präzisionsproblems bei großen Ganzzahl-IDs: Jenseits des sicheren Ganzzahlbereichs werden JSON-Zahlen als Doubles behandelt und niederwertige Ziffern verschwinden einfach. JSON-Arrays kommen gepackt und lückenfrei an, also deleteen Sie nach dem Parsen keine Elemente; das drängt das ganze Array zu einer teureren Darstellung.

5. Parse- und Serialisierungsspitzen: der Moment, in dem ein Prozess tatsächlich stirbt

Der Steady-State-Speicher ist eine Bilanz; die Spitze ist eine andere. Während JSON.parse(text) läuft, hält der Prozess drei Dinge gleichzeitig:

  1. Den Eingabestring text;
  2. Den Objektgraphen, der aufgebaut und noch nicht vollständig ist;
  3. Die temporären Strukturen des Parsers selbst.

Die Parse-Spitze ist grob „Text + Objektgraph + temporärer Overhead“. Wird text nach dem Parsen noch von einer Variable, einer Closure, einem Cache auf Modulebene oder einem reaktiven Store referenziert, wird dieser Textspeicher nie zurückgegeben.

Die Serialisierung hat ebenfalls eine Spitze. JSON.stringify(data) hält den vollständigen Objektgraphen, während es den Ausgabestring baut, mit Verkettungspuffern dazwischen. Nährt sich die Ausgabe V8s Stringlängenlimit (etwa 500 Millionen Zeichen), wirft es einen RangeError; dieser Fehler zeigt sich meist in Skripten, die eine ganze Datenbank als ein JSON-Dokument exportieren.

Das teuerste verbreitete Muster verwendet Serialisierung als Tiefenkopie:

// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));

Diese einzelne Zeile speichert dieselben Daten dreifach. Für reine Lesearbeit geben Sie eine Referenz weiter. Ist Isolation wirklich erforderlich, verwenden Sie structuredClone und akzeptieren Sie, dass auch das den gesamten Objektgraphen neu baut.

6. Mit process.memoryUsage messen

In Node liefert process.memoryUsage() fünf Zahlen auf einmal: rss (physischer Speicher des Prozesses), heapTotal, heapUsed (genutzter JS-Heap), external (Speicher in C++-Objekten) und arrayBuffers (ArrayBuffer- und Buffer-Speicher). Beim Studium der JSON-Verarbeitung ist heapUsed die richtige Linse für den Objektgraphen, und rss sagt Ihnen, ob der ganze Prozess außer Kontrolle gerät.

Ein Messskript braucht drei Disziplinen: zuerst global.gc() aufrufen, gestaffelt beproben und jeweils eine Variable ändern. Dieses Skript erfasst die Zuwächse für „Text lesen“ und „in Objekte parsen“ getrennt:

// 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,
});

Einige Fallen:

  • --expose-gc muss ausdrücklich aktiviert werden; sonst existiert global.gc nicht;
  • heapUsed deckt nur den JS-Heap ab. Datei lesende Buffers leben unter external und arrayBuffers, verwenden Sie also nicht eine Zahl, um alles zu erklären;
  • Loggen Sie zwischen den Proben kein großes Objekt mit console.log; das hält eine temporäre Referenz am Leben;
  • Der erste Lauf enthält JIT-Aufwärmen und verzögerte Kompilierung. Führen Sie mindestens drei Runden aus und nehmen Sie den Median;
  • Zum Vergleichen im Browser nehmen Sie Heap-Snapshots im DevTools-Memory-Panel und lesen Retained Size, statt das Sägezahnmuster in der Allocation Timeline anzustarren.

Ohne diese Baseline sind Behauptungen wie „Streaming behebt das“ oder „ein Cache macht es schneller“ nur Gefühle.

7. Streaming erkauft Spitzenspeicher, nicht Gesamtzeit

Der Handel beim Streaming-Parsen ist ausdrücklich: wahlfreier Zugriff gegen Speicher, der nicht mehr mit der Dateigröße skaliert.

Ansatz Spitzenspeicher Wahlfreier Zugriff Am besten für
Vollständiges JSON.parse Text plus der komplette Objektgraph Ja Unter etwa 100 MB und die meisten Felder werden gebraucht
Ein SAX-Parser wie stream-json Ein Chunk plus das aktuelle Element Nein Riesige Dateien oder nur wenige Felder nötig
Zeilenweises JSON Lines Das Objekt für eine Zeile Nein Daten, die natürlichen ein Stapel Datensätze sind
Externer Index oder Datenbank Nichts vollständig geladen Ja Wiederholte Abfragen über dieselben Daten
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);
}

Dieser Code baut nie den vollständigen Baum, die Speicherkurve bleibt also flach. Die Kosten sind ebenfalls real: Sie können nicht zu einem übersprungenen Feld zurück, globale Aggregationen werden schwierig, Fehlerbehandlung liegt bei Ihnen, und ein reiner JS-Zeichen-für-Zeichen-Scan ist meist eine Größenordnung langsamer als natives JSON.parse.

Die Entscheidung ist nicht „hat die Datei 10 MB überschritten“. Stellen Sie zwei Fragen: Brauche ich nach dem Parsen die meisten Felder? Kann ich mit sequenziellem Zugriff leben? Sind beide Antworten ja, ist vollständiges Parsen korrekt, und Ihr Aufwand gehört in das Reduzieren von Kopien und Rendern. Ist eine Antwort nein, erwägen Sie Streaming. Zur vollständigen geschichteten Strategie siehe Große JSON-Dateien parsen, rendern und streamen.

8. Weniger Kopien, frühere Freigabe

Bei Speicherarbeit ist das Entfernen einer vollständigen Kopie oft mehr wert als das Wechseln von Parsern:

  • Für reine Lesearbeit Referenzen weitergeben. Nicht „zur Sicherheit“ tief klonen, schon gar nicht mit JSON.parse(JSON.stringify(x));
  • Das Rohdokument nicht in ein Reaktivitätssystem wickeln. Vues reactive proxyed den gesamten Objektgraphen rekursiv, und die Proxy-Schicht hat eigene Speicherkosten;
  • Abgeleitete Daten separat berechnen und cachen. Braucht eine Liste nur id, title und status, legen Sie keine ganzen Datensätze in den Store;
  • Häufige Strings normalisieren. Bilden Sie Werte wie "paid" und "pending" auf numerische Enums ab, um sowohl String-Objekte als auch Vergleichskosten zu sparen;
  • Langlebige Referenzen kürzen. Nullsetzen großer Objekte bei Erledigung, Caches leeren und Callbacks abmelden, damit Closures sie nicht auf die nächste Seite tragen;
  • Worker-Kopierkosten einkalkulieren. postMessage verwendet standardmäßig strukturiertes Klonen; einen 50-MB-Objektgraphen an den Hauptthread zurückzusenden baut eine zweite Kopie. Geben Sie möglichst eine Zusammenfassung zurück.

Die andere Hälfte der Größenbilanz lebt in Hören Sie auf, JSON-Komprimierung in der falschen Reihenfolge zu machen: Plattenbytes und Leitungsbytes sind ein anderes Konto als der Objektgraph im Heap, und kein einzelner Prozentsatz erklärt beides. Wie sich Editoren und CLI-Werkzeuge beim Speicher verhalten, siehe Editoren, Kommandozeilenwerkzeuge und Automatisierung.

9. Checkliste

Symptom Wahrscheinlichere Ursache Erste Aktion
Eine 10-MB-Datei treibt den Prozess auf 50 MB Objektgraph und Text bleiben beide am Leben Gestaffelt messen; prüfen, ob der Text gehalten wird
OOM beim Parsen, normal im Steady State Die Parse-Spitze überlappt alte Daten Alte Objekte freigeben, bevor die neue Datei geparst wird
Speicher steigt langsam und fällt nie Ein Slice, Cache oder Closure hält eine Referenz Heap-Snapshot nehmen und nach Retained Size sortieren
Arrays sind größer als erwartet Eine Dezimalzahl hat den Element Kind degradiert Zahlentypen vereinheitlichen; delete vermeiden
Ein Cache hat es langsamer gemacht Schlüssel und Werte verbrauchen Speicher und erhöhen den GC-Druck Cache begrenzen und nur Zusammenfassungen speichern

Warum wächst der Speicher nach JSON.parse um das Drei- oder Vierfache?

Weil der Text nur die Eingabe ist; das Parse-Produkt ist ein Objektgraph. Jedes Objekt hat einen Header, jede Eigenschaft einen Slot und jeder String einen eigenen Header. Bei genug Knoten übersteigt der Gesamt-Overhead die Bytezahl des Textes. Der Multiplikator hängt von der Datenform ab, und flache Objekt-Arrays unterscheiden sich stark von tief verschachtelten Dokumenten.

Sollte eine 10-MB-JSON-Datei vollständig geparst oder gestreamt werden?

Sehen Sie zuerst auf den Zweck. Brauchen Sie wahlfreien Zugriff, die meisten Felder und passt die Datei in den Speicher, verwenden Sie vollständiges JSON.parse. Brauchen Sie nur wenige Felder oder passt die Datei nicht, streamen Sie. 10 MB ist keine Grenze; Streaming wird meist irgendwo über 100 MB zwingend.

Warum weicht process.memoryUsage().heapUsed von der Systemüberwachung ab?

heapUsed deckt nur den genutzten Teil des JS-Heaps ab, ohne external, arrayBuffers und Code Space. Die Systemüberwachung meldet rss, eine andere Messung. Verfolgen Sie heapUsed und rss zusammen; eine große Lücke deutet auf Buffers und native Objekte hin.

Ist JSON.parse(JSON.stringify(x)) eine sichere Tiefenkopie?

Nein. Es hält den Originalgraphen, temporären Text und neuen Graphen gleichzeitig, die Spitze ist also grob das Dreifache des Steady State, und es verwirft undefined, Funktionen und Date-Semantik. Für reine Lesearbeit Referenzen weitergeben; ist Isolation erforderlich, structuredClone verwenden und dieselben Neuaufbaukosten akzeptieren.